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>
This commit is contained in:
@@ -27,7 +27,14 @@ type PresenceBadgeProps = {
|
|||||||
};
|
};
|
||||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||||
const label = usePresenceLabel();
|
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 (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -38,9 +45,9 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||||
<Text size="L400">{label[presence]}</Text>
|
<Text size="L400">{displayLabel}</Text>
|
||||||
{status && <Text size="T200">•</Text>}
|
{displayStatus && <Text size="T200">•</Text>}
|
||||||
{status && <Text size="T200">{status}</Text>}
|
{displayStatus && <Text size="T200">{displayStatus}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
@@ -50,7 +57,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
size={size}
|
size={size}
|
||||||
variant={PresenceToColor[presence]}
|
variant={isDnd ? 'Critical' : PresenceToColor[presence]}
|
||||||
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { Method } from 'matrix-js-sdk';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import {
|
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
|
||||||
DECORATION_CATEGORIES,
|
|
||||||
DECORATION_CDN,
|
|
||||||
decorationUrl,
|
|
||||||
} from '../../lotus/avatarDecorations';
|
|
||||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||||
|
|
||||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
@@ -48,7 +44,7 @@ function DecorationPreviewCell({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${DECORATION_CDN}/${slug}.png`}
|
src={decorationUrl(slug)}
|
||||||
alt={name}
|
alt={name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -73,11 +69,11 @@ export function ProfileDecoration() {
|
|||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
mx.http
|
||||||
.authedRequest<Record<string, string>>(
|
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||||
Method.Get,
|
|
||||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
|
||||||
)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||||
setCurrent(val);
|
setCurrent(val);
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ const pending = new Map<string, Array<(val: string | null) => void>>();
|
|||||||
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
|
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
|
||||||
// can retry a couple of times, then gives up for the session.
|
// can retry a couple of times, then gives up for the session.
|
||||||
const failures = new Map<string, number>();
|
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(
|
function fetchDecoration(
|
||||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||||
@@ -66,6 +82,10 @@ function fetchDecoration(
|
|||||||
|
|
||||||
export function invalidateDecorationCache(userId: string): void {
|
export function invalidateDecorationCache(userId: string): void {
|
||||||
cache.delete(userId);
|
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 {
|
export function useAvatarDecoration(userId: string): string | null {
|
||||||
@@ -74,14 +94,22 @@ export function useAvatarDecoration(userId: string): string | null {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchDecoration(
|
const load = () => {
|
||||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
fetchDecoration(
|
||||||
userId,
|
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||||
).then((val) => {
|
userId,
|
||||||
if (!cancelled) setSlug(val);
|
).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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [mx, userId]);
|
}, [mx, userId]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user