diff options
| author | Adam Soltys <[email protected]> | 2026-11-07 09:47:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-07 09:47:20 -0700 |
| commit | e905e5be5b2a0385e89de5ab3c0d95a917307314 (patch) | |
| tree | 5ef7ba6a6f7a45ace8368de767ca101896ebed8f /src/features | |
| parent | b3a5ea14585a25e6fc7c9e35f94fcd3e1025a543 (diff) | |
| download | coinosv3-e905e5be5b2a0385e89de5ab3c0d95a917307314.tar.gz coinosv3-e905e5be5b2a0385e89de5ab3c0d95a917307314.zip | |
A flick through the feed no longer drags background work through the middle of it. "When I flick to scroll, it's not getting up to speed very quickly, and clicking around in the app feels a bit sluggish at times" (Adam, 2026-11-07). Measured on his Pixel 9 in the installed PWA: idle is quiet (0.16 s of script in 10 s), but three real flicks through Following spent 1.7 s of 9 on the main thread, frames up to 200 ms, and the work was the avatar thumbnailer finishing faces as they scrolled in: a synchronous canvas.toDataURL WebP encode per face (229 ms), the profile cache JSON.stringified twice per half-second flush (once just to measure it, up to ~2 MB with thumbnails), and a whole-page repaint per finished face that changes nothing on screen (feed rows keep the face they were admitted with). Now: the crop, resize and encode happen off the main thread (createImageBitmap with resizeWidth/Height, OffscreenCanvas.convertToBlob, FileReader), with the old canvas path as the fallback; a finished face is recorded once the page settles; scheduleRepaint holds while a finger is down or the page scrolled in the last 200 ms (at most 1.5 s), and the profile flush likewise (at most 5 s, forced on an account switch) and sizes the blob by summing rows instead of stringifying it. Tests: the create flow's heads-up / passphrase / check steps from earlier today broke every test that made a wallet by waiting for the words and clicking "Skip verification"; tools/puppeteer.js exports seedWords() and skipSeedCheck() and the 96 tests use them. room-faces and avatar-profile fail the same way on 8858591 (row.hover, scrollIntoView), before today's changes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6Ts5Tktega8VbLg31q
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 89 |
1 files changed, 71 insertions, 18 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 90bfaaf..6c4f5f8 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -760,14 +760,38 @@ export function messagesFeature(ctx) { let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt + // Is the page under the reader's finger, or still coasting from a flick? + // Background work (a repaint for a face that arrived, writing the profile + // cache) waits for it to settle: on Adam's Pixel a flick through the feed + // spent 1.7 s of 9 on the main thread doing exactly that, and taps queued + // behind it (2026-11-07, "flick to scroll … not getting up to speed … + // clicking around feels sluggish"). Never longer than `most` ms, though: + // a slow, steady read must still see what arrived. + let pageMovedAt = 0, fingerDown = false; + if (typeof window !== 'undefined') { + window.addEventListener('scroll', () => { pageMovedAt = Date.now(); }, { passive: true, capture: true }); + window.addEventListener('touchstart', () => { fingerDown = true; }, { passive: true, capture: true }); + for (const n of ['touchend', 'touchcancel']) window.addEventListener(n, (e) => { if (!e.touches || !e.touches.length) fingerDown = false; }, { passive: true, capture: true }); + } + const pageMoving = () => fingerDown || Date.now() - pageMovedAt < 200; + const whenSettled = (fn, most = 1500, step = 150) => { + const until = Date.now() + most; + const go = () => { if (pageMoving() && Date.now() < until) { setTimeout(go, step); return; } fn(); }; + go(); + }; + let repaintTimer = null; const scheduleRepaint = () => { if (repaintTimer) return; + const since = Date.now(); const run = () => { // not under a pager in motion: the page beneath is out of sight, and // its repaint (40–55 ms on Adam's phone) dropped frames from flights // through the pictures (2026-10-17) — it waits for the landing if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; } + // nor mid-flick on the page itself (up to 1.5 s): a whole-page morph + // in the middle of a fling is a dropped frame and a late tap + if (pageMoving() && Date.now() - since < 1500) { repaintTimer = setTimeout(run, 150); return; } repaintTimer = null; // a visitor's public page (a profile, a thread, a feed) repaints too if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable(); @@ -826,11 +850,14 @@ export function messagesFeature(ctx) { // memory and is written back once per burst: a batch of thirty profiles // landing during a scroll used to parse and re-serialize the whole blob // thirty times over, on the main thread, mid-scroll. - let profBlob = null, profFlush = null; + let profBlob = null, profFlush = null, profFlushSince = 0; const PROFILE_ROWS = 1500; // a row without a thumbnail is ~430 chars; IndexedDB has room const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {})); - function flushProfiles() { - profFlush = null; + function flushProfiles(force) { + // up to 5 s for the page to settle: stringifying this blob (thumbnails + // and all, up to ~2 MB) is a long main-thread task + if (!force && pageMoving() && Date.now() - (profFlushSince || 0) < 5000) { profFlush = setTimeout(flushProfiles, 300); return; } + profFlush = null; profFlushSince = 0; const s = profBlob; if (!s) return; // Who stays when space runs out: your own faces, the people you follow, @@ -848,7 +875,10 @@ export function messagesFeature(ctx) { // Thumbnails are the bulk of this blob, so they live on a budget: the // least recently seen strangers give theirs up first. The row itself // stays — that face just paints the way it used to. - let size = JSON.stringify(s).length; + // (an estimate, not a JSON.stringify of the lot just to measure it: + // a row is ~430 chars plus its thumbnail) + let size = 0; + for (const k in s) size += 430 + ((s[k] && s[k].thumb) || '').length; if (size > THUMB_BUDGET) { // never our own: the header face is the one that must paint offline for (const k of evictFirst(Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)))) { @@ -875,7 +905,7 @@ export function messagesFeature(ctx) { ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, thumbFailVersion: p.thumbFailVersion || 0 } : {}) }; - if (!profFlush) profFlush = setTimeout(flushProfiles, 500); + if (!profFlush) { profFlushSince = Date.now(); profFlush = setTimeout(flushProfiles, 500); } } // A punk picture is OUR OWN art. Every coinos user who keeps the default // publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and @@ -1081,6 +1111,39 @@ export function messagesFeature(ctx) { blobProbing.set(url, task); return task; } + // Centre-crop to a square (the way background-size:cover paints it), + // shrink, and encode — off the main thread where the browser allows: the + // crop and resize through createImageBitmap, the encode through an + // OffscreenCanvas's convertToBlob. The synchronous canvas.toDataURL it + // replaces cost ~30 ms a face on a phone, mid-scroll (2026-11-07). + async function encodeThumb(bmp) { + const side = Math.min(bmp.width, bmp.height); + if (!side) throw new Error('empty'); + const sx = (bmp.width - side) / 2, sy = (bmp.height - side) / 2; + if (typeof OffscreenCanvas !== 'undefined' && OffscreenCanvas.prototype.convertToBlob) { + let small = null; + try { + small = await createImageBitmap(bmp, sx, sy, side, side, { resizeWidth: THUMB_PX, resizeHeight: THUMB_PX, resizeQuality: 'high' }); + const oc = new OffscreenCanvas(THUMB_PX, THUMB_PX); + oc.getContext('2d').drawImage(small, 0, 0); + const asUrl = (b) => new Promise((ok, bad) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => bad(r.error); r.readAsDataURL(b); }); + let blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.75 }); + if (blob.type !== 'image/webp') blob = await oc.convertToBlob({ type: 'image/jpeg', quality: 0.7 }); + if (blob.size * 4 / 3 > THUMB_MAX) blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.5 }); // a busy picture, leaned on harder + const data = await asUrl(blob); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } finally { try { small && small.close(); } catch {} } + } + const c = document.createElement('canvas'); + c.width = c.height = THUMB_PX; + c.getContext('2d').drawImage(bmp, sx, sy, side, side, 0, 0, THUMB_PX, THUMB_PX); + let data = c.toDataURL('image/webp', 0.75); + if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); + if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); + if (data.length > THUMB_MAX) throw new Error('too big'); + return data; + } function makeThumb(pk, p) { if (!p || !p.picture || typeof document === 'undefined') return; if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway @@ -1152,18 +1215,8 @@ export function messagesFeature(ctx) { scheduleRepaint(); // the big face, where one is up, can paint from the copy now } bmp = await createImageBitmap(await res.blob()); - // centre-crop to a square, the way background-size:cover paints it - const side = Math.min(bmp.width, bmp.height); - if (!side) throw new Error('empty'); - const c = document.createElement('canvas'); - c.width = c.height = THUMB_PX; - c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, - side, side, 0, 0, THUMB_PX, THUMB_PX); - let data = c.toDataURL('image/webp', 0.75); - if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7); - if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder - if (data.length > THUMB_MAX) throw new Error('too big'); - done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }); + const data = await encodeThumb(bmp); + whenSettled(() => done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX })); } catch (e) { // a quick TypeError is the CORS refusal (a dead host takes longer) // only a refusal corsFetch confirmed (a body cut off mid-download @@ -19062,7 +19115,7 @@ export function messagesFeature(ctx) { follows = null; followsAt = 0; feed = null; relayLists = null; mutes = null; mutesAt = 0; stCache = null; stAt = 0; // the next account's state, not this one's - if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped + if (profFlush) { clearTimeout(profFlush); flushProfiles(true); } // this account's faces, written before the blob is dropped profBlob = null; timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); |
