first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
/**
* 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. Review bodies are untrusted relay content, so
* every interpolated value goes through `escapeHtml`.
*
* 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 { WRITTEN_MIN_CHARS, profileName } from '@cashumints/shared';
import { copyableIdHtml, escapeHtml, iconGradient, shortNpub } from './client';
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 "propagating to relays" note, for a review published in this session. */
propagating?: boolean;
/**
* How many relays took it, when the review was published in this session.
*
* Only said out loud when some relay refused: "published to 2 of 4" is a fact the
* author can act on (try again later, and it is already on the network), where a
* clean 4 of 4 is just noise on the card.
*/
publishedTo?: { accepted: number; total: number } | null;
/** Adds a link to the mint being reviewed. */
mint?: MintRef | 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;
}
/**
* 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.
*/
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(
review.pubkey,
)}" aria-hidden="true">${escapeHtml(initial)}</span>`;
const picture = review.profile?.picture;
if (!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,
)}" 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.
*
* `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
* to fade from.
*/
export function identityLabelHtml(review: LoadedReview, f: Formatters, resolved = false): string {
const name = profileName(review.profile);
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}`
// 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>`;
}
/** 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>`;
}
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-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 link. Empty on a mint page, where it is the page. */
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>`;
}
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.
const relays = options.publishedTo;
const partial =
relays && relays.accepted < relays.total
? f.t('reviews.publishedPartial', { accepted: relays.accepted, total: relays.total })
: f.t('reviews.published');
const propagating = options.propagating
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>`
: '';
return `<article class="review" 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>
${mintLinkHtml(options.mint)}${noteHtml(review, f)}${propagating}
</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));
}
}
}