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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user