aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js6
-rw-r--r--src/features/messages.js101
-rw-r--r--src/i18n.js2
-rw-r--r--src/style.css21
-rw-r--r--tools/feed-live-test.js170
-rw-r--r--tools/profile-batch-test.js16
6 files changed, 300 insertions, 16 deletions
diff --git a/src/app.js b/src/app.js
index 69fcf1a..e5bb0ab 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2051,6 +2051,12 @@ function onAppVisible() {
const awayAt = _awayAt(); _clearAwayAt();
if (awayAt) evaluateOverdue(Date.now() - awayAt);
armUnlockDeadline(); // background tabs throttle timers — recheck on return
+ // A phone freezes a backgrounded tab: timers stop, relay sockets are cut,
+ // and nothing that happened while you were away ever arrives. Features that
+ // hold live connections get told how long the app was gone so they can
+ // catch up rather than sit on a dead subscription.
+ const away = awayAt ? Date.now() - awayAt : 0;
+ for (const f of FEATURES) { try { f.resumed && f.resumed(away); } catch {} }
}
// At boot the in-memory timer is gone (reload / discarded tab). Drop overdue
diff --git a/src/features/messages.js b/src/features/messages.js
index 667c516..69b348b 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -502,6 +502,11 @@ export function messagesFeature(ctx) {
// against this wallet's own feed, 131 of the 133 people on screen had a
// kind 0 sitting on relays we were already connected to.
const PROF_BATCH = 200;
+ // Relays that exist to index profiles rather than to carry conversation.
+ // We don't read notes from these, so they never appear in the batch above —
+ // but they are the ones most likely to hold the kind 0 of someone whose
+ // own relays we can't reach.
+ const PROFILE_INDEX_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es', 'wss://relay.nostr.band'];
let profQueue = new Set(), profTimer = null, profInFlight = new Set();
function wantProfile(pk) {
if (!pk || profQueue.has(pk) || profInFlight.has(pk)) return;
@@ -556,9 +561,23 @@ export function messagesFeature(ctx) {
}
// 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.
+ // 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);
+ }
+ for (const [pk, ev] of idxNewest) { applyProfile(pk, ev); found.add(pk); }
+ if (idxNewest.size) scheduleRepaint();
+ }
+ const stillLeft = pks.filter((pk) => !found.has(pk));
+ if (stillLeft.length) {
+ const left = stillLeft;
await fetchRelayLists(left);
await Promise.all(outboxPlan(left).map(async ({ relays, authors }) => {
const evs = await queryOn(relays, { kinds: [0], authors }, 5000).catch(() => []);
@@ -2452,6 +2471,8 @@ export function messagesFeature(ctx) {
// only runs once a wallet opens.
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
+ // back at the top of the feed: the waiting posts belong on screen now
+ if (ui.chatOpen && ui.msgView === 'feed' && atFeedTop()) flushPending(false);
if (!ui.profilePk && !(ui.chatOpen && ui.msgView === 'feed')) return;
if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - 600) return;
if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {});
@@ -2741,11 +2762,26 @@ export function messagesFeature(ctx) {
feedAt = 0;
refreshFeed();
}
- function mergeFeed(evs) {
+ // How far from the top counts as "reading", rather than "sitting at the top
+ // of the feed". Inserting a post above what someone is reading moves the
+ // words under their eyes; at the top there is nothing to disturb.
+ const FEED_TOP_PX = 120;
+ const atFeedTop = () => {
+ try { return (window.scrollY || 0) < FEED_TOP_PX; } catch { return true; }
+ };
+
+ function mergeFeed(evs, opts = {}) {
const c = feedNow();
- const seen = new Set(c.notes.map((e) => e.id));
- const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !seen.has(e.id) && seen.add(e.id));
+ const known = new Set([...c.notes, ...(c.pending || [])].map((e) => e.id));
+ const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !known.has(e.id) && known.add(e.id));
if (!add.length) return false;
+ // A post that arrived on its own while you were reading waits behind the
+ // pill instead of shoving the page down. Anything you asked for — a
+ // refresh, a scroll to the bottom, the first load — goes straight in.
+ if (opts.live && !atFeedTop() && c.notes.length) {
+ c.pending = [...(c.pending || []), ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
+ return true;
+ }
c.notes = [...c.notes, ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
// posts arriving at the TOP shouldn't cost you the ones you'd scrolled to
const fresh = add.filter((e) => e.created_at >= (c.notes[0] || {}).created_at).length;
@@ -2753,10 +2789,30 @@ export function messagesFeature(ctx) {
try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {}
return true;
}
+
+ // Let the waiting posts in. Called by the pill, and by simply scrolling
+ // back to the top — once you are up there they cost nothing to show.
+ function flushPending(scroll) {
+ const c = feed;
+ if (!c || !(c.pending || []).length) return;
+ const add = c.pending;
+ c.pending = [];
+ const seen = new Set(c.notes.map((e) => e.id));
+ c.notes = [...c.notes, ...add.filter((e) => !seen.has(e.id))]
+ .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP);
+ c.shown = Math.min((c.shown || FEED_PAGE) + add.length, c.notes.length);
+ try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {}
+ render();
+ if (scroll) { try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } }
+ }
// One pass over the plan: each relay is asked only for the authors it
// actually carries. The slowest relay doesn't hold up the rest — every
// answer merges as it lands.
- async function feedPass(extra = {}) {
+ // `merge` rides along to mergeFeed: a catch-up after being away is exactly
+ // as disruptive as a live arrival if you were reading halfway down, so it
+ // goes behind the pill too. A first load, or paging older posts onto the
+ // bottom, does not.
+ async function feedPass(extra = {}, merge = {}) {
const authors = feedAuthors();
if (!authors.length) return false;
await fetchRelayLists(authors);
@@ -2767,16 +2823,16 @@ export function messagesFeature(ctx) {
for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
return chunks.map(async (chunk) => {
const evs = await queryOn(relays, { kinds: [1], authors: chunk, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
- if (mergeFeed(evs)) { got = true; scheduleRepaint(); }
+ if (mergeFeed(evs, merge)) { got = true; scheduleRepaint(); }
});
}));
return got;
}
- async function refreshFeed() {
+ async function refreshFeed(opts = {}) {
if (!feedAuthors().length) { if (feed) feed.status = 'ready'; return; }
- if (Date.now() - feedAt < 30_000) return;
+ if (!opts.force && Date.now() - feedAt < 30_000) return;
feedAt = Date.now();
- try { await feedPass(); } catch {} finally {
+ try { await feedPass({}, { live: !!opts.force }); } catch {} finally {
if (feed) feed.status = 'ready';
scheduleRepaint();
}
@@ -2791,7 +2847,7 @@ export function messagesFeature(ctx) {
for (const { relays, authors } of outboxPlan(feedAuthors()))
for (let i = 0; i < authors.length; i += REQ_AUTHORS)
feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: authors.slice(i, i + REQ_AUTHORS), since },
- (ev) => { if (mergeFeed([ev])) scheduleRepaint(); }));
+ (ev) => { if (mergeFeed([ev], { live: true })) scheduleRepaint(); }));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
@@ -4082,8 +4138,19 @@ export function messagesFeature(ctx) {
i ? h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null,
noteRow(ev.pubkey, ev, displayName(ev.pubkey)),
]);
+ // Posts that arrived while you were reading, waiting to be let in. A
+ // floating pill rather than an insertion: it says how many, and the tap
+ // that shows them also takes you up to them.
+ const waiting = (c.pending || []).length;
+ const pill = waiting
+ ? h('button', {
+ class: 'feed-new-pill',
+ onClick: () => flushPending(true),
+ }, '↑ ' + (waiting === 1 ? t('feedOneNew') : t('feedNNew', { n: waiting })))
+ : null;
// the chat shell draws the brand header; this is just the page under it
return h('div', { class: 'card col chat-page', style: 'gap:10px' },
+ pill,
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => { ui.msgView = 'home'; stopFeedWatch(); render(); }),
h('h3', { style: 'margin:0' }, t('feedTitle')),
@@ -4890,6 +4957,20 @@ export function messagesFeature(ctx) {
return {
id: 'messages',
+ // The app came back after being backgrounded. A phone freezes a hidden
+ // tab: the relay sockets are cut and every post made in the meantime is
+ // simply missing, which is why the feed used to sit there looking stale
+ // until you pulled it down by hand. So: re-dial and catch up. The gap is
+ // fetched (force, past the thirty-second throttle) and the live
+ // subscriptions are rebuilt, since the ones we had are talking to
+ // sockets that no longer exist.
+ resumed(awayMs) {
+ if (!ui.chatOpen || ui.msgView !== 'feed') return;
+ // a blink between apps didn't kill anything
+ if (awayMs && awayMs < 5_000) return;
+ refreshFeed({ force: true }).catch(() => {});
+ return true;
+ },
// A zap flow reporting how its payment went, so the chip that appeared
// on tap can stop pulsing (paid) or disappear (failed / never fired).
// Also the way a zap sent from a form reaches the tally right away,
diff --git a/src/i18n.js b/src/i18n.js
index 5ec5986..bb787b0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -715,6 +715,8 @@ const STR = {
arkLnPaidTitle: 'Paid!',
or: 'or',
nlSignIn: 'Sign in',
+ feedOneNew: '1 new post',
+ feedNNew: '{n} new posts',
composePreview: 'Preview',
composePreviewHide: 'Hide preview',
composePreviewTag: 'Preview',
diff --git a/src/style.css b/src/style.css
index fc25801..8ca9478 100644
--- a/src/style.css
+++ b/src/style.css
@@ -598,6 +598,27 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.quote-card.clickable { cursor: pointer; }
.quote-card.clickable:hover { border-color: var(--line-strong); }
.quote-loading { flex-direction: row; align-items: center; gap: 8px; }
+/* Posts that landed while you were reading further down. Floats over the
+ feed near the top, out of the way of the thumb, and takes you up to them
+ when tapped. Fixed, not sticky: the feed is the page scroller, and a
+ sticky element inside the card would scroll away with the card. */
+.feed-new-pill {
+ /* The header scrolls away with the page, so this rides at the very top of
+ the window — floating over a post for the moment it is there beats
+ sitting in the middle of one. */
+ position: fixed; top: calc(env(safe-area-inset-top, 0px) + 10px);
+ left: 50%; transform: translateX(-50%);
+ z-index: 30; width: auto; padding: 7px 16px; border-radius: 999px;
+ background: var(--accent); color: var(--accent-fg); border: none;
+ font-size: 13px; font-weight: 650; cursor: pointer;
+ box-shadow: 0 3px 12px rgba(0, 0, 0, .28);
+ animation: pill-in .18s ease-out;
+}
+@keyframes pill-in {
+ from { opacity: 0; transform: translate(-50%, -8px); }
+ to { opacity: 1; transform: translate(-50%, 0); }
+}
+
/* What a draft will look like once it's out there. Framed and labelled, so
it can hold real post content — pictures, players, quoted notes — without
ever being mistaken for the post itself. */
diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js
new file mode 100644
index 0000000..fd50191
--- /dev/null
+++ b/tools/feed-live-test.js
@@ -0,0 +1,170 @@
+// Two things about a feed you leave and come back to.
+//
+// A backgrounded tab is frozen by the phone: timers stop and the relay
+// sockets are cut, so every post made while you were away is simply missing
+// and the feed sits there looking stale until you pull it down by hand. It
+// catches up by itself now.
+//
+// And a post that lands while you are reading halfway down must not shove the
+// page under your thumb. It waits behind a pill that says how many, and the
+// tap that shows them takes you up to them.
+//
+// Run: bun tools/feed-live-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+// A real key, because a post arriving on a subscription is signature-checked
+// on the way in — an unsigned fake would be dropped and prove nothing.
+const SK = generateSecretKey();
+const AUTHOR = getPublicKey(SK);
+const signed = (content, ago) => finalizeEvent(
+ { kind: 1, created_at: Math.floor(Date.now() / 1000) - ago, tags: [], content }, SK);
+// enough to scroll past; these ride in through the cache, which is trusted
+const seeded = Array.from({ length: 25 }, (_, i) => signed('post number ' + (i + 1), 600 + i * 60));
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5296, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ window.__feedReqs = 0;
+ const subs = []; // [socket, subId] for every open kind-1 subscription
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2] && Array.isArray(m[2].kinds) && m[2].kinds.includes(1)) {
+ window.__feedReqs++;
+ subs.push([ws, m[1]]);
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+ // Hand a real event to the app exactly as a relay would: down an open
+ // subscription, through the signature gate, into mergeFeed.
+ window.__inject = (ev) => {
+ let n = 0;
+ for (const [ws, id] of subs) {
+ if (ws.readyState !== 1) continue;
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
+ n++;
+ }
+ return n;
+ };
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5296/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(500);
+ await click('import existing'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mn = generateMnemonic(wordlist);
+ await page.type('textarea', mn);
+ await click('open wallet');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(1000);
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
+ await sleep(4000);
+ check('the feed has posts in it', (await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length)) > 0);
+
+ console.log('\n[a post arriving while you read]');
+ // scroll down: this is someone reading, not someone sitting at the top
+ await page.evaluate(() => window.scrollTo(0, 900));
+ await sleep(500);
+ const before = await page.evaluate(() => ({
+ y: Math.round(window.scrollY),
+ first: (document.querySelector('.notes-feed > .row') || {}).innerText || '',
+ }));
+ check('...reading further down', before.y > 400, 'scrollY ' + before.y);
+
+ // a brand new post lands on the live subscription
+ const fresh = signed('BRAND NEW POST', 0);
+ const delivered = await page.evaluate((ev) => window.__inject(ev), fresh);
+ check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)');
+ await sleep(900);
+ const after = await page.evaluate(() => ({
+ y: Math.round(window.scrollY),
+ pill: (document.querySelector('.feed-new-pill') || {}).textContent || '',
+ inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''),
+ }));
+ check('it does not shove itself into the page', !after.inFeed);
+ check('...it says so in a pill instead', /1 new post/i.test(after.pill), after.pill || 'no pill');
+ check('...and the page has not moved under you', Math.abs(after.y - before.y) < 40,
+ `scrollY ${before.y} → ${after.y}`);
+
+ await page.evaluate(() => document.querySelector('.feed-new-pill').click());
+ await sleep(1400);
+ const opened = await page.evaluate(() => ({
+ y: Math.round(window.scrollY),
+ inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''),
+ pill: !!document.querySelector('.feed-new-pill'),
+ }));
+ check('tapping it shows the post', opened.inFeed);
+ check('...and takes you up to it', opened.y < 120, 'scrollY ' + opened.y);
+ check('...and the pill is done', !opened.pill);
+
+ console.log('\n[scrolling back up on your own]');
+ await page.evaluate(() => window.scrollTo(0, 900));
+ await sleep(400);
+ await page.evaluate((ev) => window.__inject(ev), signed('ANOTHER ONE', 0));
+ await sleep(800);
+ check('a pill again', !!(await page.$('.feed-new-pill')));
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await sleep(900);
+ check('...and scrolling to the top lets it in by itself',
+ await page.evaluate(() => /ANOTHER ONE/.test(document.querySelector('.notes-feed')?.innerText || '')
+ && !document.querySelector('.feed-new-pill')));
+
+ console.log('\n[coming back after being away]');
+ // the throttle would otherwise swallow a refresh this soon after the last
+ // the app was hidden for a while, then focused again
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ // longer than the five seconds that count as a blink between apps
+ await sleep(6500);
+ const reqsBefore = await page.evaluate(() => window.__feedReqs || 0);
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await sleep(3500);
+ const reqsAfter = await page.evaluate(() => window.__feedReqs || 0);
+ check('coming back re-asks the relays without being told',
+ reqsAfter > reqsBefore, `${reqsBefore} → ${reqsAfter} kind-1 requests`);
+
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the feed keeps up, without moving the page' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/profile-batch-test.js b/tools/profile-batch-test.js
index bf8dcf4..8f8250c 100644
--- a/tools/profile-batch-test.js
+++ b/tools/profile-batch-test.js
@@ -89,13 +89,17 @@ try {
const reqs = await page.evaluate(() => window.__k0);
const rowCount = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length);
- // A screenful of faces must cost a handful of asks, not one per row. (A few
- // singles are expected and fine: our own profile, which onboarding reads
- // before it writes a name, and the outbox fallback chasing one straggler to
- // the relay only they publish to.)
+ // A screenful of faces must cost a fixed handful of asks — the number must
+ // not grow with the rows, which is the bug this whole thing is about (one
+ // REQ per row, eighty at once, most refused). The bound is a constant, not
+ // a fraction of the rows, because that is the actual property: a couple of
+ // debounced batches, each of which may also ask the profile-index relays
+ // and then chase stragglers to their own relays. Singles are expected among
+ // them — our own profile, and one straggler on a relay only they use.
+ const ASK_CEILING = 12;
const asks = await page.evaluate(() => [...(window.__k0asks || [])].length);
- check('a screenful of faces costs a handful of asks, not one per row',
- reqs.length > 0 && asks <= Math.max(4, rowCount / 4),
+ check('a screenful of faces costs a fixed handful of asks, however many rows',
+ reqs.length > 0 && asks <= ASK_CEILING,
`${asks} asks for ${rowCount} rows, batches of ${[...new Set(reqs)].filter((n) => n > 1).join(',')}`);
check('...one request covers the screen', Math.max(0, ...reqs) >= 8, 'biggest carried ' + Math.max(0, ...reqs) + ' pubkeys');