@@ -0,0 +1,991 @@
|
||||
---
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import type { MintDetail } from '@cashumints/shared';
|
||||
import { renderBones } from 'boneyard-js';
|
||||
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 {
|
||||
mint: MintDetail;
|
||||
}
|
||||
const { mint } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const dist = mint.rating_distribution;
|
||||
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 = mint.review_count > 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-mint-url={mint.url}
|
||||
data-mint-pubkey={mint.pubkey ?? ''}
|
||||
data-mint-name={mint.name ?? mint.host}
|
||||
>
|
||||
<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(mint.review_count)}</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
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={mint.review_count}>
|
||||
<div data-boneyard="reviews-panel" 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: mint.review_count })}
|
||||
</p>
|
||||
<Fragment set:html={noscriptFallback} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<p class="rev-status" data-review-status>
|
||||
{
|
||||
mint.review_count === 0
|
||||
? t('reviews.empty')
|
||||
: `${t('reviews.loading', { n: mint.review_count })}…`
|
||||
}
|
||||
</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">
|
||||
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
|
||||
<div class="modal-head">
|
||||
{/* The mint's name is data, dropped into a translated sentence. */}
|
||||
<h2 id="write-title">{t('reviews.dialog.title', { name: mint.name ?? mint.host })}</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>
|
||||
|
||||
<p class="modal-note">{t('reviews.dialog.note')}</p>
|
||||
|
||||
<div class="write-id" data-write-identity></div>
|
||||
|
||||
<fieldset class="star-picker" data-star-picker>
|
||||
<legend class="sr-only">{t('reviews.dialog.ratingLegend')}</legend>
|
||||
{
|
||||
[1, 2, 3, 4, 5].map((value) => (
|
||||
<label class="star-option">
|
||||
<input type="radio" name="rating" value={value} required />
|
||||
<span aria-hidden="true">★</span>
|
||||
<span class="sr-only">{t('reviews.dialog.starsSr', { n: value })}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
<span class="star-label" data-star-label aria-live="polite">
|
||||
{t('reviews.dialog.chooseRating')}
|
||||
</span>
|
||||
</fieldset>
|
||||
|
||||
<label class="write-label" for="review-body">{t('reviews.dialog.bodyLabel')}</label>
|
||||
<div class="write-box">
|
||||
<textarea
|
||||
id="review-body"
|
||||
name="content"
|
||||
rows="5"
|
||||
aria-describedby="review-count"
|
||||
placeholder={t('reviews.dialog.bodyPlaceholder')}></textarea>
|
||||
<span class="write-count" id="review-count" data-char-count>
|
||||
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="write-status" data-write-status hidden role="status"></p>
|
||||
<p class="write-error" data-write-error hidden role="alert"></p>
|
||||
|
||||
<div class="write-actions">
|
||||
<span class="write-why" data-write-why></span>
|
||||
<button type="button" class="btn" data-write-close>{t('common.cancel')}</button>
|
||||
<button type="submit" class="btn primary" data-write-submit disabled>
|
||||
<span class="write-spinner" aria-hidden="true"></span>
|
||||
<span data-write-submit-label>{t('reviews.dialog.submit')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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 { getExtension, loadProfiles, loadReviews, publishReview, safeNpub, type LoadedReview } from '../lib/reviews-client';
|
||||
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from '../lib/nostr-signer';
|
||||
// The identity row is the same identity the header shows, drawn by the same helpers.
|
||||
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from '../lib/auth-ui';
|
||||
import { closeModal, openModal, wireModal } from '../lib/modal';
|
||||
import type { Profile } from '@cashumints/shared';
|
||||
// The card itself lives in one place: /reviews renders the identical markup from
|
||||
// the same functions.
|
||||
import { applyProfiles, isWritten, reviewHtml, summaryHtml } 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();
|
||||
|
||||
/*
|
||||
* 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]');
|
||||
if (root) {
|
||||
const mintUrl = root.dataset['mintUrl'] ?? '';
|
||||
const mintPubkey = root.dataset['mintPubkey'] || null;
|
||||
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[] = [];
|
||||
let filter: 'all' | '5' | 'critical' | 'recent' = 'all';
|
||||
let page = 1;
|
||||
/** 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 ---------- */
|
||||
|
||||
function filtered(): LoadedReview[] {
|
||||
return filter === '5' ? all.filter((r) => r.rating === 5)
|
||||
: filter === 'critical' ? all.filter((r) => r.rating !== null && r.rating <= 2)
|
||||
: filter === 'recent' ? all.filter((r) => r.created_at > Date.now() / 1000 - 90 * 86400)
|
||||
: all;
|
||||
}
|
||||
|
||||
/* ---------- 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);
|
||||
}
|
||||
|
||||
function renderInto(target: HTMLElement): void {
|
||||
const rows = filtered().filter(isWritten);
|
||||
|
||||
if (rows.length === 0) {
|
||||
// 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,
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
|
||||
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.
|
||||
swapBones(list, renderInto);
|
||||
} 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.
|
||||
set('all', all.length);
|
||||
set('5', all.filter((r) => r.rating === 5).length);
|
||||
set('critical', all.filter((r) => r.rating !== null && r.rating <= 2).length);
|
||||
}
|
||||
|
||||
/* ---------- 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(mintUrl, mintPubkey), 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) {
|
||||
showRelayError();
|
||||
return;
|
||||
}
|
||||
|
||||
all = loaded;
|
||||
// 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();
|
||||
|
||||
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 {
|
||||
patchProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (target.closest('[data-review-retry]')) {
|
||||
void load();
|
||||
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 ---------- */
|
||||
|
||||
const dialog = document.querySelector<HTMLDialogElement>('[data-write-dialog]');
|
||||
const form = dialog?.querySelector<HTMLFormElement>('[data-write-form]');
|
||||
const errorBox = dialog?.querySelector<HTMLElement>('[data-write-error]');
|
||||
const statusBox = dialog?.querySelector<HTMLElement>('[data-write-status]');
|
||||
const submit = dialog?.querySelector<HTMLButtonElement>('[data-write-submit]');
|
||||
const submitLabel = dialog?.querySelector<HTMLElement>('[data-write-submit-label]');
|
||||
const textarea = dialog?.querySelector<HTMLTextAreaElement>('textarea');
|
||||
const charCount = dialog?.querySelector<HTMLElement>('[data-char-count]');
|
||||
const starLabel = dialog?.querySelector<HTMLElement>('[data-star-label]');
|
||||
const identityRow = dialog?.querySelector<HTMLElement>('[data-write-identity]');
|
||||
const whyDisabled = dialog?.querySelector<HTMLElement>('[data-write-why]');
|
||||
|
||||
/** Reviews are capped at this length, and the counter turns red past it. */
|
||||
const MAX_CHARS = 2000;
|
||||
/** Below this a review says nothing anyone can use. */
|
||||
const MIN_CHARS = 10;
|
||||
|
||||
// One phrase per star, in the reader's words rather than the site's. Shown as
|
||||
// "2: had problems" beside the picker, and read out by the live region.
|
||||
// One phrase per star, from the catalog. Read through `t` rather than held in a
|
||||
// table here, so the phrases follow the page's language after a view transition.
|
||||
const starPhrase = (rating: number): string => t(`reviews.dialog.stars.${rating}`);
|
||||
|
||||
/** True once a publish is in flight, so nothing else runs over it. */
|
||||
let publishing = false;
|
||||
/** Who is going to sign, resolved when the dialog opens. */
|
||||
let canSign = false;
|
||||
|
||||
function showError(message: string): void {
|
||||
if (!errorBox) return;
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = message === '';
|
||||
}
|
||||
|
||||
function showStatus(message: string): void {
|
||||
if (!statusBox) return;
|
||||
statusBox.textContent = message;
|
||||
statusBox.hidden = message === '';
|
||||
}
|
||||
|
||||
/* ---------- draft safety ---------- */
|
||||
|
||||
/**
|
||||
* A written review survives an accidental close.
|
||||
*
|
||||
* sessionStorage, keyed by mint: it lasts as long as the tab and no longer, which
|
||||
* is the life of the intention. Nothing here is secret, but nothing here belongs
|
||||
* on the machine a week later either.
|
||||
*/
|
||||
const DRAFT_KEY = `cashumints:draft:${mintUrl}`;
|
||||
|
||||
function saveDraft(): void {
|
||||
if (!form) return;
|
||||
const data = new FormData(form);
|
||||
const rating = String(data.get('rating') ?? '');
|
||||
const content = String(data.get('content') ?? '');
|
||||
try {
|
||||
if (!rating && !content.trim()) sessionStorage.removeItem(DRAFT_KEY);
|
||||
else sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ rating, content }));
|
||||
} catch {
|
||||
// Storage blocked. The draft lives in the open dialog and nowhere else.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
// Nothing stored to begin with.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraft(): void {
|
||||
if (!form) return;
|
||||
let draft: { rating?: string; content?: string } | null = null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(DRAFT_KEY);
|
||||
draft = raw ? (JSON.parse(raw) as { rating?: string; content?: string }) : null;
|
||||
} catch {
|
||||
draft = null;
|
||||
}
|
||||
if (!draft) return;
|
||||
|
||||
if (draft.rating) {
|
||||
const star = form.querySelector<HTMLInputElement>(`input[name="rating"][value="${draft.rating}"]`);
|
||||
if (star) star.checked = true;
|
||||
}
|
||||
if (draft.content && textarea) textarea.value = draft.content;
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
}
|
||||
|
||||
/* ---------- the form's own state ---------- */
|
||||
|
||||
function chosenRating(): number {
|
||||
return Number(new FormData(form ?? undefined).get('rating') ?? 0);
|
||||
}
|
||||
|
||||
function syncStarLabel(): void {
|
||||
if (!starLabel) return;
|
||||
const rating = chosenRating();
|
||||
starLabel.textContent = rating
|
||||
// The rating is a bare numeral here, so it is passed as a string: "3: good",
|
||||
// never "3,0: good".
|
||||
? t('reviews.dialog.starLabel', { n: String(rating), phrase: starPhrase(rating) })
|
||||
: t('reviews.dialog.chooseRating');
|
||||
}
|
||||
|
||||
function syncCount(): void {
|
||||
const length = textarea?.value.length ?? 0;
|
||||
if (charCount) {
|
||||
charCount.textContent = t('reviews.dialog.charCount', { n: length, max: MAX_CHARS });
|
||||
charCount.classList.toggle('over', length > MAX_CHARS);
|
||||
}
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "Sign and publish" is available, and if not, why.
|
||||
*
|
||||
* The reason is always on screen: a button that is off for an unstated reason is
|
||||
* a button people click twice and then leave.
|
||||
*/
|
||||
function syncSubmit(): void {
|
||||
if (!submit) return;
|
||||
const length = textarea?.value.trim().length ?? 0;
|
||||
const rating = chosenRating();
|
||||
|
||||
const over = (textarea?.value.length ?? 0) - MAX_CHARS;
|
||||
const reason = publishing
|
||||
? ''
|
||||
: !canSign ? t('reviews.dialog.why.login')
|
||||
: !rating ? t('reviews.dialog.why.rating')
|
||||
: length === 0 ? t('reviews.dialog.why.words')
|
||||
: length < MIN_CHARS ? t('reviews.dialog.why.more', { n: MIN_CHARS - length })
|
||||
: over > 0 ? t('reviews.dialog.why.over', { n: over })
|
||||
: '';
|
||||
|
||||
submit.disabled = publishing || reason !== '';
|
||||
if (whyDisabled) whyDisabled.textContent = publishing ? '' : reason;
|
||||
}
|
||||
|
||||
/** The publish button, mid-flight or back to rest. */
|
||||
function setBusy(busy: boolean, label = t('reviews.dialog.submit')): void {
|
||||
publishing = busy;
|
||||
submit?.classList.toggle('is-busy', busy);
|
||||
if (submitLabel) submitLabel.textContent = label;
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/* ---------- who is signing ---------- */
|
||||
|
||||
/**
|
||||
* Draw the identity row, and decide whether publishing is possible at all.
|
||||
*
|
||||
* Three states, in the order they are likely:
|
||||
* - logged in: the session's signer, named, with a way to switch
|
||||
* - no session but a NIP-07 extension: quick-sign, exactly as before logins
|
||||
* existed, and no session is created by using it
|
||||
* - neither: the row becomes the way in, and the publish button says so
|
||||
*
|
||||
* The extension's pubkey is asked for here, on open, and never on page load: it
|
||||
* is a question some extensions put to the reader, and a page that asks it for
|
||||
* every visitor who might one day write a review is a page that nags.
|
||||
*/
|
||||
async function renderIdentity(): Promise<void> {
|
||||
if (!identityRow) return;
|
||||
|
||||
const session = readSession();
|
||||
if (session) {
|
||||
// A session that names an extension is only as good as the extension: it can
|
||||
// be uninstalled, or disabled for this site, between one visit and the next.
|
||||
canSign = session.method !== 'extension' || getExtension() !== null;
|
||||
syncSubmit();
|
||||
if (!canSign) {
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.extensionGone'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(
|
||||
t('login.open'),
|
||||
)}</button>`;
|
||||
return;
|
||||
}
|
||||
const npub = await toNpub(session.pubkey);
|
||||
|
||||
const draw = (profile: Profile | null): void => {
|
||||
const { primary, secondary, named } = identityLines(profile, npub, t);
|
||||
// Whichever of the two lines is the npub is the machine-flavoured one, and
|
||||
// that is the one set in mono (DESIGN.md). The other is a name or a phrase.
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(session.pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub${named ? ' mono' : ''}">${escapeHtml(secondary)}</span></span>` +
|
||||
methodChipHtml(session.method, t) +
|
||||
`<button type="button" class="write-id-switch" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.switch'),
|
||||
)}</button>`;
|
||||
};
|
||||
|
||||
// Drawn from the npub first, then again if a name turns up: the row is the
|
||||
// same height either way, so nothing under it moves when the name lands.
|
||||
draw(null);
|
||||
const profile = await resolveProfile(session.pubkey);
|
||||
if (readSession()?.pubkey !== session.pubkey || !profile?.found) return;
|
||||
draw(profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getExtension()) {
|
||||
canSign = false;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.needSigner'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.loginToPublish'),
|
||||
)}</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
canSign = true;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-name">${escapeHtml(
|
||||
t('reviews.dialog.identity.extension'),
|
||||
)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(
|
||||
t('reviews.dialog.identity.readingKey'),
|
||||
)}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
|
||||
let pubkey = '';
|
||||
try {
|
||||
pubkey = (await getExtension()?.getPublicKey()) ?? '';
|
||||
} catch {
|
||||
// Declined, or the extension is locked. Publishing still works: it will ask
|
||||
// again at signing time, which is the request that actually matters.
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||
const sub = identityRow.querySelector('.write-id-sub');
|
||||
if (sub) sub.textContent = t('reviews.dialog.identity.willAsk');
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = await toNpub(pubkey);
|
||||
const profile = await resolveProfile(pubkey);
|
||||
const { primary, named } = identityLines(profile, npub, t);
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(
|
||||
t('reviews.dialog.identity.extension'),
|
||||
)}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
}
|
||||
|
||||
/* ---------- opening and closing ---------- */
|
||||
|
||||
if (dialog) {
|
||||
wireModal(dialog, {
|
||||
// A written review is not thrown away on a stray click outside the box.
|
||||
onBackdrop: () =>
|
||||
(textarea?.value.trim().length ?? 0) === 0 ||
|
||||
window.confirm(t('reviews.dialog.confirmClose')),
|
||||
onClose: () => {
|
||||
saveDraft();
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
},
|
||||
});
|
||||
|
||||
// The signer talks back while a signature is in flight: reconnecting, waiting,
|
||||
// approve-in-another-window. Mirrored into the dialog so the reader is never
|
||||
// watching a spinner with nothing behind it.
|
||||
onSignerStatus((update) => {
|
||||
if (dialog.open && publishing) showStatus(update.message);
|
||||
});
|
||||
|
||||
// Logging in from the identity row redraws it, without closing this dialog.
|
||||
onSessionChange(() => {
|
||||
if (dialog.open) void renderIdentity();
|
||||
});
|
||||
}
|
||||
|
||||
textarea?.addEventListener('input', () => {
|
||||
syncCount();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
form?.addEventListener('change', (event) => {
|
||||
if ((event.target as HTMLInputElement).name !== 'rating') return;
|
||||
syncStarLabel();
|
||||
syncSubmit();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
for (const button of document.querySelectorAll('[data-write-close]')) {
|
||||
button.addEventListener('click', () => closeModal(dialog!));
|
||||
}
|
||||
|
||||
for (const trigger of document.querySelectorAll('[data-write-open]')) {
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!dialog) return;
|
||||
showError('');
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
restoreDraft();
|
||||
openModal(dialog);
|
||||
void renderIdentity();
|
||||
// Somewhere to land that is not the close button: the rating is the first
|
||||
// thing to decide, and a checked star keeps its own place in the group.
|
||||
const checked = form?.querySelector<HTMLInputElement>('input[name="rating"]:checked');
|
||||
(checked ?? form?.querySelector<HTMLInputElement>('input[name="rating"]'))?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- publishing ---------- */
|
||||
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!form || !submit || publishing) return;
|
||||
|
||||
const rating = chosenRating();
|
||||
const content = textarea?.value ?? '';
|
||||
|
||||
const signer = activeSigner();
|
||||
if (!signer) {
|
||||
// No session and no extension. The row already says so; open the way in.
|
||||
openLogin(submit);
|
||||
return;
|
||||
}
|
||||
|
||||
showError('');
|
||||
setBusy(true, t('reviews.dialog.signing'));
|
||||
showStatus(
|
||||
signer.method === 'extension'
|
||||
? t('reviews.dialog.waitingExtension')
|
||||
: t('reviews.dialog.waitingSigner'),
|
||||
);
|
||||
|
||||
try {
|
||||
/*
|
||||
* The author's own kind 0, fetched while the relays are still taking the
|
||||
* review. Their pubkey is only knowable once the event is signed, so
|
||||
* `onSigned` starts this at the earliest moment there is: by the time the
|
||||
* card below is inserted the name is usually already in hand, and the author
|
||||
* does not watch their own review appear as "Anon" until the next reload.
|
||||
*
|
||||
* `refresh` skips the cache deliberately. Anyone who has reviewed before may
|
||||
* be sitting on an hour-old negative entry for their own key, and they are
|
||||
* exactly the reader who would notice their name missing.
|
||||
*/
|
||||
let profileLookup: Promise<Profile | null> | null = null;
|
||||
|
||||
const result = await publishReview({
|
||||
signer,
|
||||
mintUrl,
|
||||
mintPubkey,
|
||||
rating,
|
||||
content,
|
||||
onSigned: (signed) => {
|
||||
setBusy(true, t('reviews.dialog.publishing'));
|
||||
profileLookup = loadProfiles([signed.pubkey], { refresh: true })
|
||||
.then((profiles) => profiles.get(signed.pubkey) ?? null)
|
||||
.catch(() => {
|
||||
// Relays unreachable for the lookup. The card keeps "Anon" over its
|
||||
// npub, which is what every other unresolved card shows.
|
||||
return null;
|
||||
});
|
||||
},
|
||||
onProgress: (accepted, _settled, total) => {
|
||||
showStatus(t('reviews.dialog.publishingTo', { accepted, total }));
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic insert at the top. The relays will echo it back on next load.
|
||||
optimistic.set(result.event.id, { accepted: result.accepted, total: result.total });
|
||||
all.unshift({
|
||||
id: result.event.id,
|
||||
pubkey: result.event.pubkey,
|
||||
npub: safeNpub(result.event.pubkey),
|
||||
created_at: result.event.created_at,
|
||||
rating,
|
||||
content: content.trim(),
|
||||
mint_url: mintUrl,
|
||||
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 });
|
||||
}
|
||||
|
||||
// The relays answer the publish before the 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.
|
||||
void (profileLookup as Promise<Profile | null> | null)?.then((profile) => {
|
||||
if (!profile?.found) return;
|
||||
for (const review of all) {
|
||||
if (review.pubkey === result.event.pubkey) review.profile = profile;
|
||||
}
|
||||
patchProfiles();
|
||||
});
|
||||
|
||||
clearDraft();
|
||||
form.reset();
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
closeModal(dialog!);
|
||||
} catch (err) {
|
||||
// Back to an editable dialog with everything still in it. A declined
|
||||
// signature is not an error the reader made, and a relay that would not take
|
||||
// it is worth another press of the same button.
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
showError(
|
||||
message === 'no-relay'
|
||||
? t('reviews.dialog.error.noRelay')
|
||||
: signerErrorMessage(err, t),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
Reference in New Issue
Block a user