Files
CashuMints.space/web/src/lib/review-cards.ts
T

593 lines
25 KiB
TypeScript

/**
* 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 = `<span class="avatar${identity.name ? '' : ' key'}" style="background:${iconGradient(
review.pubkey,
)}" aria-hidden="true">${escapeHtml(initial)}</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(
identity.picture,
)}" alt="" width="38" height="38" loading="lazy" referrerpolicy="no-referrer" ` +
`onload="this.classList.add('loaded')" onerror="this.remove()"></span>`;
}
/**
* 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
? `<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" 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. */
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 `<span class="rev-identity">${identityLabelHtml(review, f)}</span>${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 `<p class="rev-note"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>${escapeHtml(
f.t('reviews.anonNote'),
)}</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);
return `<span class="rev-meta">
<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>`;
}
/** 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 `<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 for a few seconds after a review is published.
const propagating = options.propagating
? `<p class="rev-note propagating">${escapeHtml(f.t('reviews.propagating'))}</p>`
: '';
// 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)}
${body}
${mintLinkHtml(options.mint)}${noteHtml(review, f)}${propagating}
${actionsHtml(review, f, options)}
</article>`;
}
/* ---------- 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
? `<a class="rs-mint" href="${escapeHtml(options.mint.href)}">${escapeHtml(options.mint.label)}</a>`
: '';
// A summary row is one line, so the name and the npub sit side by side on it.
return `<li class="rs-row" data-review-pubkey="${escapeHtml(review.pubkey)}">
${identityLabelHtml(review, f)}${npubHtml(review, f)}${mint}
<span class="rs-stars${bad ? ' bad' : ''}"><span class="sr-only">${escapeHtml(
ratingText,
)}</span><span aria-hidden="true">${starString(review.rating)}</span></span>
<span class="rs-date">${escapeHtml(f.relative(review.created_at))}</span>
</li>`;
}
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 = `<b>${f.number(rows.length)}</b>`;
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 `<details class="rating-summary"${open ? ' open' : ''}${key}>
<summary>
<span class="rs-line">${line}</span>
<span class="rs-toggle">${escapeHtml(
f.t(rows.length === 1 ? 'reviews.summary.showOne' : 'reviews.summary.showMany'),
)}</span>
</summary>
<ul class="rs-list">${rows
.map((review) => summaryRowHtml(review, f, { mint: options.mintFor?.(review) ?? null }))
.join('')}</ul>
</details>`;
}
/**
* 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<HTMLElement>('[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<HTMLImageElement>('.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<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;
}