From 29ff16546aa00d76878e3b18bc96fc1d870cb967 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 24 Jul 2026 15:37:16 -0400 Subject: [PATCH] fix: avatar-decoration live-update + CDN override + profile 404; DND badge color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatar decorations (useAvatarDecoration.ts / ProfileDecoration.tsx): - invalidateDecorationCache now notifies a per-user listener set (and clears the give-up counter), so changing your own decoration updates mounted avatars (timeline, member list) live instead of only after a remount. Concurrent re-fetches de-dupe via the existing `pending` map. - Picker grid thumbnails use decorationUrl() instead of the raw DECORATION_CDN literal, so a VITE_DECORATION_CDN override no longer breaks the grid while real avatars work. - Settings reads the full /profile/{userId} instead of the /{field} sub-resource, which 404s (console error) for anyone without a decoration set — matching the pattern already used by useAvatarDecoration. Presence (Presence.tsx): PresenceBadge renders DND (unavailable + status 'dnd') as red "Do Not Disturb" to match PresenceRingAvatar and the settings picker; it was the lone outlier showing a yellow "Idle". Bug-hunt findings from LOTUS_TODO. Two review agents (correctness + upstream-behavior); gate-green (tsc, eslint, prettier, 914 tests, build). Both flagged only pre-existing edge notes (in-flight piggyback staleness, 'dnd' free-text collision shared with the ring avatar) — neither introduced here. Co-Authored-By: Claude Opus 4.8 --- src/app/components/presence/Presence.tsx | 17 +++++--- .../settings/account/ProfileDecoration.tsx | 16 +++----- src/app/hooks/useAvatarDecoration.ts | 40 ++++++++++++++++--- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 9b9838525..1bdef048b 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -27,7 +27,14 @@ type PresenceBadgeProps = { }; export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { const label = usePresenceLabel(); - const ariaLabel = status ? `${label[presence]} — ${status}` : label[presence]; + // DND is encoded as unavailable + status_msg 'dnd'; render it red/"Do Not + // Disturb" to match PresenceRingAvatar and the settings picker (which both + // special-case 'dnd' → Critical) — the badge was the lone outlier showing a + // yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line. + const isDnd = presence === Presence.Unavailable && status === 'dnd'; + const displayLabel = isDnd ? 'Do Not Disturb' : label[presence]; + const displayStatus = isDnd ? undefined : status; + const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel; return ( - {label[presence]} - {status && } - {status && {status}} + {displayLabel} + {displayStatus && } + {displayStatus && {displayStatus}} } @@ -50,7 +57,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { aria-label={ariaLabel} ref={triggerRef} size={size} - variant={PresenceToColor[presence]} + variant={isDnd ? 'Critical' : PresenceToColor[presence]} fill={presence === Presence.Offline ? 'Soft' : 'Solid'} radii="Pill" /> diff --git a/src/app/features/settings/account/ProfileDecoration.tsx b/src/app/features/settings/account/ProfileDecoration.tsx index 101f9da5c..8dd5fdaad 100644 --- a/src/app/features/settings/account/ProfileDecoration.tsx +++ b/src/app/features/settings/account/ProfileDecoration.tsx @@ -4,11 +4,7 @@ import { Method } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { SettingTile } from '../../../components/setting-tile'; -import { - DECORATION_CATEGORIES, - DECORATION_CDN, - decorationUrl, -} from '../../lotus/avatarDecorations'; +import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations'; import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration'; const PROFILE_FIELD = 'io.lotus.avatar_decoration'; @@ -48,7 +44,7 @@ function DecorationPreviewCell({ }} > {name}(null); useEffect(() => { + // Fetch the whole profile, not the `/{field}` sub-resource: an unset field + // 404s (a console error for anyone without a decoration). The full profile + // returns 200 with all fields incl. custom MSC4133 ones — read it out. mx.http - .authedRequest>( - Method.Get, - `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`, - ) + .authedRequest>(Method.Get, `/profile/${encodeURIComponent(userId)}`) .then((res) => { const val = (res[PROFILE_FIELD] as string | undefined) ?? null; setCurrent(val); diff --git a/src/app/hooks/useAvatarDecoration.ts b/src/app/hooks/useAvatarDecoration.ts index 0ecdc56ff..1c7c993bf 100644 --- a/src/app/hooks/useAvatarDecoration.ts +++ b/src/app/hooks/useAvatarDecoration.ts @@ -12,6 +12,22 @@ const pending = new Map void>>(); // Transient-failure attempt counts (userId → n) so a flaky federated lookup // can retry a couple of times, then gives up for the session. const failures = new Map(); +// Mounted hooks per userId, so an invalidation (e.g. you change your own +// decoration) re-fetches live instead of waiting for a remount. +const listeners = new Map void>>(); + +function subscribeDecoration(userId: string, cb: () => void): () => void { + let set = listeners.get(userId); + if (!set) { + set = new Set(); + listeners.set(userId, set); + } + set.add(cb); + return () => { + set.delete(cb); + if (set.size === 0) listeners.delete(userId); + }; +} function fetchDecoration( authedRequest: (method: Method, path: string) => Promise>, @@ -66,6 +82,10 @@ function fetchDecoration( export function invalidateDecorationCache(userId: string): void { cache.delete(userId); + // Also clear the give-up counter so the next fetch starts fresh. + failures.delete(userId); + // Notify mounted avatars for this user so they re-fetch immediately. + listeners.get(userId)?.forEach((cb) => cb()); } export function useAvatarDecoration(userId: string): string | null { @@ -74,14 +94,22 @@ export function useAvatarDecoration(userId: string): string | null { useEffect(() => { let cancelled = false; - fetchDecoration( - (method, path) => mx.http.authedRequest>(method, path), - userId, - ).then((val) => { - if (!cancelled) setSlug(val); - }); + const load = () => { + fetchDecoration( + (method, path) => mx.http.authedRequest>(method, path), + userId, + ).then((val) => { + if (!cancelled) setSlug(val); + }); + }; + load(); + // Re-run on invalidation (fetchDecoration re-fetches since the cache entry + // was cleared; concurrent mounts for the same user still de-dupe via + // `pending`). Keeps live avatars in sync when the decoration changes. + const unsubscribe = subscribeDecoration(userId, load); return () => { cancelled = true; + unsubscribe(); }; }, [mx, userId]);