aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 23:09:14 -0700
committerAdam Soltys <[email protected]>2026-09-10 23:09:14 -0700
commit7539975beed48ed7aa9c35495d34f5b8b547cc93 (patch)
treed92aaea48ac7e3f4d0a5a8363602ef22c164e895
parent48e8e588f1a3dee26a2497d1a1757f2f294413a3 (diff)
downloadcoinosv3-7539975beed48ed7aa9c35495d34f5b8b547cc93.tar.gz
coinosv3-7539975beed48ed7aa9c35495d34f5b8b547cc93.zip
A transient failure shouldn't cost a face its thumbnail for good.
Pinguimdarelva's picture is 52KB on a CDN that answers in 700ms one minute and times out the next — I measured both from this machine. The thumbnail attempt hit one of the bad minutes, and `thumbFail` was written as if the host had refused us: never retried, so that avatar went back to fetching the original on every boot. Only a tainted canvas is a real refusal (no CORS header, nothing to be done), and that is remembered for good. Everything else — a load error, or a host that won't answer inside twenty seconds — is stamped with the time and tried again in six hours. The timeout also frees the slot: three thumbnails run at a time, and a hanging image used to hold one of them indefinitely. And zap tallies are remembered between sessions. They were asked of the relays on every boot, which takes seconds, so a room full of zapped messages painted bare and filled in afterwards. The totals (never the receipts) are kept locally and painted straight away; the moment the relays answer for a message, their word replaces it — so a tally that changed, or a receipt that was deleted, still settles correctly. Opening a DM thread doesn't forget what the chat had learned, and the store is capped at 400. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
-rw-r--r--src/features/messages.js95
-rw-r--r--tools/zap-tally-cache-test.js69
2 files changed, 153 insertions, 11 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 59dcb1d..b9be343 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -61,6 +61,7 @@ export function messagesFeature(ctx) {
s.read ||= {}; // { ['dm:'+pk | 'ch:'+id]: created_at } — last message we've seen
s.notify ||= {}; // { [cid]: true } — communities that may buzz your phone (opt-in)
s.drafts ||= {}; // { ['dm:'+pk | 'ch:'+id]: text } — half-typed messages
+ s.zaps ||= {}; // { [eventId]: { s: sats, m: 1 } } — last known zap tallies
for (const c of s.communities) c.added_at ||= Date.now();
// pre-multi-community shape: joined was { [pubkey]: true } for coinos
for (const k of Object.keys(s.joined))
@@ -310,7 +311,7 @@ export function messagesFeature(ctx) {
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(),
...(p.thumbFor === p.picture && p.thumb ? { thumb: p.thumb, thumbFor: p.thumbFor } : {}),
- ...(p.thumbFail ? { thumbFail: p.thumbFail } : {}) };
+ ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, ...(p.thumbHard ? { thumbHard: true } : {}) } : {}) };
// Thumbnails are the bulk of this blob, so they live on a budget: the
// least recently seen faces give theirs up first. The row itself stays —
// that face just paints the way it used to.
@@ -352,7 +353,11 @@ export function messagesFeature(ctx) {
const prev = profiles.get(pk);
if (!prev || !entry || !entry.picture) return entry;
if (prev.thumb && prev.thumbFor === entry.picture) { entry.thumb = prev.thumb; entry.thumbFor = prev.thumbFor; }
- if (prev.thumbFail === entry.picture) entry.thumbFail = prev.thumbFail;
+ if (prev.thumbFail === entry.picture) {
+ entry.thumbFail = prev.thumbFail;
+ entry.thumbFailAt = prev.thumbFailAt || 0;
+ if (prev.thumbHard) entry.thumbHard = true;
+ }
return entry;
};
@@ -376,11 +381,19 @@ export function messagesFeature(ctx) {
// layers the original over it anyway
const THUMB_MAX = 9000; // chars of data URL for one face
const THUMB_BUDGET = 180_000; // ...and for all of them together
+ 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 thumbing = new Set();
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
- if (p.thumbFor === p.picture || p.thumbFail === p.picture) return;
+ if (p.thumbFor === p.picture) return;
if (localPunk(p.picture)) return; // our own art, already the right size on disk
+ // A failed attempt is usually the network, not the host: these images sit
+ // on CDNs that answer in 700ms one minute and time out the next. Only a
+ // tainted canvas (no CORS header, and there's nothing to be done about
+ // that) is remembered for good; everything else is tried again later —
+ // one slow minute used to cost a face its thumbnail permanently.
+ if (p.thumbFail === p.picture && (p.thumbHard || Date.now() - (p.thumbFailAt || 0) < THUMB_RETRY)) return;
if (thumbing.has(pk) || thumbing.size >= 3) return; // a few at a time
// Making the thumbnail costs one more fetch of the original today to
// save every fetch after it — but not on a connection someone is
@@ -395,24 +408,30 @@ export function messagesFeature(ctx) {
persistProfile(pk, entry);
if (patch.thumb) scheduleRepaint();
};
+ const failed = (hard) => done({ thumbFail: url, thumbFailAt: Date.now(), ...(hard ? { thumbHard: true } : {}) });
const img = new Image();
img.crossOrigin = 'anonymous';
img.decoding = 'async';
+ const slow = setTimeout(() => { img.onload = img.onerror = null; failed(false); }, THUMB_SLOW);
img.onload = () => {
+ clearTimeout(slow);
try {
// centre-crop to a square, the way background-size:cover paints it
const side = Math.min(img.naturalWidth, img.naturalHeight);
- if (!side) return done({ thumbFail: url });
+ if (!side) return failed(true);
const c = document.createElement('canvas');
c.width = c.height = THUMB_PX;
c.getContext('2d').drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2,
side, side, 0, 0, THUMB_PX, THUMB_PX);
+ // toDataURL is what throws on a tainted canvas, so a failure from
+ // here down is the host's CORS policy, not the picture.
let data = c.toDataURL('image/webp', 0.75);
if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
- done(data.length <= THUMB_MAX ? { thumb: data, thumbFor: url } : { thumbFail: url });
- } catch { done({ thumbFail: url }); } // tainted canvas — this host sends no CORS header
+ if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder
+ done(data.length <= THUMB_MAX ? { thumb: data, thumbFor: url } : { thumbFail: url, thumbFailAt: Date.now(), thumbHard: true });
+ } catch { failed(true); } // tainted canvas — this host sends no CORS header
};
- img.onerror = () => done({ thumbFail: url });
+ img.onerror = () => { clearTimeout(slow); failed(false); };
img.src = url;
}
@@ -1969,7 +1988,7 @@ export function messagesFeature(ctx) {
if (from && my.includes(from)) { cur.mine = true; voidPending(x[1], ev.created_at * 1000); }
changed = true;
}
- if (changed) scheduleRepaint();
+ if (changed) { saveZapTotals(); scheduleRepaint(); }
}
// Ask once per id (batched per paint), and keep ONE live subscription on
// the ids most recently on screen, so a zap landing while you watch —
@@ -1983,14 +2002,67 @@ export function messagesFeature(ctx) {
const batch = [...zapQueue]; zapQueue = new Set();
const relays = zapRelays();
for (let i = 0; i < batch.length; i += 150) {
- queryOn(relays, { kinds: ZAP_KINDS, '#e': batch.slice(i, i + 150) }, 4000)
- .then((evs) => evs.forEach(noteReceipt)).catch(() => {});
+ const slice = batch.slice(i, i + 150);
+ queryOn(relays, { kinds: ZAP_KINDS, '#e': slice }, 4000)
+ .then((evs) => {
+ evs.forEach(noteReceipt);
+ // the relays have spoken for these ids: their live tally is the
+ // truth now, remembered or not (a zap that was deleted has to be
+ // able to disappear)
+ for (const id of slice) zapSeeds().delete(id);
+ saveZapTotals();
+ scheduleRepaint();
+ }).catch(() => {});
}
zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200);
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} }
zapLiveUnsub = subscribeOn(relays, { kinds: ZAP_KINDS, '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteReceipt);
}, 250);
}
+ // Tallies are asked of the relays again on every boot, and that takes
+ // seconds — long enough that a room full of zapped messages paints bare and
+ // fills in afterwards. So the totals themselves are remembered (just the
+ // numbers, never the receipts) and painted straight away; the moment the
+ // relays answer for a message, their word replaces it.
+ const ZAP_REMEMBER = 400; // tallies kept between sessions
+ let zapSeed = null;
+ function zapSeeds() {
+ if (!zapSeed) {
+ zapSeed = new Map();
+ try {
+ for (const [id, v] of Object.entries(st().zaps || {})) if (v && v.s) zapSeed.set(id, { sats: v.s, mine: !!v.m });
+ } catch {}
+ }
+ return zapSeed;
+ }
+ let zapSaveT = null;
+ function saveZapTotals() {
+ clearTimeout(zapSaveT);
+ zapSaveT = setTimeout(() => {
+ try {
+ const s2 = st();
+ // Update what's recently been on screen, and KEEP the rest: opening a
+ // DM thread must not forget what the community chat had learned.
+ const out = { ...(s2.zaps || {}) };
+ for (const id of zapRecent) {
+ const z = zapTotals.get(id) || zapSeeds().get(id);
+ if (z && z.sats) out[id] = z.mine ? { s: z.sats, m: 1 } : { s: z.sats };
+ else delete out[id]; // the relays answered, and it's zero
+ }
+ let n = Object.keys(out).length;
+ if (n > ZAP_REMEMBER) {
+ const recent = new Set(zapRecent);
+ for (const k of Object.keys(out)) { // insertion order: oldest first
+ if (n <= ZAP_REMEMBER) break;
+ if (!recent.has(k)) { delete out[k]; n--; }
+ }
+ }
+ s2.zaps = out;
+ save(s2);
+ } catch {}
+ }, 1500);
+ }
+
// After OUR zap the receipt trails the payment by seconds — ask again.
function recheckZap(id) {
for (const ms of [4000, 12000, 30000]) setTimeout(() => { zapAsked.delete(id); watchZaps([id]); }, ms);
@@ -2061,7 +2133,7 @@ export function messagesFeature(ctx) {
// The chip: a little bolt + the sats total. Absent until the first receipt
// — or until you zap it yourself, which is its own kind of receipt.
function zapChip(id, { onClick, cls = '' } = {}) {
- const z = zapTotals.get(id);
+ const z = zapTotals.get(id) || zapSeeds().get(id);
const p = pendingOf(id);
const optimistic = p && p.state !== 'void' ? p.sats : 0;
const sats = (z ? z.sats : 0) + optimistic;
@@ -3862,6 +3934,7 @@ export function messagesFeature(ctx) {
listsSynced = false;
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];
+ clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null;
zapTotals.clear(); zapAsked.clear(); zapPending.clear(); // 'mine' is per identity — refetch under the next
},
};
diff --git a/tools/zap-tally-cache-test.js b/tools/zap-tally-cache-test.js
new file mode 100644
index 0000000..98cfe7e
--- /dev/null
+++ b/tools/zap-tally-cache-test.js
@@ -0,0 +1,69 @@
+// Zap tallies are asked of the relays on every boot, which takes seconds. The
+// last known totals are remembered locally so a room of zapped messages
+// paints its chips straight away — and the relays' answer replaces them, so a
+// tally that changed (or a receipt that was deleted) still settles correctly.
+//
+// Run: bun tools/zap-tally-cache-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+import { cacheKeyFor } from '../src/wallet.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; };
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5236, 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();
+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:5236/', { 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 mnemonic = generateMnemonic(wordlist);
+ await page.type('textarea', mnemonic);
+ await click('open wallet');
+ await waitText('receive', 20000);
+
+ // the feature state slot this wallet reads (seed-keyed, as loadFeatureState does)
+ const key = cacheKeyFor(mnemonic + '\n') + ':messages';
+
+ // the shape the chat paints from: remembered totals keyed by event id
+ const painted = await page.evaluate((k) => {
+ const s = JSON.parse(localStorage.getItem(k) || '{}');
+ s.zaps = { ['a'.repeat(64)]: { s: 2100, m: 1 }, ['b'.repeat(64)]: { s: 21 } };
+ localStorage.setItem(k, JSON.stringify(s));
+ return Object.keys(s.zaps).length;
+ }, key);
+ check('seeded two remembered tallies', painted === 2);
+
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ // open the chat — the screen that rewrites this state, and where a stale
+ // copy being saved back would quietly drop every remembered tally
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(2500);
+ const kept = await page.evaluate((k) => {
+ const s = JSON.parse(localStorage.getItem(k) || '{}');
+ return Object.keys(s.zaps || {}).length;
+ }, key);
+ check('they survive a refresh and a trip through chat', kept === 2, kept + ' kept');
+
+ // and they are readable as tallies, not re-fetched from scratch: the
+ // seeded totals are what a chip would paint before any relay answers
+ const readable = await page.evaluate((k) => {
+ const s = JSON.parse(localStorage.getItem(k) || '{}');
+ const v = s.zaps[Object.keys(s.zaps)[0]];
+ return v && typeof v.s === 'number' && v.s > 0;
+ }, key);
+ check('a remembered tally carries its sats', readable);
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ tallies survive a refresh' : '\n❌ failed');
+process.exit(ok ? 0 : 1);