diff options
| author | Adam Soltys <[email protected]> | 2026-10-01 22:48:11 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-01 22:48:11 -0700 |
| commit | 23f18ff13e0a8c612d04727de2ccfadf2d13ff7c (patch) | |
| tree | 578915726c8b4285d6e0dcaedb6d24049996d773 /src | |
| parent | c6b279abd5e502ce05c1418797714782356b7707 (diff) | |
| download | coinosv3-23f18ff13e0a8c612d04727de2ccfadf2d13ff7c.tar.gz coinosv3-23f18ff13e0a8c612d04727de2ccfadf2d13ff7c.zip | |
Shared note links open on the post, with names and faces
The server renders /nevent1 and /note1 pages with the thread inlined
(post, root, replies, kind 0s) and Open Graph tags; the client seeds its
thread cache from it and opens the thread on the first paint. Without
it, a pending frame holds the screen instead of the front door. Profile
lookups ask our relays and the profile indexes at once, streamed,
instead of one after the other behind five-second waits. A thread
opened straight from a link has no Back button.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 5 | ||||
| -rw-r--r-- | src/features/messages.js | 93 |
2 files changed, 64 insertions, 34 deletions
@@ -1014,6 +1014,9 @@ function goBack(toParent) { if (navIndex > 0) history.back(); else { toParent(); render(); } } +// Whether an in-app Back has anywhere of ours to go. A page opened straight +// from a link has nothing under it; its Back would invent a parent. +const canGoBack = () => navIndex > 0; // ---------------------------------------------------------------- utilities let toastTimer; @@ -5710,7 +5713,7 @@ async function importSnapshotFile(e) { // sits just before boot so every helper it captures is defined; the hooks are // only invoked at runtime (first render happens after loadLocale below). const ctx = { - h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal, showSend, + h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, canGoBack, goHome, openExternal, showSend, fmtAmount, unitLabel, unitTag, getUnit: () => unit, toggleUnit, download, // in fiat the typed figure is money, so it needs today's price to become sats parseAmount: (v, u) => parseAmount(v, u, rateNow()), diff --git a/src/features/messages.js b/src/features/messages.js index a7f6820..45098df 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -911,36 +911,25 @@ export function messagesFeature(ctx) { for (const pk of pks) profInFlight.add(pk); try { const found = new Set(); - for (let i = 0; i < pks.length; i += PROF_BATCH) { - const slice = pks.slice(i, i + PROF_BATCH); - const evs = await queryOn(zapRelays(), { kinds: [0], authors: slice }, 5000).catch(() => []); - const newest = new Map(); - for (const ev of evs || []) { - const c = newest.get(ev.pubkey); - if (!c || ev.created_at > c.created_at) newest.set(ev.pubkey, ev); - } - for (const [pk, ev] of newest) { if (applyProfile(pk, ev)) found.add(pk); } - // paint what this batch found before going after the stragglers — - // the outbox fallback below can take seconds, and there's no reason - // for a face we already have to wait behind one we don't - if (newest.size) scheduleRepaint(); - } - // Whoever our relays have never heard of: ask the relays they publish - // to. Same outbox plan the feed uses, so it costs the lists we already - // have rather than a round trip each — plus the relays whose whole job - // is holding kind 0s, which we deliberately don't read notes from and - // so never ask in the batch above. - const left = pks.filter((pk) => !found.has(pk)); - if (left.length) { - const idx = await queryOn(PROFILE_INDEX_RELAYS, { kinds: [0], authors: left }, 5000).catch(() => []); - const idxNewest = new Map(); - for (const ev of idx || []) { - const c = idxNewest.get(ev.pubkey); - if (!c || ev.created_at > c.created_at) idxNewest.set(ev.pubkey, ev); + // Our relays and the profile indexes at once, each answer applied as + // it lands. Asking them one after the other, each waiting out its + // slowest relay, put a five-second floor under anyone whose kind 0 + // only an index held: a visitor opening a shared post saw a punk and + // an npub for seven and a half seconds. + let painted = 0; + const asked = new Set(pks); + const take = (ev) => { + if (ev && ev.kind === 0 && asked.has(ev.pubkey) && applyProfile(ev.pubkey, ev)) { + found.add(ev.pubkey); + if (!painted) painted = setTimeout(() => { painted = 0; scheduleRepaint(); }, 30); } - for (const [pk, ev] of idxNewest) { if (applyProfile(pk, ev)) found.add(pk); } - if (idxNewest.size) scheduleRepaint(); + }; + const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS])]; + const asks = []; + for (let i = 0; i < pks.length; i += PROF_BATCH) { + asks.push(queryStreamOn(relays, { kinds: [0], authors: pks.slice(i, i + PROF_BATCH) }, take, 5000).catch(() => {})); } + await Promise.all(asks); const stillLeft = pks.filter((pk) => !found.has(pk)); if (stillLeft.length) { const left = stillLeft; @@ -1929,11 +1918,42 @@ export function messagesFeature(ctx) { const restoringUrlThread = (() => { try { return !!urlNote && history.state?.nav?.noteThread?.focusId === urlNote.id; } catch { return false; } })(); + // The server renders /nevent1… and /note1… with the thread already in the + // page (feed/note-page.js): the post, its root and replies, and their + // authors' kind 0s, in a JSON script tag. Into the thread cache it goes, + // in the shape a reload leaves there, so the thread opens from it with + // names and faces on the first paint instead of after the relays answer. + // No signature check: this data arrives in the same document as the code + // that reads it (the server verified it off the relays). + if (urlNote && !restoringUrlThread && typeof document !== 'undefined') { + try { + const el = document.getElementById('boot-thread'); + const d = el && JSON.parse(el.textContent); + if (d && d.v === 1 && d.root && Array.isArray(d.replies) + && [d.root, ...d.replies].some((e) => e && e.id === urlNote.id)) { + const faces = {}; + for (const ev of d.profiles || []) { + let m = null; + try { m = JSON.parse(ev.content); } catch {} + if (m && typeof m === 'object' && !Array.isArray(m) && ev.pubkey) { + faces[ev.pubkey] = { name: m.display_name || m.name || null, picture: m.picture || null, eventAt: ev.created_at, t: Date.now() }; + } + } + createThreadStore().save({ root: d.root, replies: d.replies }, urlNote.id, faces, {}, d.quotes || []); + } + } catch {} + } if (urlNote && !restoringUrlThread) { try { history.replaceState(null, '', '/'); } catch {} ui.pubProf = true; + // holds the frame (screenView) until the thread opens, so the front door + // never flashes up while the note is being found + ui.pubNotePending = urlNote.id; // a reload of a thread the history restores by itself needs no fetch - setTimeout(() => { if (ui.noteThread && ui.noteThread.focusId === urlNote.id) return; openNoteRef(urlNote).catch(() => {}); }, 0); + setTimeout(() => { + if (ui.noteThread && ui.noteThread.focusId === urlNote.id) { ui.pubNotePending = null; return; } + openNoteRef(urlNote).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); + }, 0); } // Resolve and open right away — not in init(), which only runs once a @@ -7275,7 +7295,7 @@ export function messagesFeature(ctx) { const saved = threadStore.find(ref.id); const cached = saved && (saved.root.id === ref.id ? saved.root : saved.replies.find((e) => e.id === ref.id)); if (cached) { openNoteThread(cached); return; } - toast(t('noteRefLoading')); + if (!ui.pubNotePending) toast(t('noteRefLoading')); // the pending frame says so itself const ev = quoted.get(ref.id)?.ev || await findNote(ref).catch(() => null); if (ev) openNoteThread(ev); else toast(t('noteRefNotFound')); @@ -7557,9 +7577,11 @@ export function messagesFeature(ctx) { ctx.brandHeader(!ui.pubProf && wallet.loaded), h('div', { class: 'card col thread-card', style: 'gap:0;padding:2px 14px' }, ...kids), // a real Back: the history entry under the thread restores the feed - // (or profile) and its scroll place; ui-only only when the thread was - // opened straight from a link and there is nothing under it - h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back')), + // (or profile) and its scroll place. A thread opened straight from a + // link has nothing of ours under it, so no button that invents one. + !ctx.canGoBack || ctx.canGoBack() + ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back')) + : null, ...noteOverlays()); } @@ -9938,6 +9960,11 @@ export function messagesFeature(ctx) { // — an auto-restored wallet would otherwise flash its home page for the // beat the registrar lookup takes. The shell shows the name straight // off the URL; no spinner, the empty beat reads calmer. + // Same for a note deep link while its thread is being found. + if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')))); if (ui.pubProfPending) return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, |
