6 Commits
Author SHA1 Message Date
Michilis 4c1156f766 Merge pull request 'Stop a listing page from rewriting the next one's grid after navigation.' (#11) from dev into main
Reviewed-on: #11
2026-09-10 18:29:13 +00:00
michilisandClaude Fable 5.1 ae7664fbe0 Stop a listing page from rewriting the next one's grid after navigation.
Switching between /mints, /fedimints and /lnurl-mints showed the right list
for a moment and then flashed back to the previous ecosystem's mints. The
client router keeps every page's script alive, and onReady re-runs each
setup on every arrival, so a visited page's setup also ran on the next page.
All three grids were marked with the same bare data-mint-grid, so the stale
setup found the new grid, fetched its own type and overwrote it.

Mark and query each grid by ecosystem (data-mint-grid="cashu" etc.), the way
the home page already scopes data-home-grid, so a stale setup finds nothing
and bails. Skip the render in hydrateMintGrid when the grid has already been
detached by the router. Document the re-run-everywhere contract on onReady,
and add a static wiring test so a bare marker cannot come back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:36:26 +02:00
Michilis d40f8b3d0f Merge pull request 'Show a brief publishing note that fades out after a few seconds.' (#10) from dev into main
Reviewed-on: #10
2026-09-01 03:48:29 +00:00
michilisandCursor 70b35f4ccc Show a brief publishing note that fades out after a few seconds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:42:00 +02:00
Michilis e79d54ba34 Merge pull request 'Order the latest-reviews strip by created_at alone.' (#9) from dev into main
Reviewed-on: #9
2026-08-25 15:45:39 +00:00
michilisandClaude Opus 5 1eade490c8 Order the latest-reviews strip by created_at alone.
The home page carousel floated reviews whose author had a kind 0 ahead of
everything else, capped at 90 days old, so a named review from two months ago
sat between two reviews from this week. Under a heading that says "Latest
reviews", with each card's foot printing the very timestamp being overruled,
that reads as broken rather than as curation.

Strict recency now, newest first, as the last thing that happens to the list,
with the event id breaking ties so two builds of the same events agree. Names
and avatars are still resolved and still shown; they no longer decide the
order.

The candidate scan stops at the limit rather than gathering twelve times it:
the scan already runs newest first, so nothing further down can outrank what
it has, and the build resolves ten profiles per locale instead of a hundred
and twenty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:23:22 +02:00
34 changed files with 152 additions and 90 deletions
+24 -14
View File
@@ -394,14 +394,28 @@ function escapeText(value: string): string {
let pendingPaint = 0;
/** Set for one paint after publishing, to mark the new card as it goes in. */
let markNewest = false;
/**
* Reviews published in this session, shown at the top until relays echo them back.
*
* Keyed by event id, holding how many relays took it: a review that only two of
* four relays accepted says so on its card, because that is a thing the author
* may want to act on.
*/
/** Reviews published in this session, shown at the top until relays echo them back. */
const optimistic = new Map<string, { accepted: number; total: number }>();
/** Optimistic cards whose brief publishing note is still visible. */
const publishingNotes = new Set<string>();
const PUBLISHING_NOTE_MS = 4_000;
const PUBLISHING_NOTE_FADE_MS = 220;
function dismissPublishingNote(id: string): void {
publishingNotes.delete(id);
const note = document.querySelector<HTMLElement>(
`[id="review-${id.toLowerCase()}"] .propagating`,
);
if (!note) return;
note.classList.add('is-out');
window.setTimeout(() => note.remove(), PUBLISHING_NOTE_FADE_MS);
}
function markOptimistic(id: string, accepted: number, total: number): void {
optimistic.set(id, { accepted, total });
publishingNotes.add(id);
window.setTimeout(() => dismissPublishingNote(id), PUBLISHING_NOTE_MS);
}
/* ---------- rendering ---------- */
@@ -476,8 +490,7 @@ function escapeText(value: string): string {
target.innerHTML = slice
.map((review) =>
reviewHtml(review, f, {
propagating: optimistic.has(review.id),
publishedTo: optimistic.get(review.id) ?? null,
propagating: publishingNotes.has(review.id),
permalink,
}),
)
@@ -667,10 +680,7 @@ function escapeText(value: string): string {
* 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,
});
markOptimistic(handedForward.id, handedForward.accepted, handedForward.total);
all.unshift({
id: handedForward.id,
pubkey: handedForward.pubkey,
@@ -810,7 +820,7 @@ function escapeText(value: string): string {
},
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 });
markOptimistic(published.id, published.accepted, published.total);
all.unshift({
id: published.id,
pubkey: published.pubkey,
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "دي أول مراجعة من الـnpub ده، ومفيش نشاط تاني ليه",
"reviews.published": "نُشرت",
"reviews.publishedPartial": "نُشرت إلى {accepted} من {total} إعادة شحن.",
"reviews.propagating": "البروغات، قد يستغرق الأمر لحظة للظهور في مكان آخر.",
"reviews.propagating": "بننشر على موزّعات Nostr",
"reviews.summary.line.one": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "{count} تصنيف دون تعليق على {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "أول تقييم من هذا npub، لم يتم العثور على أي نشاط آخر",
"reviews.published": "تم النشر.",
"reviews.publishedPartial": "تم النشر إلى {accepted} من أصل {total} مرحل.",
"reviews.propagating": "أثناء النشر، قد يستغرق الأمر لحظة حتى يظهر في مكان آخر.",
"reviews.propagating": "جارٍ النشر إلى مرحلات Nostr",
"reviews.summary.line.one": "{count} تقييم بدون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تقييمًا بدون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "تقييم {count} بدون تعليق على {when}: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "První přezkum z tohoto npub, žádná jiná činnost nenalezena",
"reviews.published": "Publikováno.",
"reviews.publishedPartial": "Zveřejněno{accepted}z{total}relé.",
"reviews.propagating": "Propagatování může chvíli trvat, než se objeví jinde.",
"reviews.propagating": "Publikování na Nostr relayích",
"reviews.summary.line.one": "{count}hodnocení bez komentáře:{breakdown}",
"reviews.summary.line.other": "{count}ratingy bez komentáře:{breakdown}",
"reviews.summary.line.few": "{count}ratingy bez komentáře:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Første anmeldelse af denne npub; der er ikke fundet andre aktiviteter",
"reviews.published": "Udgivet.",
"reviews.publishedPartial": "Udgivet til {accepted} blandt {total}-relæerne.",
"reviews.propagating": "Når indholdet opdateres, kan det tage et øjeblik, før det vises andre steder.",
"reviews.propagating": "Udgiver til Nostr-relays",
"reviews.summary.line.one": "{count}-vurdering uden kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-vurderinger uden kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-vurdering uden kommentar til {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Erste Rezension von diesem Verlag, keine weiteren Aktivitäten gefunden",
"reviews.published": "Veröffentlicht.",
"reviews.publishedPartial": "Veröffentlicht auf {accepted} der {total}-Relais.",
"reviews.propagating": "Die Übertragung kann einen Moment dauern, bis sie an anderer Stelle angezeigt wird.",
"reviews.propagating": "Veröffentlichen auf Nostr-Relays",
"reviews.summary.line.one": "{count}-Bewertung ohne Kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-Bewertungen ohne Kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-Bewertung ohne Kommentar auf {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Πρώτη κριτική από αυτό το npub, δεν βρέθηκε άλλη δραστηριότητα",
"reviews.published": "Δημοσίευση.",
"reviews.publishedPartial": "Δημοσίευση στο{accepted} του{total} ⁇ λεμάν.",
"reviews.propagating": "Διαδοχικά, μπορεί να πάρει μια στιγμή για να εμφανιστεί αλλού.",
"reviews.propagating": "Δημοσίευση στα relays του Nostr",
"reviews.summary.line.one": "{count} αξιολόγηση χωρίς σχόλιο:{breakdown}",
"reviews.summary.line.other": "{count} αξιολογήσεις χωρίς σχόλιο:{breakdown}",
"reviews.summary.lineDay.one": "{count} αξιολόγηση χωρίς σχόλιο σχετικά με{when}:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "First review from this npub, no other activity found",
"reviews.published": "Published.",
"reviews.publishedPartial": "Published to {accepted} of {total} relays.",
"reviews.propagating": "Propagating, it may take a moment to appear elsewhere.",
"reviews.propagating": "Publishing to Nostr relays",
"reviews.summary.line.one": "{count} rating without a comment: {breakdown}",
"reviews.summary.line.other": "{count} ratings without a comment: {breakdown}",
"reviews.summary.lineDay.one": "{count} rating without a comment on {when}: {breakdown}",
+1 -1
View File
@@ -286,7 +286,7 @@
"reviews.anonNote": "Primera reseña de este npub, no se ha encontrado ninguna otra actividad",
"reviews.published": "Publicada.",
"reviews.publishedPartial": "Publicada en {accepted} de {total} relays.",
"reviews.propagating": "Propagándose, puede tardar un momento en aparecer en otros sitios.",
"reviews.propagating": "Publicando en relés Nostr",
"reviews.summary.line.one": "{count} valoración sin comentario: {breakdown}",
"reviews.summary.line.other": "{count} valoraciones sin comentario: {breakdown}",
"reviews.summary.lineDay.one": "{count} valoración sin comentario del {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Premier avis sur ce npub, aucune autre activité trouvée",
"reviews.published": "Publié.",
"reviews.publishedPartial": "Publié sur {accepted} des relais {total}.",
"reviews.propagating": "En se propageant, cela peut prendre un moment pour apparaître ailleurs.",
"reviews.propagating": "Publication sur les relais Nostr",
"reviews.summary.line.one": "Note {count} sans commentaire : {breakdown}",
"reviews.summary.line.other": "Notes {count} sans commentaire : {breakdown}",
"reviews.summary.lineDay.one": "Note {count} sans commentaire sur {when} : {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "इस npub से पहली समीक्षा, कोई अन्य गतिविधि नहीं मिली।",
"reviews.published": "प्रकाशित",
"reviews.publishedPartial": "{total} रिलेज़ में से {accepted} पर प्रकाशित।",
"reviews.propagating": "प्रसारित करते समय, कहीं और प्रकट होने में थोड़ा समय लग सकता है।",
"reviews.propagating": "Nostr रिले पर प्रकाशित किया जा रहा है",
"reviews.summary.line.one": "बिना टिप्पणी के {count} रेटिंग: {breakdown}",
"reviews.summary.line.other": "टिप्पणी के बिना रेटिंग: {count}; रेटिंग: {breakdown}",
"reviews.summary.lineDay.one": "{count} रेटिंग, {when} पर बिना टिप्पणी के: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Ulasan pertama dari npub ini, tidak ada aktivitas lain ditemukan",
"reviews.published": "Diterbitkan.",
"reviews.publishedPartial": "Diterbitkan ke {accepted} dari {total} relay.",
"reviews.propagating": "Sedang disebarkan; mungkin perlu waktu untuk muncul di tempat lain.",
"reviews.propagating": "Mempublikasikan ke relay Nostr",
"reviews.summary.line.one": "Rating {count} tanpa komentar: {breakdown}",
"reviews.summary.line.other": "Rating {count} tanpa komentar: {breakdown}",
"reviews.summary.lineDay.one": "{count} rating tanpa komentar di {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Prima recensione da questo npub, nessun'altra attività trovata",
"reviews.published": "Pubblicato.",
"reviews.publishedPartial": "Pubblicato in {accepted} di {total} relè.",
"reviews.propagating": "Propagando, può richiedere un momento per apparire altrove.",
"reviews.propagating": "Pubblicazione sui relay Nostr",
"reviews.summary.line.one": "{count} valutazione senza un commento: {breakdown}",
"reviews.summary.line.other": "{count} valutazioni senza un commento: {breakdown}",
"reviews.summary.lineDay.one": "{count} valutazione senza un commento su {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "このnpubからの最初のレビュー、他の活動が見つかりません",
"reviews.published": "掲載情報",
"reviews.publishedPartial": "に公開 {accepted} の {total} リレー.",
"reviews.propagating": "伝播は、他の場所で出現する瞬間を取るかもしれません。",
"reviews.propagating": "Nostrリレーに公開中",
"reviews.summary.line.one": "{count} コメントのない評価: {breakdown}",
"reviews.summary.line.other": "{count} コメントのない評価: {breakdown}",
"reviews.summary.lineDay.one": "{count} 評価なし にコメント {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Eerste review van deze npub, verder geen activiteit gevonden",
"reviews.published": "Gepubliceerd.",
"reviews.publishedPartial": "Gepubliceerd op {accepted} van de {total} relays.",
"reviews.propagating": "Wordt verspreid, het kan even duren voordat hij elders verschijnt.",
"reviews.propagating": "Publiceren naar Nostr-relays",
"reviews.summary.line.one": "{count} beoordeling zonder tekst: {breakdown}",
"reviews.summary.line.other": "{count} beoordelingen zonder tekst: {breakdown}",
"reviews.summary.lineDay.one": "{count} beoordeling zonder tekst op {when}: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Pierwsza recenzja z tego npubu, nie znaleziono żadnej innej aktywności",
"reviews.published": "Opublikowano.",
"reviews.publishedPartial": "Opublikowano dla {accepted} sztafetyi {total}.",
"reviews.propagating": "Rozmnażając, może potrzebować chwili, by pojawić się gdzie indziej.",
"reviews.propagating": "Publikowanie na relayach Nostr",
"reviews.summary.line.one": "{count} ocena bez komentarza: {breakdown}",
"reviews.summary.line.few": "{count} oceny bez komentarza: {breakdown}",
"reviews.summary.line.many": "{count} ocen bez komentarza: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Primeira revisão deste npub, nenhuma outra atividade encontrada",
"reviews.published": "Publicado.",
"reviews.publishedPartial": "Publicado em {accepted} dos relés {total}.",
"reviews.propagating": "Propagando, pode demorar um pouco para aparecer em outro lugar.",
"reviews.propagating": "Publicando nos relays Nostr",
"reviews.summary.line.one": "Classificação {count} sem comentários: {breakdown}",
"reviews.summary.line.other": "Avaliações {count} sem comentários: {breakdown}",
"reviews.summary.lineDay.one": "Classificação {count} sem comentários em {when}: {breakdown}",
+1 -1
View File
@@ -290,7 +290,7 @@
"reviews.anonNote": "Prima revizuire a acestei npub-uri, nicio altă activitate nu a fost găsită",
"reviews.published": "Publicat.",
"reviews.publishedPartial": "Publicată în{accepted} din{total} relee.",
"reviews.propagating": "Propaganda, poate dura un moment să apară în altă parte.",
"reviews.propagating": "Se publică pe relayurile Nostr",
"reviews.summary.line.one": "{count}rating fără comentarii:{breakdown}",
"reviews.summary.line.other": "{count} ratinguri fără comentarii:{breakdown}",
"reviews.summary.line.few": "{count} ratinguri fără comentarii:{breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Первый отзыв с этого npub, другой активности не найдено",
"reviews.published": "Опубликовано.",
"reviews.publishedPartial": "Опубликовано на {accepted} из {total} relays.",
"reviews.propagating": "Распространяется по сети, в других местах отзыв может появиться не сразу.",
"reviews.propagating": "Публикация на relays Nostr",
"reviews.summary.line.one": "Рейтинг «{count}» без комментариев: {breakdown}",
"reviews.summary.line.other": "Рейтинги «{count}» без комментариев: {breakdown}",
"reviews.summary.line.few": "Рейтинги «{count}» без комментариев: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Första recensionen av denna npub, inga andra aktiviteter har hittats",
"reviews.published": "Publicerad.",
"reviews.publishedPartial": "Publicerad till reläet {accepted} i serien {total}.",
"reviews.propagating": "När innehållet sprids kan det ta en stund innan det visas på andra ställen.",
"reviews.propagating": "Publicerar till Nostr-relayer",
"reviews.summary.line.one": "{count}-betyg utan kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-betyg utan kommentar: {breakdown}",
"reviews.summary.lineDay.one": "Betyg för {count} utan kommentar om {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Bu npub'dan ilk inceleme, başka bir faaliyet bulunmadı",
"reviews.published": "Yayınlandı.",
"reviews.publishedPartial": "{accepted} {total} rölesine yayımlandı.",
"reviews.propagating": "Yayılırken başka bir yerde ortaya çıkması biraz zaman alabilir.",
"reviews.propagating": "Nostr relay'lerine yayınlanıyor",
"reviews.summary.line.one": "{count} yorumsuz puan: {breakdown}",
"reviews.summary.line.other": "{count} yorumsuz puanlar: {breakdown}",
"reviews.summary.lineDay.one": "{when} yorumsuz {count} puanı: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Перший відгук від цього npub, іншої активності не знайдено",
"reviews.published": "Опубліковано.",
"reviews.publishedPartial": "Опубліковано на {accepted} із {total} relays.",
"reviews.propagating": "Поширюється мережею, поява в інших місцях може тривати деякий час.",
"reviews.propagating": "Публікація на relays Nostr",
"reviews.summary.line.one": "{count} рейтинг без коментаря: {breakdown}",
"reviews.summary.line.few": "{count} оцінки без коментарів: {breakdown}",
"reviews.summary.line.many": "{count} оцінок без коментарів: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "اس ناول سے پہلا جائزہ، کوئی اور سرگرمی نہیں پائی گئی",
"reviews.published": "شائع ہوا۔",
"reviews.publishedPartial": "شائع شدہ{accepted}کا مطلب{total}ریلویز.",
"reviews.propagating": "اِس لئے شاید آپ کسی اَور ملک میں جا کر اُس کے بارے میں بات کریں ۔",
"reviews.propagating": "Nostr ریلے پر شائع ہو رہا ہے",
"reviews.summary.line.one": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
"reviews.summary.line.other": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
"reviews.summary.lineDay.one": "{count}بغیر تبصرے کے خواندگی{when}:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Xem lại lần đầu từ npb này, không tìm thấy hoạt động nào khác",
"reviews.published": "Xuất bản.",
"reviews.publishedPartial": "Do {accepted} của {total} rơle.",
"reviews.propagating": "Tuyên truyền, có thể phải mất một lúc mới xuất hiện.",
"reviews.propagating": "Đang đăng lên các relay Nostr",
"reviews.summary.line.one": "{count} đánh giá mà không bình luận: {breakdown}",
"reviews.summary.line.other": "{breakdown} {count} đánh giá mà không bình luận: 918274242",
"reviews.summary.lineDay.one": "{count} đánh giá mà không bình luận gì về {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "这是关于这款npub的首条评论,未发现其他相关活动",
"reviews.published": "已发布。",
"reviews.publishedPartial": "已发布至 {total} 继电器中的 {accepted}。",
"reviews.propagating": "正在同步,内容可能需要片刻时间才会显示在其他地方。",
"reviews.propagating": "正在发布到 Nostr 中继",
"reviews.summary.line.one": "{count} 的评分(无评论):{breakdown}",
"reviews.summary.line.other": "{count} 的评分(无评论):{breakdown}",
"reviews.summary.lineDay.one": "{count}的评分,未对{when}发表评论:{breakdown}",
+4
View File
@@ -302,6 +302,10 @@ export async function hydrateMintGrid(options: HydrateOptions): Promise<void> {
const all = await fetchListing(type);
if (!all) return;
// The reader may have navigated away while the fetch was in flight. The router has
// already swapped this grid out of the document, so rendering into it (and telling the
// caller to re-apply filters against cards nobody can see) is wasted work.
if (!grid.isConnected) return;
const items = limit === undefined ? all : all.slice(0, limit);
const cards = renderMintGrid(grid, items, {
+16 -28
View File
@@ -55,19 +55,6 @@ export interface BuildReview {
* characters or matching a common test string do not qualify. They still appear in
* full on the mint page, nothing is hidden, this strip is just a shop window.
*/
/** How many candidates to gather per card shown, so the ordering has a real choice. */
const POOL_FACTOR = 12;
/**
* How recent a review must still be to be promoted for having a named author.
*
* The ordering below prefers reviews whose author has a kind 0, and this is the leash
* on that preference. Without it the strip would headline a named review from two
* years ago under a heading that says "Latest reviews"; with it, a named review from
* within the season can lead and anything older cannot.
*/
const PROMOTE_MAX_AGE_S = 90 * 24 * 60 * 60;
const TEST_STRINGS = new Set([
'test', 'testing', 'test test', 'hello', 'hi', 'ok', 'okay', 'good', 'nice',
'great', 'cool', 'gm', 'a', '.', '..', '...', 'asdf', 'qwerty', '123',
@@ -227,29 +214,30 @@ export async function fetchLatestReviews(
profile: null,
});
if (candidates.length >= limit * POOL_FACTOR) break;
// The scan runs newest first, so the first `limit` survivors are the latest
// `limit` reviews. Nothing further down the list can outrank them.
if (candidates.length >= limit) break;
}
const profiles = await fetchProfiles(candidates.map((c) => c.pubkey));
for (const review of candidates) review.profile = profiles.get(review.pubkey) ?? null;
/*
* Recent reviews with a named author come first; everything else stays newest
* first behind them.
* Strict recency, newest first, as the last thing that happens to this list.
*
* Most review keys have published exactly one event in their life: the review
* itself. Nothing can be fetched for them, so a strict recency order fills this
* strip with anonymous npubs while a named review sits a few rows below the
* cut. This is the home page shop window, which already drops junk bodies, so
* it prefers a review a reader can attach a person to. Nothing is hidden:
* every review is on its mint page, and PROMOTE_MAX_AGE_S keeps "latest"
* meaning latest.
* The heading says "Latest reviews", so the order under it is the event
* `created_at` and nothing else. This used to float reviews whose author had a
* kind 0 to the front, which read as broken on the page: a named review from two
* months ago sat between two reviews from this week, and the card feet said so,
* because the "2mo ago" label is formatted from the very same `created_at` this
* sorts on. Author names are still resolved and still shown — they just no longer
* decide the order.
*
* Sorted here rather than left to the scan above, so that the invariant holds
* whatever the gathering loop does later, and `id` breaks ties between two events
* that share a second so two builds of the same events agree.
*/
const cutoff = Math.floor(Date.now() / 1000) - PROMOTE_MAX_AGE_S;
const promoted = (review: BuildReview): number =>
review.profile?.found && review.created_at >= cutoff ? 1 : 0;
candidates.sort((a, b) => promoted(b) - promoted(a) || b.created_at - a.created_at);
candidates.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id));
return candidates.slice(0, limit);
} catch {
+3 -17
View File
@@ -37,16 +37,8 @@ export interface MintRef {
}
export interface CardOptions {
/** Adds the "propagating to relays" note, for a review published in this session. */
/** Adds the transient publishing 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;
/**
@@ -303,15 +295,9 @@ function actionsHtml(review: LoadedReview, f: Formatters, options: CardOptions):
/* ---------- the card ---------- */
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');
// The note pulses gently for a few seconds after a review is published.
const propagating = options.propagating
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>`
? `<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
+2 -2
View File
@@ -138,7 +138,7 @@ const schema = [
)
}
<div class="mint-grid" data-mint-grid>
<div class="mint-grid" data-mint-grid="fedimint">
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
</div>
@@ -259,7 +259,7 @@ const schema = [
const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
const grid = document.querySelector<HTMLElement>('[data-mint-grid="fedimint"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
+2 -2
View File
@@ -142,7 +142,7 @@ const schema = [
{t('lnurlMints.showing', { total: mints.length, online, offline })}
</p>
<div class="mint-grid" data-mint-grid>
<div class="mint-grid" data-mint-grid="lnurl">
{mints.map((mint, i) => (
<MintCard
mint={mint}
@@ -272,7 +272,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
const grid = document.querySelector<HTMLElement>('[data-mint-grid="lnurl"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
+2 -2
View File
@@ -125,7 +125,7 @@ const schema = [
{t('mints.showing', { total: mints.length, online, offline })}
</p>
<div class="mint-grid" data-mint-grid>
<div class="mint-grid" data-mint-grid="cashu">
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
</div>
@@ -244,7 +244,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
const grid = document.querySelector<HTMLElement>('[data-mint-grid="cashu"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
+9
View File
@@ -208,6 +208,15 @@ export function armSharedTransitions(): void {
* only does its work at module scope is dead after the first navigation. It does fire
* `astro:page-load` on every arrival, including the first, but on the first it waits
* for `window.load`, which is far too late to be the only trigger. Hence both.
*
* The listener is never removed, on purpose: a layout island (the language switcher,
* the login dialog) has DOM on every page, and this is what keeps it alive. The flip
* side is that `setup` runs on *every* page the router swaps in, not only pages that
* include the script. A page-level script must therefore look its DOM up by a marker
* unique to that page — `data-mint-grid="cashu"`, `data-home-grid="fedimint"` — and do
* nothing when the marker is absent. A marker shared between pages means a stale page's
* setup finds the current page's DOM and rewrites it: the listing pages once flashed
* back to the previous ecosystem's mints for exactly that reason.
*/
export function onReady(setup: () => void): void {
let done = false;
+6 -1
View File
@@ -1268,8 +1268,13 @@ main { flex: 1; }
0%, 25% { opacity: .85; }
100% { opacity: 0; }
}
/* Still travelling. Stops when the relays echo the review back on the next load. */
/* Visible for a few seconds after a review is published from this tab. */
.rev-note.propagating { animation: soft-pulse 2s var(--ease-in-out) infinite; }
.rev-note.propagating.is-out {
animation: none;
opacity: 0;
transition: opacity var(--dur-quick) var(--ease-out);
}
/* ---------- status dot ---------- */
.status-dot { position: relative; }
+60
View File
@@ -0,0 +1,60 @@
/**
* The three listing pages must each own their grid.
*
* `<ClientRouter />` swaps pages without reloading, and `onReady` in `scripts/reveal.ts`
* re-runs every page's setup on every arrival — deliberately, since that is what keeps
* the layout islands alive. So once `/mints` has been visited, its setup also runs on
* `/fedimints`. If both pages mark their grid the same way, the stale cashu setup finds
* the fedimint grid, fetches `?type=cashu` and rewrites it: the page shows fedimints for
* a moment, then flashes back to cashu mints. That happened.
*
* The guard is that each page's marker carries its ecosystem, and its script only looks
* for its own. This checks the template, the query and the hydrate call agree, and that
* no bare `data-mint-grid` has crept back in. Plain node, source read as text, like
* `i18n-wiring.test.mjs`.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/pages/[...locale]');
const read = (file) => readFileSync(path.join(pagesDir, file), 'utf8');
const LISTINGS = [
['mints.astro', 'cashu'],
['fedimints.astro', 'fedimint'],
['lnurl-mints.astro', 'lnurl'],
];
for (const [file, type] of LISTINGS) {
test(`${file} marks, queries and hydrates its grid as ${type}`, () => {
const source = read(file);
assert.match(
source,
new RegExp(`<div class="mint-grid" data-mint-grid="${type}">`),
`${file}: template grid is not marked data-mint-grid="${type}"`,
);
assert.match(
source,
new RegExp(`querySelector<HTMLElement>\\('\\[data-mint-grid="${type}"\\]'\\)`),
`${file}: script does not query [data-mint-grid="${type}"]`,
);
assert.match(
source,
new RegExp(`type: '${type}',`),
`${file}: hydrateMintGrid is not called with type '${type}'`,
);
// A bare marker, in either the template or the query, is the bug coming back.
assert.doesNotMatch(source, /data-mint-grid[>\s]/, `${file}: bare data-mint-grid in template`);
assert.doesNotMatch(source, /\[data-mint-grid\]/, `${file}: bare [data-mint-grid] selector`);
});
}
test('the three listings use three different markers', () => {
const types = new Set(LISTINGS.map(([, type]) => type));
assert.equal(types.size, LISTINGS.length);
});