2026-06-14 11:24:04 -04:00
|
|
|
import { useEffect, useState } from 'react';
|
2026-06-19 11:21:29 -04:00
|
|
|
import { MatrixError, Method } from 'matrix-js-sdk';
|
2026-06-14 11:24:04 -04:00
|
|
|
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;
|
|
|
|
|
})
|
2026-06-19 11:21:29 -04:00
|
|
|
.catch((err: unknown) => {
|
|
|
|
|
// A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no
|
|
|
|
|
// decoration". A transient failure (429 rate-limit, 5xx, network) must
|
|
|
|
|
// NOT be cached: doing so permanently hides the user's decoration for the
|
|
|
|
|
// whole session. This matters most for the member list and timeline, which
|
|
|
|
|
// mount many avatars at once and can trip homeserver rate limits — a
|
|
|
|
|
// single 429 in that burst would otherwise wipe the decoration until a
|
|
|
|
|
// full reload. Leaving the cache unset lets the next mount retry.
|
|
|
|
|
const status = err instanceof MatrixError ? err.httpStatus : undefined;
|
|
|
|
|
if (status === 404) {
|
|
|
|
|
cache.set(userId, null);
|
|
|
|
|
}
|
2026-06-14 11:24:04 -04:00
|
|
|
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;
|
|
|
|
|
}
|