Files
CashuMints.space/web/src/components/Reviews.astro
T
michilisandCursor c74c7fc187 Ship LNURL mint pages, indexing UI, and reviews rewrite.
Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:43 +02:00

810 lines
33 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
/**
* Island: the reviews panel.
*
* Prerendered with the counts the API already knows, so the panel occupies its final
* space and shows real numbers with JavaScript off. Review bodies live on Nostr, so
* the list itself hydrates from relays on load.
*
* Used by both a mint page and a federation page, unchanged. What is being reviewed
* arrives as a `ReviewSubject` — which `k`, which `d`, which `u` — and nothing below
* this line asks which ecosystem produced it. See `lib/review-subject.ts`.
*/
import type { RatingDistribution } from '@cashumints/shared';
import type { ReviewSubject } from '../lib/review-subject';
import { renderBones } from 'boneyard-js';
import WriteForm from './WriteForm.astro';
import reviewsBones from '../bones/reviews-panel.bones.json';
import { BONE_COLOR } from '../lib/skeleton';
import { useI18n } from '../i18n';
import { formatters } from '../i18n/format';
import { pageLocale } from '../i18n/paths';
interface Props {
/**
* What is being reviewed: the tags a review carries and the filters that find them.
*
* Null on the 404 page alone, which renders this panel for a mint it has not resolved
* yet: the resolver writes the subject onto the root and tells the island to start.
* Every prerendered page passes a real one.
*/
subject: ReviewSubject | null;
/** The counts the API already knows, prerendered so the panel is honest without JS. */
reviewCount: number;
distribution: RatingDistribution;
/** Named in the write dialog's heading. The thing's own name, never translated. */
name: string;
}
const { subject, reviewCount, distribution, name } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const f = formatters(t);
const dist = distribution;
/*
* The placeholder asks about the thing being reviewed, which is the one sentence in
* this panel that cannot be ecosystem-neutral without going vague: "how did minting and
* melting work out" means nothing about a federation, and "how did it go" means nothing
* about anything. One key per ecosystem, falling back to the Cashu wording for a type
* whose own has not been written yet.
*/
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject?.type ?? 'cashu'}`;
const bodyPlaceholder = t.has(placeholderKey)
? t(placeholderKey)
: t('reviews.dialog.bodyPlaceholder.cashu');
const critical = dist['1'] + dist['2'];
const fiveStar = dist['5'];
/**
* Bones are drawn into the prerendered HTML, not by the island.
*
* `renderBones` is a pure string builder, so it runs here at build time just as well
* as in the browser. Doing it here means the skeleton is on screen at first paint
* instead of after the island's chunk has downloaded, which on a slow connection is
* the difference between a skeleton and a second of loading text followed by a jump.
*
* One block per captured breakpoint, picked by media query in global.css, because
* bone heights differ per width and the server cannot know the viewport.
*/
const boneBlocks = Object.entries(reviewsBones.breakpoints)
.map(([width, skeleton]) => ({ width: Number(width), html: renderBones(skeleton, BONE_COLOR, false) }))
.sort((a, b) => a.width - b.width);
// Bones stand in for reviews the API has already counted. With none counted there is
// nothing to wait for, and the empty state below is the truthful thing to prerender.
const showBoneBlocks = reviewCount > 0 && boneBlocks.length > 0;
/**
* Without JavaScript the relays are never queried, so the bones would sit there for
* good. Drop them and say what is actually going on.
*
* Emitted as raw HTML on purpose: Astro hoists every <style> it parses out of the
* template and into the page's stylesheet, which would hide the bones for everyone.
*/
const noscriptFallback =
`<noscript><style>[data-review-items] .by-bones{display:none}</style>` +
`<p class="rev-status">${escapeText(t('reviews.noscript'))}</p></noscript>`;
/** This block goes in as raw HTML, so the one translated string in it is escaped. */
function escapeText(value: string): string {
return value
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
---
{/*
The island's root is `data-reviews-panel`, not `data-reviews`: a mint card already
carries that one, as its review count, and this island now looks for its root on
every page the router brings in, not only on a mint page.
*/}
<section
class="panel"
aria-labelledby="reviews-title"
data-reveal
data-reviews-panel
data-subject={subject ? JSON.stringify(subject) : ''}
>
<div class="panel-head">
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
{/* A protocol name, not a label: NIP-87 is NIP-87 in every language. */}
<span class="nip-badge">NIP-87</span>
<div class="filters" role="group" aria-label={t('reviews.filter.group')}>
<button class="filter" type="button" data-filter="all" aria-pressed="true">
{t('reviews.filter.all')} · <span data-count="all">{f.number(reviewCount)}</span>
</button>
<button class="filter" type="button" data-filter="5" aria-pressed="false">
{t('reviews.filter.five')} · <span data-count="5">{f.number(fiveStar)}</span>
</button>
<button class="filter" type="button" data-filter="critical" aria-pressed="false">
{t('reviews.filter.critical')} · <span data-count="critical">{f.number(critical)}</span>
</button>
<button class="filter" type="button" data-filter="recent" aria-pressed="false">
{t('reviews.filter.recent')}
</button>
{/*
Not a filter but styled as one: hides reviews AND ratings from keys with no
kind-0 profile. Hidden until the island knows how many that is; without
JavaScript there is nothing it could hide anyway.
*/}
<button class="filter" type="button" data-anon-toggle aria-pressed="false" hidden>
{t('reviews.filter.hideAnon')}<span data-count="anon"></span>
</button>
</div>
</div>
{/* One line of consequence when the toggle above is on: how much is showing. */}
<p class="anon-note" data-anon-note hidden></p>
{/*
Filled by the island with one line summarising every review that carried a rating
and no words. Empty (and invisible) until the relays answer, and on a mint where
every review has a body it stays empty.
*/}
<div data-rating-summary></div>
{/*
Only the panel body is skeletoned: the header, badge, filter chips and their
counts above are prerendered and stay put. `data-boneyard` marks the capture
target for `pnpm bones`, which snapshots this element's single child.
*/}
<div data-review-list data-review-count={reviewCount}>
{/*
`data-boneyard` only when there is something to measure.
`pnpm bones` captures two pages, and the second is a /mint/ URL with no prerendered
page — which now carries this panel too, with no subject and nothing in it. Marked
as a capture target there, it would overwrite the reviews bones taken from the real
mint page a moment earlier with the bones of an empty box.
*/}
<div data-boneyard={subject ? 'reviews-panel' : undefined} data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
<div data-review-items>
{
showBoneBlocks ? (
<Fragment>
<div class="by-bones by-shimmer" aria-hidden="true">
{boneBlocks.map((block) => (
<div class="by-bp" data-by-bp={block.width} set:html={block.html} />
))}
</div>
<p class="sr-only" role="status">
{t('reviews.loading', { n: reviewCount })}
</p>
<Fragment set:html={noscriptFallback} />
</Fragment>
) : (
<p class="rev-status" data-review-status>
{
reviewCount === 0
? t('reviews.empty')
: `${t('reviews.loading', { n: reviewCount })}…`
}
</p>
)
}
</div>
</div>
</div>
<div class="load-row" data-pager hidden>
<div class="pager" role="group" aria-label={t('common.reviewPages')}></div>
</div>
</section>
<!--
The write review dialog.
A modal in the site's one modal shell (.modal, see global.css): centred, backdrop
blurred, page locked behind it, focus trapped by the browser because it is a real
<dialog> opened with showModal().
Everything except the identity row is prerendered. The identity row cannot be: who
is signing is only knowable in the browser, and asking a NIP-07 extension for a
pubkey is a question worth asking once the reader has opened the dialog, not on
every page load.
-->
<dialog class="modal write-dialog" data-write-dialog aria-labelledby="write-title">
<WriteForm locale={locale} placeholder={bodyPlaceholder}>
<div class="modal-head" slot="head">
{/* The mint's name is data, dropped into a translated sentence. */}
<h2 id="write-title">{t('reviews.dialog.title', { name })}</h2>
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</WriteForm>
</dialog>
<style>
/* The write dialog's own styles live in global.css beside the other modals: the
identity row and the status line are injected by the island, and Astro's scoped
selectors do not reach markup added through innerHTML. */
.load-row { padding: 16px 20px; display: flex; justify-content: center; border-top: 1px solid var(--line-soft); }
.pager { display: flex; gap: 6px; align-items: center; font-size: 13.5px; flex-wrap: wrap; justify-content: center; }
</style>
<script>
import { escapeHtml, wireCopyableIds } from '../lib/client';
import { useI18n } from '../i18n/client';
import { formatters } from '../i18n/format';
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../scripts/reveal';
import { loadProfiles, loadReviews, safeNpub, type LoadedReview } from '../lib/reviews-client';
import type { ReviewSubject } from '../lib/review-subject';
// Stars, body, identity row, draft safety and publishing: shared with the
// review-by-URL dialog the index pages open, so there is one signing flow on the site.
import { takeFreshReview, wireWriteForm } from '../lib/write-review';
// The card itself lives in one place: /reviews renders the identical markup from
// the same functions.
import {
anchoredReviewId, applyProfiles, armReadMore, isAnon, isWritten, readHideAnon,
revealAnchoredReview, reviewHtml, storeHideAnon, summaryHtml, wireReviewCards,
} from '../lib/review-cards';
import { adoptBones, armBuildSnapshot, clearBones, isBuildMode, showBones, swapBones } from '../lib/skeleton';
import reviewsBones from '../bones/reviews-panel.bones.json';
const setup = () => {
wireCopyableIds();
wireReviewCards();
/*
* Built here, not at module scope. `onReady` runs this again after every view
* transition, so a reader who clicks from an English mint page to a Dutch one gets a
* panel whose filters, empty states and dialog are all in the language they just
* asked for, rather than the one the module happened to load in.
*/
const t = useI18n();
const f = formatters(t);
const root = document.querySelector<HTMLElement>('[data-reviews-panel]');
/*
* The subject crosses from the page as JSON in a data attribute rather than as
* separate attributes per field. It is one object with one meaning, the page and the
* island are the same build, and a malformed one is a bug rather than user input —
* but it is still parsed defensively, because a panel that threw here would take the
* write dialog down with it.
*
* A function returning a const rather than a `let` assigned in a try: the closures
* below capture it, and TypeScript will not carry a narrowing into a closure over a
* mutable binding.
*/
const readSubject = (): ReviewSubject | null => {
try {
return JSON.parse(root?.dataset['subject'] ?? 'null') as ReviewSubject | null;
} catch {
return null;
}
};
const parsedSubject = readSubject();
if (root && parsedSubject) {
// Re-bound inside the guard as a non-nullable const. `load` below is a hoisted
// function declaration, and TypeScript will not carry the guard's narrowing into
// one of those; this is the local it can.
const subject: ReviewSubject = parsedSubject;
const listWrap = root.querySelector<HTMLElement>('[data-review-list]')!;
const list = root.querySelector<HTMLElement>('[data-review-items]')!;
const summarySlot = root.querySelector<HTMLElement>('[data-rating-summary]')!;
const pagerWrap = root.querySelector<HTMLElement>('[data-pager]')!;
const pager = pagerWrap.querySelector<HTMLElement>('.pager')!;
const PAGE_SIZE = 10;
/** Relays get this long to answer before the bones give way to the error state. */
const RELAY_TIMEOUT_MS = 8000;
/** How many reviews the API counted at build time. Drives whether bones are worth showing. */
const expected = Number(listWrap.dataset['reviewCount'] ?? '0');
let all: LoadedReview[] = [];
/**
* A review published from the review-by-URL dialog on an index page, on its way
* here. Read once, at setup, so a reload does not resurrect it; merged in below
* when the relays answer. See `takeFreshReview`.
*/
const handedForward = takeFreshReview(subject.key);
let filter: 'all' | '5' | 'critical' | 'recent' = 'all';
let page = 1;
/**
* Hide reviews and ratings from keys with no kind-0 profile. Shared with the
* feed page through one localStorage key, so the preference follows the reader
* across mint pages, federation pages and /reviews alike.
*/
let hideAnon = readHideAnon();
/**
* True once the kind-0 lookup has settled, either way. Before that, an
* unresolved profile means "not looked up yet", and the empty state must not
* claim "all reviews here are anon" about names that are still in flight.
*/
let profilesSettled = false;
/** True while bones are on screen, so the first real render cross-fades over them. */
let bonesUp = false;
/**
* A scheduled repaint, while the outgoing list is fading.
*
* At most one is ever in flight: a filter changed twice in 120ms does not queue a
* second fade, it lands on whichever filter is set when the timer runs, because
* the paint reads the current state rather than a captured one.
*/
let pendingPaint = 0;
/** Set for one paint after publishing, to mark the new card as it goes in. */
let markNewest = false;
/**
* Reviews published in this session, shown at the top until relays echo them back.
*
* Keyed by event id, holding how many relays took it: a review that only two of
* four relays accepted says so on its card, because that is a thing the author
* may want to act on.
*/
const optimistic = new Map<string, { accepted: number; total: number }>();
/* ---------- rendering ---------- */
/**
* The list the panel works from: everything, or everything with a resolved name.
*
* `isAnon` is the same test that makes a card say "Anon", so this toggle and that
* label cannot disagree. The one exemption is a review published in this session:
* its author's own card must not vanish under them while their kind-0 is still
* being fetched.
*/
function visible(): LoadedReview[] {
if (!hideAnon) return all;
return all.filter((r) => !isAnon(r) || optimistic.has(r.id));
}
function filtered(): LoadedReview[] {
const rows = visible();
return filter === '5' ? rows.filter((r) => r.rating === 5)
: filter === 'critical' ? rows.filter((r) => r.rating !== null && r.rating <= 2)
: filter === 'recent' ? rows.filter((r) => r.created_at > Date.now() / 1000 - 90 * 86400)
: rows;
}
/* ---------- rating only summary ---------- */
/**
* One row for every rating with no comment, collapsed by default. The filter
* counts above still count these; this only decides how much room they take.
*/
function renderSummary(): void {
const rows = filtered().filter((review) => !isWritten(review));
const open = summarySlot.querySelector('details')?.open ?? false;
summarySlot.innerHTML = summaryHtml(rows, f, open);
}
/** Where a copied "link to this review" points: this very page. */
const permalink = window.location.origin + window.location.pathname;
function renderInto(target: HTMLElement): void {
const rows = filtered().filter(isWritten);
if (rows.length === 0) {
// The toggle must never leave a silently empty panel: when it alone emptied
// the list, say so, with the way back inline. Only once the lookups have
// settled, though: before that "all anon" would be a claim about names that
// are still in flight.
if (hideAnon && all.length > 0 && visible().length === 0) {
target.innerHTML = profilesSettled
? `<p class="rev-status">${escapeHtml(t('reviews.allAnon', { n: all.length }))} ` +
`<button class="rev-retry" type="button" data-anon-off>${escapeHtml(
t('reviews.allAnonShow', { n: all.length }),
)}</button></p>`
: `<p class="rev-status">${escapeHtml(t('reviews.loading', { n: all.length }))}…</p>`;
pagerWrap.hidden = true;
return;
}
// With ratings but no words, "no reviews yet" would be false: the summary row
// above is showing them. Say what is actually missing instead.
const message =
all.length === 0 ? t('reviews.empty')
: filtered().length > 0 ? t('reviews.emptyWritten')
: t('reviews.emptyFilter');
target.innerHTML = `<p class="rev-status">${escapeHtml(message)}</p>`;
pagerWrap.hidden = true;
return;
}
const pages = Math.ceil(rows.length / PAGE_SIZE);
page = Math.min(page, pages);
const slice = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
target.innerHTML = slice
.map((review) =>
reviewHtml(review, f, {
propagating: optimistic.has(review.id),
publishedTo: optimistic.get(review.id) ?? null,
permalink,
}),
)
.join('');
// Long bodies clamp to six lines with a "Read more"; the measurement needs the
// rows to be in the document, which they now are.
armReadMore(target);
pagerWrap.hidden = pages <= 1;
if (pages > 1) {
const button = (label: string, target: number, current: boolean, aria?: string) =>
`<button class="pg${current ? ' on' : ''}" type="button" data-page="${target}"${
aria ? ` aria-label="${aria}"` : ''
}${current ? ' aria-current="true"' : ''}>${label}</button>`;
// The chevrons are glyphs, not words. Only their labels are translated.
const parts = [button('‹', Math.max(1, page - 1), false, t('common.previousPage'))];
for (let p = 1; p <= pages; p++) parts.push(button(String(p), p, p === page));
parts.push(button('›', Math.min(pages, page + 1), false, t('common.nextPage')));
pager.innerHTML = parts.join('');
}
}
/**
* Draw the list. The first call after the relays answer hands over from the
* bones; every later one redraws in place, because by then the reviews are
* already here and there is nothing to wait for.
*/
function paint(): void {
renderSummary();
if (bonesUp) {
bonesUp = false;
// swapBones staggers the rows in as the bones fade off them. It fills a
// detached staging element, where the clamp cannot measure, so the rows are
// armed here, once they are in the document.
swapBones(list, renderInto);
armReadMore(list);
} else {
renderInto(list);
enterStagger([...list.children] as HTMLElement[]);
}
if (markNewest) {
markNewest = false;
// Published in this session: a soft amber edge for a second and a half, then
// it is an ordinary review like every other one.
list.querySelector<HTMLElement>('.review')?.classList.add('is-new');
}
}
/**
* Repaint, optionally letting the outgoing list fade out first.
*
* `animate` is for a change the reader made (a filter, a page): the old rows fade
* out in 120ms and the new ones rise in staggered behind them, under 350ms in
* total. Only one repaint is ever scheduled, and it draws whatever the state is
* when it runs, so hammering the filters cannot stack or queue animations.
*/
function render(animate = false): void {
if (pendingPaint) return;
if (!animate || bonesUp || prefersReducedMotion()) {
paint();
return;
}
list.classList.add('leaving');
pendingPaint = window.setTimeout(() => {
pendingPaint = 0;
list.classList.remove('leaving');
paint();
}, 120);
}
const patchProfiles = (): void =>
applyProfiles(root!, f, (pubkey) => all.find((r) => r.pubkey === pubkey));
function updateCounts(): void {
const set = (key: string, value: number) => {
const el = root!.querySelector(`[data-count="${key}"]`);
if (el) el.textContent = f.number(value);
};
// Counts cover every review, written or not, so the filters stay honest about
// how many ratings a mint has even though most of them render as one line.
// They count what the panel can currently show: with anon hidden, the hidden
// reviews come out of every count, and the note below says how many that is.
const rows = visible();
set('all', rows.length);
set('5', rows.filter((r) => r.rating === 5).length);
set('critical', rows.filter((r) => r.rating !== null && r.rating <= 2).length);
// The toggle itself: state, and how many reviews it hides (or would hide).
const anonCount = all.filter((r) => isAnon(r)).length;
const chip = root!.querySelector<HTMLButtonElement>('[data-anon-toggle]');
if (chip) {
chip.hidden = all.length === 0 || (anonCount === 0 && !hideAnon);
chip.setAttribute('aria-pressed', String(hideAnon));
const slot = chip.querySelector('[data-count="anon"]');
if (slot) slot.textContent = ` · ${f.number(anonCount)}`;
}
// The community rating in the verdict strip never moves with this toggle; this
// note is the one line that accounts for the difference.
const note = root!.querySelector<HTMLElement>('[data-anon-note]');
if (note) {
note.hidden = !hideAnon || all.length === 0 || rows.length === all.length;
if (!note.hidden) {
note.textContent = t('reviews.anonHidden', { n: rows.length, total: all.length });
}
}
}
/** Flip the toggle, remember it, and redraw everything that counts. */
function setHideAnon(on: boolean): void {
if (hideAnon === on) return;
hideAnon = on;
storeHideAnon(on);
page = 1;
render(true);
updateCounts();
}
/* ---------- loading ---------- */
/** Bones never spin forever: past this the reader gets an error and a way out. */
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('relay-timeout')), ms);
work.then(
(value) => {
window.clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
window.clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
function showRelayError(): void {
bonesUp = false;
clearBones(list);
list.innerHTML =
`<p class="rev-status error" role="status">${escapeHtml(t('reviews.relayError'))} ` +
`<button class="rev-retry" type="button" data-review-retry>${escapeHtml(t('common.retry'))}</button></p>`;
pagerWrap.hidden = true;
}
async function load(): Promise<void> {
// Bones stand in for reviews the API has already counted. With none counted
// the prerendered "no reviews yet" line is the truthful thing to leave up.
// The bones are already in the prerendered HTML, so first load only has to take
// them over. After the error state has replaced them a retry has to draw them
// again, and that path renders from the same bone data.
if (expected > 0) {
const bones =
adoptBones(list, 3) ??
showBones(list, reviewsBones, {
groups: 3,
label: t('reviews.loading', { n: expected }),
});
bonesUp = bones !== null;
}
let loaded: LoadedReview[];
try {
loaded = await withTimeout(loadReviews(subject), RELAY_TIMEOUT_MS);
} catch {
showRelayError();
return;
}
// The relay pool swallows per relay failures and resolves empty, so nothing
// coming back for a mint the API counted reviews for means the relays did not
// answer, not that the reviews are gone. Saying "no reviews yet" there would
// be a lie the reader cannot check.
if (loaded.length === 0 && expected > 0 && !handedForward) {
showRelayError();
return;
}
all = loaded;
/*
* The author arrived here from the review-by-URL dialog moments ago. Their own
* review is on the relays but may not be in this answer yet, so it goes to the
* top as an optimistic card — the same card, with the same "propagating" note,
* that they would have seen had they written it on this page.
*/
if (handedForward && !all.some((review) => review.id === handedForward.id)) {
optimistic.set(handedForward.id, {
accepted: handedForward.accepted,
total: handedForward.total,
});
all.unshift({
id: handedForward.id,
pubkey: handedForward.pubkey,
npub: safeNpub(handedForward.pubkey),
created_at: handedForward.created_at,
rating: handedForward.rating,
content: handedForward.content,
mint_url: subject.key,
profile: null,
});
markNewest = true;
}
/*
* A `#review-{id}` deep link: land on the page that holds the card, then
* scroll to it once it is drawn. A shared link wins over the reader's own
* filters for this one load, including the anon toggle: the review someone
* sent them must not be invisible when they arrive. That override is not
* persisted.
*/
const anchored = anchoredReviewId();
const anchorTarget = anchored ? all.find((r) => r.id === anchored) : undefined;
if (anchorTarget && isWritten(anchorTarget)) {
filter = 'all';
if (hideAnon && isAnon(anchorTarget)) hideAnon = false;
const rows = filtered().filter(isWritten);
const index = rows.findIndex((r) => r.id === anchored);
if (index >= 0) page = Math.floor(index / PAGE_SIZE) + 1;
}
// Rendered with npub fallbacks first: the text is what people came for, and
// names arrive into a line that is already the right height.
render();
updateCounts();
if (anchorTarget && isWritten(anchorTarget)) {
// Next frame: the bones cross-fade has already inserted the rows, but the
// scroll should measure the settled layout.
window.requestAnimationFrame(() => {
if (anchored) revealAnchoredReview(anchored, scrollBehavior());
});
}
try {
const profiles = await loadProfiles(all.map((r) => r.pubkey));
for (const review of all) review.profile = profiles.get(review.pubkey) ?? null;
} catch {
// Names stay as short npubs. Nothing else changes.
} finally {
profilesSettled = true;
if (hideAnon) {
// Resolved names move reviews out of the anon bucket, so the list itself
// changes: redraw in place, without the entrance stagger, so cards appear
// and counts shift with nothing else flickering.
renderSummary();
renderInto(list);
} else {
patchProfiles();
}
updateCounts();
}
}
root.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.closest('[data-review-retry]')) {
void load();
return;
}
// The inline "Show them" in the all-anon empty state, and the chip itself.
if (target.closest('[data-anon-off]')) {
setHideAnon(false);
return;
}
if (target.closest('[data-anon-toggle]')) {
setHideAnon(!hideAnon);
return;
}
const filterButton = target.closest<HTMLButtonElement>('[data-filter]');
if (filterButton) {
filter = filterButton.dataset['filter'] as typeof filter;
page = 1;
for (const b of root!.querySelectorAll('[data-filter]')) {
b.setAttribute('aria-pressed', String(b === filterButton));
}
render(true);
return;
}
const pageButton = target.closest<HTMLButtonElement>('[data-page]');
if (pageButton) {
page = Number(pageButton.dataset['page']);
render(true);
root!.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
}
});
if (isBuildMode()) {
// `pnpm bones` is measuring this panel. Relays are not involved: render the
// fixture reviews through the same function the real ones go through, so the
// bones Boneyard captures are the shape of a real card and stay that way when
// the card's markup changes.
// Imported dynamically so the fixture text never reaches a real visitor.
void armBuildSnapshot()
.then(() => import('../lib/skeleton-fixtures'))
.then(({ FIXTURE_REVIEWS }) => {
all = FIXTURE_REVIEWS;
renderInto(list);
});
} else {
void load();
}
/* ---------- write a review ---------- */
/*
* The form itself is `lib/write-review.ts`, which the review-by-URL dialog on the
* index pages drives too. What stays here is the half that is about *this list*:
* putting the fresh review at the top, resetting the filters so it is visible, and
* pulsing the button that opened the dialog.
*/
const dialog = document.querySelector<HTMLDialogElement>('[data-write-dialog]');
if (dialog) {
const writer = wireWriteForm({
dialog,
subject: () => subject,
onProfile: (pubkey, profile) => {
// The relays answer the publish before the kind-0 lookup answers, near enough
// every time, so the card goes up as "Anon" and the author's name cross-fades
// in a moment later, exactly as it does on every other card.
for (const review of all) if (review.pubkey === pubkey) review.profile = profile;
patchProfiles();
},
onPublished: (published) => {
// Optimistic insert at the top. The relays will echo it back on next load.
optimistic.set(published.id, { accepted: published.accepted, total: published.total });
all.unshift({
id: published.id,
pubkey: published.pubkey,
npub: safeNpub(published.pubkey),
created_at: published.created_at,
rating: published.rating,
content: published.content,
mint_url: subject.key,
profile: null,
});
filter = 'all';
page = 1;
for (const b of root!.querySelectorAll('[data-filter]')) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-filter') === 'all'));
}
markNewest = true;
render();
updateCounts();
// The dialog is about to close, so the confirmation belongs on the button that
// opened it: one amber pulse, on success only.
const trigger = document.querySelector<HTMLElement>('[data-write-open]');
if (trigger && !prefersReducedMotion()) {
trigger.classList.remove('did-it');
void trigger.offsetWidth;
trigger.classList.add('did-it');
trigger.addEventListener('animationend', () => trigger.classList.remove('did-it'), { once: true });
}
},
});
for (const button of dialog.querySelectorAll('[data-write-close]')) {
button.addEventListener('click', () => writer.close());
}
for (const trigger of document.querySelectorAll('[data-write-open]')) {
trigger.addEventListener('click', () => writer.open());
}
}
}
};
onReady(setup);
/*
* The 404 resolver renders this panel for a mint that had no subject when the page was
* built, and dispatches this once it has one. Setup runs again and finds it.
*
* Safe to re-run: the first pass with no subject does nothing at all — every listener
* and every fetch is inside the `if (root && parsedSubject)` guard — so nothing is
* bound twice.
*/
document.addEventListener('cashumints:subject', setup);
</script>