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:
+316
-26
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user