@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.
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; "Filter this
post" puts it in one of them. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their
author deletes them through /clientapi. A threadiverse post takes a downvote; the persona's own public post is given who
may quote it, and a post quoting it can have its quote revoked; a notification is dismissed from its line, and a
warning from the server's staff (moderation_warning) shows what they did and what they wrote. A moderator removes
another's post from its menu, with a note and a warning, asked first. The persona's reading preferences
(CascadingState.Preferences) say whether a content warning starts open and which media wait behind a button: those
marked sensitive, all, or none. A `status` notification (an account followed with notify posted) shows the post. 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 (Status is { } status)
{
@if (Author is { } phoneAuthor)
{
@* alt="": the author's name is next to it. *@
}
@CustomEmojis.Text(status.Account?.Name, status.Account?.Emojis ?? [])@("@" + status.Account?.Acct)
@if (Post.Location is { Source: LocationSource.Cdn } fronted)
{
@fronted.Label
}
else if (Post.Location is { } location)
{
@(location.Label ?? Localizer["Near you"])
}
@if (status.EditedAt is not null)
{
}
@if (status.Privapub?.Wall is { } wall)
{
}
@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)]
@* its author takes it back here: it has no other action *@
@if (IsOwn && !Compact)
{
@Localizer["Delete"]
}
}
@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"])
}
@if (!Post.Located && !Compact)
{
@* on a phone the actions are five equal cells (Reply, Boost, Favourite, React, More); Bookmark and the thread are in
More, and the downvotes, the counts and Expand show from md *@
@* under the post on a desktop, as a sheet over the page on a phone, where the card's column is too narrow *@
@* a phone's way to the nerd stats the card's corners hold on hover, all in one sheet *@
@if (isDetailsOpen)
{
@Localizer["Details"]
@if (Post.Location is { Source: LocationSource.Cdn } detailsCdn)
{
}
else if (Post.Location is not null)
{
}
}
@code {
[Inject] StatusService StatusService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] TimelineService TimelineService { get; set; }
[Inject] InstanceService InstanceService { get; set; }
[Inject] ProvenanceService ProvenanceService { get; set; }
[Inject] ModerationService ModerationService { get; set; }
[Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback OnDeleted { get; set; }
/// Its notification was dismissed: the page takes the item out.
[Parameter] public EventCallback OnDismissed { 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 isDetailsOpen;
MediaAttachment viewingPicture;
bool isQuoting;
bool isEditing;
bool isActing;
bool isOptionsOpen;
bool isSpoilerOpen;
bool showFiltered;
bool isArticleOpen;
bool isReadingProvenance;
bool isReadingServer;
bool isHistoryOpen;
bool isReporting;
bool isFilteringPost;
bool isChoosingQuotePolicy;
bool isModeratingPost;
// the staff removed the post: the page takes it out once the dialog closes
bool removedByModerator;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
List history = [];
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 content warning starts open when the persona reads them so (reading:expand:spoilers)
isSpoilerOpen = CascadingState?.Preferences.ExpandSpoilers == true;
}
/// Whether a picture, video or sound waits behind a button: a blur filter's always; else as the persona's
/// reading preference says, those marked sensitive (default), none or all.
bool HidesMedia(Status status) => IsBlurred || (CascadingState?.ExpandMedia ?? ExpandMedia.Default) switch
{
ExpandMedia.ShowAll => false,
ExpandMedia.HideAll => true,
_ => status.Sensitive
};
string HiddenMediaText(Status status) => IsBlurred ? Localizer["Filtered media, show"]
: status.Sensitive ? Localizer["Sensitive media, show"]
: Localizer["Hidden media, show"];
// 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
// through.
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, CascadingState?.Preferences));
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) => @
@foreach (var media in status.MediaAttachments)
{
@if (HidesMedia(status) && !revealedMedia.Contains(media.Id))
{
@* its blurhash shows behind it: the colours, nothing of what it shows *@
}
else
{
@Media(media, status.MediaAttachments.Count > 1)
}
}
@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"]
}
;
// media that didn't load (gone from their server, or refused): said so, with the way to their original
HashSet unavailableMedia = [];
RenderFragment Media(MediaAttachment media, bool several) => unavailableMedia.Contains(media.Id) ? Unavailable(media) : media.Type switch
{
"image" => @,
// under reduced motion a gifv waits for a tap (its controls)
"gifv" => @,
"video" => @,
"audio" => @,
_ => @@(media.Description ?? media.Url)
};
RenderFragment Unavailable(MediaAttachment media) =>
@