Custom emoji, downvotes, blurhash and focal points on the cards

What other platforms send now shows as they meant it:
- custom emoji (a post's or account's :shortcode:) as their pictures in
  posts, names, content warnings, bios and fields: only the shortcodes the
  server listed, only http(s) pictures, never inside a tag, the shortcode
  kept as alt text;
- a threadiverse post's downvotes next to its favourites;
- blurhash placeholders until a picture loads (wwwroot/js/blurhash.js, a
  small decoder that watches the page), and a picture's focal point;
- an event's online link, participants and capacity, and whether it is
  tentative or cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 17:55:39 +02:00
1 parent 5e8fdc43af
commit 2024798732
9 files changed
+236 -9

No files matched your search

+38 -5
View File
@@ -54,7 +54,7 @@
{
<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>
<a class="font-bold truncate" href="@booster.Url" target="_blank" rel="noopener noreferrer" title="@booster.Acct">@CustomEmojis.Text(booster.Name, booster.Emojis)</a>
<span>@Localizer["boosted"]</span>
</p>
}
@@ -81,7 +81,7 @@
<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>
<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 self-center text-xs">@("@" + status.Account?.Acct)</span>
</span>
</NerdTip>
@@ -137,7 +137,7 @@
<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>
<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)
@@ -229,6 +229,13 @@
<span>@status.FavouritesCount</span>
}
</DButton>
@* a threadiverse post's downvotes: its upvotes are its favourites *@
@if (status.Privapub?.Votes is { Down: > 0 } votes)
{
<span class="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" 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>
@@ -400,7 +407,7 @@
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>
<div class="post-content text-sm md:text-base break-words">@((MarkupString)CustomEmojis.Html(content, status.Emojis))</div>
}
@if (status.MediaAttachments.Count > 0)
@@ -492,6 +499,25 @@
{
<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")">
@@ -544,7 +570,7 @@
{
"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)" />
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"
@@ -785,6 +811,13 @@
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.