aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-29 11:01:43 -0700
committerAdam Soltys <[email protected]>2026-09-29 11:01:43 -0700
commit3948ad95beb6e5e40dd275b7c4909ec8c4dee8a9 (patch)
tree43e3057ae2c7118f7d25aa6b4931755b15e36322 /src/features
parent75d437e81824865ba21fb9c732b351b75c78a180 (diff)
downloadcoinosv3-3948ad95beb6e5e40dd275b7c4909ec8c4dee8a9.tar.gz
coinosv3-3948ad95beb6e5e40dd275b7c4909ec8c4dee8a9.zip
Quoted notes are found on more relays and never show as a bare "view note"; Back from a post keeps the feed's place.
- findNote: the reference's hints, its author's NIP-65 relays and ours, plus a wide net of big public relays, all at once, first answer wins. A real quote (SuiGenerisJohn → 0c5e0f08…) sat only on nostr.mom, named by neither the nevent nor its author's relay list. - A feed row whose quote isn't in yet shows the quote card with "Fetching note…" / "Couldn't find that note" instead of a link; the card replaces it once found. - Opening a post remembers the scroll position like opening a profile does; Back restores it instead of landing at the top of the feed. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js65
1 files changed, 48 insertions, 17 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 00f66b9..b5b4c01 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3079,17 +3079,28 @@ export function messagesFeature(ctx) {
// A profile opens at its top: keeping the feed's scroll offset landed the
// reader deep in that person's posts, which read as a thread. Back (a
// popstate) puts them where they were on the page they left.
- const profReturn = []; // [{ pk, y }] per profile opened, newest last
+ // The same for a post opened into its thread: Back lands on the row the
+ // reader tapped, not the top of the feed.
+ const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
+ const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
+ : ui.profilePk ? 'p:' + ui.profilePk : '';
+ function rememberPlace(key) {
+ if (pageKey() === key) return;
+ let y = 0; try { y = window.scrollY || 0; } catch {}
+ pageReturn.push({ key, y });
+ if (pageReturn.length > 20) pageReturn.shift();
+ }
if (typeof window !== 'undefined') window.addEventListener('popstate', () => {
- const top = profReturn[profReturn.length - 1];
- if (!top || ui.profilePk === top.pk) return;
- profReturn.pop();
- requestAnimationFrame(() => { try { window.scrollTo(0, top.y); } catch {} });
+ const top = pageReturn[pageReturn.length - 1];
+ if (!top || pageKey() === top.key) return;
+ pageReturn.pop();
+ const y = top.y;
+ // twice: the page under it may paint its rows a frame late
+ requestAnimationFrame(() => { try { window.scrollTo(0, y); } catch {}
+ requestAnimationFrame(() => { try { if (Math.abs(window.scrollY - y) > 2) window.scrollTo(0, y); } catch {} }); });
});
function openProfile(pk) {
- let y = 0; try { y = window.scrollY || 0; } catch {}
- if (ui.profilePk !== pk) profReturn.push({ pk, y });
- if (profReturn.length > 20) profReturn.shift();
+ rememberPlace('p:' + pk);
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
@@ -5389,15 +5400,35 @@ export function messagesFeature(ctx) {
// wanted, half a minute on, with any relay hints the reference carried.
const QUOTE_RETRY_MS = 30_000;
const quoted = new Map(); // id -> { status, ev, at }
+ // Where a referenced note may be: the reference's hints, its author's own
+ // relays, ours — and when none of those has it, a wider net of big public
+ // relays (a quoted note in the wild sat only on nostr.mom, which neither
+ // the reference nor its author's relay list named).
+ const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.nostr.band', 'wss://nostr.wine', 'wss://relay.snort.social',
+ 'wss://offchain.pub', 'wss://relay.nostr.bg', 'wss://nostr.oxtr.dev', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+ // All at once, first answer wins: asked in turn, a note on a far relay
+ // took longer than the feed waits for a row.
+ function findNote(ref) {
+ const pick = (evs) => (evs || []).find((e) => e.id === ref.id) || null;
+ const ask = (relays) => (relays.length ? queryOn(relays, { ids: [ref.id] }, 4500).then(pick).catch(() => null) : Promise.resolve(null));
+ const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
+ const asks = [
+ ask(near),
+ ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
+ ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
+ ];
+ return new Promise((resolve) => {
+ let left = asks.length;
+ for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); });
+ });
+ }
function quotedNote(ref) {
let c = quoted.get(ref.id);
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); }
c.at = Date.now();
c.promise = (async () => {
- const relays = [...new Set([...(ref.relays || []), ...zapRelays()])];
- const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []);
- c.ev = (evs || [])[0] || c.ev || null;
+ c.ev = (await findNote(ref).catch(() => null)) || c.ev || null;
c.status = c.ev ? 'ready' : 'missing';
if (c.ev) cacheNotifNotes([c.ev]);
// the open thread quotes it: keep it with the thread for the next reload
@@ -5428,9 +5459,10 @@ export function messagesFeature(ctx) {
let c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref);
if (feedPaint && !c?.ev) {
const live = quoteWarm(ref);
- if (!(live.ev && live.warm)) return h('a', {
- href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); },
- }, t('noteRefLink'));
+ // not (yet) found: the card says so where the quote will be — never a
+ // bare "view note" link that only leads to the same answer
+ if (!(live.ev && live.warm)) return h('div', { class: 'quote-card' },
+ h('span', { class: 'small faint' }, t(live.status === 'missing' ? 'noteRefNotFound' : 'noteRefLoading')));
c = { ...live };
feedPaint.quotes.set(ref.id, c);
}
@@ -6900,6 +6932,7 @@ export function messagesFeature(ctx) {
return c;
}
function openNoteThread(ev) {
+ rememberPlace('thread');
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true };
ui.profOverThread = false; // a freshly opened thread goes on top
render();
@@ -6912,9 +6945,7 @@ export function messagesFeature(ctx) {
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'));
- const relays = [...new Set([...(ref.relays || []), ...NOTE_RELAYS])];
- const evs = await queryOn(relays, { ids: [ref.id] }, 4000).catch(() => []);
- const ev = (evs || [])[0];
+ const ev = quoted.get(ref.id)?.ev || await findNote(ref).catch(() => null);
if (ev) openNoteThread(ev);
else toast(t('noteRefNotFound'));
}