Posts for later, hashtags followed, and Explore

The composer's clock button gives a post a time (scheduled_at, at least five
minutes ahead; located and group posts have none), and /scheduled moves or
drops what waits. A hashtag's feed tab shows its week and follows it, so its
public posts come home. Explore (/explore) shows what PrivaPub says is
trending, hashtags, posts and links from public posts it holds, the
directory by activity or by joining, and the hashtags the persona follows.
DiscoveryService does the calls; the e2e tests check each against PrivaPub's
own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 03:53:51 +02:00
1 parent 9a752d9288
commit 6d5cf17723
17 files changed
+1001 -7

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>Which order the directory lists accounts in.</summary>
public enum DirectoryOrder
{
/// <summary>By their latest public post.</summary>
Active,
/// <summary>By the day they say they joined.</summary>
New
}
/// <summary>
/// What there is to find on PrivaPub: hashtags (their week, following them), what is trending there (hashtags, posts
/// and links, from public posts it already holds) and the directory of accounts that let themselves be discovered.
/// Lists come back empty and values null on failure; nothing throws.
/// </summary>
public class DiscoveryService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
public async Task<Tag> Tag(string name, CancellationToken cancellationToken = default)
{
using var response = await httpService.GetAnon(APIs.Mastodon.GET_M_Tag(name), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<Tag>(cancellationToken);
await LogFailure(response, nameof(Tag), cancellationToken);
return null;
}
/// <summary>Follows or stops following a hashtag; Data is the Tag.</summary>
public async Task<WebResult> FollowTag(string name, bool follow, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_TagAction(name, follow ? "follow" : "unfollow"), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Tag>(cancellationToken) };
}
public Task<List<Tag>> FollowedTags(CancellationToken cancellationToken = default) =>
List<Tag>(APIs.Mastodon.GET_FollowedTags, ["limit=200"], nameof(FollowedTags), cancellationToken);
public Task<List<Tag>> TrendingTags(CancellationToken cancellationToken = default) =>
List<Tag>(APIs.Mastodon.GET_TrendingTags, ["limit=20"], nameof(TrendingTags), cancellationToken);
public Task<List<Status>> TrendingStatuses(CancellationToken cancellationToken = default) =>
List<Status>(APIs.Mastodon.GET_TrendingStatuses, ["limit=40"], nameof(TrendingStatuses), cancellationToken);
public Task<List<TrendLink>> TrendingLinks(CancellationToken cancellationToken = default) =>
List<TrendLink>(APIs.Mastodon.GET_TrendingLinks, ["limit=20"], nameof(TrendingLinks), cancellationToken);
public const int DirectoryPage = 40;
public Task<List<Account>> Directory(DirectoryOrder order, bool localOnly, int offset, CancellationToken cancellationToken = default) =>
List<Account>(APIs.Mastodon.GET_Directory,
[$"order={(order is DirectoryOrder.New ? "new" : "active")}", $"local={(localOnly ? "true" : "false")}", $"limit={DirectoryPage}", $"offset={offset}"],
nameof(Directory), cancellationToken);
async Task<List<T>> List<T>(string route, string[] parameters, string caller, CancellationToken cancellationToken)
{
using var response = await httpService.GetAnon(route, parameters, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<T>>(cancellationToken) ?? [];
await LogFailure(response, caller, cancellationToken);
return [];
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(DiscoveryService)}.{caller}", cancellationToken);
}
}
}
+38 -1
View File
@@ -54,15 +54,52 @@ namespace decePubClient.Services
Sensitive = form.HasContentWarning,
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
Visibility = form.Visibility,
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName
Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture)
}, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (form.ScheduledAtUtc is not null)
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
return new WebResult { Data = FeedPost.Of(status) };
}
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
public async Task<List<ScheduledStatus>> Scheduled(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_ScheduledStatuses, ["limit=40"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(Scheduled)}", cancellationToken);
return [];
}
return [.. (await response.MastodonReadFromJsonAsync<List<ScheduledStatus>>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
}
/// <summary>Moves a scheduled post to another time; Data is the ScheduledStatus.</summary>
public async Task<WebResult> Reschedule(string scheduledId, DateTime atUtc, CancellationToken cancellationToken = default)
{
using var response = await httpService.Put(APIs.Mastodon.PUT_M_ScheduledStatus(scheduledId),
new ScheduleForm { ScheduledAt = atUtc.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
}
/// <summary>Drops a scheduled post: it is never published.</summary>
public async Task<WebResult> Unschedule(string scheduledId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_ScheduledStatus(scheduledId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult();
}
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
async Task<WebResult> PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken)
{