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