diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/hats.js | 38 | ||||
| -rw-r--r-- | src/features/messages.js | 18 |
2 files changed, 55 insertions, 1 deletions
diff --git a/src/features/hats.js b/src/features/hats.js index c8b27b6..22b573a 100644 --- a/src/features/hats.js +++ b/src/features/hats.js @@ -134,6 +134,40 @@ export function hatsFeature(ctx) { }, 800); }; + // ---- boot stamps --------------------------------------------------------- + // This feature is a DEFERRED chunk — for the beat between first paint and + // the chunk landing, avatars render bare and hats popped in late. The core + // bundle can't hold the hat art, so wrapAvatar leaves a STAMP per head + // (art + percent geometry, size-independent) in localStorage, and the + // avatar factory replays stamps until this chunk takes over. + let stamps = null, stampT = 0; + const stampStore = () => { + if (stamps === null) { try { stamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { stamps = {}; } } + return stamps; + }; + const stamp = (pk, hat) => { + const s = stampStore(); + if (!hat) { + // a bare head clears its stamp, or a removed hat would ghost on boot + if (s[pk]) { delete s[pk]; saveStamps(); } + return; + } + const next = { s: posStyle(hat), a: hat.art, t: Date.now() }; + if (!s[pk] || s[pk].a !== next.a || s[pk].s !== next.s) { + s[pk] = next; + const keys = Object.keys(s); + if (keys.length > 40) { + keys.sort((a, b) => (s[a].t || 0) - (s[b].t || 0)); + for (const k of keys.slice(0, keys.length - 40)) delete s[k]; + } + saveStamps(); + } else s[pk].t = Date.now(); + }; + const saveStamps = () => { + clearTimeout(stampT); + stampT = setTimeout(() => { try { localStorage.setItem('hat-stamps', JSON.stringify(stamps)); } catch {} }, 500); + }; + const pending = new Set(); let fetchT = 0, failedAt = 0; const queueFetch = (pk) => { @@ -373,8 +407,12 @@ export function hatsFeature(ctx) { id: 'hats', // The avatar factory (and the search rows) offer every avatar up for // decoration; a bare head comes back null and is used as-is. + // Present the moment this chunk lands — the avatar factory stops + // replaying boot stamps once a real answer (hat or bare) is available. + hatsReady() { return true; }, wrapAvatar(pk, node) { const hat = hatById(hatFor(pk)); + stamp(pk, hat); // keep the boot stamp current (or clear it) if (!hat) return null; return h('span', { class: 'hat-wrap' }, node, diff --git a/src/features/messages.js b/src/features/messages.js index d6eacc9..0aa7291 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1592,9 +1592,25 @@ export function messagesFeature(ctx) { node.classList.add('clickable'); node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); } - return hook('wrapAvatar', pk, node) || node; + return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node; }; + // The hats feature is a deferred chunk — until it lands, replay last + // session's boot stamps (hat art + geometry it persisted) so a hat sits on + // the head from the FIRST frame instead of popping in a beat later. Once + // hatsReady answers, the stamps stand down: a null from wrapAvatar then + // genuinely means "bare head". + let _hatStamps = null; + function hatStamp(pk, node) { + if (hook('hatsReady')) return null; + if (_hatStamps === null) { + try { _hatStamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { _hatStamps = {}; } + } + const s = _hatStamps[pk]; + if (!s || !s.a) return null; + return h('span', { class: 'hat-wrap' }, node, h('span', { class: 'ava-hat', style: s.s, html: s.a })); + } + // ---- profiles: view + own kind-0 editor --------------------------------- const fullProfiles = new Map(); // pk -> { raw kind0 content object, fetched_at } |
