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>
810 lines
33 KiB
Plaintext
810 lines
33 KiB
Plaintext
---
|
||
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
---
|
||
|
||
{/*
|
||
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>
|