Files
decePubClient/Services/TimelineService.cs
T
thepraandClaude Opus 5.5 658683f816 The persona's notification policy, and what it filtered
Settings has a Notifications section, also at /notifications/filtered: what notifications from people the persona does
not follow, people who do not follow it, new accounts, private mentions it did not ask for and limited accounts become
(accept, filter, drop), and the filtered ones, one request per account, let in (with that account from then on) or
dismissed. The Notifications tab says when requests wait. e2e: a stranger's like waits in a request reached from the
tab and comes back once let in; the policy and the stranger's permission are put back afterwards (Pasture.Mongo).

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

322 lines
17 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>The persona's notification policy; null when it cannot be read.</summary>
public async Task<NotificationPolicy> NotificationPolicy(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationPolicy, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationPolicy)}", cancellationToken);
return null;
}
return await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken);
}
/// <summary>Saves the persona's notification policy; Data is the policy as PrivaPub keeps it.</summary>
public async Task<WebResult> SetNotificationPolicy(NotificationPolicy policy, CancellationToken cancellationToken = default)
{
using var response = await httpService.Patch(APIs.Mastodon.PATCH_NotificationPolicy, new NotificationPolicy
{
ForNotFollowing = policy.ForNotFollowing,
ForNotFollowers = policy.ForNotFollowers,
ForNewAccounts = policy.ForNewAccounts,
ForPrivateMentions = policy.ForPrivateMentions,
ForLimitedAccounts = policy.ForLimitedAccounts
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<NotificationPolicy>(cancellationToken) };
}
/// <summary>The requests gathering the filtered notifications, newest first; [] when they cannot be read.</summary>
public async Task<List<NotificationRequest>> NotificationRequests(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_NotificationRequests, ["limit=80"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(TimelineService)}.{nameof(NotificationRequests)}", cancellationToken);
return [];
}
return await response.MastodonReadFromJsonAsync<List<NotificationRequest>>(cancellationToken) ?? [];
}
/// <summary>Lets a request's notifications in, and its account from then on, or dismisses them.</summary>
public async Task<WebResult> AnswerNotificationRequest(string requestId, bool accept, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_NotificationRequest(requestId, accept), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <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];
}
}
}