/** * The review card, as HTML strings. * * Extracted from the mint page's reviews island so the global feed at /reviews can * render the identical card: same identity resolution, same copy-to-clipboard npub, * same "ratings without a comment collapse into one line" rule. Two pages rendering * 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, 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 * read from the page because these run in two places: the review feed prerenders cards * at build time, where there is no document to read a locale from, and the islands * render the identical markup in the browser. One argument is the price of the two * staying identical. * * What is never translated: the review body, the reviewer's name, their NIP-05 and * 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 { 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'; /** The mint a card points at. Only the global feed passes one: on a mint page it is the page. */ export interface MintRef { href: string; label: string; } export interface CardOptions { /** Adds the transient publishing note for a review published in this session. */ propagating?: boolean; /** 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. */ 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 identity = reviewerIdentity(review); const initial = identity.name ? (identity.name[0] ?? '?').toUpperCase() : (review.npub[5] ?? '?'); const fallback = ``; if (!identity.picture) return `${fallback}`; // 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 `${fallback}`; } /** * 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 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 identity = reviewerIdentity(review); const mark = resolved ? ' resolved' : ''; const nip05 = identity.nip05 ? `${escapeHtml(identity.nip05)}` : ''; return identity.name ? `${escapeHtml(identity.name)}${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. : `${escapeHtml( f.t('reviews.anon'), )}`; } /** The shortened npub, as the copy control. Same value whatever the name says. */ export function npubHtml(review: LoadedReview, f: Formatters): string { return copyableIdHtml({ full: review.npub, short: shortNpub(review.npub), label: f.t('common.copyFull', { kind: f.t('mint.kind.npub') }), className: 'rev-npub', }); } /** * Who wrote it: the kind-0 name (with its NIP-05 beside it) or "Anon", and the * shortened npub under it as the copy control. Two lines, one identity: the name is * for reading, the npub is for checking, and neither is a hex pubkey. * * The block is a fixed height whichever branch runs, so a name arriving from a relay * drops into a line that already had the space. */ export function identityHtml(review: LoadedReview, f: Formatters): string { return `${identityLabelHtml(review, f)}${npubHtml(review, f)}`; } /** * The provenance note is a warning, not a stamp. It only earns its place when a harsh * review comes from an npub with no other trace on the network at all; on every other * card it was noise on nearly every row. */ export function noteHtml(review: LoadedReview, f: Formatters): string { const unknown = review.profile !== null && !review.profile.found; if (!unknown || review.rating === null || review.rating > 2) return ''; return `

${escapeHtml( f.t('reviews.anonNote'), )}

`; } /** 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); return ` ${escapeHtml( ratingText, )} ${escapeHtml(f.relative(review.created_at))} `; } /** What a screen reader hears in place of the star row. */ function ratingLabel(rating: number | null, f: Formatters): string { return rating === null ? f.t('reviews.srNoRating') // The rating is a small integer, passed as a string so it is not run through the // locale's number formatting: "4" should not become "4,0" anywhere. : f.t('reviews.srRating', { rating: String(rating) }); } /** * 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 `

` + `` + `${escapeHtml(mint.label)}

`; } /* ---------- 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( ``, ); const nevent = reviewNevent(review); if (nevent) { const openNostr = escapeHtml(f.t('reviews.actions.openNostr')); parts.push( `` + ``, ); } if (options.permalink && /^[0-9a-f]{64}$/i.test(review.id)) { const copyLink = escapeHtml(f.t('reviews.actions.copyLink')); parts.push( ``, ); } return `${parts.join('')}`; } /* ---------- the card ---------- */ export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string { // The note pulses gently for a few seconds after a review is published. const propagating = options.propagating ? `

${escapeHtml(f.t('reviews.propagating'))}

` : ''; // 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 = `

${reviewBodyHtml(review.content)}

` + `
`; return `
${avatarHtml(review)} ${identityHtml(review, f)} ${metaHtml(review, f)} ${body} ${mintLinkHtml(options.mint)}${noteHtml(review, f)}${propagating} ${actionsHtml(review, f, options)}
`; } /* ---------- rating only summary ---------- */ /** * "18 five star, 1 four star, 3 one star", highest rating first. * * One catalog key per star count rather than a number and a spelled-out word glued * together: languages put the number, the numeral word and the noun in different * orders, and Spanish and Dutch both inflect "star" where English does not here. */ export function ratingBreakdown(rows: LoadedReview[], f: Formatters): string { const parts: string[] = []; for (let stars = 5; stars >= 1; stars--) { const count = rows.filter((r) => r.rating === stars).length; if (count > 0) parts.push(f.t(`reviews.breakdown.${stars}`, { n: count })); } const unrated = rows.filter((r) => r.rating === null).length; if (unrated > 0) parts.push(f.t('reviews.breakdown.none', { n: unrated })); return parts.join(', '); } export function summaryRowHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string { const bad = review.rating !== null && review.rating <= 2; const ratingText = ratingLabel(review.rating, f); const mint = options.mint ? `${escapeHtml(options.mint.label)}` : ''; // A summary row is one line, so the name and the npub sit side by side on it. return `
  • ${identityLabelHtml(review, f)}${npubHtml(review, f)}${mint} ${escapeHtml( ratingText, )} ${escapeHtml(f.relative(review.created_at))}
  • `; } export interface SummaryOptions { /** Asked per row, so the global feed can link each rating to its own mint. */ mintFor?: (review: LoadedReview) => MintRef | null; /** * Day this block covers, already formatted. The mint page has one block for the * whole mint and omits it; the feed has one block per day and needs to say which. */ when?: string; /** Identifies the block across re-renders, so an opened one stays open. */ key?: string; } /** * One collapsed block for the ratings that came with no comment. * * Dozens of "rated 5, no comment" cards drown the reviews that say something, on a * mint page and even more so in a feed across every mint. They are not hidden: the * list is one click away and the filter counts above still count them. */ export function summaryHtml( rows: LoadedReview[], f: Formatters, open: boolean, options: SummaryOptions = {}, ): string { if (rows.length === 0) return ''; const key = options.key ? ` data-summary-key="${escapeHtml(options.key)}"` : ''; /* * Two whole sentences rather than one with an optional " on {date}" spliced in. * English tolerates a trailing fragment; Spanish and Dutch put the date somewhere * else in the clause, and a translator handed a dangling " on {when}" cannot move it. * The count arrives as pre-escaped bold markup so its position is theirs to choose * too. */ const count = `${f.number(rows.length)}`; const breakdown = escapeHtml(ratingBreakdown(rows, f)); const line = options.when ? f.t('reviews.summary.lineDay', { n: rows.length, count, when: escapeHtml(options.when), breakdown, }) : f.t('reviews.summary.line', { n: rows.length, count, breakdown }); return `
    ${line} ${escapeHtml( f.t(rows.length === 1 ? 'reviews.summary.showOne' : 'reviews.summary.showMany'), )}
    `; } /** * Upgrade rendered cards in place once the kind-0 lookups land. * * A patch rather than a re-render: only cards whose profile actually resolved change, * so nothing that is already correct flickers, and the identity line has a fixed * height either way so nothing moves. */ export function applyProfiles( root: ParentNode, f: Formatters, lookup: (pubkey: string) => LoadedReview | undefined, ): void { for (const card of root.querySelectorAll('[data-review-pubkey]')) { const review = lookup(card.dataset['reviewPubkey'] ?? ''); if (!review?.profile) continue; const isRow = card.classList.contains('rs-row'); if (review.profile.found) { // Only the name row is rewritten. The npub under it is the same either way, so // its copy button is left alone rather than rebuilt underneath a cursor. const label = card.querySelector('.rev-name'); if (label) label.outerHTML = identityLabelHtml(review, f, true); if (!isRow) { const avatar = card.querySelector('.rev-avatar'); if (avatar) avatar.outerHTML = avatarHtml(review); // A cached picture can finish loading before its onload attribute is live. const pic = card.querySelector('.avatar.pic'); if (pic?.complete && pic.naturalWidth > 0) pic.classList.add('loaded'); } } else if (!isRow && !card.querySelector('.rev-note')) { // Nothing found for this npub. That only matters on a harsh review, and // `noteHtml` returns an empty string on every other one. card.insertAdjacentHTML('beforeend', noteHtml(review, f)); } } } /* ---------- 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('.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('.rev-body'); const more = wrap.querySelector('[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 = ''; 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('[data-more]'); if (more) { const body = more.closest('.rev-text')?.querySelector('.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('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 = `${ACT_TICK}`; 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; }