aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-13 09:36:11 -0700
committerAdam Soltys <[email protected]>2026-09-13 09:36:11 -0700
commit810d3d8bfd523a8a2a4a51cd2e426d72af049d13 (patch)
tree9b0f6110bbeec8b1de7158d0ef0d7f8d81708eb5
parent848c3edd115360739b5680fec45ef6b71b3b9c1c (diff)
downloadcoinosv3-810d3d8bfd523a8a2a4a51cd2e426d72af049d13.tar.gz
coinosv3-810d3d8bfd523a8a2a4a51cd2e426d72af049d13.zip
A profile is already there when you land on it.
Tapping a face opened an empty page that grew a banner, then a bio, then a column of posts as relays answered — measured as the browser measures it, a cumulative layout shift of 0.412, where 0.25 is already "poor". None of those fetches needed to happen on the tap: The bio and banner were free all along. The batched kind-0 fetch that names everyone in the feed downloads their WHOLE profile and then throws away everything but the name, picture, nip05 and lud16. It keeps `about` and `banner` now, and the page reads them until the full kind 0 lands — after which `full` is the truth, including when it says there is no bio. The posts were in memory. The feed you just tapped through holds up to two hundred notes and an open thread holds its replies; the ones by this author paint immediately while the relay fetch gets their real page. And the rest is a head start: an avatar (or the name beside it) begins its profile's fetches on pointerdown rather than click. That is a hundred odd milliseconds free, and unlike the prefetch-every-row this replaces — sixty requests for twenty rows, which is what starved the profile batch — it only ever asks about the person actually being tapped. Measured after: layout shift 0.000, bio in the first frame, posts in the first frame. tools/profile-open-test.js asserts exactly that, and fails on the old code with 0.412 and a blank page, so it cannot quietly rot. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
-rw-r--r--src/features/messages.js72
-rw-r--r--tools/profile-open-test.js121
2 files changed, 183 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 254e5ca..6cb3322 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -309,7 +309,8 @@ export function messagesFeature(ctx) {
// spreads a cold-relay miss across sessions.
if (!p || (!p.name && !p.picture)) return;
const s = wallet.loadFeatureState('profiles', {});
- s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, t: Date.now(),
+ s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null,
+ about: p.about || null, banner: p.banner || null, t: Date.now(),
...(p.thumbFor === p.picture && p.thumb
? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}),
...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
@@ -397,8 +398,11 @@ export function messagesFeature(ctx) {
// ...and for all of them together. Raised with THUMB_PX: a 144px face
// costs about 5.4KB against 96px's 3.2KB, and the budget is what decides
// how many faces paint instantly rather than fetching their original —
- // this holds about fifty, which covers a feed and an inbox at once.
- const THUMB_BUDGET = 280_000;
+ // this holds about fifty, which covers a feed and an inbox at once. The
+ // blob carries each profile's bio and banner as well now — a couple of
+ // hundred bytes each, and the reason a profile page paints on the first
+ // tap — so the ceiling is raised to keep the same number of faces.
+ const THUMB_BUDGET = 320_000;
const THUMB_SLOW = 20_000; // a host that won't answer must not hold a slot
const THUMB_RETRY = 6 * 3600_000; // ...and must not be written off for good
const THUMB_RETRY_MAX = 14 * 24 * 3600_000; // a host that never works, backed off
@@ -515,6 +519,13 @@ export function messagesFeature(ctx) {
picture: m.picture || null,
nip05: m.nip05 || null,
lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null,
+ // The batch downloaded this person's WHOLE kind 0 to get their name.
+ // Keeping the bio and the banner costs one more field each and is the
+ // difference between a profile page that paints and one that jumps
+ // when the fetch lands a second later. 1000 chars is what the page
+ // renders anyway.
+ about: typeof m.about === 'string' ? m.about.slice(0, 1000) : null,
+ banner: typeof m.banner === 'string' ? m.banner.slice(0, 400) : null,
} : null;
// An empty answer must never clobber a remembered face with a punk: keep
// what we had and just refresh the clock.
@@ -1979,6 +1990,12 @@ export function messagesFeature(ctx) {
if (p && p.picture) makeThumb(pk, p);
if (clickable) {
node.classList.add('clickable');
+ // Start the profile's own fetches on touch-DOWN, not on click. The gap
+ // between the two is a hundred-odd milliseconds of free head start,
+ // and unlike prefetching every row on paint (which cost sixty requests
+ // for twenty rows and starved the profile batch) this only ever asks
+ // about the person actually being tapped.
+ node.addEventListener('pointerdown', () => { try { prefetchProfilePage(pk); } catch {} }, { passive: true });
node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); });
}
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
@@ -2053,6 +2070,8 @@ export function messagesFeature(ctx) {
const entry = {
name: m.display_name || m.name || null, picture: m.picture || null,
nip05: m.nip05 || null, lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null,
+ about: typeof m.about === 'string' ? m.about.slice(0, 1000) : null,
+ banner: typeof m.banner === 'string' ? m.banner.slice(0, 400) : null,
t: Date.now(),
};
keepThumb(pk, entry);
@@ -2379,14 +2398,34 @@ export function messagesFeature(ctx) {
}
const notesRelays = async (pk) => [...new Set([...NOTE_RELAYS, ...(await relaysOf(pk))])];
+ // Posts of theirs we already hold. Tapping a face in the feed is the
+ // common way onto a profile, and the feed we just came from is full of
+ // that person's posts — an open thread holds their replies too. Painting
+ // those first means the page opens with real content instead of an empty
+ // column that fills in a beat later, and the relay fetch below replaces
+ // them the moment it answers.
+ function notesInHand(pk) {
+ const out = new Map();
+ for (const e of (feed && feed.notes) || []) if (e.pubkey === pk) out.set(e.id, e);
+ for (const c of threadCache.values()) {
+ if (c.root && c.root.pubkey === pk) out.set(c.root.id, c.root);
+ for (const e of c.replies || []) if (e.pubkey === pk) out.set(e.id, e);
+ }
+ return [...out.values()].sort((a, b) => b.created_at - a.created_at);
+ }
+
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) return c;
// stored posts paint the page instantly; the relay fetch below freshens
const stored = pageCache().notes[pk];
+ const seed = stored ? stored.v : notesInHand(pk);
+ // Seeded from memory the status stays 'loading': these are posts we
+ // happen to have, not their page, so the real answer still replaces
+ // them — but there is something to read while it comes.
c = stored
? { status: 'ready', notes: stored.v }
- : { status: 'loading', notes: [] };
+ : { status: 'loading', notes: seed };
notesCache.set(pk, c);
(async () => {
const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500);
@@ -3263,6 +3302,7 @@ export function messagesFeature(ctx) {
h('span', {
// the name, like the avatar, is its own tap-target (profile)
style: 'font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer',
+ onPointerdown: () => { try { prefetchProfilePage(pk); } catch {} },
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
@@ -3711,8 +3751,15 @@ export function messagesFeature(ctx) {
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
- // an about of "~" or a lone character is noise, not a bio
- const about = full && typeof full.about === 'string' ? full.about.trim() : '';
+ // an about of "~" or a lone character is noise, not a bio.
+ // Until the full kind 0 lands, the light profile cache answers — it was
+ // filled by the same batched fetch that named this person in the feed,
+ // so the bio is already in hand and the page doesn't grow a paragraph
+ // half a second after it opens. Once `full` is known it is the truth,
+ // including when it says there is no bio.
+ const about = full !== undefined
+ ? (typeof full.about === 'string' ? full.about.trim() : '')
+ : (typeof lp.about === 'string' ? lp.about.trim() : '');
const showAbout = about.length > 1;
// The cover photo: nostr's standard kind-0 `banner`. Legacy coinos.io
// wore these proudly — migrated accounts bring theirs along, and anyone
@@ -3723,7 +3770,10 @@ export function messagesFeature(ctx) {
const draft = mine && ui.profEdit ? ui.profEdit : null;
const urlish = (s) => (typeof s === 'string' && /^https?:\/\//i.test(s.trim()) ? s.trim() : null);
const bannerUrl = draft ? urlish(draft.banner)
- : (full && urlish(full.banner)) || null;
+ : full !== undefined ? urlish(full.banner)
+ // same as the bio: the cached copy holds the space so the whole page
+ // doesn't drop by the height of a cover photo when it arrives
+ : urlish(lp.banner);
const draftPic = draft && urlish(draft.picture);
return h('div', { class: 'col', style: 'gap:16px' },
// full header on profiles too — losing the search button here made
@@ -3891,9 +3941,11 @@ export function messagesFeature(ctx) {
// edge, no card walls (see .notes-feed).
(() => {
const c = notesFor(pk);
- // while loading: an invisible copy of the empty-state line holds the
- // height, so the page doesn't jump when the answer lands (no spinner)
- if (c.status === 'loading')
+ // while loading with nothing to show: an invisible copy of the
+ // empty-state line holds the height, so the page doesn't jump when
+ // the answer lands (no spinner). With posts already in hand — from
+ // the feed we came from — those are shown instead of the blank.
+ if (c.status === 'loading' && !c.notes.length)
return h('div', { class: 'small faint', style: 'text-align:center;visibility:hidden' }, t('profNotesNone'));
if (!c.notes.length)
return h('div', { class: 'small faint', style: 'text-align:center' }, t('profNotesNone'));
diff --git a/tools/profile-open-test.js b/tools/profile-open-test.js
new file mode 100644
index 0000000..7c88dee
--- /dev/null
+++ b/tools/profile-open-test.js
@@ -0,0 +1,121 @@
+// Tapping a face in the feed should land on a profile that is already
+// there: the bio, the banner and some of their posts painted in the first
+// frame, not half a second later once relays answer — which is what made
+// the page jump under your thumb.
+//
+// The bio and banner come free: the batched kind-0 fetch that named these
+// people in the feed downloaded their whole profile, so it is already in
+// hand. The posts come from the feed itself.
+//
+// Measured as the browser measures it — layout-shift entries after the tap.
+//
+// Run: bun tools/profile-open-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 { buildHtml } from '../build.js';
+import { SimplePool } from 'nostr-tools/pool';
+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; };
+
+// Real people with real bios: pull recent posters, then keep only the ones
+// whose kind 0 actually carries an `about`, so "the bio painted" is a claim
+// that can be true.
+const pool = new SimplePool();
+const RELAYS = ['wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io'];
+const recent = await pool.querySync(RELAYS, { kinds: [1], limit: 150, since: Math.floor(Date.now() / 1000) - 7200 },
+ { maxWait: 8000 }).catch(() => []);
+const posters = [...new Set(recent.map((e) => e.pubkey))].slice(0, 40);
+const withBio = new Map();
+for (const e of await pool.querySync(RELAYS, { kinds: [0], authors: posters }, { maxWait: 8000 }).catch(() => [])) {
+ try {
+ const m = JSON.parse(e.content);
+ if ((m.display_name || m.name) && typeof m.about === 'string' && m.about.trim().length > 30) {
+ const p = withBio.get(e.pubkey);
+ if (!p || e.created_at > p.created_at) withBio.set(e.pubkey, e);
+ }
+ } catch {}
+}
+const authors = [...withBio.keys()];
+const notes = recent.filter((e) => withBio.has(e.pubkey));
+if (authors.length < 4 || notes.length < 6) { console.log(' ✗ not enough people with bios posting right now'); process.exit(1); }
+console.log(` ${authors.length} people with a real bio, ${notes.length} of their posts`);
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5276, 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.__shift = 0;
+ window.__shiftOn = false;
+ new PerformanceObserver((l) => {
+ for (const e of l.getEntries()) if (!e.hadRecentInput && window.__shiftOn) window.__shift += e.value;
+ }).observe({ type: 'layout-shift', buffered: true });
+});
+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:5276/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('get started'); 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, pks, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ }, [base, authors, notes.map((e) => ({ id: e.id, pubkey: e.pubkey, kind: 1, created_at: e.created_at, content: e.content.slice(0, 200), tags: [] }))]);
+ 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(); });
+ // let the feed settle completely — this test is about what happens AFTER
+ await sleep(12000);
+
+ // tap the first face whose person we know has a bio
+ const target = await page.evaluate((pks) => {
+ const rows = [...document.querySelectorAll('.notes-feed > .row')];
+ for (const r of rows) {
+ const a = r.querySelector('.note-avatar');
+ if (!a) continue;
+ window.__shiftOn = true;
+ a.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
+ a.click();
+ return true;
+ }
+ return false;
+ }, authors);
+ check('a face in the feed opens a profile', target);
+
+ // the very next frame: what is on the page?
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
+ const first = await page.evaluate(() => ({
+ npub: /npub1/.test(document.body.innerText),
+ // the bio is the paragraph in the profile card, not a post
+ bio: (document.querySelector('.card p') || {}).textContent || '',
+ posts: document.querySelectorAll('.notes-feed > .row').length,
+ }));
+ check('the bio is there in the first frame', first.bio.trim().length > 20,
+ first.bio ? first.bio.trim().slice(0, 48).replace(/\n/g, ' ') + '…' : 'blank');
+ check('...and so are some of their posts', first.posts > 0, first.posts + ' posts');
+
+ // and nothing jumps while the relays answer
+ await sleep(7000);
+ const shift = await page.evaluate(() => window.__shift);
+ const after = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length);
+ // 0.1 is the browser's own "good" threshold for cumulative layout shift
+ check('the page does not jump while it loads', shift < 0.1, 'layout shift ' + shift.toFixed(3));
+ check('the relay answer fills in behind it', after > 0, first.posts + ' posts on open, ' + after + ' after');
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the profile is already there when you land on it' : '\n❌ failed');
+process.exit(ok ? 0 : 1);