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

+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;