@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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. Review bodies are untrusted relay content, so
|
||||
* every interpolated value goes through `escapeHtml`.
|
||||
*
|
||||
* 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 { WRITTEN_MIN_CHARS, profileName } from '@cashumints/shared';
|
||||
import { copyableIdHtml, escapeHtml, iconGradient, shortNpub } from './client';
|
||||
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 "propagating to relays" note, for a review published in this session. */
|
||||
propagating?: boolean;
|
||||
/**
|
||||
* How many relays took it, when the review was published in this session.
|
||||
*
|
||||
* Only said out loud when some relay refused: "published to 2 of 4" is a fact the
|
||||
* author can act on (try again later, and it is already on the network), where a
|
||||
* clean 4 of 4 is just noise on the card.
|
||||
*/
|
||||
publishedTo?: { accepted: number; total: number } | null;
|
||||
/** Adds a link to the mint being reviewed. */
|
||||
mint?: MintRef | 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function avatarHtml(review: LoadedReview): string {
|
||||
const name = profileName(review.profile);
|
||||
// With no name the letter comes from the npub, not from the word "Anon": every
|
||||
// anonymous reviewer would otherwise get the same tile, and the point of the
|
||||
// generated avatar is telling two of them apart.
|
||||
const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase();
|
||||
const fallback = `<span class="avatar" style="background:${iconGradient(
|
||||
review.pubkey,
|
||||
)}" aria-hidden="true">${escapeHtml(initial)}</span>`;
|
||||
|
||||
const picture = review.profile?.picture;
|
||||
if (!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(
|
||||
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.
|
||||
*
|
||||
* `resolved` marks a name that arrived after the card was drawn, so it can cross-fade
|
||||
* in over the npub it replaces. The first render never carries it: there is nothing
|
||||
* to fade from.
|
||||
*/
|
||||
export function identityLabelHtml(review: LoadedReview, f: Formatters, resolved = false): string {
|
||||
const name = profileName(review.profile);
|
||||
const mark = resolved ? ' resolved' : '';
|
||||
const nip05 =
|
||||
name && review.profile?.nip05
|
||||
? `<span class="nip05${mark}" title="${escapeHtml(review.profile.nip05)}">${escapeHtml(review.profile.nip05)}</span>`
|
||||
: '';
|
||||
return name
|
||||
? `<span class="rev-name${mark}">${escapeHtml(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">${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>`;
|
||||
}
|
||||
|
||||
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-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 link. Empty on a mint page, where it is the page. */
|
||||
function mintLinkHtml(mint: MintRef | null | undefined): string {
|
||||
if (!mint) return '';
|
||||
return `<p class="rev-mint"><a href="${escapeHtml(mint.href)}">${escapeHtml(mint.label)}</a></p>`;
|
||||
}
|
||||
|
||||
export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
|
||||
// The note pulses gently while the review is still in flight. It stops the moment
|
||||
// the relays echo the review back, because then the card is redrawn without it.
|
||||
const relays = options.publishedTo;
|
||||
const partial =
|
||||
relays && relays.accepted < relays.total
|
||||
? f.t('reviews.publishedPartial', { accepted: relays.accepted, total: relays.total })
|
||||
: f.t('reviews.published');
|
||||
const propagating = options.propagating
|
||||
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>`
|
||||
: '';
|
||||
|
||||
return `<article class="review" data-review-pubkey="${escapeHtml(review.pubkey)}">
|
||||
${avatarHtml(review)}
|
||||
<span class="rev-who">${identityHtml(review, f)}</span>
|
||||
${metaHtml(review, f)}
|
||||
<p class="rev-body">${escapeHtml(review.content)}</p>
|
||||
${mintLinkHtml(options.mint)}${noteHtml(review, f)}${propagating}
|
||||
</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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user