2026-10-04 11:03:14 +02:00
|
|
|
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
|
|
|
|
|
{
|
2026-10-05 02:49:51 +02:00
|
|
|
/// <summary>What a feed shows: its kind, and the group, hashtag, list or position some kinds need.</summary>
|
2026-10-04 11:03:14 +02:00
|
|
|
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
|
2026-10-05 02:49:51 +02:00
|
|
|
double? Latitude = default, double? Longitude = default, string ListId = default);
|
2026-10-04 11:03:14 +02:00
|
|
|
|
|
|
|
|
/// <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),
|
2026-10-05 02:49:51 +02:00
|
|
|
TimelineType.Lists when query.ListId is { Length: > 0 } listId =>
|
|
|
|
|
await Statuses(APIs.Mastodon.GET_M_ListTimeline(listId), [], maxId, minId, cancellationToken),
|
2026-10-04 11:03:14 +02:00
|
|
|
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),
|
2026-10-05 06:20:18 +02:00
|
|
|
TimelineType.Direct => await Conversations(maxId, minId, cancellationToken),
|
2026-10-04 11:03:14 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 04:35:23 +02:00
|
|
|
/// <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>
|
2026-10-04 11:03:14 +02:00
|
|
|
async Task<WebResult> Notifications(string maxId, string minId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
2026-10-05 04:35:23 +02:00
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_GroupedNotifications, Paging([], maxId, minId), cancellationToken: cancellationToken);
|
2026-10-04 11:03:14 +02:00
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
2026-10-05 04:35:23 +02:00
|
|
|
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);
|
2026-10-04 11:03:14 +02:00
|
|
|
var (nextMaxId, prevMinId) = response.PageCursors();
|
|
|
|
|
return new WebResult
|
|
|
|
|
{
|
|
|
|
|
Data = new FeedPage
|
|
|
|
|
{
|
2026-10-05 04:35:23 +02:00
|
|
|
Posts = [.. grouped.NotificationGroups.Select(g => FeedPost.Of(g, accounts, statuses))],
|
|
|
|
|
MaxId = grouped.NotificationGroups.Count == 0 ? null : nextMaxId,
|
|
|
|
|
MinId = prevMinId ?? grouped.NotificationGroups.FirstOrDefault()?.MostRecentNotificationId ?? minId
|
2026-10-04 11:03:14 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 04:35:23 +02:00
|
|
|
/// <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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 06:20:18 +02:00
|
|
|
/// <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)
|
2026-10-04 11:03:14 +02:00
|
|
|
{
|
2026-10-05 06:20:18 +02:00
|
|
|
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);
|
2026-10-04 11:03:14 +02:00
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
|
2026-10-05 06:20:18 +02:00
|
|
|
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);
|
2026-10-04 11:03:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|