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
208 lines
8.0 KiB
Plaintext
208 lines
8.0 KiB
Plaintext
@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();
|
|
}
|
|
}
|