Deleting a post, taking it off the persona's wall, muting or blocking an account (from a card or a profile), leaving a group, dropping a scheduled post and signing out acted at the first tap, a few pixels from their neighbours. They now ask first: new ConfirmService (Ask returns whether the reader confirmed, as PublicNoticeService does) and ConfirmHost in MainLayout, a sheet from the bottom on a phone and a dialog from md, with Cancel and the action named, in red when it deletes or cuts off. Logout is a button that asks, then goes to /logout as before. The reversible ones (unmute, unblock, removing a conversation from the list, a picture from the composer) don't ask; the inline confirms of lists, filters and the login's deletion stay. The desktop tests that delete a reply, take a post off a wall or drop a scheduled post confirm; a phone flow deletes a post after cancelling once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
1297 lines
57 KiB
Plaintext
1297 lines
57 KiB
Plaintext
@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)">
|
||
|
||
@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>
|
||
@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>
|
||
@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 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">
|
||
@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 (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 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 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>@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" => @<a class="block w-full" href="@media.Url" target="_blank" rel="noopener noreferrer">
|
||
<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)" />
|
||
</a>,
|
||
"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);
|
||
}
|
||
|
||
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
|
||
}
|