/**
* 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 ``; } /** 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)}
` + `