tests/e2e (Playwright for .NET 1.61.0 on xunit v3, the Chromium 1228 already cached, never downloaded, never in CI) runs the published app against SocialPub's pasture and its town: run.sh publishes it with the Pasture environment (wwwroot/appsettings.Pasture.json, published only then; the deploy fails if it ever ships) and serves it as https://decepub.test. The tests sign in through the real login page and persona chooser, check that every request goes only to decepub.test and privapub.test with the right credential on the right API, render one post of each kind from each platform as its author wrote it, and check every action on the other side of the wire (town.sh stored): favourites, boosts and replies with their undoing, follows from a profile, votes, reactions. Each test is also a cell of the town's report (out/client.jsonl). They found two PrivaPub bugs fixed there (a blocked author's status answered null; sensitive remote media invented a content warning) and a reply addressed to nobody. New in the app, all checked by those tests: - profiles at /account/{id}: header, bio, fields, counts, the account's posts as posts, posts and replies, or media, and follow, unfollow, cancel a request, mute and block (AccountService); post authors and notifications open them in the app; - search at /search: accounts, posts and hashtags, a handle or an address resolved by PrivaPub; - poll voting on the card, and emoji reactions shown and given with one tap; before a persona's first reaction or vote it is told once that both are public (PublicNoticeService, the owner's decision); - follow requests answered from their notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
826 lines
34 KiB
Plaintext
826 lines
34 KiB
Plaintext
@inherits LocalizableComponentBase
|
||
|
||
@* 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. 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. *@
|
||
<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="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 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">
|
||
<i aria-hidden="true" class="@NotificationIcon(notification.Type)"></i>
|
||
<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" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@booster.Name</a>
|
||
<span>@Localizer["boosted"]</span>
|
||
</p>
|
||
}
|
||
else if (Post.Conversation is { } conversation)
|
||
{
|
||
<p class="inline-flex flex-wrap items-center gap-2 min-w-0 text-xs">
|
||
<i class="ion-md-mail @(conversation.Unread ? "text-primary" : null)" aria-hidden="true"></i>
|
||
<span class="truncate">@string.Join(", ", conversation.Accounts.Select(a => "@" + a.Acct))</span>
|
||
</p>
|
||
}
|
||
|
||
@if (Status is { } status)
|
||
{
|
||
<header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 min-w-0 justify-between text-xs md:text-sm">
|
||
<NerdTip Heading="@Localizer["Author"]" Rows="AuthorRows()" Pending="isReadingServer" OnOpen="ReadServer" Align="DTooltipAlign.Start" Class="min-w-0 flex-1">
|
||
<span class="inline-flex gap-2 min-w-0 max-w-full">
|
||
<b class="shrink truncate" title="@status.Account?.Name">@status.Account?.Name</b>
|
||
<span class="flex-1 min-w-6 opacity-50 truncate 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-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-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>
|
||
@if (status.EditedAt is not null)
|
||
{
|
||
<span class="opacity-60">*</span>
|
||
}
|
||
</NerdTip>
|
||
<i aria-hidden="true" class="@VisibilityIcon(status.Visibility) text-base" title="@VisibilityText(status.Visibility)"></i>
|
||
</span>
|
||
</header>
|
||
|
||
@if (status.Privapub?.Title is { Length: > 0 } title)
|
||
{
|
||
<p class="text-sm md:text-base font-bold break-words">@title</p>
|
||
}
|
||
|
||
@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>@spoiler</span>
|
||
</Header>
|
||
<ChildContent>
|
||
@Body(status)
|
||
</ChildContent>
|
||
</DCollapse>
|
||
}
|
||
else
|
||
{
|
||
@Body(status)
|
||
}
|
||
|
||
@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 (!Post.Located && !Compact)
|
||
{
|
||
<footer class="flex gap-2 mt-1 justify-between">
|
||
<div class="flex gap-2">
|
||
<DButton Size="DSize.Sm" 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>@status.RepliesCount</span>
|
||
}
|
||
</DButton>
|
||
<DButton Size="DSize.Sm" 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>@status.ReblogsCount</span>
|
||
}
|
||
</DButton>
|
||
<DButton Size="DSize.Sm" 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>@status.FavouritesCount</span>
|
||
}
|
||
</DButton>
|
||
<DButton Size="DSize.Sm" 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"
|
||
TriggerClass="@(isReactionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" ContentClass="mb-2 flex gap-1 p-2 rounded-box"
|
||
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>
|
||
<NerdTip Heading="@Localizer["Counts"]" Rows="CountRows(status)" Class="self-center">
|
||
<i class="ion-md-stats opacity-40" aria-label="@Localizer["Counts"]"></i>
|
||
</NerdTip>
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
<DDropdown @bind-IsOpen="isOptionsOpen" Align="DDropdownAlign.End" CloseOnContentClick="true" Placement="DDropdownPlacement.Top"
|
||
TriggerClass="@(isOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")"
|
||
ContentClass="mb-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 (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 (!IsOwn)
|
||
{
|
||
<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
|
||
{
|
||
<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" href="@($"expand/{Uri.EscapeDataString(status.Id)}")" title="@Localizer["Expand"]">
|
||
<i aria-hidden="true" class="ion-md-expand text-lg"></i>
|
||
</NavLink>
|
||
}
|
||
</div>
|
||
</footer>
|
||
|
||
@if (isReplying)
|
||
{
|
||
<Composer InReplyTo="status" OnPublished="Replied" />
|
||
}
|
||
}
|
||
}
|
||
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] 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; }
|
||
|
||
bool isReplying;
|
||
bool isActing;
|
||
bool isOptionsOpen;
|
||
bool isSpoilerOpen;
|
||
bool isReadingProvenance;
|
||
bool isReadingServer;
|
||
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);
|
||
|
||
protected override void OnInitialized() => IsLoading = false;
|
||
|
||
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)content)</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 && !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>@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>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@if (status.Quote?.QuotedStatus is { } quoted)
|
||
{
|
||
<Content Post="FeedPost.Of(quoted)" Compact="true" IncludeExpand="false" CssContainer="neo-inset! shadow-none" />
|
||
}
|
||
|
||
@if (status.EmojiReactions.Count > 0)
|
||
{
|
||
<div class="flex flex-wrap gap-1" 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 (status.Tags.Count > 0)
|
||
{
|
||
<div class="flex flex-wrap gap-1">
|
||
@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)" />
|
||
</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;
|
||
}
|
||
|
||
HashSet<int> pollChoices = [];
|
||
|
||
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."]);
|
||
}
|
||
|
||
async Task Delete()
|
||
{
|
||
isActing = true;
|
||
var result = await StatusService.Delete(Status.Id);
|
||
isActing = false;
|
||
if (result.IsInvalid)
|
||
{
|
||
Toast.AddResponseError(result);
|
||
return;
|
||
}
|
||
|
||
Toast.AddResponseSuccess(Localizer["Message deleted."]);
|
||
await OnDeleted.InvokeAsync(Post);
|
||
}
|
||
|
||
async Task AccountAction(string action)
|
||
{
|
||
isActing = true;
|
||
var result = await StatusService.AccountAction(Status.Account.Id, action);
|
||
isActing = false;
|
||
if (result.IsInvalid)
|
||
{
|
||
Toast.AddResponseError(result);
|
||
return;
|
||
}
|
||
|
||
Toast.AddResponseSuccess(action == "block" ? Localizer["{0} blocked.", Status.Account.Name] : Localizer["{0} muted.", Status.Account.Name]);
|
||
}
|
||
|
||
void ToggleReply() => isReplying = !isReplying;
|
||
|
||
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) ?? "?";
|
||
|
||
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;
|
||
return notification.Type switch
|
||
{
|
||
"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
|
||
}
|