Ship LNURL mint pages, indexing UI, and reviews rewrite.
Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/ index client flows so the site surfaces the new mint type end to end. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+121
-493
@@ -13,6 +13,7 @@
|
||||
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';
|
||||
@@ -20,8 +21,14 @@ 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. */
|
||||
subject: ReviewSubject;
|
||||
/**
|
||||
* 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;
|
||||
@@ -43,7 +50,7 @@ const dist = distribution;
|
||||
* 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}`;
|
||||
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject?.type ?? 'cashu'}`;
|
||||
const bodyPlaceholder = t.has(placeholderKey)
|
||||
? t(placeholderKey)
|
||||
: t('reviews.dialog.bodyPlaceholder.cashu');
|
||||
@@ -98,7 +105,7 @@ function escapeText(value: string): string {
|
||||
aria-labelledby="reviews-title"
|
||||
data-reveal
|
||||
data-reviews-panel
|
||||
data-subject={JSON.stringify(subject)}
|
||||
data-subject={subject ? JSON.stringify(subject) : ''}
|
||||
>
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
|
||||
@@ -144,7 +151,15 @@ function escapeText(value: string): string {
|
||||
target for `pnpm bones`, which snapshots this element's single child.
|
||||
*/}
|
||||
<div data-review-list data-review-count={reviewCount}>
|
||||
<div data-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
|
||||
{/*
|
||||
`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 ? (
|
||||
@@ -191,8 +206,8 @@ function escapeText(value: string): string {
|
||||
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">
|
||||
<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')}>
|
||||
@@ -201,52 +216,7 @@ function escapeText(value: string): string {
|
||||
</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={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>
|
||||
</WriteForm>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
@@ -263,13 +233,11 @@ function escapeText(value: string): string {
|
||||
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 { loadProfiles, loadReviews, safeNpub, type LoadedReview } from '../lib/reviews-client';
|
||||
import type { ReviewSubject } from '../lib/review-subject';
|
||||
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';
|
||||
// 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 {
|
||||
@@ -334,6 +302,12 @@ function escapeText(value: string): string {
|
||||
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;
|
||||
/**
|
||||
@@ -619,13 +593,37 @@ function escapeText(value: string): string {
|
||||
// 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) {
|
||||
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
|
||||
@@ -731,451 +729,81 @@ function escapeText(value: string): string {
|
||||
|
||||
/* ---------- 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]');
|
||||
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:${subject.key}`;
|
||||
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
for (const button of dialog.querySelectorAll('[data-write-close]')) {
|
||||
button.addEventListener('click', () => writer.close());
|
||||
}
|
||||
|
||||
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,
|
||||
subject,
|
||||
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: 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 });
|
||||
}
|
||||
|
||||
// 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),
|
||||
);
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user