feat: avatar decorations (P5-13)
256×256 APNG overlays from avatardecoration.com (open CORS CDN). Stored in the user's Matrix profile as io.lotus.avatar_decoration via MSC4133 so all Lotus Chat users see each other's decorations. - avatarDecorations.ts: curated catalog of 110 original-IP decorations across 9 categories (Gaming, Cyber, Space, Fantasy, Elements, Japanese, Nature, Spooky, Cozy) - useAvatarDecoration: per-user profile fetch with module-level cache and in-flight deduplication so concurrent renders for the same userId share one HTTP request - AvatarDecoration: position:relative wrapper that overlays the APNG 8px beyond the avatar on all sides; renders nothing when no decoration is set (zero cost for undecorated users) - ProfileDecoration: scrollable grid picker in Settings → Profile, grouped by category with live preview; Save button appears only when the selection differs from what's saved - Applied at all five avatar display sites: message timeline, members drawer, knock list, @mention autocomplete, notifications inbox Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { 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>>();
|
||||
|
||||
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);
|
||||
|
||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
cache.set(userId, val);
|
||||
return val;
|
||||
})
|
||||
.catch(() => {
|
||||
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);
|
||||
}
|
||||
|
||||
export function useAvatarDecoration(userId: string): string | null {
|
||||
const mx = useMatrixClient();
|
||||
const [slug, setSlug] = useState<string | null>(() => cache.get(userId) ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchDecoration(
|
||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||
userId,
|
||||
).then((val) => {
|
||||
if (!cancelled) setSlug(val);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
return slug;
|
||||
}
|
||||
Reference in New Issue
Block a user