@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) {
@* alt="": the author's name is next to it. *@
}
@if (Post.Notification is { } notification) {

@if (Post.GroupAccounts.Count > 1) { } @NotificationText(notification) @notification.CreatedAt.GetPassedTime(Localizer)

} 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) {
@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) { @reaction.Name } else { @reaction.Name } @reaction.Count }
} @if (Post.Located) {

@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"])

} @if (!Post.Located && !Compact) {
@if (status.Privapub?.Locked == true) { @* its community's moderators locked the thread: no reply is taken *@ @if (status.RepliesCount > 0) { @status.RepliesCount } } else { @if (status.RepliesCount > 0) { @status.RepliesCount } } @if (status.ReblogsCount > 0) { @status.ReblogsCount } @if (status.FavouritesCount > 0) { @status.FavouritesCount } @* a threadiverse post's downvotes: its upvotes are its favourites *@ @if (status.Privapub?.Votes is { Down: > 0 } votes) { @votes.Down } @foreach (var emoji in QuickReactions) { @emoji }
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@ @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) { @Localizer["Report"] @Localizer["Mute"] @Localizer["Block"] } else { @if (CanPin(status)) { @(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"]) } @Localizer["Edit"] @Localizer["Delete"] } @if (IncludeExpand) { }
@if (isReplying) { } @if (isQuoting) { } @if (isEditing) { } @if (isReporting && status.Account is { } reported) { } @if (showInteractedBy is { } boosts) { } @if (isHistoryOpen) { @Localizer["Edit history"]
    @foreach (var version in Enumerable.Reverse(history)) {
  1. @if (version.SpoilerText is { Length: > 0 } warning) {

    @CustomEmojis.Text(warning, status.Emojis)

    }
    @((MarkupString)CustomEmojis.Html(version.Content, status.Emojis))
  2. }
} } } } else if (Post.Notification?.Account is { } follower) {

@follower.Name @("@" + follower.Acct)

@if (Post.Notification.Type == "follow_request" && requestAnswer is null) {
@Localizer["Accept"] @Localizer["Refuse"]
} else if (requestAnswer is { } accepted) {

@(accepted ? Localizer["Accepted."] : Localizer["Refused."])

} }
} @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; bool isHistoryOpen; bool isReporting; // 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 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) => @
@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 ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id)) { } else { @Media(media, status.MediaAttachments.Count > 1) } }
} @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) { }
}
; RenderFragment Media(MediaAttachment media, bool several) => media.Type switch { "image" => @ @media.Description , "gifv" => @, "video" => @, "audio" => @, _ => @ @(media.Description ?? media.Url) }; 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; } [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 = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"]; bool isReactionsOpen; 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() { isActing = true; var result = await StatusService.Delete(Status.Id); isActing = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } Toast.AddResponseSuccess(Localizer["Message deleted."]); 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() { 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) { 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); } 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[MessageType.Public.ToString()], "unlisted" => Localizer[MessageType.Unlisted.ToString()], "private" => Localizer[MessageType.FollowersOnly.ToString()], "direct" => Localizer[MessageType.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", _ => "ion-md-notifications" }; 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" => Localizer["{0} reacted {1}", name, notification.Emoji], _ => 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 }