@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 (Post.Notification is { } notification) {

@if (Post.GroupAccounts.Count > 1) { } @NotificationText(notification) @if (notification.Type == "pleroma:emoji_reaction" && notification.EmojiUrl is { Length: > 0 } reactionPicture) { @($ } @notification.CreatedAt.GetPassedTime(Localizer) @if (!Compact) { }

@if (notification.ModerationWarning is { } warning) {
@WarningActionText(warning.Action) @if (warning.Text is { Length: > 0 } warningText) {

@warningText

} @if (warning.StatusIds.Count > 0) {

@Localizer["It is about {0} of your posts.", warning.StatusIds.Count]

}
} } else if (Post.BoostedBy is { } booster) {

@CustomEmojis.Text(booster.Name, booster.Emojis) @Localizer["boosted"]

} else if (Post.Conversation is { } conversation) {

@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct)) @if (conversation.Unread) { @Localizer["unread"] }

} @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) {

@Localizer["On the wall of {0}", wall.Name]

} @if (Warnings.Count > 0 && !showFiltered) {
@Localizer["Filtered: {0}", string.Join(", ", Warnings.Select(w => w.Filter.Title))] @Localizer["Show anyway"]
} else { @if (status.Privapub?.Title is { Length: > 0 } title) {

@title

} @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 *@
@if (status.Privapub.Cover is { Length: > 0 } cover) { } @if (ArticleExcerpt(status) is { Length: > 0 } excerpt) {

@excerpt

} @Localizer["Read article"]
} 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) {
@foreach (var reaction in status.EmojiReactions) { @if (reaction.Url is { Length: > 0 } image) { @ReactionOf(reaction) } else { @reaction.Name } @reaction.Count }
} @if (Post.Located) {

@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 *@
@if (status.Privapub?.Locked == true) { @* its community's moderators locked the thread: no reply is taken *@ @if (status.RepliesCount > 0) { @status.RepliesCount.Compact() } } else { @if (status.RepliesCount > 0) { @status.RepliesCount.Compact() } } @if (status.ReblogsCount > 0) { @status.ReblogsCount.Compact() } @if (status.FavouritesCount > 0) { @status.FavouritesCount.Compact() } @* a threadiverse post's downvotes: its upvotes are its favourites, and the persona's downvote replaces its favourite (on a phone, in More) *@ @if (CanDownvote(status) && status.Privapub.Votes is { } votes) { } else if (status.Privapub?.Votes is { Down: > 0 } ownVotes) { } @* the quick reactions, then the server's custom emoji and the rest; opens downwards, like the menu *@
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@ @if (IncludeExpand) { @Localizer["Thread"] } @Localizer["Bookmark"] @if (CanDownvote(status) && status.Privapub.Votes is { } phoneVotes) { @(phoneVotes.Downvoted == true ? Localizer["Remove the downvote"] : Localizer["Downvote"]) @if (phoneVotes.Down > 0) { @phoneVotes.Down } } @Localizer["Details"] @if (status.Url is { Length: > 0 } url) { @Localizer["Open original"] } @if (status.FavouritesCount > 0) { @Localizer["Liked by"] } @if (status.ReblogsCount > 0) { @Localizer["Boosted by"] } @if (CanQuote(status)) { @Localizer["Quote"] } @if (IsOnOwnWall(status)) { @Localizer["Take off my wall"] } @if (!IsOwn) { @if (QuotesOwnPost(status)) { @Localizer["Revoke quote"] } @Localizer["Filter this post"] @Localizer["Report"] @Localizer["Mute"] @Localizer["Block"] @if (CascadingState.IsModerator && !Post.Located) { @Localizer["Remove post (moderation)"] } } else { @if (CanPin(status)) { @(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"]) @Localizer["Who may quote it"] } @Localizer["Edit"] @Localizer["Delete"] } @if (IncludeExpand) { }
@* 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) { }
} @if (isReporting && status.Account is { } reported) { } @if (isModeratingPost) {
} @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) => @
@if (status.Content is { Length: > 0 } content) {
@((MarkupString)CustomEmojis.Html(content, status.Emojis))
} @if (status.MediaAttachments.Count > 0) {
@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) } }
} @if (viewingPicture is { } picture) { } @if (status.Card is { } card && status.MediaAttachments.Count == 0) { @if (card.Image is { Length: > 0 } image) { @card.ImageDescription } @card.Title @card.Description @(card.ProviderName ?? CardHost(card.Url)) } @if (status.Poll is { } poll) {
@if (CanVote(poll)) { @for (var i = 0; i < poll.Options.Count; i++) { var index = i; } @Localizer["Vote"] } else { @for (var i = 0; i < poll.Options.Count; i++) { var option = poll.Options[i]; var share = poll.VotesCount > 0 && option.VotesCount is { } votes ? votes * 100d / poll.VotesCount : 0;
@option.Title @if (poll.OwnVotes.Contains(i)) { } @share.ToString("0")%
} } @Localizer["{0} votes", poll.VotesCount] · @(poll.Expired ? Localizer["closed"] : Localizer["open"])
} @if (status.Privapub?.Event is { } happening) {
@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"] }
}
} @if (status.Quote?.QuotedStatus is { } quoted) { } @if (status.Tags.Count > 0) {
@foreach (var tag in status.Tags) { }
}
; // 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) => @
@Localizer["Media unavailable"] @if ((media.RemoteUrl ?? media.Url) is { Length: > 0 } original) { @Localizer["Open original"] }
; async Task Act(StatusAction action) { isActing = true; var result = await StatusService.Act(Status.Id, action); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } // Unreblog answers with the original; reblog with the boost wrapper. var updated = (Status)result.Data; Post.Status = updated.Reblog ?? updated; } // a threadiverse post (Lemmy, PieFed, Mbin, NodeBB…) takes downvotes. PrivaPub sends votes on every post, so the card // offers one where they mean something: the author's server is a threadiverse one, the post is a community's Page, or it // has downvotes already. Not on the persona's own post. bool CanDownvote(Status status) => !Compact && !Post.Located && !IsOwn && status.Privapub?.Votes is { } votes && (votes.Down > 0 || votes.Downvoted == true || status.Privapub.ObjectType == "Page" || IsThreadiverse(Server?.Software)); static bool IsThreadiverse(string software) => software?.ToLowerInvariant() is "lemmy" or "piefed" or "mbin" or "kbin" or "sublinks" or "lotide" or "nodebb"; // a vote is public, like a reaction: the persona is told so once, before its first async Task Downvote(Status status) { var on = status.Privapub?.Votes?.Downvoted != true; if (on && !await Notices.Confirm(CascadingState.PersonaId)) return; await Act(on ? StatusAction.Downvote : StatusAction.Undownvote); } // the notification goes, its whole group with it (/api/v2/notifications/:group_key/dismiss) async Task DismissNotification() { var notification = Post.Notification; isActing = true; var result = await TimelineService.DismissNotification(notification.GroupKey ?? $"ungrouped-{notification.Id}"); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } await OnDismissed.InvokeAsync(Post); } // in a filter now: the post is read again, so the filter applies here at once async Task Filtered() { isFilteringPost = false; if (await StatusService.Get(Status.Id) is { } filtered) Post.Status = filtered.Reblog ?? filtered; } void QuotePolicyChanged(Status updated) { isChoosingQuotePolicy = false; Post.Status = updated.Reblog ?? updated; } // someone else's post quoting the persona's own: the persona may take its permission back bool QuotesOwnPost(Status status) => !IsOwn && CascadingState.PersonaId is { } personaId && status.Quote?.QuotedStatus?.Account?.Id == personaId; async Task RevokeQuote() { if (!await Confirm.Ask(Localizer["Revoke this quote of your post?"], Localizer["Your post no longer shows in it."], Localizer["Revoke quote"], true)) return; isActing = true; var result = await StatusService.RevokeQuote(Status.Quote.QuotedStatus.Id, Status.Id); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } var updated = (Status)result.Data; Post.Status = updated.Reblog ?? updated; Toast.AddResponseSuccess(Localizer["Quote revoked."]); } [Inject] AccountService AccountService { get; set; } [Inject] PublicNoticeService Notices { get; set; } bool? requestAnswer; async Task AnswerRequest(Account follower, bool accept) { isActing = true; var result = await AccountService.Answer(follower.Id, accept); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } requestAnswer = accept; } static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"]; // what PrivaPub takes for a reaction already on the post: a custom emoji's (it has a picture) as `:shortcode:`, so that // another server's is found among the post's; a unicode one as it is static string ReactionOf(EmojiReaction reaction) => reaction.Url is { Length: > 0 } ? $":{reaction.Name}:" : reaction.Name; async Task React(string emoji, bool add) { if (add && !await Notices.Confirm(CascadingState.PersonaId)) return; isActing = true; var result = await StatusService.React(Status.Id, emoji, add); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } var updated = (Status)result.Data; Status.EmojiReactions = (updated?.Reblog ?? updated)?.EmojiReactions ?? Status.EmojiReactions; } // a remote event the persona can take part in from here: not one by invitation, nor one that takes its participants // on another site bool CanJoin(PrivaPubEvent happening) => !Compact && !IsOwn && !Post.Located && happening.JoinMode is not ("invite" or "external"); async Task Participate(bool join) { if (join && !await Notices.Confirm(CascadingState.PersonaId, PublicAct.Participation)) return; isActing = true; var result = await StatusService.Participate(Status.Id, join); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } var updated = (Status)result.Data; if (Status.Privapub?.Event is { } happening) happening.Participation = (updated?.Reblog ?? updated)?.Privapub?.Event?.Participation; } HashSet pollChoices = []; // folded until asked for, in a feed: an article with its title (an Article or a Page, or a long titled Note, as // WriteFreely sends its posts), not the post opened in its thread, nor one behind a content warning (the warning // folds it already) bool IsFoldedArticle(Status status) => !isArticleOpen && !Highlighted && status.SpoilerText is not { Length: > 0 } && status.Privapub is { Title.Length: > 0 } extra && (extra.ObjectType is "Article" or "Page" || (status.Content?.Length ?? 0) > LongRead); // how much markup a titled post holds before a feed folds it const int LongRead = 600; // the article's own summary, or the start of its text static string ArticleExcerpt(Status status) { if (status.Privapub?.Excerpt is { Length: > 0 } excerpt) return excerpt; var text = System.Text.RegularExpressions.Regex.Replace(status.Content ?? "", "<[^>]+>", " "); text = System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ")).Trim(); return text.Length <= 280 ? text : text[..280].TrimEnd() + "…"; } // a profile shows the persona's own public and unlisted posts it pins, as Mastodon allows bool CanPin(Status status) => !Compact && !Post.Located && status.Reblog is null && status.Visibility is ("public" or "unlisted"); bool CanVote(Poll poll) => !poll.Expired && poll.Voted != true && !IsOwn && !Compact && !Post.Located; void ToggleChoice(Poll poll, int index) { if (!poll.Multiple) pollChoices.Clear(); if (!pollChoices.Remove(index)) pollChoices.Add(index); } async Task Vote(Poll poll) { if (!await Notices.Confirm(CascadingState.PersonaId)) return; isActing = true; var result = await StatusService.Vote(poll.Id, pollChoices); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } Status.Poll = (Poll)result.Data ?? poll; pollChoices.Clear(); Toast.AddResponseSuccess(Localizer["Voted."]); } // a conversation opened is read async Task OpenConversation() { if (Post.Conversation is not { Unread: true } conversation) return; var result = await TimelineService.ReadConversation(conversation.Id); if (!result.IsInvalid) conversation.Unread = false; } async Task ReadConversation() { var result = await TimelineService.ReadConversation(Post.Conversation.Id); if (result.IsInvalid) { Toast.AddResponseError(result); return; } Post.Conversation.Unread = false; } // off the persona's list until a newer message; the messages stay async Task RemoveConversation() { var result = await TimelineService.RemoveConversation(Post.Conversation.Id); if (result.IsInvalid) { Toast.AddResponseError(result); return; } await OnDeleted.InvokeAsync(Post); } async Task Delete() { // a located post never left PrivaPub: no server is asked if (!await Confirm.Ask(Localizer["Delete this post?"], Post.Located ? null : Localizer["It goes from here, and the servers that have it are asked to delete it too."], Localizer["Delete"], true)) return; isActing = true; var result = Post.Located ? await StatusService.DeleteLocated(Status.Id, CascadingState.PersonaId) : await StatusService.Delete(Status.Id); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } Toast.AddResponseSuccess(Localizer["Message deleted."]); await OnDeleted.InvokeAsync(Post); } // a moderator takes another's post down (/clientapi/moderator/posts/remove): deleted when it was written here, hidden // here when it is a copy from elsewhere async Task RemoveAsModerator(ActDialog.Answer answer) { var result = await ModerationService.RemovePost(new RemovePostForm { PostId = Status.Id, Note = answer.Note, Warning = answer.Warning }); if (result.IsInvalid) return result; removedByModerator = true; Toast.AddResponseSuccess(Localizer["Post removed."]); return result; } async Task ModerationClosed() { isModeratingPost = false; if (removedByModerator) await OnDeleted.InvokeAsync(Post); } // someone's post on the persona's wall: its owner deletes it there (PrivaPub tells the wall's readers, and Smithereen // deletes the post) bool IsOnOwnWall(Status status) => !IsOwn && status.Privapub?.Wall?.Id is { } owner && owner == CascadingState.PersonaId; async Task TakeOffWall() { if (!await Confirm.Ask(Localizer["Take this post off your wall?"], null, Localizer["Take off my wall"], true)) return; isActing = true; var result = await StatusService.Delete(Status.Id); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } Toast.AddResponseSuccess(Localizer["Taken off your wall."]); await OnDeleted.InvokeAsync(Post); } async Task AccountAction(string action) { var name = Status.Account?.Name ?? Status.Account?.Acct; if (!await Confirm.Ask(action == "block" ? Localizer["Block {0}?", name] : Localizer["Mute {0}?", name], null, Localizer[action == "block" ? "Block" : "Mute"], action == "block")) return; isActing = true; var result = await StatusService.AccountAction(Status.Account.Id, action); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } Toast.AddResponseSuccess(action == "block" ? Localizer["{0} blocked.", Status.Account.Name] : Localizer["{0} muted.", Status.Account.Name]); } void ToggleReply() => isReplying = !isReplying; void ToggleQuote() => isQuoting = !isQuoting; void ToggleEdit() => isEditing = !isEditing; // the server says whether the persona may quote it; without that, a public or unlisted post may be asked static bool CanQuote(Status status) => status.QuoteApproval?.CurrentUser is { } answer ? answer is "automatic" or "manual" : status.Visibility is "public" or "unlisted"; async Task Quoted(FeedPost quote) { isQuoting = false; await OnReplied.InvokeAsync(quote); } async Task OpenHistory(Status status) { isHistoryOpen = true; history = await StatusService.History(status.Id); } void HistoryClosed(bool open) => isHistoryOpen = open; void Edited(FeedPost edited) { isEditing = false; if (edited?.Status is { } status) Post.Status = status; } async Task Replied(FeedPost reply) { isReplying = false; Status.RepliesCount++; await OnReplied.InvokeAsync(reply); } static List Pictures(Status status) => [.. status.MediaAttachments.Where(m => m.Type == "image")]; void OpenPicture(MediaAttachment media) => viewingPicture = media; async Task OpenDetails() { isDetailsOpen = true; await Task.WhenAll(ReadProvenance(), ReadServer()); } async Task ReadProvenance() { if (Status is null || Post.Located) return; isReadingProvenance = true; await ProvenanceService.Get(Status.Id); isReadingProvenance = false; } // The author's server through time, and its CDN's page when it has one: read on the first hover. async Task ReadServer() { if (Post.Host is null) return; isReadingServer = true; await InstanceService.History(Post.Host); if (Post.Location?.CdnKey is { } cdn) await InstanceService.Cdn(cdn); isReadingServer = false; } static bool CanBoost(Status status) => status.Visibility is "public" or "unlisted"; static string Coordinate(double? value) => value?.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); static string Number(double? value) => value?.ToString("0.#", System.Globalization.CultureInfo.CurrentCulture) ?? "?"; // where a cropped picture keeps its subject: Mastodon's focus runs from -1 to 1, left to right and bottom to top static string FocalPoint(MediaAttachment media) => media.Meta?.Focus is { } focus ? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"object-position: {(focus.X + 1) * 50:0.#}% {(1 - focus.Y) * 50:0.#}%") : null; static bool IsWebLink(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"; static string CardHost(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : url; // PrivaPub (like Mastodon) answers a placeholder for a missing header: /media/missing-header.png. static string HeaderBackground(Account account) => account.Header is { Length: > 0 } header && !header.Contains("/missing-") ? $"background-image:linear-gradient(to left, var(--color-base-100), transparent), url('{header}');" : null; string EventTime(PrivaPubEvent happening) => happening.Start is { } start ? happening.End is { } end ? $"{start.ToLocalTime():g} – {end.ToLocalTime():t}" : start.ToLocalTime().ToString("g") : Localizer["Event"]; string VisibilityText(string visibility) => visibility switch { "public" => Localizer[Visibility.Public.ToString()], "unlisted" => Localizer[Visibility.Unlisted.ToString()], "private" => Localizer[Visibility.FollowersOnly.ToString()], "direct" => Localizer[Visibility.Direct.ToString()], _ => Localizer["Located"] }; static string VisibilityIcon(string visibility) => visibility switch { "public" => "ion-md-globe", "unlisted" => "ion-md-unlock", "private" => "ion-md-lock", "direct" => "ion-md-paper-plane", _ => "ion-md-locate" }; static string LocationIcon(LocationSource source) => source switch { LocationSource.Place or LocationSource.Event => "ion-md-pin", LocationSource.NearYou => "ion-md-locate", LocationSource.Country => "ion-md-flag", _ => "ion-md-planet" }; static string NotificationIcon(string type) => type switch { "favourite" => "ion-md-heart text-pink-400", "reblog" => "ion-md-repeat text-info", "mention" => "ion-md-at text-success", "follow" or "follow_request" => "ion-md-person-add text-primary", "poll" => "ion-md-podium", "update" or "quoted_update" => "ion-md-create", "quote" => "ion-md-quote", "moderation_warning" => "ion-md-warning text-warning", "status" => "ion-md-notifications text-primary", _ => "ion-md-notifications" }; // what a warning from the server's staff says they did string WarningActionText(string action) => action switch { "silence" => Localizer["This persona is silenced: its posts stay out of public timelines, hashtags, trends and search."], "suspend" => Localizer["This persona was suspended."], "delete_statuses" => Localizer["A post of yours was removed."], "mark_statuses_as_sensitive" or "sensitive" => Localizer["Your media were marked as sensitive."], "disable" => Localizer["This login was disabled."], _ => Localizer["A warning from this server's staff."] }; string NotificationText(Notification notification) { var name = notification.Account?.Name; var others = Post.GroupCount - 1; // two are both named; more are the newest and a count var second = others == 1 && Post.GroupAccounts.Count > 1 ? Post.GroupAccounts[1].Name : null; return notification.Type switch { "favourite" when second is not null => Localizer["{0} and {1} favourited your post", name, second], "reblog" when second is not null => Localizer["{0} and {1} boosted your post", name, second], "follow" when second is not null => Localizer["{0} and {1} followed you", name, second], "favourite" when others > 0 => Localizer["{0} and {1} others favourited your post", name, others], "reblog" when others > 0 => Localizer["{0} and {1} others boosted your post", name, others], "follow" when others > 0 => Localizer["{0} and {1} others followed you", name, others], "favourite" => Localizer["{0} favourited your post", name], "reblog" => Localizer["{0} boosted your post", name], "mention" => Localizer["{0} mentioned you", name], "follow" => Localizer["{0} followed you", name], "follow_request" => Localizer["{0} asked to follow you", name], "status" => Localizer["{0} posted", name], "poll" => Localizer["A poll you took part in has ended"], "update" => Localizer["{0} edited a post", name], "quote" => Localizer["{0} quoted your post", name], "pleroma:emoji_reaction" when notification.EmojiUrl is { Length: > 0 } => Localizer["{0} reacted with", name], "pleroma:emoji_reaction" => Localizer["{0} reacted {1}", name, notification.Emoji], "moderation_warning" => Localizer["This server's staff warned you"], _ => Localizer["{0}: {1}", name, notification.Type] }; } #region Nerd stats IEnumerable PostRows() { var status = Status; yield return NerdRow.Of("id", status.Id); yield return NerdRow.Of("created_at", status.CreatedAt); yield return NerdRow.Of("edited_at", status.EditedAt); yield return NerdRow.Of("uri", status.Uri); yield return NerdRow.Of("language", status.Language); yield return NerdRow.Of("visibility", status.Visibility); yield return NerdRow.Of("type", status.Privapub?.ObjectType); yield return NerdRow.Of("source", status.Privapub?.Source?.MediaType); yield return NerdRow.Of("client", status.Application?.Name); if (ProvenanceService.Known(status.Id) is not { } provenance) yield break; yield return NerdRow.Of("path", provenance.Path); yield return NerdRow.Of("activity", provenance.Activity is { } activity ? $"{activity.Type} {activity.Id}" : null); yield return NerdRow.Of("inbox", provenance.Inbox); yield return NerdRow.Of("fetched_by", provenance.FetchedBy); yield return NerdRow.Of("published", provenance.Published); yield return NerdRow.Of("received_at", provenance.ReceivedAt); if (provenance.ReceivedAt is { } received && provenance.Published is { } published) yield return new(Localizer["delivery latency"], NerdRow.Duration(received - published)); yield return NerdRow.Of("signature", provenance.Signature is { } signature ? $"{signature.Scheme} {signature.Algorithm} ({string.Join(' ', signature.Headers)})" : null); yield return NerdRow.Of("key_id", provenance.Signature?.KeyId); yield return NerdRow.Of("raw", provenance.RawBytes > 0 ? NerdRow.Bytes(provenance.RawBytes) + (provenance.RawTruncated ? " (truncated)" : null) : null); yield return NerdRow.Of("sha256", provenance.RawHash is { Length: > 16 } hash ? hash[..16] + "…" : provenance.RawHash); yield return NerdRow.Of("revisions", provenance.Revisions.Count > 0 ? provenance.Revisions.Count : null); yield return NerdRow.Of("refetched", provenance.Refetched ? true : null); yield return NerdRow.Of("extensions", provenance.Extensions.Count > 0 ? string.Join(", ", provenance.Extensions) : null); yield return NerdRow.Of("@context", provenance.ContextNamespaces.Count > 0 ? string.Join(", ", provenance.ContextNamespaces) : null); } IEnumerable AuthorRows() { var account = Status?.Account; if (account is null) yield break; yield return NerdRow.Of("acct", account.Acct); yield return NerdRow.Of("uri", account.Uri); yield return NerdRow.Of("created_at", account.CreatedAt); yield return NerdRow.Of(Localizer["followers"], account.FollowersCount); yield return NerdRow.Of(Localizer["following"], account.FollowingCount); yield return NerdRow.Of(Localizer["posts"], account.StatusesCount); yield return NerdRow.Of("bot", account.Bot ? true : null); yield return NerdRow.Of("locked", account.Locked ? true : null); if (Server is not { } server) yield break; yield return NerdRow.Of(Localizer["server"], server.NodeName is { Length: > 0 } name && name != server.Host ? $"{server.Host} ({name})" : server.Host); yield return NerdRow.Of(Localizer["software"], $"{server.Software} {server.Version}".Trim()); yield return NerdRow.Of(Localizer["Users"], server.Users is { } users ? server.ActiveMonth is { } active ? $"{users:N0} ({active:N0} {Localizer["active this month"]})" : users.ToString("N0") : null); yield return NerdRow.Of(Localizer["local posts"], server.LocalPosts); yield return NerdRow.Of(Localizer["registrations"], server.OpenRegistrations is { } open ? open ? Localizer["open"] : Localizer["closed"] : null); yield return NerdRow.Of(Localizer["last delivery"], server.LastSuccessAt); yield return NerdRow.Of(Localizer["failures in a row"], server.ConsecutiveFailures > 0 ? server.ConsecutiveFailures : null); yield return NerdRow.Of(Localizer["described"], server.DescribedAt); foreach (var row in HistoryRows()) yield return row; } // The server's weeks, newest first: how many are known, and every change of place or CDN. IEnumerable HistoryRows() { var history = InstanceService.KnownHistory(Post.Host); if (history is not { Count: > 0 }) yield break; yield return NerdRow.Of(Localizer["weeks known"], $"{history.Count} ({history[^1].Week} – {history[0].Week})"); var changes = 0; for (var i = history.Count - 1; i >= 0 && changes < 6; i--) { var place = WeekPlace(history[i]); if (i < history.Count - 1 && place == WeekPlace(history[i + 1])) continue; yield return new(history[i].Week, place); changes++; } } string WeekPlace(InstanceWeek week) => week.Geo switch { { Cdn: { Length: > 0 } cdn } => "☁ " + cdn, { } geo => JoinPlace(geo.City, InstanceService.CountryName(geo.Country)) ?? Localizer["unknown"], _ => week.Reachable ? Localizer["unknown"] : Localizer["unreachable"] }; static string JoinPlace(params string[] parts) => parts.Where(p => !string.IsNullOrWhiteSpace(p)) is var known && known.Any() ? string.Join(", ", known) : null; IEnumerable CdnRows() { var location = Post.Location; var geo = ServerGeo; yield return NerdRow.Of("CDN", location.Label); yield return NerdRow.Of(Localizer["domain"], location.CdnKey); yield return NerdRow.Of(Localizer["found by"], geo?.CdnSource switch { "ranges" => Localizer["the CDN's published addresses"], "headers" => Localizer["the CDN's marks in the server's answers"], "asn" => Localizer["a network that carries only the CDN"], _ => geo?.CdnSource }); if (location.Before is { } before) yield return NerdRow.Of(Localizer["before the CDN"], $"{JoinPlace(before.City, InstanceService.CountryName(before.Country)) ?? Localizer["unknown"]} ({before.Week})"); if (InstanceService.KnownCdn(location.CdnKey) is not { } cdn) yield break; yield return NerdRow.Of(Localizer["servers behind it"], cdn.Servers); if (cdn.Ranges > 0) yield return NerdRow.Of(Localizer["published ranges"], $"{cdn.Ranges} ({NerdRow.Time(cdn.RangesUpdatedAt ?? default)})"); var weeks = cdn.Weeks.Take(6).Reverse().ToList(); if (weeks.Count > 0) yield return NerdRow.Of(Localizer["weekly"], string.Join(" · ", weeks.Select(w => $"{w.Week[^3..]} {w.Servers}" + (w.Joined.Count > 0 ? $" +{w.Joined.Count}" : null) + (w.Left.Count > 0 ? $" −{w.Left.Count}" : null)))); } IEnumerable LocationRows() { var location = Post.Location; yield return new(Localizer["source"], location.Source switch { LocationSource.Place => Localizer["the post's own place"], LocationSource.Event => Localizer["the event's venue"], LocationSource.Server => Localizer["the author's server"], LocationSource.Country => Localizer["the author's server's country"], _ => Localizer["Near you"] }); yield return NerdRow.Of("lat, lng", $"{Coordinate(location.Latitude)}, {Coordinate(location.Longitude)}"); yield return NerdRow.Of(Localizer["precision"], $"±{location.PrecisionKm:0} km"); if (location.Source is LocationSource.Server or LocationSource.Country && ServerGeo is { } geo) { yield return NerdRow.Of(Localizer["network"], geo.Network); yield return NerdRow.Of(Localizer["located by"], geo.Source); yield return NerdRow.Of(Localizer["located at"], geo.LocatedAt); } if (InstanceService.Known(InstanceService.SelfHost)?.Geo is { Latitude: { } selfLatitude, Longitude: { } selfLongitude } && location.Source is not LocationSource.NearYou) yield return new(Localizer["from PrivaPub"], $"{NerdRow.Kilometres(selfLatitude, selfLongitude, location.Latitude, location.Longitude):N0} km"); } static IEnumerable MediaRows(MediaAttachment media) { yield return NerdRow.Of("type", media.Type); yield return NerdRow.Of("size", media.Meta?.Original?.Size); yield return NerdRow.Of("aspect", media.Meta?.Original?.Aspect); yield return NerdRow.Of("length", media.Meta?.Length); yield return NerdRow.Of("focus", media.Meta?.Focus is { } focus ? $"{focus.X:0.##}, {focus.Y:0.##}" : null); yield return NerdRow.Of("blurhash", media.Blurhash); yield return NerdRow.Of("served by", Uri.TryCreate(media.Url, UriKind.Absolute, out var served) ? served.Host : null); yield return NerdRow.Of("origin", Uri.TryCreate(media.RemoteUrl, UriKind.Absolute, out var origin) ? origin.Host : null); yield return NerdRow.Of("alt", media.Description is { Length: > 0 } alt ? $"{alt.Length} chars" : "none"); } IEnumerable CountRows(Status status) { yield return NerdRow.Of(Localizer["replies"], status.RepliesCount); yield return NerdRow.Of(Localizer["boosts"], status.ReblogsCount); yield return NerdRow.Of(Localizer["Favourites"], status.FavouritesCount); yield return NerdRow.Of(Localizer["quotes"], status.QuotesCount > 0 ? status.QuotesCount : null); yield return NerdRow.Of(Localizer["votes"], status.Privapub?.Votes is { } votes && votes.Up + votes.Down > 0 ? $"+{votes.Up} −{votes.Down}" : null); if (status.FavouritesCount > 0) yield return NerdRow.Of(Localizer["boosts per favourite"], Math.Round((double)status.ReblogsCount / status.FavouritesCount, 2)); } #endregion }