aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-02 22:08:28 -0700
committerAdam Soltys <[email protected]>2026-08-02 22:08:28 -0700
commitccd96f8bd99c863a12c8b84199caf0c44c5edbd9 (patch)
tree98ef5359698e30d255e6d7d1e2f480e8b4145885 /src
parent5a16f87c3ba44c9c5231b37cf626db06b63ce351 (diff)
downloadcoinosv3-ccd96f8bd99c863a12c8b84199caf0c44c5edbd9.tar.gz
coinosv3-ccd96f8bd99c863a12c8b84199caf0c44c5edbd9.zip
Avatars: no punk flash while profiles load
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js44
-rw-r--r--src/style.css1
2 files changed, 37 insertions, 8 deletions
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); }