Files
decePubClient/Components/Content.razor
T
thepraandClaude Opus 5.5 5522fe4fbb Pictures open in the app
A tap on a picture opened its file in a new tab, leaving the app (and, on a phone, its place in the feed). It now opens
MediaViewer: the whole screen on a phone, a large dialog from md, the picture whole with its alt text under it, the
post's other pictures by the previous and next buttons or the arrow keys, and "Open original" for whoever wants the
file. Phone flow: a picture of a town post opens the viewer over the whole screen, which closes from its button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:44:22 +02:00

1342 lines
59 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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)
{
<article class="flex gap-3 w-full rounded-xl neo-raised @(Highlighted ? "neo-sm" : "neo-2xs") @CssContainer"
data-post-id="@(Compact ? null : Post.Key)" data-lat="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Latitude))"
data-lng="@(Compact || Post.Location?.HasPoint != true ? null : Coordinate(Post.Location.Longitude))"
data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)"
data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)"
data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))"
data-uri="@(Compact ? null : Post.Status?.Uri)" data-thread="@(IncludeExpand && !Compact && Status is not null && !Post.Located ? "" : null)">
@if (Author is { } author)
{
<div class="hidden md:flex flex-col py-3 pl-3 md:py-4 md:pl-4 flex-none rounded-l-xl bg-cover bg-no-repeat bg-center"
style="@HeaderBackground(author)">
<a class="block h-12 w-12 md:h-14 md:w-14" href="@($"account/{Uri.EscapeDataString(author.Id)}")" title="@author.Acct">
@* alt="": the author's name is next to it. *@
<img alt="" loading="lazy" class="h-12 w-12 md:h-14 md:w-14 object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(author.Avatar ?? "imgs/icon-192.png")" />
</a>
</div>
}
<div class="flex flex-col gap-2 flex-1 py-3 pr-3 max-md:pl-3 md:py-4 md:pr-4 min-w-0">
@if (Post.Notification is { } notification)
{
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs" data-notification="@notification.Type" data-count="@Post.GroupCount">
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
@if (Post.GroupAccounts.Count > 1)
{
<span class="flex" aria-hidden="true">
@foreach (var account in Post.GroupAccounts.Take(4))
{
@* alt="": the names are in the text next to them. *@
<img alt="" class="h-5 w-5 -ml-2 first:ml-0 rounded-full object-cover ring-2 ring-base-100" src="@(account.Avatar ?? "imgs/icon-192.png")" title="@account.Name" />
}
</span>
}
<span>@NotificationText(notification)</span>
<span class="opacity-60" title="@NerdRow.Time(notification.CreatedAt)">@notification.CreatedAt.GetPassedTime(Localizer)</span>
</p>
}
else if (Post.BoostedBy is { } booster)
{
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs">
<i class="ion-md-repeat text-info" aria-hidden="true"></i>
<a class="font-bold truncate pointer-coarse:py-3.5" href="@($"account/{Uri.EscapeDataString(booster.Id)}")" title="@booster.Acct">@CustomEmojis.Text(booster.Name, booster.Emojis)</a>
<span>@Localizer["boosted"]</span>
</p>
}
else if (Post.Conversation is { } conversation)
{
<p class="flex flex-wrap items-center gap-2 min-w-0 text-xs" data-conversation-id="@conversation.Id">
<i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
<span class="flex-1 min-w-0 truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
@if (conversation.Unread)
{
<DBadge Size="DSize.Xs" Color="DColor.Primary" data-conversation-unread>@Localizer["unread"]</DBadge>
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="ReadConversation" title="@Localizer["Mark as read"]" data-conversation-read>
<i class="ion-md-checkmark"></i>
</DButton>
}
<DButton Size="DSize.Xs" Disabled="IsDefaultDisabled" OnClick="RemoveConversation" title="@Localizer["Remove from the list"]" data-conversation-remove>
<i class="ion-md-close"></i>
</DButton>
</p>
}
@if (Status is { } status)
{
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-sm">
@if (Author is { } phoneAuthor)
{
<a class="md:hidden flex-none self-center grid place-items-center h-11 w-11" href="@($"account/{Uri.EscapeDataString(phoneAuthor.Id)}")"
title="@phoneAuthor.Acct">
@* alt="": the author's name is next to it. *@
<img alt="" loading="lazy" class="h-10 w-10 object-cover rounded-full neo-raised neo-2xs bg-base-100" src="@(phoneAuthor.Avatar ?? "imgs/icon-192.png")" />
</a>
}
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1 max-md:self-center">
<span class="inline-flex max-md:flex-col md:gap-2 min-w-0 max-w-full">
<b class="shrink truncate" title="@status.Account?.Name">@CustomEmojis.Text(status.Account?.Name, status.Account?.Emojis ?? [])</b>
<span class="flex-1 min-w-6 opacity-50 truncate md:self-center text-xs">@("@" + status.Account?.Acct)</span>
</span>
</NerdTip>
<span class="flex-none inline-flex gap-2 items-center">
@if (Post.Location is { Source: LocationSource.Cdn } fronted)
{
<NerdTip Heading="@Localizer["The server is behind {0}, so its place is unknown.", fronted.Label]" Rows="CdnRows()" Pending="isReadingServer"
OnOpen="ReadServer" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<span class="badge badge-ghost badge-sm gap-1 max-w-28 md:max-w-40">
<i class="ion-md-cloud" aria-hidden="true"></i>
<span class="truncate">@fronted.Label</span>
</span>
</NerdTip>
}
else if (Post.Location is { } location)
{
<NerdTip Heading="@Localizer["Place"]" Rows="LocationRows()" Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<span class="badge badge-ghost badge-sm gap-1 max-w-28 md:max-w-40">
<i class="@LocationIcon(location.Source)" aria-hidden="true"></i>
<span class="truncate">@(location.Label ?? Localizer["Near you"])</span>
</span>
</NerdTip>
}
<NerdTip Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" OnOpen="ReadProvenance"
Placement="DTooltipPlacement.Bottom" Align="DTooltipAlign.End">
<time datetime="@NerdRow.Time(status.CreatedAt)" class="opacity-80">@status.CreatedAt.GetPassedTime(Localizer)</time>
</NerdTip>
@if (status.EditedAt is not null)
{
<button type="button" class="link link-hover opacity-60 touch-target" data-history title="@Localizer["Edit history"]"
@onclick="() => OpenHistory(status)">@Localizer["edited"]</button>
}
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
</span>
</header>
@if (status.Privapub?.Wall is { } wall)
{
<p class="inline-flex items-center gap-2 min-w-0 text-xs opacity-80" data-wall="@wall.Acct">
<i class="ion-md-clipboard" aria-hidden="true"></i>
<a class="truncate" href="@($"account/{Uri.EscapeDataString(wall.Id)}")" title="@wall.Acct">
@Localizer["On the wall of {0}", wall.Name]
</a>
</p>
}
@if (Warnings.Count > 0 && !showFiltered)
{
<div class="flex flex-wrap items-center gap-2 text-sm rounded-lg neo-inset neo-2xs px-3 py-2" data-filtered>
<i class="ion-md-funnel opacity-70" aria-hidden="true"></i>
<span class="flex-1 min-w-0 break-words">@Localizer["Filtered: {0}", string.Join(", ", Warnings.Select(w => w.Filter.Title))]</span>
<DButton Size="DSize.Xs" OnClick="() => showFiltered = true">@Localizer["Show anyway"]</DButton>
</div>
}
else
{
@if (status.Privapub?.Title is { Length: > 0 } title)
{
<p class="text-sm md:text-base font-bold break-words">@title</p>
}
@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 *@
<div class="flex flex-col gap-2" data-article>
@if (status.Privapub.Cover is { Length: > 0 } cover)
{
<img alt="" loading="lazy" class="w-full max-h-56 object-cover rounded-lg" src="@cover" />
}
@if (ArticleExcerpt(status) is { Length: > 0 } excerpt)
{
<p class="text-sm opacity-80 break-words line-clamp-4">@excerpt</p>
}
<DButton Size="DSize.Sm" class="self-start" OnClick="() => isArticleOpen = true" data-article-open>
<i aria-hidden="true" class="ion-md-book"></i><span>@Localizer["Read article"]</span>
</DButton>
</div>
}
else if (status.SpoilerText is { Length: > 0 } spoiler)
{
<DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0"
ContentClass="text-sm">
<Header>
<span class="inline-flex gap-2 items-center"><i class="ion-md-warning text-warning"></i>@CustomEmojis.Text(spoiler, status.Emojis)</span>
</Header>
<ChildContent>
@Body(status)
</ChildContent>
</DCollapse>
}
else
{
@Body(status)
}
@* the reactions stand outside the content warning: they are not what it hides *@
@if (status.EmojiReactions.Count > 0)
{
<div class="flex flex-wrap gap-1 pointer-coarse:gap-2" data-reactions>
@foreach (var reaction in status.EmojiReactions)
{
<DButton Size="DSize.Xs" Active="reaction.Me" Disabled="@(IsDefaultDisabled || isActing || Compact || Post.Located)"
OnClick="() => React(reaction.Name, !reaction.Me)" title="@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>
}
</div>
}
@if (Post.Located)
{
<p class="inline-flex gap-2 items-center text-xs opacity-70">
<i class="ion-md-locate"></i>
<span>@Localizer["{0} km from you, shown within {1} km", Number(Post.DistanceKm), Number(Post.RangeKm)]</span>
</p>
}
@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 *@
<p class="inline-flex gap-2 items-center text-xs opacity-70" data-approval="@status.Privapub.Approval">
<i aria-hidden="true" class="@(status.Privapub.Approval == "pending" ? "ion-md-hourglass" : "ion-md-close-circle")"></i>
<span>@(status.Privapub.Approval == "pending" ? Localizer["Waiting for its author's approval"] : Localizer["Its author did not approve it"])</span>
</p>
}
@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 *@
<footer class="grid grid-cols-5 gap-1 mt-1 md:flex md:gap-2 md:justify-between">
<div class="contents md:flex md:gap-2">
@if (status.Privapub?.Locked == true)
{
@* its community's moderators locked the thread: no reply is taken *@
<span class="btn btn-sm btn-disabled max-md:w-full" title="@Localizer["Locked by its community's moderators"]" data-locked>
<i aria-hidden="true" class="ion-md-lock text-lg"></i>
@if (status.RepliesCount > 0)
{
<span class="md:hidden">@status.RepliesCount.Compact()</span>
<span class="hidden md:inline">@status.RepliesCount</span>
}
</span>
}
else
{
<DButton Size="DSize.Sm" class="max-md:w-full" Active="isReplying" Disabled="IsDefaultDisabled" OnClick="ToggleReply" title="@Localizer[isReplying ? "Close" : "Reply"]">
<i aria-hidden="true" class="@(isReplying ? "ion-md-close-circle" : "ion-md-text") text-lg text-success"></i>
@if (status.RepliesCount > 0)
{
<span class="md:hidden">@status.RepliesCount.Compact()</span>
<span class="hidden md:inline">@status.RepliesCount</span>
}
</DButton>
}
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Reblogged" Disabled="IsDefaultDisabled || isActing || !CanBoost(status)"
OnClick="() => Act(status.Reblogged ? StatusAction.Unreblog : StatusAction.Reblog)" title="@Localizer["Boost"]">
<i aria-hidden="true" class="ion-md-repeat text-lg text-info"></i>
@if (status.ReblogsCount > 0)
{
<span class="md:hidden">@status.ReblogsCount.Compact()</span>
<span class="hidden md:inline">@status.ReblogsCount</span>
}
</DButton>
<DButton Size="DSize.Sm" class="max-md:w-full" Active="status.Favourited" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Favourited ? StatusAction.Unfavourite : StatusAction.Favourite)" title="@Localizer["Favourite"]">
<i aria-hidden="true" class="@(status.Favourited ? "ion-md-heart" : "ion-md-heart-empty") text-lg text-pink-400"></i>
@if (status.FavouritesCount > 0)
{
<span class="md:hidden">@status.FavouritesCount.Compact()</span>
<span class="hidden md:inline">@status.FavouritesCount</span>
}
</DButton>
@* a threadiverse post's downvotes: its upvotes are its favourites *@
@if (status.Privapub?.Votes is { Down: > 0 } votes)
{
<span class="hidden md:inline-flex items-center gap-1 px-1 text-sm opacity-70 tabular-nums" title="@Localizer["Downvotes"]" data-downvotes>
<i aria-hidden="true" class="ion-md-arrow-down"></i>@votes.Down
</span>
}
<DButton Size="DSize.Sm" class="hidden md:inline-flex" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]">
<i aria-hidden="true" class="ion-md-bookmark text-lg @(status.Bookmarked ? "text-warning" : null)"></i>
</DButton>
<DDropdown @bind-IsOpen="isReactionsOpen" Placement="DDropdownPlacement.Top" CloseOnContentClick="true" SheetOnNarrow="true" class="max-md:w-full"
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
ContentClass="mb-2 flex gap-1 p-2 rounded-box max-md:grid max-md:grid-cols-4 max-md:gap-2" title="@Localizer["React"]">
<Trigger><i aria-hidden="true" class="ion-md-happy text-lg"></i></Trigger>
<ChildContent>
@foreach (var emoji in QuickReactions)
{
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => React(emoji, true)">@emoji</DButton>
}
</ChildContent>
</DDropdown>
<span class="hidden md:contents">
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
</NerdTip>
</span>
</div>
<div class="contents md:flex md:gap-2">
@* opens sideways and downwards: opened upwards, the first card's menu was cut off by the top of the feed *@
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.Start" CloseOnContentClick="true" Placement="DDropdownPlacement.Left"
SheetOnNarrow="true" class="max-md:w-full"
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active max-md:w-full" : "btn btn-sm max-md:w-full")"
ContentClass="mr-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Other"]">
<Trigger>
<i aria-hidden="true" class="ion-md-more text-lg"></i>
</Trigger>
<ChildContent>
@if (IncludeExpand)
{
<NavLink class="btn btn-sm justify-start whitespace-nowrap md:hidden" href="@($"expand/{Uri.EscapeDataString(status.Id)}")"
@onclick="OpenConversation" data-thread-link>
<i aria-hidden="true" class="ion-md-expand text-base"></i>
<span>@Localizer["Thread"]</span>
</NavLink>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" data-bookmark-phone>
<i aria-hidden="true" class="ion-md-bookmark text-base @(status.Bookmarked ? "text-warning" : null)"></i>
<span>@Localizer["Bookmark"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap md:hidden" OnClick="OpenDetails" data-details>
<i aria-hidden="true" class="ion-md-stats text-base"></i>
<span>@Localizer["Details"]</span>
</DButton>
@if (status.Url is { Length: > 0 } url)
{
<a class="btn btn-sm justify-start whitespace-nowrap" href="@url" target="_blank" rel="noopener noreferrer">
<i aria-hidden="true" class="ion-md-open text-base"></i>
<span>@Localizer["Open original"]</span>
</a>
}
@if (status.FavouritesCount > 0)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = false)" data-liked-by>
<i aria-hidden="true" class="ion-md-heart text-base"></i>
<span>@Localizer["Liked by"]</span>
</DButton>
}
@if (status.ReblogsCount > 0)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = true)" data-boosted-by>
<i aria-hidden="true" class="ion-md-repeat text-base"></i>
<span>@Localizer["Boosted by"]</span>
</DButton>
}
@if (CanQuote(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleQuote" data-quote>
<i aria-hidden="true" class="ion-md-quote text-base"></i>
<span>@Localizer["Quote"]</span>
</DButton>
}
@if (IsOnOwnWall(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="TakeOffWall" data-take-off-wall>
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Take off my wall"]</span>
</DButton>
}
@if (!IsOwn)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
<span>@Localizer["Report"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
<span>@Localizer["Mute"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("block"))">
<i aria-hidden="true" class="ion-md-remove-circle text-base text-error"></i>
<span>@Localizer["Block"]</span>
</DButton>
}
else
{
@if (CanPin(status))
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Pinned ? StatusAction.Unpin : StatusAction.Pin)" data-pin>
<i aria-hidden="true" class="ion-md-pin text-base"></i>
<span>@(status.Pinned ? Localizer["Unpin from profile"] : Localizer["Pin on profile"])</span>
</DButton>
}
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleEdit" data-edit>
<i aria-hidden="true" class="ion-md-create text-base"></i>
<span>@Localizer["Edit"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="Delete">
<i aria-hidden="true" class="ion-md-trash text-base text-error"></i>
<span>@Localizer["Delete"]</span>
</DButton>
}
</ChildContent>
</DDropdown>
@if (IncludeExpand)
{
<NavLink class="btn btn-sm hidden md:inline-flex" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]"
@onclick="OpenConversation">
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
</NavLink>
}
</div>
</footer>
@* under the post on a desktop, as a sheet over the page on a phone, where the card's column is too narrow *@
<PhoneSheet IsOpen="isReplying" OnClose="() => isReplying = false" Heading="@Localizer["Reply to {0}", "@" + status.Account?.Acct]">
<Composer InReplyTo="status" OnPublished="Replied" />
</PhoneSheet>
<PhoneSheet IsOpen="isQuoting" OnClose="() => isQuoting = false" Heading="@Localizer["Quote"]">
<Composer Quoting="status" OnPublished="Quoted" />
</PhoneSheet>
<PhoneSheet IsOpen="isEditing" OnClose="() => isEditing = false" Heading="@Localizer["Edit"]">
<Composer Editing="status" OnPublished="Edited" />
</PhoneSheet>
@* a phone's way to the nerd stats the card's corners hold on hover, all in one sheet *@
@if (isDetailsOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="open => isDetailsOpen = open" CloseText="@Localizer["Close"]"
data-details-sheet>
<Heading>@Localizer["Details"]</Heading>
<ChildContent>
<div class="flex flex-col gap-4">
<NerdRows Heading="@Localizer["Post"]" Rows="PostRows()" Pending="isReadingProvenance" />
<NerdRows Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" />
@if (Post.Location is { Source: LocationSource.Cdn } detailsCdn)
{
<NerdRows Heading="@Localizer["The server is behind {0}, so its place is unknown.", detailsCdn.Label]" Rows="CdnRows()" Pending="isReadingServer" />
}
else if (Post.Location is not null)
{
<NerdRows Heading="@Localizer["Place"]" Rows="LocationRows()" />
}
<NerdRows Heading="@Localizer["Counts"]" Rows="CountRows(status)" />
</div>
</ChildContent>
</DModal>
}
@if (isReporting && status.Account is { } reported)
{
<ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" />
}
@if (showInteractedBy is { } boosts)
{
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
}
@if (isHistoryOpen)
{
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
<Heading>@Localizer["Edit history"]</Heading>
<ChildContent>
<ol class="flex flex-col gap-4 mt-3">
@foreach (var version in Enumerable.Reverse(history))
{
<li class="flex flex-col gap-1" data-version>
<time class="text-xs opacity-70" datetime="@NerdRow.Time(version.CreatedAt)">@version.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</time>
@if (version.SpoilerText is { Length: > 0 } warning)
{
<p class="inline-flex gap-2 items-center text-sm"><i class="ion-md-warning text-warning"></i>@CustomEmojis.Text(warning, status.Emojis)</p>
}
<div class="post-content text-sm break-words">@((MarkupString)CustomEmojis.Html(version.Content, status.Emojis))</div>
</li>
}
</ol>
</ChildContent>
</DModal>
}
}
}
}
else if (Post.Notification?.Account is { } follower)
{
<p class="text-sm">
<a class="font-bold" href="@($"account/{Uri.EscapeDataString(follower.Id)}")">@follower.Name</a>
<span class="opacity-60">@("@" + follower.Acct)</span>
</p>
@if (Post.Notification.Type == "follow_request" && requestAnswer is null)
{
<div class="flex gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, true)">
<i class="ion-md-checkmark"></i><span>@Localizer["Accept"]</span>
</DButton>
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => AnswerRequest(follower, false)">
<i class="ion-md-close"></i><span>@Localizer["Refuse"]</span>
</DButton>
</div>
}
else if (requestAnswer is { } accepted)
{
<p class="text-xs opacity-70">@(accepted ? Localizer["Accepted."] : Localizer["Refused."])</p>
}
}
</div>
</article>
}
@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; }
[Parameter, EditorRequired] public FeedPost Post { get; set; }
[Parameter] public EventCallback<FeedPost> OnDeleted { get; set; }
[Parameter] public EventCallback<FeedPost> OnReplied { get; set; }
[Parameter] public EventCallback<string> OnTag { get; set; }
[Parameter] public string CssContainer { get; set; }
/// <summary>Deeper shadow, for the post a thread page is about.</summary>
[Parameter] public bool Highlighted { get; set; }
[Parameter] public bool IncludeExpand { get; set; } = true;
/// <summary>Without actions, e.g. a quoted post.</summary>
[Parameter] public bool Compact { get; set; }
/// <summary>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.</summary>
[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;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
List<StatusEdit> history = [];
readonly HashSet<string> 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<FilterResult> 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<FilterResult> 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) => @<div class="flex flex-col gap-3">
@if (status.Content is { Length: > 0 } content)
{
<div class="post-content text-sm md:text-base break-words">@((MarkupString)CustomEmojis.Html(content, status.Emojis))</div>
}
@if (status.MediaAttachments.Count > 0)
{
<div class="grid gap-2 @(status.MediaAttachments.Count > 1 ? "grid-cols-2" : "grid-cols-1")">
@foreach (var media in status.MediaAttachments)
{
<NerdTip Heading="@Localizer["Media"]" Rows="MediaRows(media)" Class="block w-full" TapOpens="false">
@if ((status.Sensitive || IsBlurred) && !revealedMedia.Contains(media.Id))
{
<button type="button" class="flex items-center justify-center w-full aspect-video rounded-lg neo-inset neo-2xs text-sm"
@onclick="() => revealedMedia.Add(media.Id)">
<i class="ion-md-eye-off mr-2"></i>@(IsBlurred ? Localizer["Filtered media, show"] : Localizer["Sensitive media, show"])
</button>
}
else
{
@Media(media, status.MediaAttachments.Count > 1)
}
</NerdTip>
}
</div>
}
@if (viewingPicture is { } picture)
{
<MediaViewer Pictures="Pictures(status)" Start="Pictures(status).IndexOf(picture)" OnClose="() => viewingPicture = null" />
}
@if (status.Card is { } card && status.MediaAttachments.Count == 0)
{
<a class="flex gap-3 rounded-lg p-2 neo-inset neo-2xs min-w-0" href="@card.Url" target="_blank" rel="noopener noreferrer">
@if (card.Image is { Length: > 0 } image)
{
<img alt="@card.ImageDescription" loading="lazy" class="h-16 w-24 flex-none object-cover rounded-md" src="@image" />
}
<span class="flex flex-col min-w-0 text-xs">
<b class="truncate">@card.Title</b>
<span class="line-clamp-2 opacity-70">@card.Description</span>
<span class="truncate opacity-50">@(card.ProviderName ?? CardHost(card.Url))</span>
</span>
</a>
}
@if (status.Poll is { } poll)
{
<div class="flex flex-col gap-1 pointer-coarse:gap-2 text-xs" data-poll>
@if (CanVote(poll))
{
@for (var i = 0; i < poll.Options.Count; i++)
{
var index = i;
<label class="flex items-center gap-2 rounded-md neo-inset neo-2xs px-2 py-1 pointer-coarse:min-h-11 cursor-pointer">
<input type="@(poll.Multiple ? "checkbox" : "radio")" class="@(poll.Multiple ? "checkbox checkbox-xs" : "radio radio-xs")"
name="@($"poll-{poll.Id}")" checked="@pollChoices.Contains(index)" @onchange="() => ToggleChoice(poll, index)" />
<span class="min-w-0 break-words">@poll.Options[index].Title</span>
</label>
}
<DButton Size="DSize.Xs" Color="DColor.Primary" class="self-start" Disabled="@(IsDefaultDisabled || isActing || pollChoices.Count == 0)"
OnClick="() => Vote(poll)">
@Localizer["Vote"]
</DButton>
}
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;
<div class="relative rounded-md overflow-hidden neo-inset neo-2xs px-2 py-1">
<div class="absolute inset-y-0 left-0 bg-primary/20" style="width:@(share.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture))%"></div>
<span class="relative flex justify-between gap-2">
<span>
@option.Title
@if (poll.OwnVotes.Contains(i))
{
<i class="ion-md-checkmark-circle text-primary" title="@Localizer["Your vote"]" aria-label="@Localizer["Your vote"]"></i>
}
</span>
<span>@share.ToString("0")%</span>
</span>
</div>
}
}
<span class="opacity-60">@Localizer["{0} votes", poll.VotesCount] · @(poll.Expired ? Localizer["closed"] : Localizer["open"])</span>
</div>
}
@if (status.Privapub?.Event is { } happening)
{
<div class="flex flex-col gap-1 text-xs rounded-lg p-2 neo-inset neo-2xs">
<span class="inline-flex gap-2 items-center"><i class="ion-md-calendar"></i>@EventTime(happening)</span>
@foreach (var venue in happening.Places)
{
<span class="inline-flex gap-2 items-center"><i class="ion-md-pin"></i>@(venue.Name ?? venue.Address ?? venue.Url)</span>
}
@if (IsWebLink(happening.OnlineUrl))
{
<a class="inline-flex gap-2 items-center link" href="@happening.OnlineUrl" target="_blank" rel="noopener noreferrer"><i class="ion-md-globe"></i>@Localizer["Online"]</a>
}
@if (happening.Participants is { } going)
{
<span class="inline-flex gap-2 items-center tabular-nums">
<i class="ion-md-people"></i>@(happening.Capacity is { } capacity ? Localizer["{0} of {1} going", going, capacity] : Localizer["{0} going", going])
</span>
}
@switch (happening.Status?.ToUpperInvariant())
{
case "CANCELLED":
<span class="badge badge-error badge-sm">@Localizer["Cancelled"]</span>
break;
case "TENTATIVE":
<span class="badge badge-warning badge-sm">@Localizer["Tentative"]</span>
break;
}
@if (CanJoin(happening))
{
<div class="flex flex-wrap gap-2 items-center" data-participation="@(happening.Participation ?? "none")">
@switch (happening.Participation)
{
case "accepted":
<span class="badge badge-success badge-sm"><i class="ion-md-checkmark"></i>@Localizer["You are going"]</span>
break;
case "pending":
<span class="badge badge-warning badge-sm"><i class="ion-md-hourglass"></i>@Localizer["Waiting for the organiser"]</span>
break;
case "rejected":
<span class="badge badge-error badge-sm"><i class="ion-md-close-circle"></i>@Localizer["The organiser said no"]</span>
break;
}
@if (happening.Participation is "accepted" or "pending")
{
<DButton Size="DSize.Xs" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Participate(false)" data-event-leave>
@Localizer["Leave"]
</DButton>
}
else
{
<DButton Size="DSize.Xs" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isActing)" OnClick="() => Participate(true)" data-event-join>
<i class="ion-md-people"></i>@Localizer["Join"]
</DButton>
}
</div>
}
</div>
}
@if (status.Quote?.QuotedStatus is { } quoted)
{
<Content Post="FeedPost.Of(quoted)" Compact="true" IncludeExpand="false" CssContainer="neo-inset! shadow-none" />
}
@if (status.Tags.Count > 0)
{
<div class="flex flex-wrap gap-1 pointer-coarse:gap-2">
@foreach (var tag in status.Tags)
{
<button type="button" class="badge badge-soft badge-sm" @onclick="() => OnTag.InvokeAsync(tag.Name)">#@tag.Name</button>
}
</div>
}
</div>;
RenderFragment Media(MediaAttachment media, bool several) => media.Type switch
{
"image" => @<button type="button" class="block w-full cursor-zoom-in" @onclick="() => OpenPicture(media)"
aria-label="@(media.Description ?? Localizer["Picture"])" data-picture>
<img alt="@media.Description" title="@media.Description" loading="lazy" class="w-full rounded-lg object-cover @(several ? "aspect-square" : "max-h-[60vh]")"
src="@(media.PreviewUrl ?? media.Url)" data-blurhash="@media.Blurhash" style="@FocalPoint(media)" />
</button>,
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>,
"video" => @<video class="w-full max-h-[50vh] aspect-video rounded-lg neo-inset neo-2xs" controls playsinline preload="metadata"
poster="@media.PreviewUrl" title="@media.Description" src="@media.Url"></video>,
"audio" => @<audio class="w-full" controls preload="metadata" title="@media.Description" src="@media.Url"></audio>,
_ => @<a class="btn btn-sm justify-start" href="@media.Url" target="_blank" rel="noopener noreferrer">
<i class="ion-md-document"></i><span class="truncate">@(media.Description ?? media.Url)</span>
</a>
};
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<int> 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()
{
if (!await Confirm.Ask(Localizer["Delete this post?"], 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 = 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()
{
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<MediaAttachment> 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[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<NerdRow> 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<NerdRow> 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<NerdRow> 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<NerdRow> 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<NerdRow> 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<NerdRow> 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<NerdRow> 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
}