R5 the server's own settings in decePub: instance, rules, announcements, relays, media limits, the staff's log, About
Build / Build (push) Successful in 1m5s

Administration's Pleroma placeholders give way to real sections: Instance (title, descriptions, contact, thumbnail,
privacy policy, terms, the weekly activity), Rules, Announcements, Relays, Media limits and the Moderation log, next to
Reports, blocked servers (now published or obfuscated), statistics and backups; moderators see Reports and the log. Every
save sends the whole settings form built from the latest read, since PrivaPub replaces what it carries. /about shows the
server's documents to anyone. The report dialog picks the rules a violation breaks, and reports show them. Announcements
show above the feed tabs, dismissed and reacted to, live from the user stream. Login names the server.

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:32:57 +02:00
1 parent b8d7858acb
commit 4c88980374
33 files changed
+3743 -239

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// What this server says of itself to anyone, through the Mastodon instance API, never with credentials: its title,
/// descriptions, contact and rules (/api/v2/instance, read once a session and forgotten when the administrator saves),
/// its long description, privacy policy and terms of service (sanitized HTML from the administrator's Markdown), and
/// the domain blocks it publishes. Values come back null and lists empty on failure; nothing throws.
/// </summary>
public class AboutService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
InstanceV2 instance;
/// <summary>The instance's description, kept for the session; read again when `fresh`.</summary>
public async Task<InstanceV2> Instance(bool fresh = false, CancellationToken cancellationToken = default)
{
if (instance is not null && !fresh)
return instance;
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Instance), cancellationToken);
return instance;
}
return instance = await response.MastodonReadFromJsonAsync<InstanceV2>(cancellationToken) ?? instance;
}
/// <summary>Drops what was read, after the administrator changed it.</summary>
public void Forget() => instance = null;
/// <summary>The server's rules in their order (not the ones its administrator removed).</summary>
public async Task<List<InstanceRule>> Rules(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceRules, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<InstanceRule>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Rules), cancellationToken);
return [];
}
public Task<InstanceDocument> ExtendedDescription(CancellationToken cancellationToken = default) =>
Document(APIs.Mastodon.GET_InstanceExtendedDescription, nameof(ExtendedDescription), cancellationToken);
public Task<InstanceDocument> PrivacyPolicy(CancellationToken cancellationToken = default) =>
Document(APIs.Mastodon.GET_InstancePrivacyPolicy, nameof(PrivacyPolicy), cancellationToken);
/// <summary>The terms of service, or null while the server has none (its 404).</summary>
public async Task<TermsOfService> Terms(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceTermsOfService, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<TermsOfService>(cancellationToken);
if (response.StatusCode is not HttpStatusCode.NotFound)
await LogFailure(response, nameof(Terms), cancellationToken);
return null;
}
/// <summary>The blocks the administrator published, as the server shows them (an obfuscated one partly starred).</summary>
public async Task<List<PublicDomainBlock>> DomainBlocks(CancellationToken cancellationToken = default)
{
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceDomainBlocks, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<PublicDomainBlock>>(cancellationToken) ?? [];
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
return [];
}
async Task<InstanceDocument> Document(string route, string caller, CancellationToken cancellationToken)
{
using var response = await httpService.GetTotallyAnon(route, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<InstanceDocument>(cancellationToken);
await LogFailure(response, caller, cancellationToken);
return null;
}
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(AboutService)}.{caller}", cancellationToken);
}
}
}
+6 -4
View File
@@ -286,10 +286,11 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary>
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward,
CancellationToken cancellationToken = default)
/// <summary>Reports an account, with the posts named, to the persona's server's moderators (a violation with the
/// server's rules it breaks); forwarded, its own server gets the report too, from PrivaPub's actor and never naming
/// the persona.</summary>
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, List<string> ruleIds,
bool forward, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
{
@@ -297,6 +298,7 @@ namespace decePubClient.Services
StatusIds = statusIds,
Comment = comment?.Trim(),
Category = category.ToString().ToLowerInvariant(),
RuleIds = category is ReportCategory.Violation ? ruleIds : [],
Forward = forward
}, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+20 -4
View File
@@ -9,10 +9,11 @@ using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
/// 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.
/// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator),
/// servers blocked 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). The server's own settings,
/// announcements and relays are InstanceAdminService's. 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)
{
@@ -34,6 +35,21 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>One page of the staff's log, newest first: those before `before` (the previous page's Next) when given.
/// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure.</summary>
public async Task<ViewAdminActionsPage> Log(string before, int limit, CancellationToken cancellationToken = default)
{
var parameters = new List<string> { $"limit={limit}" };
if (before is { Length: > 0 })
parameters.Add($"before={Uri.EscapeDataString(before)}");
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorLog, [.. parameters], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewAdminActionsPage>(cancellationToken);
await LogFailure(response, nameof(Log), cancellationToken);
return null;
}
/// <summary>The servers blocked for everyone here, by name.</summary>
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
{
+48
View File
@@ -0,0 +1,48 @@
using System.Net;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>
/// The administrators' announcements as the current persona reads them (/api/v1/announcements): the ones shown now,
/// dismissed one by one, and reacted to with a unicode emoji (eight different ones at most on each). Dismissals and
/// reactions are the persona's own and never leave PrivaPub; the stream tells of new, changed and removed ones
/// (StreamingService.Announced). The administrators write them through InstanceAdminService.
/// </summary>
public class AnnouncementService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The announcements shown now, with whether the persona dismissed each.</summary>
public async Task<List<Announcement>> List(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Announcements, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<Announcement>>(cancellationToken) ?? [];
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(AnnouncementService)}.{nameof(List)}", cancellationToken);
return [];
}
public async Task<WebResult> Dismiss(string announcementId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissAnnouncement(announcementId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>Adds the persona's reaction, or takes it back; PrivaPub refuses anything but one unicode emoji (422).</summary>
public async Task<WebResult> React(string announcementId, string emoji, bool add, CancellationToken cancellationToken = default)
{
var route = APIs.Mastodon.M_AnnouncementReaction(announcementId, emoji);
using var response = add
? await httpService.Put(route, cancellationToken: cancellationToken)
: await httpService.Delete(route, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
}
}
+178
View File
@@ -0,0 +1,178 @@
using System.Net;
using System.Net.Http.Headers;
using decePubClient.Extensions;
using decePubClient.Models;
using Microsoft.AspNetCore.Components.Forms;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// The server's own settings from the administrator's page (PrivaPub's InstanceAdminController, /clientapi/admin): what
/// it says of itself, its rules, its thumbnail, its media limits, whether its weekly activity is published, its
/// announcements and the relays it subscribes to. PrivaPub checks the administrator against its database on every call.
/// A save replaces everything the form carries (a rule left out is removed), so SaveSettings always starts from the
/// settings as PrivaPub has them now and changes only what the caller changes. Lists come back empty and values null on
/// failure; nothing throws.
/// </summary>
public class InstanceAdminService(IHttpService httpService, AboutService aboutService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The settings as the page shows them, deleted rules too; null on failure.</summary>
public async Task<ViewServerSettings> Settings(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken);
await LogFailure(response, nameof(Settings), cancellationToken);
return null;
}
/// <summary>The form that saves the settings exactly as they are: the rules not deleted in their order, the media
/// overrides, every text.</summary>
public static ServerSettingsForm FormOf(ViewServerSettings settings) => new()
{
Title = settings.Title,
ShortDescription = settings.ShortDescription,
Description = settings.Description,
ContactEmail = settings.ContactEmail,
ContactAccountId = settings.ContactAccountId,
PrivacyPolicy = settings.PrivacyPolicy,
TermsOfService = settings.TermsOfService,
TermsEffectiveAt = settings.TermsEffectiveAt,
Rules = [.. settings.Rules.Where(r => !r.IsDeleted).OrderBy(r => r.Position).Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text, Hint = r.Hint })],
Media = new()
{
QuotaBytesPerRoot = settings.Media.QuotaBytesPerRoot,
MaxImageBytes = settings.Media.MaxImageBytes,
MaxVideoBytes = settings.Media.MaxVideoBytes,
MaxSeconds = settings.Media.MaxSeconds
},
PublishActivity = settings.PublishActivity
};
/// <summary>
/// Reads the settings as PrivaPub has them now, lets `change` edit the form made of them, and saves the whole form;
/// Data is the ViewServerSettings saved. What another panel or another administrator saved meanwhile stays.
/// </summary>
public async Task<WebResult> SaveSettings(Action<ServerSettingsForm> change, CancellationToken cancellationToken = default)
{
using var current = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken);
if (!current.IsSuccessStatusCode)
return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken);
if (await current.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken) is not { } latest)
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
var form = FormOf(latest);
change(form);
using var response = await httpService.Post(APIs.ClientApi.POST_AdminSettings, form, cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>A new thumbnail, streamed from the browser's file; PrivaPub checks it, strips it and fits it within
/// 1200×630. Data is the ViewServerSettings.</summary>
public async Task<WebResult> SaveThumbnail(IBrowserFile file, long maximumSize, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminThumbnail, (Func<HttpContent>)(() =>
{
var picture = new StreamContent(file.OpenReadStream(maximumSize, cancellationToken));
picture.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType);
return new MultipartFormDataContent { { picture, "file", file.Name } };
}), cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>Back to the default picture; the uploaded one is trashed. Data is the ViewServerSettings.</summary>
public async Task<WebResult> RemoveThumbnail(CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_AdminThumbnail, cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>Every announcement, published or not, with its reads and reactions.</summary>
public async Task<List<ViewAnnouncement>> Announcements(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminAnnouncements, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewAnnouncement>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Announcements), cancellationToken);
return [];
}
/// <summary>Writes a new announcement (no Id) or changes one; Data is the ViewAnnouncement.</summary>
public async Task<WebResult> SaveAnnouncement(AnnouncementForm form, CancellationToken cancellationToken = default)
{
var route = form.Id is { Length: > 0 } ? APIs.ClientApi.POST_AdminAnnouncementUpdate : APIs.ClientApi.POST_AdminAnnouncementInsert;
using var response = await httpService.Post(route, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAnnouncement>(cancellationToken) };
}
/// <summary>Deletes an announcement, its reads and reactions with it; every persona's stream hears it go.</summary>
public async Task<WebResult> DeleteAnnouncement(string id, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminAnnouncementDelete(id), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The relays the server subscribes to: the configuration's, then the settings'.</summary>
public async Task<List<ViewRelay>> Relays(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminRelays, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewRelay>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Relays), cancellationToken);
return [];
}
/// <summary>Subscribes to a relay by its actor's or inbox's https address. PrivaPub answers 202 and subscribes in the
/// background, so the list it answers with (Data) shows the relay before its state does; read it again shortly.</summary>
public async Task<WebResult> AddRelay(string address, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminRelayInsert, new RelayForm { Address = address?.Trim() },
cancellationToken: cancellationToken);
return await RelayList(response, cancellationToken);
}
/// <summary>Unsubscribes from a relay the settings named (a configured one is refused with 409); answered 202 like
/// AddRelay.</summary>
public async Task<WebResult> RemoveRelay(string address, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRelayDelete(address), cancellationToken: cancellationToken);
return await RelayList(response, cancellationToken);
}
async Task<WebResult> Saved(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
// the title and the rules everyone reads have changed
aboutService.Forget();
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken) };
}
async Task<WebResult> RelayList(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<List<ViewRelay>>(cancellationToken) ?? [] };
}
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.Forbidden))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(InstanceAdminService)}.{caller}", cancellationToken);
}
}
}
+66 -6
View File
@@ -16,24 +16,37 @@ namespace decePubClient.Services
/// what it is about (a Status or a Notification for those, the deleted id for delete).</summary>
public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default);
/// <summary>What the user stream told of the administrators' announcements: one new or changed (announcement), one
/// gone (announcement.delete, its id), or a reaction's count now (announcement.reaction).</summary>
public sealed record AnnouncementMessage(string Event, Announcement Announcement = default, string DeletedId = default,
AnnouncementReactionEvent Reaction = default);
/// <summary>
/// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the
/// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as
/// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the
/// page to poll meanwhile. Nothing throws.
/// The announcements come on the user stream, which HearAnnouncements keeps subscribed whatever feed is followed; what
/// else it tells is raised only while the feed followed is the user stream itself.
/// </summary>
public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration,
ILoggingService logger) : IAsyncDisposable
{
const string UserStream = "user";
ClientWebSocket socket;
CancellationTokenSource running;
StreamSubscription current;
bool announcements;
public bool IsOpen => socket?.State == WebSocketState.Open && current is not null;
/// <summary>Raised for each message of the stream subscribed now.</summary>
public event Func<StreamMessage, Task> Received;
/// <summary>Raised for each announcement event, while HearAnnouncements is on.</summary>
public event Func<AnnouncementMessage, Task> Announced;
Uri Endpoint
{
get
@@ -47,23 +60,49 @@ namespace decePubClient.Services
/// <summary>Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew.</summary>
public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default)
{
if (subscription == current && (running is not null || subscription is null))
if (subscription == current && (running is not null || subscription is null && !announcements))
return;
var previous = current;
current = subscription;
if (socket?.State == WebSocketState.Open)
{
if (previous is not null)
// the user stream stays while the announcements are heard on it
if (previous is not null && !(announcements && previous.Stream == UserStream))
await Say("unsubscribe", previous, cancellationToken);
if (subscription is not null)
await Say("subscribe", subscription, cancellationToken);
return;
}
if (subscription is not null && running is null)
Start();
}
/// <summary>Hears the announcements (the user stream) or stops hearing them; the socket opens for them alone when no
/// feed is followed.</summary>
public async Task HearAnnouncements(bool hear, CancellationToken cancellationToken = default)
{
if (hear == announcements)
{
running = new();
_ = Run(running.Token);
if (hear)
Start();
return;
}
announcements = hear;
if (socket?.State == WebSocketState.Open)
{
if (current?.Stream != UserStream)
await Say(hear ? "subscribe" : "unsubscribe", new StreamSubscription(UserStream), cancellationToken);
return;
}
if (hear)
Start();
}
void Start()
{
if (running is not null || current is null && !announcements)
return;
running = new();
_ = Run(running.Token);
}
async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken)
@@ -132,6 +171,8 @@ namespace decePubClient.Services
await socket.ConnectAsync(Endpoint, cancellationToken);
if (current is not null)
await Say("subscribe", current, cancellationToken);
if (announcements && current?.Stream != UserStream)
await Say("subscribe", new StreamSubscription(UserStream), cancellationToken);
}
async Task Read(CancellationToken cancellationToken)
@@ -152,6 +193,24 @@ namespace decePubClient.Services
if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue<string>() is not { } name
|| message["payload"]?.GetValue<string>() is not { } payload)
continue;
if (name.StartsWith("announcement", StringComparison.Ordinal))
{
var announced = name switch
{
"announcement" => new AnnouncementMessage(name, Announcement: JsonSerializer.Deserialize<Announcement>(payload, SUtility.MastodonSerializer)),
"announcement.delete" => new AnnouncementMessage(name, DeletedId: payload),
"announcement.reaction" => new AnnouncementMessage(name,
Reaction: JsonSerializer.Deserialize<AnnouncementReactionEvent>(payload, SUtility.MastodonSerializer)),
_ => null
};
if (announced is not null && announcements && Announced is { } listeners)
await listeners(announced);
continue;
}
// the user stream held for the announcements tells the home feed's posts and notifications too: they are the
// feed's only while it is the one followed
if ((message["stream"] as JsonArray)?.FirstOrDefault()?.GetValue<string>() == UserStream && current?.Stream != UserStream)
continue;
var told = name switch
{
"update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize<Status>(payload, SUtility.MastodonSerializer)),
@@ -164,7 +223,8 @@ namespace decePubClient.Services
}
}
/// <summary>Closes the socket; the page polls from then on.</summary>
/// <summary>Closes the socket; the page polls from then on. The announcements stay asked for: the next Follow opens
/// the socket for them too.</summary>
public async Task Stop()
{
current = null;