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
{
/// What a feed shows: its kind, and the group, hashtag or position some kinds need.
public sealed record FeedQuery(TimelineType Type, string PersonaId, string GroupId = default, bool IsCommunity = false, string Tag = default,
double? Latitude = default, double? Longitude = default);
///
/// 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.
///
public class TimelineService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public const int PageSize = 20;
/// The most ids /api/v1/statuses reads at once.
const int MaxIdsPerRead = 20;
public async Task 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 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>(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 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>(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
}
};
}
/// Direct conversations, newest first; PrivaPub answers one page.
async Task 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>(cancellationToken) ?? [];
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } };
}
/// A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.
async Task 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>(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)] } };
}
/// Located posts within their range of the reader, nearest first, with their authors.
async Task 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>(cancellationToken) ?? [];
var authors = new Dictionary();
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
}
})
]
}
};
}
/// Statuses by id, PrivaPub's batch read (/api/v1/statuses?id[]=), in chunks it accepts.
public async Task> Read(List ids, CancellationToken cancellationToken = default)
{
var statuses = new Dictionary();
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>(cancellationToken) ?? [])
statuses[status.Id] = status;
}
return statuses;
}
async Task 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(cancellationToken) : null;
}
static string[] Paging(string[] parameters, string maxId, string minId)
{
var paging = new List(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];
}
}
}