Files
decePubClient/Components/AnnouncementsBanner.razor
T

207 lines
8.0 KiB
Plaintext
Raw Normal View History

@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();
}
}