Files
cinny/src/app/hooks/useAvatarDecoration.ts
T
jaredandClaude Opus 4.8 29ff16546a fix: avatar-decoration live-update + CDN override + profile 404; DND badge color
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 <noreply@anthropic.com>
2026-07-24 15:37:16 -04:00

118 lines
4.4 KiB
TypeScript

import { useEffect, useState } from 'react';
import { MatrixError, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
// Module-level cache — survives re-renders, lives for the app session.
// userId → slug | null (null = fetched, no decoration set)
const cache = new Map<string, string | null>();
// Callbacks waiting for a userId's result
const pending = new Map<string, Array<(val: string | null) => 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<string, number>();
// 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<string, Set<() => 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<Record<string, string>>,
userId: string,
): Promise<string | null> {
if (cache.has(userId)) return Promise.resolve(cache.get(userId) ?? null);
// De-duplicate in-flight requests for the same userId
if (pending.has(userId)) {
return new Promise((resolve) => {
pending.get(userId)!.push(resolve);
});
}
const waiters: Array<(val: string | null) => void> = [];
pending.set(userId, waiters);
// Fetch the WHOLE profile, not the single `/{field}` sub-resource: an unset
// field returns 404, which the browser logs as a failed request — a console
// 404 for every user without a decoration. The full profile returns 200 with
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
cache.set(userId, val);
return val;
})
.catch((err: unknown) => {
const status = err instanceof MatrixError ? err.httpStatus : undefined;
// Definitive rejections (404 unknown user / 403 can't view / 400) — cache
// "no decoration" so we never refetch a profile we can't read (otherwise
// every avatar mount re-floods our HS with failing federated lookups).
if (status === 404 || status === 403 || status === 400) {
cache.set(userId, null);
} else {
// Transient (429 rate-limit / 5xx / network). Allow a couple of retries
// — a single 429 in a member-list burst shouldn't permanently hide a
// decoration — then give up for the session so a persistently-failing
// federated link (e.g. a 502'ing remote server) can't loop forever.
const attempts = (failures.get(userId) ?? 0) + 1;
failures.set(userId, attempts);
if (attempts >= 2) cache.set(userId, null);
}
return null;
})
.finally(() => {
const v = cache.get(userId) ?? null;
pending.delete(userId);
waiters.forEach((cb) => cb(v));
});
}
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 {
const mx = useMatrixClient();
const [slug, setSlug] = useState<string | null>(() => cache.get(userId) ?? null);
useEffect(() => {
let cancelled = false;
const load = () => {
fetchDecoration(
(method, path) => mx.http.authedRequest<Record<string, string>>(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]);
return slug;
}