import React, { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react'; import { type CallEmbed } from '../../plugins/call'; import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useAvatarDecoration } from '../../hooks/useAvatarDecoration'; import { decorationUrl } from './avatarDecorations'; /** * [lotus #6] Pushes each call participant's avatar-decoration image URL to the * forked Element Call (`io.lotus.decorations`), which renders it on the in-call * video-tile avatars. Mounted only while joined, so the EC-side handler exists. * * The decoration roster is per-user slugs resolved via `useAvatarDecoration`; * we render one invisible probe per member to reuse that hook + its cache, then * debounce-send the aggregated `{ userId: url }` map whenever it changes. */ function DecorationProbe({ userId, onResolve, }: { userId: string; onResolve: (userId: string, url: string | null) => void; }): null { const slug = useAvatarDecoration(userId); useEffect(() => { onResolve(userId, slug ? decorationUrl(slug) : null); }, [userId, slug, onResolve]); return null; } export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }): ReactElement { const session = useCallSession(callEmbed.room); const members = useCallMembers(session); const map = useRef>(new Map()); const pushTimer = useRef | undefined>(undefined); const userIds = useMemo( () => Array.from(new Set(members.map((m) => m.userId).filter((u): u is string => !!u))), [members], ); const push = useCallback(() => { const decorations: Record = {}; map.current.forEach((url, userId) => { decorations[userId] = url; }); callEmbed.call.transport.send('io.lotus.decorations', { decorations }).catch(() => undefined); }, [callEmbed]); const schedulePush = useCallback(() => { if (pushTimer.current) clearTimeout(pushTimer.current); pushTimer.current = setTimeout(push, 300); }, [push]); // [Gitea #17] The fork asks for a re-push when its decoration handler // (re)registers; our map hasn't changed, so the change-driven push above // would never fire on its own. useEffect(() => callEmbed.onForkStateRequest(schedulePush), [callEmbed, schedulePush]); const onResolve = useCallback( (userId: string, url: string | null) => { const prev = map.current.get(userId); if (url) { if (prev !== url) { map.current.set(userId, url); schedulePush(); } } else if (prev !== undefined) { map.current.delete(userId); schedulePush(); } }, [schedulePush], ); // Drop decorations for participants who left the call. useEffect(() => { const present = new Set(userIds); let changed = false; map.current.forEach((_url, userId) => { if (!present.has(userId)) { map.current.delete(userId); changed = true; } }); if (changed) schedulePush(); }, [userIds, schedulePush]); useEffect( () => () => { if (pushTimer.current) clearTimeout(pushTimer.current); }, [], ); return ( <> {userIds.map((userId) => ( ))} ); }