first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+991
View File
@@ -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, '&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-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>