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

+5
View File
@@ -224,6 +224,11 @@ supplies:
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending, shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending,
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`). accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag), a threadiverse
post's downvotes next to its favourites, blurhash placeholders until a picture loads (`wwwroot/js/blurhash.js`
watches the page for `img[data-blurhash]`), a picture's focal point (`object-position`), and an event's online
link, participants and status.
- **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's - **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's
author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. author and a notification's account open the profile in the app; "Open original" is the only way to the remote site.
A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing. A card in a fixed-height flex column with `overflow-hidden` needs `flex-none`, or it collapses to nothing.
+38 -5
View File
@@ -54,7 +54,7 @@
{ {
<p class="inline-flex flex-1 items-center gap-2 min-w-0 text-xs"> <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> <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> <span>@Localizer["boosted"]</span>
</p> </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"> <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"> <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"> <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 class="flex-1 min-w-6 opacity-50 truncate self-center text-xs">@("@" + status.Account?.Acct)</span>
</span> </span>
</NerdTip> </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" <DCollapse @bind-IsOpen="isSpoilerOpen" Icon="DCollapseIcon.Arrow" class="rounded-lg neo-xs" HeaderClass="text-sm py-2 min-h-0"
ContentClass="text-sm"> ContentClass="text-sm">
<Header> <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> </Header>
<ChildContent> <ChildContent>
@Body(status) @Body(status)
@@ -229,6 +229,13 @@
<span>@status.FavouritesCount</span> <span>@status.FavouritesCount</span>
} }
</DButton> </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" <DButton Size="DSize.Sm" Active="status.Bookmarked" Disabled="IsDefaultDisabled || isActing"
OnClick="() => Act(status.Bookmarked ? StatusAction.Unbookmark : StatusAction.Bookmark)" title="@Localizer["Bookmark"]"> 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> <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"> RenderFragment Body(Status status) => @<div class="flex flex-col gap-3">
@if (status.Content is { Length: > 0 } content) @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) @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> <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)) @if (CanJoin(happening))
{ {
<div class="flex flex-wrap gap-2 items-center" data-participation="@(happening.Participation ?? "none")"> <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"> "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]")" <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>, </a>,
"gifv" => @<video class="w-full rounded-lg" autoplay muted loop playsinline title="@media.Description" src="@media.Url"></video>, "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" "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) ?? "?"; 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; 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. // PrivaPub (like Mastodon) answers a placeholder for a missing header: /media/missing-header.png.
+47
View File
@@ -0,0 +1,47 @@
using System.Net;
using System.Text.RegularExpressions;
using decePubClient.Models.Mastodon;
using Microsoft.AspNetCore.Components;
namespace decePubClient.Helpers
{
/// <summary>
/// A post's or an account's custom emoji (`:shortcode:` among its <c>emojis</c>) shown as their pictures. The shortcode
/// stays as the picture's alt text, so it is what a screen reader reads and what is copied. Only shortcodes the
/// server listed for that post or account are replaced, and only with an http(s) picture.
/// </summary>
public static partial class CustomEmojis
{
[GeneratedRegex(@":([A-Za-z0-9_+-]{1,64}):")]
private static partial Regex Shortcode();
[GeneratedRegex(@"(<[^>]*>)")]
private static partial Regex Tag();
/// <summary>Plain text (a name, a content warning): escaped, then its shortcodes as pictures.</summary>
public static MarkupString Text(string text, IReadOnlyCollection<CustomEmoji> emojis) =>
new(Replace(WebUtility.HtmlEncode(text ?? string.Empty), emojis));
/// <summary>HTML the server sanitized: the shortcodes in its text, never inside a tag.</summary>
public static string Html(string html, IReadOnlyCollection<CustomEmoji> emojis) =>
emojis.Count == 0 || string.IsNullOrEmpty(html)
? html
: string.Concat(Tag().Split(html).Select(part => part.StartsWith('<') ? part : Replace(part, emojis)));
static string Replace(string text, IReadOnlyCollection<CustomEmoji> emojis)
{
if (emojis.Count == 0 || !text.Contains(':'))
return text;
var known = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var emoji in emojis)
if (!string.IsNullOrEmpty(emoji.Shortcode) && Uri.TryCreate(emoji.Url, UriKind.Absolute, out var url)
&& url.Scheme is "https" or "http")
known.TryAdd(emoji.Shortcode, url.AbsoluteUri);
return Shortcode().Replace(text, match => known.TryGetValue(match.Groups[1].Value, out var url)
? $"<img class=\"custom-emoji\" src=\"{WebUtility.HtmlEncode(url)}\" alt=\"{match.Value}\" title=\"{match.Value}\" draggable=\"false\" />"
: match.Value);
}
}
}
+4 -4
View File
@@ -39,7 +39,7 @@
<img alt="" class="h-20 w-20 md:h-24 md:w-24 flex-none object-cover rounded-full neo-raised neo-2xs bg-base-100" <img alt="" class="h-20 w-20 md:h-24 md:w-24 flex-none object-cover rounded-full neo-raised neo-2xs bg-base-100"
src="@(account.Avatar ?? "imgs/icon-192.png")" /> src="@(account.Avatar ?? "imgs/icon-192.png")" />
<div class="flex flex-col min-w-0 flex-1 pb-1"> <div class="flex flex-col min-w-0 flex-1 pb-1">
<b class="text-lg truncate">@account.Name</b> <b class="text-lg truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-sm opacity-70 truncate">@("@" + FullAcct)</span> <span class="text-sm opacity-70 truncate">@("@" + FullAcct)</span>
</div> </div>
@if (!IsOwn) @if (!IsOwn)
@@ -111,7 +111,7 @@
@if (!string.IsNullOrWhiteSpace(account.Note)) @if (!string.IsNullOrWhiteSpace(account.Note))
{ {
<div class="post-content px-4 pt-3 text-sm md:text-base break-words">@((MarkupString)account.Note)</div> <div class="post-content px-4 pt-3 text-sm md:text-base break-words">@((MarkupString)CustomEmojis.Html(account.Note, account.Emojis))</div>
} }
@if (account.Fields.Count > 0) @if (account.Fields.Count > 0)
@@ -119,8 +119,8 @@
<dl class="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 pt-3 text-sm"> <dl class="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] gap-x-3 gap-y-1 px-4 pt-3 text-sm">
@foreach (var field in account.Fields) @foreach (var field in account.Fields)
{ {
<dt class="font-semibold truncate">@field.Name</dt> <dt class="font-semibold truncate">@CustomEmojis.Text(field.Name, account.Emojis)</dt>
<dd class="post-content break-words min-w-0">@((MarkupString)field.Value)</dd> <dd class="post-content break-words min-w-0">@((MarkupString)CustomEmojis.Html(field.Value, account.Emojis))</dd>
} }
</dl> </dl>
} }
+18
View File
@@ -1577,4 +1577,22 @@
<data name="The event&#x27;s server shows who takes part, {0} included. Your other personas are never named." xml:space="preserve"> <data name="The event&#x27;s server shows who takes part, {0} included. Your other personas are never named." xml:space="preserve">
<value>Il server dell'evento mostra chi partecipa, {0} compreso. Le tue altre persone non vengono mai nominate.</value> <value>Il server dell'evento mostra chi partecipa, {0} compreso. Le tue altre persone non vengono mai nominate.</value>
</data> </data>
<data name="Downvotes" xml:space="preserve">
<value>Voti negativi</value>
</data>
<data name="Online" xml:space="preserve">
<value>Online</value>
</data>
<data name="{0} going" xml:space="preserve">
<value>{0} partecipano</value>
</data>
<data name="{0} of {1} going" xml:space="preserve">
<value>{0} su {1} partecipano</value>
</data>
<data name="Cancelled" xml:space="preserve">
<value>Annullato</value>
</data>
<data name="Tentative" xml:space="preserve">
<value>Da confermare</value>
</data>
</root> </root>
+18
View File
@@ -1577,4 +1577,22 @@
<data name="The event&#x27;s server shows who takes part, {0} included. Your other personas are never named." xml:space="preserve"> <data name="The event&#x27;s server shows who takes part, {0} included. Your other personas are never named." xml:space="preserve">
<value>The event's server shows who takes part, {0} included. Your other personas are never named.</value> <value>The event's server shows who takes part, {0} included. Your other personas are never named.</value>
</data> </data>
<data name="Downvotes" xml:space="preserve">
<value>Downvotes</value>
</data>
<data name="Online" xml:space="preserve">
<value>Online</value>
</data>
<data name="{0} going" xml:space="preserve">
<value>{0} going</value>
</data>
<data name="{0} of {1} going" xml:space="preserve">
<value>{0} of {1} going</value>
</data>
<data name="Cancelled" xml:space="preserve">
<value>Cancelled</value>
</data>
<data name="Tentative" xml:space="preserve">
<value>Tentative</value>
</data>
</root> </root>
+10
View File
@@ -25,6 +25,16 @@
display: none; display: none;
} }
/* a custom emoji (CustomEmojis): the height of the text it sits in */
.custom-emoji {
display: inline;
height: 1.3em;
width: auto;
max-width: 3em;
vertical-align: -0.25em;
object-fit: contain;
}
.post-content .ellipsis::after { .post-content .ellipsis::after {
content: "…"; content: "…";
} }
+1
View File
@@ -28,6 +28,7 @@
<script>navigator.serviceWorker.register('service-worker.js');</script> <script>navigator.serviceWorker.register('service-worker.js');</script>
<script type="module" src="vendor/cally.js"></script> <script type="module" src="vendor/cally.js"></script>
<script src="main.js"></script> <script src="main.js"></script>
<script src="js/blurhash.js"></script>
</body> </body>
</html> </html>
+95
View File
@@ -0,0 +1,95 @@
// Blurhash placeholders (https://blurha.sh): an image carrying data-blurhash shows the colours its hash encodes,
// decoded into a small canvas, until it has loaded. The page is watched, so the cards rendered later get theirs too.
(() => {
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
const size = 32
const base83 = (text) => {
let value = 0
for (const c of text) {
const digit = alphabet.indexOf(c)
if (digit < 0)
throw new Error("not a blurhash")
value = value * 83 + digit
}
return value
}
const toLinear = (value) => {
const v = value / 255
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
}
const toSrgb = (value) => {
const v = Math.max(0, Math.min(1, value))
return Math.round(v <= 0.0031308 ? v * 12.92 * 255 : (1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255)
}
const signedSquare = (value) => Math.sign(value) * value * value
// the hash's pixels at size × size, or null when it is not a blurhash
function decode(hash) {
if (!hash || hash.length < 6)
return null
const dimensions = base83(hash[0])
const columns = (dimensions % 9) + 1, rows = Math.floor(dimensions / 9) + 1
if (hash.length !== 4 + 2 * columns * rows)
return null
const maximum = (base83(hash[1]) + 1) / 166
const average = base83(hash.slice(2, 6))
const colours = [[toLinear(average >> 16), toLinear((average >> 8) & 255), toLinear(average & 255)]]
for (let i = 1; i < columns * rows; i++) {
const value = base83(hash.slice(4 + i * 2, 6 + i * 2))
const channel = (quantised) => signedSquare((quantised - 9) / 9) * maximum
colours.push([channel(Math.floor(value / 361)), channel(Math.floor(value / 19) % 19), channel(value % 19)])
}
const pixels = new Uint8ClampedArray(size * size * 4)
for (let y = 0; y < size; y++)
for (let x = 0; x < size; x++) {
let r = 0, g = 0, b = 0
for (let j = 0; j < rows; j++)
for (let i = 0; i < columns; i++) {
const basis = Math.cos(Math.PI * x * i / size) * Math.cos(Math.PI * y * j / size)
const colour = colours[i + j * columns]
r += colour[0] * basis
g += colour[1] * basis
b += colour[2] * basis
}
const p = 4 * (x + y * size)
pixels[p] = toSrgb(r)
pixels[p + 1] = toSrgb(g)
pixels[p + 2] = toSrgb(b)
pixels[p + 3] = 255
}
return pixels
}
function paint(image) {
if (image.dataset.blurhashPainted || (image.complete && image.naturalWidth))
return
image.dataset.blurhashPainted = "1"
try {
const pixels = decode(image.dataset.blurhash)
if (!pixels)
return
const canvas = document.createElement("canvas")
canvas.width = size
canvas.height = size
canvas.getContext("2d").putImageData(new ImageData(pixels, size, size), 0, 0)
image.style.backgroundImage = `url(${canvas.toDataURL()})`
image.style.backgroundSize = "cover"
image.addEventListener("load", () => image.style.removeProperty("background-image"), { once: true })
} catch (e) {
window.logFromJs?.(e.message, "blurhash")
}
}
const scan = (root) => root.querySelectorAll("img[data-blurhash]").forEach(paint)
new MutationObserver(records => {
for (const record of records)
for (const node of record.addedNodes)
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches("img[data-blurhash]"))
paint(node)
scan(node)
}
}).observe(document.documentElement, { childList: true, subtree: true })
scan(document)
})()