Expand i18n locales and improve reviews UI

Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-21 05:06:58 +02:00
co-authored by Cursor
parent 1c5df18e81
commit 47e6537dde
51 changed files with 15291 additions and 246 deletions
+316 -26
View File
@@ -7,8 +7,10 @@
* a review two ways would be two things to keep in step, and they would drift.
*
* Everything here is a pure string builder except `applyProfiles`, which patches
* already-rendered cards in place. Review bodies are untrusted relay content, so
* every interpolated value goes through `escapeHtml`.
* already-rendered cards in place, and the two wiring helpers at the bottom, which
* only run in a browser. Review bodies are untrusted relay content, so every
* interpolated value goes through `escapeHtml`, and the body itself goes through
* `reviewBodyHtml` (see lib/review-body.ts for the rules).
*
* Every function that has words in it takes a `Formatters`, which carries both the
* translator and the locale's date and number formatting. It is passed in rather than
@@ -21,8 +23,10 @@
* their npub. That is what someone wrote and who wrote it, not site copy, and it
* renders byte for byte the same in all three languages.
*/
import { WRITTEN_MIN_CHARS, profileName } from '@cashumints/shared';
import { copyableIdHtml, escapeHtml, iconGradient, shortNpub } from './client';
import { PRIMARY_RELAY, WRITTEN_MIN_CHARS, profileName } from '@cashumints/shared';
import { nip19 } from 'nostr-tools';
import { copyableIdHtml, iconGradient, shortNpub } from './client';
import { escapeHtml, reviewBodyHtml } from './review-body';
import { starString, type Formatters } from '../i18n/format';
import type { LoadedReview } from './reviews-client';
@@ -45,6 +49,12 @@ export interface CardOptions {
publishedTo?: { accepted: number; total: number } | null;
/** Adds a link to the mint being reviewed. */
mint?: MintRef | null;
/**
* The page this review permanently lives at, absolute and without a fragment;
* `#review-{id}` is appended here. With one set, the card grows a "copy link"
* action. On a mint page it is the page itself; on the feed it is the mint's page.
*/
permalink?: string | null;
}
/** A review with something to read, as opposed to a bare rating. */
@@ -52,29 +62,87 @@ export function isWritten(review: LoadedReview): boolean {
return review.content.trim().length >= WRITTEN_MIN_CHARS;
}
/* ---------- identity ---------- */
/**
* Who wrote a review, resolved once and rendered from this object everywhere.
*
* The one place the name row's fields are decided, so a future trust hint (say, a
* web-of-trust signal from an external API) is a field added here and a span added
* in `identityLabelHtml`, with no change to the card layout around it.
*/
export interface ReviewerIdentity {
pubkey: string;
npub: string;
/** The kind-0 name, or null. Null is exactly what the "Anon" label means. */
name: string | null;
/** The NIP-05, already deduped against the name; null when absent or redundant. */
nip05: string | null;
picture: string | null;
}
export function reviewerIdentity(review: LoadedReview): ReviewerIdentity {
const name = profileName(review.profile);
let nip05 = (name ? review.profile?.nip05 : null) ?? null;
/*
* One identity, not the same string twice. A profile whose name IS its NIP-05
* ("azzamo.network" beside `_@azzamo.network`, which sanitizing strips to the bare
* domain) used to render the domain twice in a row. If the name and the nip05 (or
* its domain half) are effectively the same string, the name alone says it all.
*/
if (name && nip05) {
const flatName = name.trim().toLowerCase();
const flatNip = nip05.trim().toLowerCase();
const domain = flatNip.includes('@') ? flatNip.slice(flatNip.lastIndexOf('@') + 1) : flatNip;
if (flatName === flatNip || flatName === domain) nip05 = null;
}
return {
pubkey: review.pubkey,
npub: review.npub,
name,
nip05,
picture: review.profile?.picture ?? null,
};
}
/**
* Whether a review is anonymous: no kind-0 name resolved for its pubkey.
*
* This is, by construction, the same test that makes the card say "Anon", so the
* "hide anon" toggle and the label can never disagree about who is anonymous.
*/
export function isAnon(review: LoadedReview): boolean {
return profileName(review.profile) === null;
}
/**
* The reviewer's avatar: the generated initial always, with the kind-0 picture layered
* over it. A picture that fails to load removes itself and what was already underneath
* shows through, so a dead image URL costs nothing.
*
* The initial: first character of the resolved name, uppercased. Unresolved keys show
* the first character after `npub1`, kept lowercase and set in mono, so the tile reads
* as a key fragment rather than pretending to be somebody's initial. Both are
* deterministic per pubkey, which is what keeps two anonymous reviewers tellable apart.
*/
export function avatarHtml(review: LoadedReview): string {
const name = profileName(review.profile);
// With no name the letter comes from the npub, not from the word "Anon": every
// anonymous reviewer would otherwise get the same tile, and the point of the
// generated avatar is telling two of them apart.
const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase();
const fallback = `<span class="avatar" style="background:${iconGradient(
const identity = reviewerIdentity(review);
const initial = identity.name
? (identity.name[0] ?? '?').toUpperCase()
: (review.npub[5] ?? '?');
const fallback = `<span class="avatar${identity.name ? '' : ' key'}" style="background:${iconGradient(
review.pubkey,
)}" aria-hidden="true">${escapeHtml(initial)}</span>`;
const picture = review.profile?.picture;
if (!picture) return `<span class="rev-avatar">${fallback}</span>`;
if (!identity.picture) return `<span class="rev-avatar">${fallback}</span>`;
// The picture fades over the initial once it has actually decoded, so a slow image
// arrives rather than popping, and a dead one costs nothing: it removes itself and
// what was already underneath shows through. Neither path moves any layout.
return `<span class="rev-avatar">${fallback}<img class="avatar pic" src="${escapeHtml(
picture,
identity.picture,
)}" alt="" width="38" height="38" loading="lazy" referrerpolicy="no-referrer" ` +
`onload="this.classList.add('loaded')" onerror="this.remove()"></span>`;
}
@@ -82,22 +150,29 @@ export function avatarHtml(review: LoadedReview): string {
/**
* Just the name row, for patching a card in place when its profile lands.
*
* Renders from one `ReviewerIdentity`, nothing else: see that type's docblock.
*
* `resolved` marks a name that arrived after the card was drawn, so it can cross-fade
* in over the npub it replaces. The first render never carries it: there is nothing
* in over the label it replaces. The first render never carries it: there is nothing
* to fade from.
*
* The NIP-05 is informational only. This site never fetches the `.well-known`
* behind one (a cross-origin lottery), so it renders without any verified mark and
* its title says exactly what it is.
*/
export function identityLabelHtml(review: LoadedReview, f: Formatters, resolved = false): string {
const name = profileName(review.profile);
const identity = reviewerIdentity(review);
const mark = resolved ? ' resolved' : '';
const nip05 =
name && review.profile?.nip05
? `<span class="nip05${mark}" title="${escapeHtml(review.profile.nip05)}">${escapeHtml(review.profile.nip05)}</span>`
: '';
return name
? `<span class="rev-name${mark}">${escapeHtml(name)}</span>${nip05}`
const nip05 = identity.nip05
? `<span class="nip05${mark}" title="${escapeHtml(f.t('reviews.nip05Title'))}">${escapeHtml(identity.nip05)}</span>`
: '';
return identity.name
? `<span class="rev-name${mark}">${escapeHtml(identity.name)}</span>${nip05}`
// No kind 0 for this key. The placeholder is site copy, not a name someone chose,
// so it is the one part of an identity that does get translated.
: `<span class="rev-name anon">${escapeHtml(f.t('reviews.anon'))}</span>`;
: `<span class="rev-name anon" title="${escapeHtml(f.t('reviews.anonTitle'))}">${escapeHtml(
f.t('reviews.anon'),
)}</span>`;
}
/** The shortened npub, as the copy control. Same value whatever the name says. */
@@ -135,6 +210,7 @@ export function noteHtml(review: LoadedReview, f: Formatters): string {
)}</p>`;
}
/** Stars and relative date on one right-aligned line, top-aligned with the name row. */
export function metaHtml(review: LoadedReview, f: Formatters): string {
const bad = review.rating !== null && review.rating <= 2;
const ratingText = ratingLabel(review.rating, f);
@@ -142,6 +218,7 @@ export function metaHtml(review: LoadedReview, f: Formatters): string {
<span class="rev-stars${bad ? ' bad' : ''}"><span class="sr-only">${escapeHtml(
ratingText,
)}</span><span aria-hidden="true">${starString(review.rating)}</span></span>
<span class="rev-dot" aria-hidden="true">·</span>
<span class="rev-date">${escapeHtml(f.relative(review.created_at))}</span>
</span>`;
}
@@ -155,12 +232,76 @@ function ratingLabel(rating: number | null, f: Formatters): string {
: f.t('reviews.srRating', { rating: String(rating) });
}
/** The mint this review is about, as a link. Empty on a mint page, where it is the page. */
/**
* The mint this review is about, as a small chip. Empty on a mint page, where the
* mint is the page. The icon marks it as a place to go, not a bare string.
*/
function mintLinkHtml(mint: MintRef | null | undefined): string {
if (!mint) return '';
return `<p class="rev-mint"><a href="${escapeHtml(mint.href)}">${escapeHtml(mint.label)}</a></p>`;
return `<p class="rev-mint"><a class="rev-mint-chip" href="${escapeHtml(mint.href)}">` +
`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
`<path d="M3 10.5 12 4l9 6.5"/><path d="M5 10v9h14v-9"/><path d="M10 19v-5h4v5"/></svg>` +
`<span>${escapeHtml(mint.label)}</span></a></p>`;
}
/* ---------- card actions ---------- */
/** The review event as a NIP-19 nevent, with this site's relay as the hint. */
export function reviewNevent(review: LoadedReview): string | null {
// Fixture and optimistic ids may not be event ids; a card without the njump
// action beats a card that throws while rendering.
if (!/^[0-9a-f]{64}$/i.test(review.id)) return null;
try {
return nip19.neventEncode({ id: review.id.toLowerCase(), relays: [PRIMARY_RELAY] });
} catch {
return null;
}
}
/**
* The quiet actions row: copy npub, open the event on njump, copy the permalink.
* Icons only, labelled by title + aria-label; the copy buttons are driven by the
* delegated handler in `wireReviewCards`.
*/
function actionsHtml(review: LoadedReview, f: Formatters, options: CardOptions): string {
const parts: string[] = [];
const copyNpub = escapeHtml(f.t('reviews.actions.copyNpub'));
parts.push(
`<button type="button" class="rev-act" data-copy-act="${escapeHtml(review.npub)}"` +
` title="${copyNpub}" aria-label="${copyNpub}">` +
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
`<circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3"/></svg></button>`,
);
const nevent = reviewNevent(review);
if (nevent) {
const openNostr = escapeHtml(f.t('reviews.actions.openNostr'));
parts.push(
`<a class="rev-act" href="https://njump.me/${escapeHtml(nevent)}" target="_blank"` +
` rel="noopener nofollow" title="${openNostr}" aria-label="${openNostr}">` +
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
`<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg></a>`,
);
}
if (options.permalink && /^[0-9a-f]{64}$/i.test(review.id)) {
const copyLink = escapeHtml(f.t('reviews.actions.copyLink'));
parts.push(
`<button type="button" class="rev-act" data-copy-act="${escapeHtml(
`${options.permalink}#review-${review.id}`,
)}" title="${copyLink}" aria-label="${copyLink}">` +
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
`<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>` +
`<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>`,
);
}
return `<span class="rev-actions">${parts.join('')}</span>`;
}
/* ---------- the card ---------- */
export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
// The note pulses gently while the review is still in flight. It stops the moment
// the relays echo the review back, because then the card is redrawn without it.
@@ -173,12 +314,30 @@ export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOpt
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>`
: '';
return `<article class="review" data-review-pubkey="${escapeHtml(review.pubkey)}">
// The anchor id only for real event ids, so `#review-{id}` deep links resolve and
// a fixture id cannot mint a colliding element id.
const anchor = /^[0-9a-f]{64}$/i.test(review.id) ? ` id="review-${review.id.toLowerCase()}"` : '';
/*
* The body is clamped by `armReadMore` after it is on the page, not here: rendering
* it clamped would hide the tail of a long review from everyone without JavaScript,
* and the measurement of "is this taller than six lines" needs a layout to ask.
*/
const body =
`<div class="rev-text"><p class="rev-body">${reviewBodyHtml(review.content)}</p>` +
`<button type="button" class="rev-more" data-more hidden aria-expanded="false"` +
` data-more-label="${escapeHtml(f.t('reviews.readMore'))}"` +
` data-less-label="${escapeHtml(f.t('reviews.readLess'))}">${escapeHtml(
f.t('reviews.readMore'),
)}</button></div>`;
return `<article class="review"${anchor} data-review-pubkey="${escapeHtml(review.pubkey)}">
${avatarHtml(review)}
<span class="rev-who">${identityHtml(review, f)}</span>
${metaHtml(review, f)}
<p class="rev-body">${escapeHtml(review.content)}</p>
${body}
${mintLinkHtml(options.mint)}${noteHtml(review, f)}${propagating}
${actionsHtml(review, f, options)}
</article>`;
}
@@ -314,3 +473,134 @@ export function applyProfiles(
}
}
}
/* ---------- browser wiring (never runs at build time) ---------- */
/**
* Clamp long bodies (six lines, mirrored by the `.rev-body.clamped` rule) and reveal
* their "Read more" buttons, for every card under `root` that has not been armed yet.
*
* Runs after each paint, on elements that are already laid out: whether a body
* exceeds six lines is a question only the layout can answer, and clamping in the
* markup would hide the tail of long reviews from readers without JavaScript.
*/
export function armReadMore(root: ParentNode): void {
for (const wrap of root.querySelectorAll<HTMLElement>('.rev-text')) {
// A card being staged off-document (the bones cross-fade builds rows detached)
// has no layout to measure yet; it is left un-armed for the pass after insertion.
if (!wrap.isConnected || wrap.dataset['armed']) continue;
wrap.dataset['armed'] = '1';
const body = wrap.querySelector<HTMLElement>('.rev-body');
const more = wrap.querySelector<HTMLElement>('[data-more]');
if (!body || !more) continue;
body.classList.add('clamped');
// +2px of slack: rounding a line-height must not produce a button that expands
// nothing.
if (body.scrollHeight > body.clientHeight + 2) more.hidden = false;
else body.classList.remove('clamped');
}
}
/** The tick every copy confirmation on the site uses, in the actions row's size. */
const ACT_TICK =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#5FD68F" ' +
'stroke-width="2.5" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>';
let cardsWired = false;
/**
* Wire every review card's own controls, now and after every re-render.
*
* Delegated from the document because the lists are redrawn through innerHTML on
* every filter, page and profile change, and per-card listeners would be lost each
* time. Two jobs: the "Read more" clamp toggle, and the icon copy actions (npub,
* permalink), which confirm with the same tick every copy control on the site shows.
*/
export function wireReviewCards(): void {
if (cardsWired) return;
cardsWired = true;
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement | null;
const more = target?.closest<HTMLButtonElement>('[data-more]');
if (more) {
const body = more.closest('.rev-text')?.querySelector<HTMLElement>('.rev-body');
if (!body) return;
const expand = body.classList.contains('clamped');
body.classList.toggle('clamped', !expand);
more.setAttribute('aria-expanded', String(expand));
more.textContent = (expand ? more.dataset['lessLabel'] : more.dataset['moreLabel']) ?? '';
return;
}
const act = target?.closest<HTMLButtonElement>('button[data-copy-act]');
if (!act || act.dataset['copied']) return;
const value = act.dataset['copyAct'];
if (!value) return;
void navigator.clipboard.writeText(value).then(
() => {
const icon = act.querySelector('svg');
const iconWas = icon?.outerHTML ?? null;
act.dataset['copied'] = '1';
if (icon) icon.outerHTML = `<span class="copy-tick">${ACT_TICK}</span>`;
window.setTimeout(() => {
const tick = act.querySelector('.copy-tick');
if (tick && iconWas) tick.outerHTML = iconWas;
delete act.dataset['copied'];
}, 1200);
},
() => {
// Clipboard denied (insecure origin, or the user said no). Nothing to undo.
},
);
});
}
/**
* The "hide anon" preference, one key for the whole site: mint pages, federation
* pages and /reviews all read and write the same slot, so the toggle follows the
* reader wherever reviews are shown.
*/
const HIDE_ANON_KEY = 'cashumints:hide-anon';
export function readHideAnon(): boolean {
try {
return localStorage.getItem(HIDE_ANON_KEY) === '1';
} catch {
return false;
}
}
export function storeHideAnon(on: boolean): void {
try {
localStorage.setItem(HIDE_ANON_KEY, on ? '1' : '0');
} catch {
// Storage blocked: the toggle still works for this page view.
}
}
/** The event id in a `#review-{id}` fragment, or null. */
export function anchoredReviewId(): string | null {
const match = /^#review-([0-9a-f]{64})$/i.exec(window.location.hash);
return match?.[1]?.toLowerCase() ?? null;
}
/**
* Scroll the anchored card into view and give it the same one-time amber edge a
* freshly published review gets. Returns whether the card was on the page, so the
* caller can keep waiting when its island has not rendered the review yet.
*/
export function revealAnchoredReview(id: string, behavior: ScrollBehavior): boolean {
const card = document.getElementById(`review-${id}`);
if (!card) return false;
card.scrollIntoView({ behavior, block: 'center' });
// Same class as a just-published card: one amber pulse, then an ordinary review.
// Under prefers-reduced-motion the animation is globally disabled, so this is
// simply a scroll there.
card.classList.add('is-new');
window.setTimeout(() => card.classList.remove('is-new'), 1600);
return true;
}