209 lines
10 KiB
C#
209 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];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|