R4 the client catches up: follow requests, followers, downvotes, dismissing, filtering a post, quote policies, statistics in detail
Build / Build (push) Successful in 1m8s

Follow requests answered on the persona's own profile and from the Notifications tab; its followers and the accounts it
follows listed there, a follower removed (asked first). Downvotes on threadiverse posts, after the public-vote notice.
A notification dismissed from its line. "Filter this post" from a card's menu, and the Filters page showing the posts a
filter holds. Who may quote the persona's own posts, and revoking a quote of one. Statistics: a server in detail with
"describe again", the ledger's events, the server's own days. The composer deletes an upload taken out before posting.
MediaType.Documents goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 04:02:20 +02:00
1 parent b731d8c8c0
commit b8d7858acb
25 files changed
+1543 -61

No files matched your search

+20 -11
View File
@@ -30,7 +30,7 @@ namespace decePubClient.Services
/// <summary>
/// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following,
/// blocking and muting it, the persona's follow requests, and search across the fediverse (a remote handle or URL is
/// blocking and muting it, the persona's follow requests, followers and following, and search across the fediverse (a remote handle or URL is
/// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws.
/// </summary>
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
@@ -154,16 +154,10 @@ namespace decePubClient.Services
return [];
}
/// <summary>The accounts waiting for the persona to accept their follow.</summary>
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
await LogFailure(response, nameof(FollowRequests), cancellationToken);
return [];
}
/// <summary>A page of the accounts waiting for the persona to accept their follow, newest first; Data is an
/// AccountPage (the next page's cursor from the Link header, when PrivaPub sends one).</summary>
public Task<WebResult> FollowRequests(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_FollowRequests, maxId, cancellationToken);
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
@@ -237,6 +231,21 @@ namespace decePubClient.Services
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
/// <summary>A page of the persona's followers (only the persona itself may read it: PrivaPub answers [] for anyone
/// else's); Data is an AccountPage.</summary>
public Task<WebResult> Followers(string accountId, string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_M_AccountFollowers(accountId), maxId, cancellationToken);
/// <summary>Removes an account from the persona's followers (its server is told); Data is the new Relationship.</summary>
public async Task<WebResult> RemoveFromFollowers(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_RemoveFromFollowers(accountId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
}
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
+57 -1
View File
@@ -10,7 +10,8 @@ namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
/// for the whole instance and the federation statistics (/clientapi/admin). PrivaPub checks the root's role on every
/// for the whole instance, the federation statistics (the overview, the servers, one server's days and events, the
/// ledger's events, the server's own days) and the backups (/clientapi/admin). PrivaPub checks the root's role on every
/// call. Lists come back empty and values null on failure; nothing throws.
/// </summary>
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
@@ -160,6 +161,61 @@ namespace decePubClient.Services
return null;
}
/// <summary>One server: what it says of itself, its days (counters keyed as the ledger keys them) and its latest 100
/// events; null when PrivaPub knows nothing of it or it cannot be read.</summary>
public async Task<ViewHostDetail> Host(string host, int days, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_M_StatisticsHost(host), [$"days={days}"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewHostDetail>(cancellationToken);
if (response.StatusCode is not HttpStatusCode.NotFound)
await LogFailure(response, nameof(Host), cancellationToken);
return null;
}
/// <summary>Asks PrivaPub to read the server's NodeInfo and instance description again (a queued job, answered 202).</summary>
public async Task<WebResult> Describe(string host, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_StatisticsDescribe(host), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The ledger's events, newest first, those before `before` when given (the next page starts at the last
/// one's time); each filter is left out when null.</summary>
public async Task<List<ViewInteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit,
CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={limit}" };
if (host is { Length: > 0 })
parameters.Add($"host={Uri.EscapeDataString(host)}");
if (channel is { Length: > 0 })
parameters.Add($"channel={Uri.EscapeDataString(channel)}");
if (outcome is { Length: > 0 })
parameters.Add($"outcome={Uri.EscapeDataString(outcome)}");
if (reason is { Length: > 0 })
parameters.Add($"reason={Uri.EscapeDataString(reason)}");
if (before is { } at)
parameters.Add($"before={Uri.EscapeDataString(at.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture))}");
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsEvents, [.. parameters], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewInteractionEvent>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Events), cancellationToken);
return [];
}
/// <summary>The server's own days, oldest first: its client API, the documents it served and its ledger.</summary>
public async Task<List<ViewServerDay>> ServerDays(int days, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsServer, [$"days={days}"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewServerDay>>(cancellationToken) ?? [];
await LogFailure(response, nameof(ServerDays), cancellationToken);
return [];
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+17 -2
View File
@@ -11,9 +11,10 @@ namespace decePubClient.Services
{
/// <summary>
/// Uploads to PrivaPub (/api/v2/media, multipart), streamed from the browser's file; follows what it is still processing
/// (audio and video: 202, then 206 until ready); describes what isn't posted yet; and knows what PrivaPub takes.
/// (audio and video: 202, then 206 until ready); describes and deletes what isn't posted yet; and knows what PrivaPub
/// takes.
/// </summary>
public class MediaService(IHttpService httpService, CoalescingStringLocalizer localizer)
public class MediaService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
static readonly MediaConfiguration Fallback = new()
{
@@ -79,6 +80,20 @@ namespace decePubClient.Services
return new WebResult();
}
/// <summary>Deletes an upload of the persona's that no post holds (DELETE /api/v1/media/:id; PrivaPub refuses a posted
/// one with 422): the composer drops what was removed before posting. A failure is logged.</summary>
public async Task<WebResult> Discard(string mediaId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Media(mediaId), cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return new WebResult();
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not (int)HttpStatusCode.Gone)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(MediaService)}.{nameof(Discard)}", cancellationToken);
return result;
}
async Task<WebResult> Attachment(HttpResponseMessage response, CancellationToken cancellationToken)
{
var attachment = await response.MastodonReadFromJsonAsync<MediaAttachment>(cancellationToken);
+39 -2
View File
@@ -21,12 +21,16 @@ namespace decePubClient.Services
Bookmark,
Unbookmark,
Pin,
Unpin
Unpin,
/// <summary>PrivaPub's own, for the threadiverse: a downvote replaces the persona's favourite (its upvote).</summary>
Downvote,
Undownvote
}
/// <summary>
/// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts),
/// favourites, boosts, bookmarks, deleting, threads, and muting or blocking an author. Results are WebResults whose
/// favourites, boosts, bookmarks, downvotes, deleting, threads, who may quote the persona's posts (and revoking a quote
/// of one), and muting or blocking an author. Results are WebResults whose
/// Data is the updated Status (a FeedPost when publishing); nothing throws.
/// </summary>
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
@@ -228,6 +232,39 @@ namespace decePubClient.Services
return await StatusResult(response, cancellationToken);
}
/// <summary>Who may quote the persona's own public or unlisted post (PUT /statuses/:id/interaction_policy); its
/// followers are told of the change. Data is the updated Status.</summary>
public async Task<WebResult> SetQuotePolicy(string statusId, QuotePolicy policy, CancellationToken cancellationToken = default)
{
using var response = await httpService.Put(APIs.Mastodon.PUT_M_StatusInteractionPolicy(statusId),
new[] { new KeyValuePair<string, string>("quote_approval_policy", QuotePolicyValue(policy)) }, cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
/// <summary>The quote policy a status states (quote_approval.automatic): who may quote it without asking.</summary>
public static QuotePolicy QuotePolicyOf(Status status) => status.QuoteApproval?.Automatic switch
{
{ } automatic when automatic.Contains("public") => QuotePolicy.Public,
{ } automatic when automatic.Contains("followers") => QuotePolicy.Followers,
_ => QuotePolicy.Nobody
};
/// <summary>A quote policy as the Mastodon API names it.</summary>
public static string QuotePolicyValue(QuotePolicy policy) => policy switch
{
QuotePolicy.Followers => "followers",
QuotePolicy.Nobody => "nobody",
_ => "public"
};
/// <summary>Takes back the permission to quote the persona's own post from a post quoting it (FEP-044f); Data is the
/// quoting Status as it is now, its quote revoked.</summary>
public async Task<WebResult> RevokeQuote(string quotedId, string quotingId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_RevokeQuote(quotedId, quotingId), cancellationToken: cancellationToken);
return await StatusResult(response, cancellationToken);
}
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> choices, CancellationToken cancellationToken = default)
{
+8
View File
@@ -156,6 +156,14 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Dismisses a group of notifications (/api/v2/notifications/:group_key/dismiss): every notification of
/// the group goes; an ungrouped one's key is ungrouped-{id}.</summary>
public async Task<WebResult> DismissNotification(string groupKey, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissNotificationGroup(groupKey), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <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)
{