From ccd96f8bd99c863a12c8b84199caf0c44c5edbd9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 2 Aug 2026 22:08:28 -0700 Subject: Avatars: no punk flash while profiles load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The punk is a statement about having no picture, not a loading state — in-flight profiles show a quiet empty circle instead. Known profiles persist (capped at 150, daily background refresh) so your own avatar and regular correspondents paint correctly on the first frame of the next session. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/messages.js | 44 ++++++++++++++++++++++++++++++++++++-------- src/style.css | 1 + 2 files changed, 37 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 70142e0..e96cadb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -110,14 +110,38 @@ export function messagesFeature(ctx) { }, 80); }; + // Profiles persist across sessions (capped) so known faces paint right + // away instead of flashing the punk fallback; entries refresh in the + // background once a day. + const PROFILE_TTL = 24 * 3600_000; + let profilesWarmed = false; + function warmProfiles() { + if (profilesWarmed) return; + profilesWarmed = true; + const cached = wallet.loadFeatureState('profiles', {}); + for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p); + } + function persistProfile(pk, p) { + const s = wallet.loadFeatureState('profiles', {}); + s[pk] = { name: p.name || null, picture: p.picture || null, t: Date.now() }; + const keys = Object.keys(s); + if (keys.length > 150) { + for (const k of keys.sort((a, b) => (s[a].t || 0) - (s[b].t || 0)).slice(0, keys.length - 150)) delete s[k]; + } + wallet.saveFeatureState('profiles', s); + } function profileOf(pk) { - if (profiles.has(pk)) return profiles.get(pk); - profiles.set(pk, null); + warmProfiles(); + const cur = profiles.get(pk); + if (cur !== undefined && (cur === null || Date.now() - (cur.t || 0) < PROFILE_TTL)) return cur; + profiles.set(pk, cur || null); // null = loading, no fallback art yet fetchNostrProfile(pk).then((p) => { - profiles.set(pk, p || {}); + const entry = { ...(p || {}), t: Date.now() }; + profiles.set(pk, entry); + persistProfile(pk, entry); scheduleRepaint(); - }).catch(() => profiles.set(pk, {})); - return null; + }).catch(() => profiles.set(pk, { t: Date.now() })); + return cur || null; } const displayName = (pk) => { const p = profileOf(pk); @@ -765,9 +789,13 @@ export function messagesFeature(ctx) { const avatar = (pk, cls = 'chat-avatar', clickable = true) => { const p = profileOf(pk); - const node = p && p.picture - ? h('img', { class: cls, src: p.picture, alt: '' }) - : fallbackAvatar(h, pk, p && p.name, cls); + // While the profile is in flight, a quiet empty circle — the punk is a + // statement about having no picture, not a loading state. + const node = p === null + ? h('div', { class: cls + ' fallback loading' }) + : p.picture + ? h('img', { class: cls, src: p.picture, alt: '' }) + : fallbackAvatar(h, pk, p.name, cls); if (clickable) { node.classList.add('clickable'); node.addEventListener('click', (e) => { e.stopPropagation(); openProfile(pk); }); diff --git a/src/style.css b/src/style.css index fd6553a..1175220 100644 --- a/src/style.css +++ b/src/style.css @@ -403,3 +403,4 @@ hr.divider { margin: 16px 0; } .avatar-menu-wrap { position: relative; display: inline-flex; } .menu-backdrop { position: fixed; inset: 0; z-index: 40; } .avatar-menu { position: absolute; right: 0; top: calc(100% + 8px); z-index: 41; min-width: 170px; } +.chat-avatar.fallback.loading { background: var(--surface2); } -- cgit v1.2.3