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]; } } }