Files
decePubClient/Services/TimelineService.cs
T
thepraandClaude Opus 5.5 d7d836ba39 Settings, Administration and conversations; threads that fill in
Settings, which held only the theme, now belong to the persona too:
- its profile (update_credentials, a new PATCH in IHttpService): pictures,
  name, biography and fields as written, locked, bot, discoverable,
  indexable, and how its posts start (visibility, language, sensitive
  media, who may quote them); the composer starts at that visibility;
- the root's personas, each used in turn, and a new one made;
- the accounts it muted or blocked and the servers it blocked, each undone
  there, a server blocked from a pasted address or handle;
- its follows, lists, blocks, mutes, blocked servers and bookmarks exported
  and imported as the CSV files Mastodon writes (PortabilityService): an
  import resolves each row through PrivaPub, one at a time, and can stop.

Administration opens to moderators: reports, resolved there; and for
administrators the servers blocked for everyone and the federation
statistics (traffic by server, delivery success and latency, the crawler).

The Direct tab pages conversations, badges the unread ones, and a card
marks one read or takes it off the list; opening one reads it.

A remote thread is read again 4 and 15 seconds after it opens, since
PrivaPub then fetches the replies its servers hold. A thread its
community locked shows a lock instead of a reply button, reactions stay
visible below a content warning, and a searched handle opens its
account's profile whatever else matched.

The e2e tests cover each: the profile saved and read back, a persona made,
servers blocked and imported, follows exported, an instance block and the
statistics as the pasture's admin, a conversation read and removed, and a
reply only Mastodon held appearing in its thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 06:20:18 +02:00

272 lines
14 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, list 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, string ListId = 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.Lists when query.ListId is { Length: > 0 } listId =>
await Statuses(APIs.Mastodon.GET_M_ListTimeline(listId), [], 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 => await Conversations(maxId, minId, 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
}
};
}
/// <summary>Notifications folded into groups (/api/v2/notifications): likes and boosts of one post, follows of one
/// hour, each an item with its count and a few of its accounts. Paged by notification ids from the Link header.</summary>
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_GroupedNotifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var grouped = await response.MastodonReadFromJsonAsync<GroupedNotifications>(cancellationToken) ?? new GroupedNotifications();
var accounts = grouped.Accounts.DistinctBy(a => a.Id).ToDictionary(a => a.Id);
var statuses = grouped.Statuses.DistinctBy(s => s.Id).ToDictionary(s => s.Id);
var (nextMaxId, prevMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. grouped.NotificationGroups.Select(g => FeedPost.Of(g, accounts, statuses))],
MaxId = grouped.NotificationGroups.Count == 0 ? null : nextMaxId,
MinId = prevMinId ?? grouped.NotificationGroups.FirstOrDefault()?.MostRecentNotificationId ?? minId
}
};
}
/// <summary>How many groups of notifications are unread (newer than the notifications marker).</summary>
public async Task<int> UnreadNotifications(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_UnreadNotifications, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<UnreadCount>(cancellationToken))?.Count ?? 0 : 0;
}
/// <summary>Moves the notifications marker to the newest one seen, which reads everything up to it.</summary>
public async Task<WebResult> ReadNotificationsUpTo(string notificationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Markers, new MarkersForm { Notifications = new MarkerForm { LastReadId = notificationId } },
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>Direct conversations by their newest message, newest first, paged by that message's id.</summary>
async Task<WebResult> Conversations(string maxId, string minId, CancellationToken cancellationToken)
{
var parameters = new List<string> { $"limit={PageSize}" };
if (maxId is not null)
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
if (minId is not null)
parameters.Add($"min_id={Uri.EscapeDataString(minId)}");
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [.. parameters], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
var (nextMaxId, nextMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)],
MaxId = conversations.Count < PageSize ? null : nextMaxId,
MinId = nextMinId
}
};
}
/// <summary>How many of the newest conversations have a message the persona has not read.</summary>
public async Task<int> UnreadConversations(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, ["limit=40"], cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? []).Count(c => c.Unread) : 0;
}
/// <summary>Marks a conversation read; Data is the conversation.</summary>
public async Task<WebResult> ReadConversation(string conversationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_ReadConversation(conversationId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Conversation>(cancellationToken) };
}
/// <summary>Takes a conversation off the list until a newer message; its messages stay.</summary>
public async Task<WebResult> RemoveConversation(string conversationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Conversation(conversationId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <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];
}
}
}