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

+207
View File
@@ -0,0 +1,207 @@
@inherits LocalizableComponentBase
@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time
above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the
reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this
persona. New, changed and removed ones, and the reactions' counts, come through the user stream
(StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@
@if (Current is { } current)
{
<section class="flex-none flex flex-col gap-2 p-3 rounded-xl neo-raised neo-xs" aria-label="@Localizer["Announcements"]"
data-announcement-banner data-announcement="@current.Id">
<div class="flex items-center gap-2 pointer-coarse:gap-2">
<i class="ion-md-megaphone text-lg text-primary" aria-hidden="true"></i>
<b class="text-sm">@Localizer["Announcement"]</b>
<span class="text-xs opacity-70" title="@current.PublishedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@current.PublishedAt.GetPassedTime(Localizer)
</span>
@if (ShownCount > 1)
{
<span class="inline-flex items-center gap-1 pointer-coarse:gap-2">
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index == 0" OnClick="() => index--" aria-label="@Localizer["Previous announcement"]"
title="@Localizer["Previous announcement"]" data-announcement-previous>
<i class="ion-md-arrow-back" aria-hidden="true"></i>
</DButton>
<span class="text-xs tabular-nums" data-announcement-position>@Localizer["{0} of {1}", index + 1, ShownCount]</span>
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Disabled="index >= ShownCount - 1" OnClick="() => index++" aria-label="@Localizer["Next announcement"]"
title="@Localizer["Next announcement"]" data-announcement-next>
<i class="ion-md-arrow-forward" aria-hidden="true"></i>
</DButton>
</span>
}
<span class="flex-1"></span>
<DButton Size="DSize.Xs" Variant="DButtonVariant.Ghost" Shape="DButtonShape.Circle" Disabled="IsDefaultDisabled || isActing" OnClick="Dismiss"
aria-label="@Localizer["Dismiss"]" title="@Localizer["Dismiss"]" data-announcement-dismiss>
<i class="ion-md-close" aria-hidden="true"></i>
</DButton>
</div>
@if (current.StartsAt is not null || current.EndsAt is not null)
{
<p class="text-xs opacity-70" data-announcement-when>@When(current)</p>
}
<div class="post-content doc-content text-sm break-words max-h-28 md:max-h-40 overflow-y-auto">@((MarkupString)current.Content)</div>
<div class="flex flex-wrap items-center gap-1 pointer-coarse:gap-2" data-announcement-reactions>
@foreach (var reaction in current.Reactions)
{
<DButton Size="DSize.Xs" Active="reaction.Me == true" Disabled="IsDefaultDisabled || isActing" OnClick="() => React(reaction.Name, reaction.Me != true)"
title="@reaction.Name" aria-pressed="@(reaction.Me == true ? "true" : "false")" data-announcement-reaction="@reaction.Name">
@if (reaction.Url is { Length: > 0 } image)
{
<img alt="@reaction.Name" class="h-4 w-4 object-contain" src="@image" />
}
else
{
<span>@reaction.Name</span>
}
<span class="tabular-nums">@reaction.Count</span>
</DButton>
}
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Bottom" CloseOnContentClick="true" SheetOnNarrow="true"
TriggerClass="@(isReactionsOpen ? "btn btn-xs btn-active" : "btn btn-xs")"
ContentClass="mt-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]" data-announcement-react>
<Trigger><i aria-hidden="true" class="ion-md-happy"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)" data-announcement-react-emoji="@emoji">@emoji</DButton>
}
</ChildContent>
</DDropdown>
</div>
</section>
}
@code {
static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"];
[Inject] AnnouncementService AnnouncementService { get; set; }
[Inject] StreamingService Streaming { get; set; }
List<Announcement> announcements = [];
int index;
bool isActing;
bool isReactionsOpen;
string loadedPersonaId;
// what the persona has not dismissed, oldest first as the server lists them
List<Announcement> Shown => [.. announcements.Where(a => a.Read != true)];
int ShownCount => announcements.Count(a => a.Read != true);
// the one in view, or none
Announcement Current => Shown is [_, ..] shown ? shown[Math.Clamp(index, 0, shown.Count - 1)] : null;
protected override void OnInitialized() => Streaming.Announced += OnAnnounced;
// another persona reads its own: its dismissals and reactions
protected override async Task OnParametersSetAsync()
{
if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId)
return;
loadedPersonaId = CascadingState.PersonaId;
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
announcements = await AnnouncementService.List();
index = 0;
await Streaming.HearAnnouncements(true);
}
string When(Announcement announcement)
{
var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt;
var starts = announcement.StartsAt?.ToLocalTime().ToString(format);
var ends = announcement.EndsAt?.ToLocalTime().ToString(format);
return (starts, ends) switch
{
({ } from, { } to) => Localizer["From {0} to {1}", from, to],
({ } from, null) => Localizer["From {0}", from],
_ => Localizer["until {0}", ends]
};
}
async Task Dismiss()
{
if (Current is not { } announcement)
return;
isActing = true;
var result = await AnnouncementService.Dismiss(announcement.Id);
isActing = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
announcement.Read = true;
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
}
// the counts are read again afterwards: the stream may have told of this very reaction already
async Task React(string emoji, bool add)
{
if (Current is not { } announcement)
return;
isActing = true;
var result = await AnnouncementService.React(announcement.Id, emoji, add);
if (result.IsInvalid)
{
isActing = false;
Toast.AddResponseError(result);
return;
}
var position = index;
announcements = await AnnouncementService.List();
index = Math.Clamp(position, 0, Math.Max(0, ShownCount - 1));
isActing = false;
}
Task OnAnnounced(AnnouncementMessage message) => InvokeAsync(() =>
{
switch (message.Event)
{
case "announcement" when message.Announcement is { } changed:
var at = announcements.FindIndex(a => a.Id == changed.Id);
if (at < 0)
announcements.Add(changed);
else
announcements[at] = changed;
break;
case "announcement.delete" when message.DeletedId is { } id:
announcements.RemoveAll(a => a.Id == id);
break;
case "announcement.reaction" when message.Reaction is { } reaction && announcements.Find(a => a.Id == reaction.AnnouncementId) is { } reacted:
var known = reacted.Reactions.Find(r => r.Name == reaction.Name);
reacted.Reactions = reaction.Count <= 0
? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)]
: known is null
? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }]
: [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)];
break;
default:
return;
}
index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1));
StateHasChanged();
});
public override async ValueTask DisposeAsync()
{
Streaming.Announced -= OnAnnounced;
await Streaming.HearAnnouncements(false);
await base.DisposeAsync();
}
}