@inherits LocalizableComponentBase
@implements IHandleEvent
@* One feed item: a post (a boost shows the original and its booster), a notification, or a conversation's last post.
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no actions: PrivaPub keeps them off the Mastodon API. Nerd stats are on
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
@if (!IsFilteredOut)
{
@if (Author is { } author)
{
@if (Post.GroupAccounts.Count > 1)
{
@foreach (var account in Post.GroupAccounts.Take(4))
{
@* alt="": the names are in the text next to them. *@
}
}
@NotificationText(notification)@notification.CreatedAt.GetPassedTime(Localizer)
}
@if (IsFoldedArticle(status))
{
@* an article (WordPress, Ghost, WriteFreely, Lemmy's titled posts) shows its cover and excerpt in a feed, and
its whole text on demand: a feed of long reads stays a feed *@
}
else if (status.SpoilerText is { Length: > 0 } spoiler)
{
@CustomEmojis.Text(spoiler, status.Emojis)
@Body(status)
}
else
{
@Body(status)
}
@* the reactions stand outside the content warning: they are not what it hides *@
@if (status.EmojiReactions.Count > 0)
{
@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]
}
@if (status.Privapub?.Approval is "pending" or "rejected")
{
@* its target's author must approve it (GoToSocial's interaction policies): until then it went to that author alone *@
@(status.Privapub.Approval == "pending" ? Localizer["Waiting for its author's approval"] : Localizer["Its author did not approve it"])
}
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] TimelineService TimelineService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ProvenanceService ProvenanceService { get; set; }
[Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback OnDeleted { get; set; }
[Parameter] public EventCallback OnReplied { get; set; }
[Parameter] public EventCallback OnTag { get; set; }
[Parameter] public string CssContainer { get; set; }
/// Deeper shadow, for the post a thread page is about.
[Parameter] public bool Highlighted { get; set; }
[Parameter] public bool IncludeExpand { get; set; } = true;
/// Without actions, e.g. a quoted post.
[Parameter] public bool Compact { get; set; }
/// Where the card is shown, for the persona's filters (Status.Filtered): a hide filter of that context leaves
/// it out, a warn filter folds it, a blur filter blurs its media. No filter applies without it.
[Parameter] public FilterContext? FilterIn { get; set; }
bool isReplying;
bool isQuoting;
bool isEditing;
bool isActing;
bool isOptionsOpen;
bool isSpoilerOpen;
bool showFiltered;
bool isArticleOpen;
bool isReadingProvenance;
bool isReadingServer;
readonly HashSet revealedMedia = [];
Status Status => Post.Status;
Account Author => Post.Status?.Account ?? Post.Notification?.Account;
ServerInfo Server => InstanceService.Known(Post.Host);
InstanceLocation ServerGeo => Server?.Geo;
bool IsOwn => Post.IsOwnedBy(CascadingState.PersonaId);
List Matched => FilterIn is { } where && Status is { } status
? [.. status.Filtered.Where(r => r.Filter is { IsActive: true } filter && filter.Context.Contains(where))]
: [];
bool IsFilteredOut => Matched.Any(r => r.Filter.FilterAction == FilterAction.Hide);
List Warnings => [.. Matched.Where(r => r.Filter.FilterAction == FilterAction.Warn)];
bool IsBlurred => !showFiltered && Matched.Any(r => r.Filter.FilterAction == FilterAction.Blur);
protected override void OnInitialized() => IsLoading = false;
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
// (tests/e2e/Perf/FeedPerfTests).
object renderedWith;
int handlingEvents;
object RenderKey() => (Post, Post?.Status, Post?.Notification, Post?.Conversation, Post?.Location, Post?.GroupCount, Post?.GroupAccounts,
(FilterIn, Highlighted, Compact, IncludeExpand, CssContainer), (CascadingState?.PersonaId, CascadingState?.IsOnline));
protected override bool ShouldRender()
{
var key = RenderKey();
if (handlingEvents == 0 && Equals(key, renderedWith))
return false;
renderedWith = key;
return true;
}
// as ComponentBase handles an event, rendering after it and again when its task ends, with the card's own renders allowed
async Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object arg)
{
handlingEvents++;
try
{
var task = callback.InvokeAsync(arg);
var pending = task.Status is not (TaskStatus.RanToCompletion or TaskStatus.Canceled);
StateHasChanged();
if (!pending)
return;
await task;
StateHasChanged();
}
finally
{
handlingEvents--;
}
}
RenderFragment Body(Status status) => @
@EventTime(happening)
@foreach (var venue in happening.Places)
{
@(venue.Name ?? venue.Address ?? venue.Url)
}
@if (IsWebLink(happening.OnlineUrl))
{
@Localizer["Online"]
}
@if (happening.Participants is { } going)
{
@(happening.Capacity is { } capacity ? Localizer["{0} of {1} going", going, capacity] : Localizer["{0} going", going])
}
@switch (happening.Status?.ToUpperInvariant())
{
case "CANCELLED":
@Localizer["Cancelled"]
break;
case "TENTATIVE":
@Localizer["Tentative"]
break;
}
@if (CanJoin(happening))
{
@switch (happening.Participation)
{
case "accepted":
@Localizer["You are going"]
break;
case "pending":
@Localizer["Waiting for the organiser"]
break;
case "rejected":
@Localizer["The organiser said no"]
break;
}
@if (happening.Participation is "accepted" or "pending")
{
@Localizer["Leave"]
}
else
{
@Localizer["Join"]
}