593 lines
25 KiB
TypeScript
593 lines
25 KiB
TypeScript
/**
|
|
* The review card, as HTML strings.
|
|
*
|
|
* Extracted from the mint page's reviews island so the global feed at /reviews can
|
|
* render the identical card: same identity resolution, same copy-to-clipboard npub,
|
|
* same "ratings without a comment collapse into one line" rule. Two pages rendering
|
|
* a review two ways would be two things to keep in step, and they would drift.
|
|
*
|
|
* Everything here is a pure string builder except `applyProfiles`, which patches
|
|
* already-rendered cards in place, and the two wiring helpers at the bottom, which
|
|
* only run in a browser. Review bodies are untrusted relay content, so every
|
|
* interpolated value goes through `escapeHtml`, and the body itself goes through
|
|
* `reviewBodyHtml` (see lib/review-body.ts for the rules).
|
|
*
|
|
* Every function that has words in it takes a `Formatters`, which carries both the
|
|
* translator and the locale's date and number formatting. It is passed in rather than
|
|
* read from the page because these run in two places: the review feed prerenders cards
|
|
* at build time, where there is no document to read a locale from, and the islands
|
|
* render the identical markup in the browser. One argument is the price of the two
|
|
* staying identical.
|
|
*
|
|
* What is never translated: the review body, the reviewer's name, their NIP-05 and
|
|
* their npub. That is what someone wrote and who wrote it, not site copy, and it
|
|
* renders byte for byte the same in all three languages.
|
|
*/
|
|
import { PRIMARY_RELAY, WRITTEN_MIN_CHARS, profileName } from '@cashumints/shared';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { copyableIdHtml, iconGradient, shortNpub } from './client';
|
|
import { escapeHtml, reviewBodyHtml } from './review-body';
|
|
import { starString, type Formatters } from '../i18n/format';
|
|
import type { LoadedReview } from './reviews-client';
|
|
|
|
/** The mint a card points at. Only the global feed passes one: on a mint page it is the page. */
|
|
export interface MintRef {
|
|
href: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface CardOptions {
|
|
/** Adds the transient publishing note for a review published in this session. */
|
|
propagating?: boolean;
|
|
/** Adds a link to the mint being reviewed. */
|
|
mint?: MintRef | null;
|
|
/**
|
|
* The page this review permanently lives at, absolute and without a fragment;
|
|
* `#review-{id}` is appended here. With one set, the card grows a "copy link"
|
|
* action. On a mint page it is the page itself; on the feed it is the mint's page.
|
|
*/
|
|
permalink?: string | null;
|
|
}
|
|
|
|
/** A review with something to read, as opposed to a bare rating. */
|
|
export function isWritten(review: LoadedReview): boolean {
|
|
return review.content.trim().length >= WRITTEN_MIN_CHARS;
|
|
}
|
|
|
|
/* ---------- identity ---------- */
|
|
|
|
/**
|
|
* Who wrote a review, resolved once and rendered from this object everywhere.
|
|
*
|
|
* The one place the name row's fields are decided, so a future trust hint (say, a
|
|
* web-of-trust signal from an external API) is a field added here and a span added
|
|
* in `identityLabelHtml`, with no change to the card layout around it.
|
|
*/
|
|
export interface ReviewerIdentity {
|
|
pubkey: string;
|
|
npub: string;
|
|
/** The kind-0 name, or null. Null is exactly what the "Anon" label means. */
|
|
name: string | null;
|
|
/** The NIP-05, already deduped against the name; null when absent or redundant. */
|
|
nip05: string | null;
|
|
picture: string | null;
|
|
}
|
|
|
|
export function reviewerIdentity(review: LoadedReview): ReviewerIdentity {
|
|
const name = profileName(review.profile);
|
|
let nip05 = (name ? review.profile?.nip05 : null) ?? null;
|
|
|
|
/*
|
|
* One identity, not the same string twice. A profile whose name IS its NIP-05
|
|
* ("azzamo.network" beside `_@azzamo.network`, which sanitizing strips to the bare
|
|
* domain) used to render the domain twice in a row. If the name and the nip05 (or
|
|
* its domain half) are effectively the same string, the name alone says it all.
|
|
*/
|
|
if (name && nip05) {
|
|
const flatName = name.trim().toLowerCase();
|
|
const flatNip = nip05.trim().toLowerCase();
|
|
const domain = flatNip.includes('@') ? flatNip.slice(flatNip.lastIndexOf('@') + 1) : flatNip;
|
|
if (flatName === flatNip || flatName === domain) nip05 = null;
|
|
}
|
|
|
|
return {
|
|
pubkey: review.pubkey,
|
|
npub: review.npub,
|
|
name,
|
|
nip05,
|
|
picture: review.profile?.picture ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether a review is anonymous: no kind-0 name resolved for its pubkey.
|
|
*
|
|
* This is, by construction, the same test that makes the card say "Anon", so the
|
|
* "hide anon" toggle and the label can never disagree about who is anonymous.
|
|
*/
|
|
export function isAnon(review: LoadedReview): boolean {
|
|
return profileName(review.profile) === null;
|
|
}
|
|
|
|
/**
|
|
* The reviewer's avatar: the generated initial always, with the kind-0 picture layered
|
|
* over it. A picture that fails to load removes itself and what was already underneath
|
|
* shows through, so a dead image URL costs nothing.
|
|
*
|
|
* The initial: first character of the resolved name, uppercased. Unresolved keys show
|
|
* the first character after `npub1`, kept lowercase and set in mono, so the tile reads
|
|
* as a key fragment rather than pretending to be somebody's initial. Both are
|
|
* deterministic per pubkey, which is what keeps two anonymous reviewers tellable apart.
|
|
*/
|
|
export function avatarHtml(review: LoadedReview): string {
|
|
const identity = reviewerIdentity(review);
|
|
const initial = identity.name
|
|
? (identity.name[0] ?? '?').toUpperCase()
|
|
: (review.npub[5] ?? '?');
|
|
const fallback = `<span class="avatar${identity.name ? '' : ' key'}" style="background:${iconGradient(
|
|
review.pubkey,
|
|
)}" aria-hidden="true">${escapeHtml(initial)}</span>`;
|
|
|
|
if (!identity.picture) return `<span class="rev-avatar">${fallback}</span>`;
|
|
|
|
// The picture fades over the initial once it has actually decoded, so a slow image
|
|
// arrives rather than popping, and a dead one costs nothing: it removes itself and
|
|
// what was already underneath shows through. Neither path moves any layout.
|
|
return `<span class="rev-avatar">${fallback}<img class="avatar pic" src="${escapeHtml(
|
|
identity.picture,
|
|
)}" alt="" width="38" height="38" loading="lazy" referrerpolicy="no-referrer" ` +
|
|
`onload="this.classList.add('loaded')" onerror="this.remove()"></span>`;
|
|
}
|
|
|
|
/**
|
|
* Just the name row, for patching a card in place when its profile lands.
|
|
*
|
|
* Renders from one `ReviewerIdentity`, nothing else: see that type's docblock.
|
|
*
|
|
* `resolved` marks a name that arrived after the card was drawn, so it can cross-fade
|
|
* in over the label it replaces. The first render never carries it: there is nothing
|
|
* to fade from.
|
|
*
|
|
* The NIP-05 is informational only. This site never fetches the `.well-known`
|
|
* behind one (a cross-origin lottery), so it renders without any verified mark and
|
|
* its title says exactly what it is.
|
|
*/
|
|
export function identityLabelHtml(review: LoadedReview, f: Formatters, resolved = false): string {
|
|
const identity = reviewerIdentity(review);
|
|
const mark = resolved ? ' resolved' : '';
|
|
const nip05 = identity.nip05
|
|
? `<span class="nip05${mark}" title="${escapeHtml(f.t('reviews.nip05Title'))}">${escapeHtml(identity.nip05)}</span>`
|
|
: '';
|
|
return identity.name
|
|
? `<span class="rev-name${mark}">${escapeHtml(identity.name)}</span>${nip05}`
|
|
// No kind 0 for this key. The placeholder is site copy, not a name someone chose,
|
|
// so it is the one part of an identity that does get translated.
|
|
: `<span class="rev-name anon" title="${escapeHtml(f.t('reviews.anonTitle'))}">${escapeHtml(
|
|
f.t('reviews.anon'),
|
|
)}</span>`;
|
|
}
|
|
|
|
/** The shortened npub, as the copy control. Same value whatever the name says. */
|
|
export function npubHtml(review: LoadedReview, f: Formatters): string {
|
|
return copyableIdHtml({
|
|
full: review.npub,
|
|
short: shortNpub(review.npub),
|
|
label: f.t('common.copyFull', { kind: f.t('mint.kind.npub') }),
|
|
className: 'rev-npub',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Who wrote it: the kind-0 name (with its NIP-05 beside it) or "Anon", and the
|
|
* shortened npub under it as the copy control. Two lines, one identity: the name is
|
|
* for reading, the npub is for checking, and neither is a hex pubkey.
|
|
*
|
|
* The block is a fixed height whichever branch runs, so a name arriving from a relay
|
|
* drops into a line that already had the space.
|
|
*/
|
|
export function identityHtml(review: LoadedReview, f: Formatters): string {
|
|
return `<span class="rev-identity">${identityLabelHtml(review, f)}</span>${npubHtml(review, f)}`;
|
|
}
|
|
|
|
/**
|
|
* The provenance note is a warning, not a stamp. It only earns its place when a harsh
|
|
* review comes from an npub with no other trace on the network at all; on every other
|
|
* card it was noise on nearly every row.
|
|
*/
|
|
export function noteHtml(review: LoadedReview, f: Formatters): string {
|
|
const unknown = review.profile !== null && !review.profile.found;
|
|
if (!unknown || review.rating === null || review.rating > 2) return '';
|
|
return `<p class="rev-note"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>${escapeHtml(
|
|
f.t('reviews.anonNote'),
|
|
)}</p>`;
|
|
}
|
|
|
|
/** Stars and relative date on one right-aligned line, top-aligned with the name row. */
|
|
export function metaHtml(review: LoadedReview, f: Formatters): string {
|
|
const bad = review.rating !== null && review.rating <= 2;
|
|
const ratingText = ratingLabel(review.rating, f);
|
|
return `<span class="rev-meta">
|
|
<span class="rev-stars${bad ? ' bad' : ''}"><span class="sr-only">${escapeHtml(
|
|
ratingText,
|
|
)}</span><span aria-hidden="true">${starString(review.rating)}</span></span>
|
|
<span class="rev-dot" aria-hidden="true">·</span>
|
|
<span class="rev-date">${escapeHtml(f.relative(review.created_at))}</span>
|
|
</span>`;
|
|
}
|
|
|
|
/** What a screen reader hears in place of the star row. */
|
|
function ratingLabel(rating: number | null, f: Formatters): string {
|
|
return rating === null
|
|
? f.t('reviews.srNoRating')
|
|
// The rating is a small integer, passed as a string so it is not run through the
|
|
// locale's number formatting: "4" should not become "4,0" anywhere.
|
|
: f.t('reviews.srRating', { rating: String(rating) });
|
|
}
|
|
|
|
/**
|
|
* The mint this review is about, as a small chip. Empty on a mint page, where the
|
|
* mint is the page. The icon marks it as a place to go, not a bare string.
|
|
*/
|
|
function mintLinkHtml(mint: MintRef | null | undefined): string {
|
|
if (!mint) return '';
|
|
return `<p class="rev-mint"><a class="rev-mint-chip" href="${escapeHtml(mint.href)}">` +
|
|
`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
|
|
`<path d="M3 10.5 12 4l9 6.5"/><path d="M5 10v9h14v-9"/><path d="M10 19v-5h4v5"/></svg>` +
|
|
`<span>${escapeHtml(mint.label)}</span></a></p>`;
|
|
}
|
|
|
|
/* ---------- card actions ---------- */
|
|
|
|
/** The review event as a NIP-19 nevent, with this site's relay as the hint. */
|
|
export function reviewNevent(review: LoadedReview): string | null {
|
|
// Fixture and optimistic ids may not be event ids; a card without the njump
|
|
// action beats a card that throws while rendering.
|
|
if (!/^[0-9a-f]{64}$/i.test(review.id)) return null;
|
|
try {
|
|
return nip19.neventEncode({ id: review.id.toLowerCase(), relays: [PRIMARY_RELAY] });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The quiet actions row: copy npub, open the event on njump, copy the permalink.
|
|
* Icons only, labelled by title + aria-label; the copy buttons are driven by the
|
|
* delegated handler in `wireReviewCards`.
|
|
*/
|
|
function actionsHtml(review: LoadedReview, f: Formatters, options: CardOptions): string {
|
|
const parts: string[] = [];
|
|
|
|
const copyNpub = escapeHtml(f.t('reviews.actions.copyNpub'));
|
|
parts.push(
|
|
`<button type="button" class="rev-act" data-copy-act="${escapeHtml(review.npub)}"` +
|
|
` title="${copyNpub}" aria-label="${copyNpub}">` +
|
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
|
|
`<circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3"/></svg></button>`,
|
|
);
|
|
|
|
const nevent = reviewNevent(review);
|
|
if (nevent) {
|
|
const openNostr = escapeHtml(f.t('reviews.actions.openNostr'));
|
|
parts.push(
|
|
`<a class="rev-act" href="https://njump.me/${escapeHtml(nevent)}" target="_blank"` +
|
|
` rel="noopener nofollow" title="${openNostr}" aria-label="${openNostr}">` +
|
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
|
|
`<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg></a>`,
|
|
);
|
|
}
|
|
|
|
if (options.permalink && /^[0-9a-f]{64}$/i.test(review.id)) {
|
|
const copyLink = escapeHtml(f.t('reviews.actions.copyLink'));
|
|
parts.push(
|
|
`<button type="button" class="rev-act" data-copy-act="${escapeHtml(
|
|
`${options.permalink}#review-${review.id}`,
|
|
)}" title="${copyLink}" aria-label="${copyLink}">` +
|
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">` +
|
|
`<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>` +
|
|
`<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>`,
|
|
);
|
|
}
|
|
|
|
return `<span class="rev-actions">${parts.join('')}</span>`;
|
|
}
|
|
|
|
/* ---------- the card ---------- */
|
|
|
|
export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
|
|
// The note pulses gently for a few seconds after a review is published.
|
|
const propagating = options.propagating
|
|
? `<p class="rev-note propagating">${escapeHtml(f.t('reviews.propagating'))}</p>`
|
|
: '';
|
|
|
|
// The anchor id only for real event ids, so `#review-{id}` deep links resolve and
|
|
// a fixture id cannot mint a colliding element id.
|
|
const anchor = /^[0-9a-f]{64}$/i.test(review.id) ? ` id="review-${review.id.toLowerCase()}"` : '';
|
|
|
|
/*
|
|
* The body is clamped by `armReadMore` after it is on the page, not here: rendering
|
|
* it clamped would hide the tail of a long review from everyone without JavaScript,
|
|
* and the measurement of "is this taller than six lines" needs a layout to ask.
|
|
*/
|
|
const body =
|
|
`<div class="rev-text"><p class="rev-body">${reviewBodyHtml(review.content)}</p>` +
|
|
`<button type="button" class="rev-more" data-more hidden aria-expanded="false"` +
|
|
` data-more-label="${escapeHtml(f.t('reviews.readMore'))}"` +
|
|
` data-less-label="${escapeHtml(f.t('reviews.readLess'))}">${escapeHtml(
|
|
f.t('reviews.readMore'),
|
|
)}</button></div>`;
|
|
|
|
return `<article class="review"${anchor} data-review-pubkey="${escapeHtml(review.pubkey)}">
|
|
${avatarHtml(review)}
|
|
<span class="rev-who">${identityHtml(review, f)}</span>
|
|
${metaHtml(review, f)}
|
|
${body}
|
|
${mintLinkHtml(options.mint)}${noteHtml(review, f)}${propagating}
|
|
${actionsHtml(review, f, options)}
|
|
</article>`;
|
|
}
|
|
|
|
/* ---------- rating only summary ---------- */
|
|
|
|
/**
|
|
* "18 five star, 1 four star, 3 one star", highest rating first.
|
|
*
|
|
* One catalog key per star count rather than a number and a spelled-out word glued
|
|
* together: languages put the number, the numeral word and the noun in different
|
|
* orders, and Spanish and Dutch both inflect "star" where English does not here.
|
|
*/
|
|
export function ratingBreakdown(rows: LoadedReview[], f: Formatters): string {
|
|
const parts: string[] = [];
|
|
for (let stars = 5; stars >= 1; stars--) {
|
|
const count = rows.filter((r) => r.rating === stars).length;
|
|
if (count > 0) parts.push(f.t(`reviews.breakdown.${stars}`, { n: count }));
|
|
}
|
|
const unrated = rows.filter((r) => r.rating === null).length;
|
|
if (unrated > 0) parts.push(f.t('reviews.breakdown.none', { n: unrated }));
|
|
return parts.join(', ');
|
|
}
|
|
|
|
export function summaryRowHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
|
|
const bad = review.rating !== null && review.rating <= 2;
|
|
const ratingText = ratingLabel(review.rating, f);
|
|
const mint = options.mint
|
|
? `<a class="rs-mint" href="${escapeHtml(options.mint.href)}">${escapeHtml(options.mint.label)}</a>`
|
|
: '';
|
|
|
|
// A summary row is one line, so the name and the npub sit side by side on it.
|
|
return `<li class="rs-row" data-review-pubkey="${escapeHtml(review.pubkey)}">
|
|
${identityLabelHtml(review, f)}${npubHtml(review, f)}${mint}
|
|
<span class="rs-stars${bad ? ' bad' : ''}"><span class="sr-only">${escapeHtml(
|
|
ratingText,
|
|
)}</span><span aria-hidden="true">${starString(review.rating)}</span></span>
|
|
<span class="rs-date">${escapeHtml(f.relative(review.created_at))}</span>
|
|
</li>`;
|
|
}
|
|
|
|
export interface SummaryOptions {
|
|
/** Asked per row, so the global feed can link each rating to its own mint. */
|
|
mintFor?: (review: LoadedReview) => MintRef | null;
|
|
/**
|
|
* Day this block covers, already formatted. The mint page has one block for the
|
|
* whole mint and omits it; the feed has one block per day and needs to say which.
|
|
*/
|
|
when?: string;
|
|
/** Identifies the block across re-renders, so an opened one stays open. */
|
|
key?: string;
|
|
}
|
|
|
|
/**
|
|
* One collapsed block for the ratings that came with no comment.
|
|
*
|
|
* Dozens of "rated 5, no comment" cards drown the reviews that say something, on a
|
|
* mint page and even more so in a feed across every mint. They are not hidden: the
|
|
* list is one click away and the filter counts above still count them.
|
|
*/
|
|
export function summaryHtml(
|
|
rows: LoadedReview[],
|
|
f: Formatters,
|
|
open: boolean,
|
|
options: SummaryOptions = {},
|
|
): string {
|
|
if (rows.length === 0) return '';
|
|
const key = options.key ? ` data-summary-key="${escapeHtml(options.key)}"` : '';
|
|
|
|
/*
|
|
* Two whole sentences rather than one with an optional " on {date}" spliced in.
|
|
* English tolerates a trailing fragment; Spanish and Dutch put the date somewhere
|
|
* else in the clause, and a translator handed a dangling " on {when}" cannot move it.
|
|
* The count arrives as pre-escaped bold markup so its position is theirs to choose
|
|
* too.
|
|
*/
|
|
const count = `<b>${f.number(rows.length)}</b>`;
|
|
const breakdown = escapeHtml(ratingBreakdown(rows, f));
|
|
const line = options.when
|
|
? f.t('reviews.summary.lineDay', {
|
|
n: rows.length,
|
|
count,
|
|
when: escapeHtml(options.when),
|
|
breakdown,
|
|
})
|
|
: f.t('reviews.summary.line', { n: rows.length, count, breakdown });
|
|
|
|
return `<details class="rating-summary"${open ? ' open' : ''}${key}>
|
|
<summary>
|
|
<span class="rs-line">${line}</span>
|
|
<span class="rs-toggle">${escapeHtml(
|
|
f.t(rows.length === 1 ? 'reviews.summary.showOne' : 'reviews.summary.showMany'),
|
|
)}</span>
|
|
</summary>
|
|
<ul class="rs-list">${rows
|
|
.map((review) => summaryRowHtml(review, f, { mint: options.mintFor?.(review) ?? null }))
|
|
.join('')}</ul>
|
|
</details>`;
|
|
}
|
|
|
|
/**
|
|
* Upgrade rendered cards in place once the kind-0 lookups land.
|
|
*
|
|
* A patch rather than a re-render: only cards whose profile actually resolved change,
|
|
* so nothing that is already correct flickers, and the identity line has a fixed
|
|
* height either way so nothing moves.
|
|
*/
|
|
export function applyProfiles(
|
|
root: ParentNode,
|
|
f: Formatters,
|
|
lookup: (pubkey: string) => LoadedReview | undefined,
|
|
): void {
|
|
for (const card of root.querySelectorAll<HTMLElement>('[data-review-pubkey]')) {
|
|
const review = lookup(card.dataset['reviewPubkey'] ?? '');
|
|
if (!review?.profile) continue;
|
|
const isRow = card.classList.contains('rs-row');
|
|
|
|
if (review.profile.found) {
|
|
// Only the name row is rewritten. The npub under it is the same either way, so
|
|
// its copy button is left alone rather than rebuilt underneath a cursor.
|
|
const label = card.querySelector('.rev-name');
|
|
if (label) label.outerHTML = identityLabelHtml(review, f, true);
|
|
if (!isRow) {
|
|
const avatar = card.querySelector('.rev-avatar');
|
|
if (avatar) avatar.outerHTML = avatarHtml(review);
|
|
// A cached picture can finish loading before its onload attribute is live.
|
|
const pic = card.querySelector<HTMLImageElement>('.avatar.pic');
|
|
if (pic?.complete && pic.naturalWidth > 0) pic.classList.add('loaded');
|
|
}
|
|
} else if (!isRow && !card.querySelector('.rev-note')) {
|
|
// Nothing found for this npub. That only matters on a harsh review, and
|
|
// `noteHtml` returns an empty string on every other one.
|
|
card.insertAdjacentHTML('beforeend', noteHtml(review, f));
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- browser wiring (never runs at build time) ---------- */
|
|
|
|
/**
|
|
* Clamp long bodies (six lines, mirrored by the `.rev-body.clamped` rule) and reveal
|
|
* their "Read more" buttons, for every card under `root` that has not been armed yet.
|
|
*
|
|
* Runs after each paint, on elements that are already laid out: whether a body
|
|
* exceeds six lines is a question only the layout can answer, and clamping in the
|
|
* markup would hide the tail of long reviews from readers without JavaScript.
|
|
*/
|
|
export function armReadMore(root: ParentNode): void {
|
|
for (const wrap of root.querySelectorAll<HTMLElement>('.rev-text')) {
|
|
// A card being staged off-document (the bones cross-fade builds rows detached)
|
|
// has no layout to measure yet; it is left un-armed for the pass after insertion.
|
|
if (!wrap.isConnected || wrap.dataset['armed']) continue;
|
|
wrap.dataset['armed'] = '1';
|
|
const body = wrap.querySelector<HTMLElement>('.rev-body');
|
|
const more = wrap.querySelector<HTMLElement>('[data-more]');
|
|
if (!body || !more) continue;
|
|
|
|
body.classList.add('clamped');
|
|
// +2px of slack: rounding a line-height must not produce a button that expands
|
|
// nothing.
|
|
if (body.scrollHeight > body.clientHeight + 2) more.hidden = false;
|
|
else body.classList.remove('clamped');
|
|
}
|
|
}
|
|
|
|
/** The tick every copy confirmation on the site uses, in the actions row's size. */
|
|
const ACT_TICK =
|
|
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#5FD68F" ' +
|
|
'stroke-width="2.5" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>';
|
|
|
|
let cardsWired = false;
|
|
|
|
/**
|
|
* Wire every review card's own controls, now and after every re-render.
|
|
*
|
|
* Delegated from the document because the lists are redrawn through innerHTML on
|
|
* every filter, page and profile change, and per-card listeners would be lost each
|
|
* time. Two jobs: the "Read more" clamp toggle, and the icon copy actions (npub,
|
|
* permalink), which confirm with the same tick every copy control on the site shows.
|
|
*/
|
|
export function wireReviewCards(): void {
|
|
if (cardsWired) return;
|
|
cardsWired = true;
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const target = event.target as HTMLElement | null;
|
|
|
|
const more = target?.closest<HTMLButtonElement>('[data-more]');
|
|
if (more) {
|
|
const body = more.closest('.rev-text')?.querySelector<HTMLElement>('.rev-body');
|
|
if (!body) return;
|
|
const expand = body.classList.contains('clamped');
|
|
body.classList.toggle('clamped', !expand);
|
|
more.setAttribute('aria-expanded', String(expand));
|
|
more.textContent = (expand ? more.dataset['lessLabel'] : more.dataset['moreLabel']) ?? '';
|
|
return;
|
|
}
|
|
|
|
const act = target?.closest<HTMLButtonElement>('button[data-copy-act]');
|
|
if (!act || act.dataset['copied']) return;
|
|
const value = act.dataset['copyAct'];
|
|
if (!value) return;
|
|
|
|
void navigator.clipboard.writeText(value).then(
|
|
() => {
|
|
const icon = act.querySelector('svg');
|
|
const iconWas = icon?.outerHTML ?? null;
|
|
act.dataset['copied'] = '1';
|
|
if (icon) icon.outerHTML = `<span class="copy-tick">${ACT_TICK}</span>`;
|
|
window.setTimeout(() => {
|
|
const tick = act.querySelector('.copy-tick');
|
|
if (tick && iconWas) tick.outerHTML = iconWas;
|
|
delete act.dataset['copied'];
|
|
}, 1200);
|
|
},
|
|
() => {
|
|
// Clipboard denied (insecure origin, or the user said no). Nothing to undo.
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The "hide anon" preference, one key for the whole site: mint pages, federation
|
|
* pages and /reviews all read and write the same slot, so the toggle follows the
|
|
* reader wherever reviews are shown.
|
|
*/
|
|
const HIDE_ANON_KEY = 'cashumints:hide-anon';
|
|
|
|
export function readHideAnon(): boolean {
|
|
try {
|
|
return localStorage.getItem(HIDE_ANON_KEY) === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function storeHideAnon(on: boolean): void {
|
|
try {
|
|
localStorage.setItem(HIDE_ANON_KEY, on ? '1' : '0');
|
|
} catch {
|
|
// Storage blocked: the toggle still works for this page view.
|
|
}
|
|
}
|
|
|
|
/** The event id in a `#review-{id}` fragment, or null. */
|
|
export function anchoredReviewId(): string | null {
|
|
const match = /^#review-([0-9a-f]{64})$/i.exec(window.location.hash);
|
|
return match?.[1]?.toLowerCase() ?? null;
|
|
}
|
|
|
|
/**
|
|
* Scroll the anchored card into view and give it the same one-time amber edge a
|
|
* freshly published review gets. Returns whether the card was on the page, so the
|
|
* caller can keep waiting when its island has not rendered the review yet.
|
|
*/
|
|
export function revealAnchoredReview(id: string, behavior: ScrollBehavior): boolean {
|
|
const card = document.getElementById(`review-${id}`);
|
|
if (!card) return false;
|
|
card.scrollIntoView({ behavior, block: 'center' });
|
|
// Same class as a just-published card: one amber pulse, then an ordinary review.
|
|
// Under prefers-reduced-motion the animation is globally disabled, so this is
|
|
// simply a scroll there.
|
|
card.classList.add('is-new');
|
|
window.setTimeout(() => card.classList.remove('is-new'), 1600);
|
|
return true;
|
|
}
|