Expand i18n locales and improve reviews UI

Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-21 05:06:58 +02:00
co-authored by Cursor
parent 1c5df18e81
commit 47e6537dde
51 changed files with 15291 additions and 246 deletions
+154 -10
View File
@@ -117,9 +117,20 @@ function escapeText(value: string): string {
<button class="filter" type="button" data-filter="recent" aria-pressed="false">
{t('reviews.filter.recent')}
</button>
{/*
Not a filter but styled as one: hides reviews AND ratings from keys with no
kind-0 profile. Hidden until the island knows how many that is; without
JavaScript there is nothing it could hide anyway.
*/}
<button class="filter" type="button" data-anon-toggle aria-pressed="false" hidden>
{t('reviews.filter.hideAnon')}<span data-count="anon"></span>
</button>
</div>
</div>
{/* One line of consequence when the toggle above is on: how much is showing. */}
<p class="anon-note" data-anon-note hidden></p>
{/*
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
@@ -261,12 +272,16 @@ function escapeText(value: string): string {
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 {
anchoredReviewId, applyProfiles, armReadMore, isAnon, isWritten, readHideAnon,
revealAnchoredReview, reviewHtml, storeHideAnon, summaryHtml, wireReviewCards,
} 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();
wireReviewCards();
/*
* Built here, not at module scope. `onReady` runs this again after every view
@@ -321,6 +336,18 @@ function escapeText(value: string): string {
let all: LoadedReview[] = [];
let filter: 'all' | '5' | 'critical' | 'recent' = 'all';
let page = 1;
/**
* Hide reviews and ratings from keys with no kind-0 profile. Shared with the
* feed page through one localStorage key, so the preference follows the reader
* across mint pages, federation pages and /reviews alike.
*/
let hideAnon = readHideAnon();
/**
* True once the kind-0 lookup has settled, either way. Before that, an
* unresolved profile means "not looked up yet", and the empty state must not
* claim "all reviews here are anon" about names that are still in flight.
*/
let profilesSettled = false;
/** True while bones are on screen, so the first real render cross-fades over them. */
let bonesUp = false;
/**
@@ -344,11 +371,25 @@ function escapeText(value: string): string {
/* ---------- rendering ---------- */
/**
* The list the panel works from: everything, or everything with a resolved name.
*
* `isAnon` is the same test that makes a card say "Anon", so this toggle and that
* label cannot disagree. The one exemption is a review published in this session:
* its author's own card must not vanish under them while their kind-0 is still
* being fetched.
*/
function visible(): LoadedReview[] {
if (!hideAnon) return all;
return all.filter((r) => !isAnon(r) || optimistic.has(r.id));
}
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;
const rows = visible();
return filter === '5' ? rows.filter((r) => r.rating === 5)
: filter === 'critical' ? rows.filter((r) => r.rating !== null && r.rating <= 2)
: filter === 'recent' ? rows.filter((r) => r.created_at > Date.now() / 1000 - 90 * 86400)
: rows;
}
/* ---------- rating only summary ---------- */
@@ -363,10 +404,27 @@ function escapeText(value: string): string {
summarySlot.innerHTML = summaryHtml(rows, f, open);
}
/** Where a copied "link to this review" points: this very page. */
const permalink = window.location.origin + window.location.pathname;
function renderInto(target: HTMLElement): void {
const rows = filtered().filter(isWritten);
if (rows.length === 0) {
// The toggle must never leave a silently empty panel: when it alone emptied
// the list, say so, with the way back inline. Only once the lookups have
// settled, though: before that "all anon" would be a claim about names that
// are still in flight.
if (hideAnon && all.length > 0 && visible().length === 0) {
target.innerHTML = profilesSettled
? `<p class="rev-status">${escapeHtml(t('reviews.allAnon', { n: all.length }))} ` +
`<button class="rev-retry" type="button" data-anon-off>${escapeHtml(
t('reviews.allAnonShow', { n: all.length }),
)}</button></p>`
: `<p class="rev-status">${escapeHtml(t('reviews.loading', { n: all.length }))}…</p>`;
pagerWrap.hidden = true;
return;
}
// 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 =
@@ -386,9 +444,13 @@ function escapeText(value: string): string {
reviewHtml(review, f, {
propagating: optimistic.has(review.id),
publishedTo: optimistic.get(review.id) ?? null,
permalink,
}),
)
.join('');
// Long bodies clamp to six lines with a "Read more"; the measurement needs the
// rows to be in the document, which they now are.
armReadMore(target);
pagerWrap.hidden = pages <= 1;
if (pages > 1) {
@@ -414,8 +476,11 @@ function escapeText(value: string): string {
renderSummary();
if (bonesUp) {
bonesUp = false;
// swapBones staggers the rows in as the bones fade off them.
// swapBones staggers the rows in as the bones fade off them. It fills a
// detached staging element, where the clamp cannot measure, so the rows are
// armed here, once they are in the document.
swapBones(list, renderInto);
armReadMore(list);
} else {
renderInto(list);
enterStagger([...list.children] as HTMLElement[]);
@@ -460,9 +525,42 @@ function escapeText(value: string): string {
};
// 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);
// They count what the panel can currently show: with anon hidden, the hidden
// reviews come out of every count, and the note below says how many that is.
const rows = visible();
set('all', rows.length);
set('5', rows.filter((r) => r.rating === 5).length);
set('critical', rows.filter((r) => r.rating !== null && r.rating <= 2).length);
// The toggle itself: state, and how many reviews it hides (or would hide).
const anonCount = all.filter((r) => isAnon(r)).length;
const chip = root!.querySelector<HTMLButtonElement>('[data-anon-toggle]');
if (chip) {
chip.hidden = all.length === 0 || (anonCount === 0 && !hideAnon);
chip.setAttribute('aria-pressed', String(hideAnon));
const slot = chip.querySelector('[data-count="anon"]');
if (slot) slot.textContent = ` · ${f.number(anonCount)}`;
}
// The community rating in the verdict strip never moves with this toggle; this
// note is the one line that accounts for the difference.
const note = root!.querySelector<HTMLElement>('[data-anon-note]');
if (note) {
note.hidden = !hideAnon || all.length === 0 || rows.length === all.length;
if (!note.hidden) {
note.textContent = t('reviews.anonHidden', { n: rows.length, total: all.length });
}
}
}
/** Flip the toggle, remember it, and redraw everything that counts. */
function setHideAnon(on: boolean): void {
if (hideAnon === on) return;
hideAnon = on;
storeHideAnon(on);
page = 1;
render(true);
updateCounts();
}
/* ---------- loading ---------- */
@@ -527,18 +625,54 @@ function escapeText(value: string): string {
}
all = loaded;
/*
* 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
* filters for this one load, including the anon toggle: the review someone
* sent them must not be invisible when they arrive. That override is not
* persisted.
*/
const anchored = anchoredReviewId();
const anchorTarget = anchored ? all.find((r) => r.id === anchored) : undefined;
if (anchorTarget && isWritten(anchorTarget)) {
filter = 'all';
if (hideAnon && isAnon(anchorTarget)) hideAnon = false;
const rows = filtered().filter(isWritten);
const index = rows.findIndex((r) => r.id === anchored);
if (index >= 0) page = Math.floor(index / PAGE_SIZE) + 1;
}
// 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();
if (anchorTarget && isWritten(anchorTarget)) {
// Next frame: the bones cross-fade has already inserted the rows, but the
// scroll should measure the settled layout.
window.requestAnimationFrame(() => {
if (anchored) revealAnchoredReview(anchored, scrollBehavior());
});
}
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();
profilesSettled = true;
if (hideAnon) {
// Resolved names move reviews out of the anon bucket, so the list itself
// changes: redraw in place, without the entrance stagger, so cards appear
// and counts shift with nothing else flickering.
renderSummary();
renderInto(list);
} else {
patchProfiles();
}
updateCounts();
}
}
@@ -550,6 +684,16 @@ function escapeText(value: string): string {
return;
}
// The inline "Show them" in the all-anon empty state, and the chip itself.
if (target.closest('[data-anon-off]')) {
setHideAnon(false);
return;
}
if (target.closest('[data-anon-toggle]')) {
setHideAnon(!hideAnon);
return;
}
const filterButton = target.closest<HTMLButtonElement>('[data-filter]');
if (filterButton) {
filter = filterButton.dataset['filter'] as typeof filter;