One sign-in for both of PrivaPub's APIs: the root logs in on /clientapi, picks a persona, and each persona gets its own Mastodon token in exchange for the JWT (PersonaExchange). HttpService picks credentials and JSON by route (JWT and camelCase for /clientapi, the persona token and snake_case for /api, nothing for /oauth), retries a refused persona token once, and records every request for the nerd stats. The feed page is three columns: navigation with a persona switcher, the feed with a tab per kind (home, local, federated, nearby, communities and circles, direct, notifications, favourites, bookmarks, hashtags), and a 3D globe that looks at the spherical mean of the posts in view and draws a pulse and an arc to a hovered one. Posts sit at their own place, their event's venue, the reader (located posts) or their author's server as PrivaPub publishes it. Cards favourite, boost, bookmark, reply, mute, block and delete; the composer posts with a content warning, visibility, media and alt text, or as a located post. Older pages load as the end nears; newer ones are polled while visible. Nerd stats in tooltips: provenance, author and server, place, media, counts, paging, the globe's camera and frame rate. The mock (Faker, MessagesService, the Message models and store) and the OIDC template leftovers are gone. Strings in English and Italian. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
210 lines
10 KiB
C#
210 lines
10 KiB
C#
using System.Net;
|
|
|
|
using collAnon.Client.Services;
|
|
|
|
using decePubClient.Extensions;
|
|
using decePubClient.Models;
|
|
using decePubClient.Models.Mastodon;
|
|
using decePubClient.Models.Types;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.Post;
|
|
|
|
namespace decePubClient.Services
|
|
{
|
|
/// <summary>What a feed shows: its kind, and the group, hashtag or position some kinds need.</summary>
|
|
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
|
|
double? Latitude = default, double? Longitude = default);
|
|
|
|
/// <summary>
|
|
/// Pages of every feed the client shows, as FeedPosts. Mastodon lists page through max_id (older) and min_id (newer)
|
|
/// from their Link header. A circle's posts come from /clientapi as ids and are read through /api/v1/statuses (a
|
|
/// circle post reads as private there); located posts come from /clientapi/post/nearby with their authors read from
|
|
/// /api/v1/accounts, and have no next page. Results are WebResults with a FeedPage as Data; nothing throws.
|
|
/// </summary>
|
|
public class TimelineService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
|
{
|
|
public const int PageSize = 20;
|
|
/// <summary>The most ids /api/v1/statuses reads at once.</summary>
|
|
const int MaxIdsPerRead = 20;
|
|
|
|
public async Task<WebResult> Load(FeedQuery query, string maxId = default, string minId = default, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
return query.Type switch
|
|
{
|
|
TimelineType.Home => await Statuses(APIs.Mastodon.GET_HomeTimeline, [], maxId, minId, cancellationToken),
|
|
TimelineType.Local => await Statuses(APIs.Mastodon.GET_PublicTimeline, ["local=true"], maxId, minId, cancellationToken),
|
|
TimelineType.Federation => await Statuses(APIs.Mastodon.GET_PublicTimeline, ["remote=true"], maxId, minId, cancellationToken),
|
|
TimelineType.Favourites => await Statuses(APIs.Mastodon.GET_Favourites, [], maxId, minId, cancellationToken),
|
|
TimelineType.Bookmarks => await Statuses(APIs.Mastodon.GET_Bookmarks, [], maxId, minId, cancellationToken),
|
|
TimelineType.Tag when query.Tag is { Length: > 0 } tag =>
|
|
await Statuses(APIs.Mastodon.GET_M_TagTimeline(tag), [], maxId, minId, cancellationToken),
|
|
TimelineType.Groups when query.GroupId is { Length: > 0 } groupId && query.IsCommunity =>
|
|
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
|
|
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
|
|
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
|
|
TimelineType.Direct when maxId is null => await Conversations(cancellationToken),
|
|
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
|
|
_ => new WebResult { Data = new FeedPage() }
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await logger.ProcessError(ex, $"{nameof(TimelineService)}.{nameof(Load)}:{query.Type}", cancellationToken);
|
|
return new WebResult().Invalidate(localizer["The feed could not be read."], (int)HttpStatusCode.BadGateway, exception: ex);
|
|
}
|
|
}
|
|
|
|
async Task<WebResult> Statuses(string route, string[] parameters, string maxId, string minId, CancellationToken cancellationToken)
|
|
{
|
|
using var response = await httpService.Get(route, Paging(parameters, maxId, minId), cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
var statuses = await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [];
|
|
var (nextMaxId, prevMinId) = response.PageCursors();
|
|
return new WebResult
|
|
{
|
|
Data = new FeedPage
|
|
{
|
|
Posts = [.. statuses.Select(FeedPost.Of)],
|
|
MaxId = statuses.Count < PageSize ? null : nextMaxId ?? statuses.LastOrDefault()?.Id,
|
|
MinId = prevMinId ?? statuses.FirstOrDefault()?.Id ?? minId
|
|
}
|
|
};
|
|
}
|
|
|
|
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_Notifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
var notifications = await response.MastodonReadFromJsonAsync<List<Notification>>(cancellationToken) ?? [];
|
|
var (nextMaxId, prevMinId) = response.PageCursors();
|
|
return new WebResult
|
|
{
|
|
Data = new FeedPage
|
|
{
|
|
Posts = [.. notifications.Select(FeedPost.Of)],
|
|
MaxId = notifications.Count < PageSize ? null : nextMaxId ?? notifications.LastOrDefault()?.Id,
|
|
MinId = prevMinId ?? notifications.FirstOrDefault()?.Id ?? minId
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
|
|
async Task<WebResult> Conversations(CancellationToken cancellationToken)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [$"limit={PageSize}"], cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
|
|
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } };
|
|
}
|
|
|
|
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>
|
|
async Task<WebResult> Circle(FeedQuery query, CancellationToken cancellationToken)
|
|
{
|
|
using var response = await httpService.Get(APIs.ClientApi.GET_Posts,
|
|
[$"avatarId={Uri.EscapeDataString(query.PersonaId)}", $"groupId={Uri.EscapeDataString(query.GroupId)}"], cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
var posts = await response.DefaultReadFromJsonAsync<List<ViewPost>>(cancellationToken) ?? [];
|
|
var ids = posts.OrderByDescending(p => p.CreationDate).Select(p => p.Id).Take(PageSize * 2).ToList();
|
|
var statuses = await Read(ids, cancellationToken);
|
|
return new WebResult { Data = new FeedPage { Posts = [.. ids.Select(id => statuses.GetValueOrDefault(id)).Where(s => s is not null).Select(FeedPost.Of)] } };
|
|
}
|
|
|
|
/// <summary>Located posts within their range of the reader, nearest first, with their authors.</summary>
|
|
async Task<WebResult> Nearby(FeedQuery query, CancellationToken cancellationToken)
|
|
{
|
|
using var response = await httpService.Get(APIs.ClientApi.GET_Nearby,
|
|
[
|
|
$"avatarId={Uri.EscapeDataString(query.PersonaId)}",
|
|
$"latitude={query.Latitude!.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}",
|
|
$"longitude={query.Longitude!.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
|
|
], cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
var posts = await response.DefaultReadFromJsonAsync<List<ViewPost>>(cancellationToken) ?? [];
|
|
var authors = new Dictionary<string, Account>();
|
|
foreach (var authorId in posts.Select(p => p.AuthorAvatarId).Where(id => id is not null).Distinct())
|
|
if (await Account(authorId, cancellationToken) is { } account)
|
|
authors[authorId] = account;
|
|
|
|
return new WebResult
|
|
{
|
|
Data = new FeedPage
|
|
{
|
|
Posts =
|
|
[
|
|
.. posts.OrderBy(p => p.DistanceKm).Select(post => new FeedPost
|
|
{
|
|
Key = post.Id,
|
|
CursorId = post.Id,
|
|
Located = true,
|
|
DistanceKm = post.DistanceKm,
|
|
RangeKm = post.RangeKm,
|
|
Status = new()
|
|
{
|
|
Id = post.Id,
|
|
Uri = post.ObjectURI,
|
|
Account = authors.GetValueOrDefault(post.AuthorAvatarId ?? string.Empty),
|
|
InReplyToId = post.AnsweringToPostId,
|
|
Content = post.ContentHtml,
|
|
CreatedAt = post.CreationDate,
|
|
Sensitive = post.HasContentWarning,
|
|
Visibility = post.Visibility,
|
|
Privapub = post.Title is { Length: > 0 } ? new() { Title = post.Title } : null
|
|
}
|
|
})
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>Statuses by id, PrivaPub's batch read (/api/v1/statuses?id[]=), in chunks it accepts.</summary>
|
|
public async Task<Dictionary<string, Status>> Read(List<string> ids, CancellationToken cancellationToken = default)
|
|
{
|
|
var statuses = new Dictionary<string, Status>();
|
|
foreach (var chunk in ids.Chunk(MaxIdsPerRead))
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_Statuses, [.. chunk.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
|
|
cancellationToken: cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(TimelineService)}.{nameof(Read)}", cancellationToken);
|
|
continue;
|
|
}
|
|
|
|
foreach (var status in await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [])
|
|
statuses[status.Id] = status;
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
async Task<Account> Account(string accountId, CancellationToken cancellationToken)
|
|
{
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Account(accountId), cancellationToken: cancellationToken);
|
|
return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync<Account>(cancellationToken) : null;
|
|
}
|
|
|
|
static string[] Paging(string[] parameters, string maxId, string minId)
|
|
{
|
|
var paging = new List<string>(parameters) { $"limit={PageSize}" };
|
|
if (maxId is not null)
|
|
paging.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
|
if (minId is not null)
|
|
paging.Add($"min_id={Uri.EscapeDataString(minId)}");
|
|
return [.. paging];
|
|
}
|
|
}
|
|
}
|