import { useCallback, useEffect, useRef } from 'react'; import { useSetAtom } from 'jotai'; import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import { CallEmbed, useCallControlState } from '../plugins/call'; import { useCallMembersChange, useCallSession } from './useCall'; import { useMatrixClient } from './useMatrixClient'; import { getMemberName } from '../utils/room'; import { callAnnouncementAtom } from '../state/callAnnouncement'; const BURST_MS = 1500; const listNames = (names: string[], verb: string): string => { if (names.length === 0) return ''; if (names.length === 1) return `${names[0]} ${verb}`; if (names.length === 2) return `${names[0]} and ${names[1]} ${verb}`; return `${names.length} people ${verb}`; }; /** * [Gitea #168] Screen-reader announcements for call events, via the * aria-live region CallEmbedProvider renders. Nothing visible, nothing * audible for anyone else. Joins/leaves within 1.5 s are batched ("3 people * joined"); your own mute / deafen / screenshare changes are announced as * they happen; "Call ended" fires when the embed goes away. */ export function useCallAnnouncements(embed: CallEmbed, joined: boolean): void { const mx = useMatrixClient(); const setAnnouncement = useSetAtom(callAnnouncementAtom); const session = useCallSession(embed.room); const { microphone, sound, screenshare } = useCallControlState(embed.control); const announce = useCallback( (text: string) => { // Re-announce identical text by nudging it: aria-live only speaks changes. setAnnouncement((prev) => (prev === text ? `${text} ` : text)); }, [setAnnouncement], ); // --- joins / leaves, batched per user const prevUsersRef = useRef | null>(null); const pending = useRef<{ joined: Set; left: Set; timer?: number }>({ joined: new Set(), left: new Set(), }); useEffect(() => { prevUsersRef.current = new Set(session.memberships.map((m) => m.sender ?? '')); }, [session]); const flush = useCallback(() => { const { joined: j, left: l } = pending.current; const room = embed.room; const parts = [ listNames( Array.from(j, (u) => getMemberName(room, u)), 'joined', ), listNames( Array.from(l, (u) => getMemberName(room, u)), 'left', ), ].filter(Boolean); j.clear(); l.clear(); pending.current.timer = undefined; if (parts.length) announce(parts.join('. ')); }, [announce, embed.room]); useCallMembersChange( session, useCallback( (members: CallMembership[]) => { const next = new Set(members.map((m) => m.sender ?? '')); const prev = prevUsersRef.current ?? next; prevUsersRef.current = next; if (!joined) return; const me = mx.getSafeUserId(); next.forEach((u) => { if (!prev.has(u) && u !== me) { pending.current.left.delete(u); pending.current.joined.add(u); } }); prev.forEach((u) => { if (!next.has(u) && u !== me) { pending.current.joined.delete(u); pending.current.left.add(u); } }); if (pending.current.timer === undefined) { pending.current.timer = window.setTimeout(flush, BURST_MS); } }, [joined, mx, flush], ), ); // --- own state const first = useRef(true); const prevOwn = useRef({ microphone, sound, screenshare }); useEffect(() => { const was = prevOwn.current; prevOwn.current = { microphone, sound, screenshare }; if (!joined) return; if (first.current) { first.current = false; return; } if (was.microphone !== microphone) announce(microphone ? 'You are unmuted' : 'You are muted'); if (was.sound !== sound) announce(sound ? 'You are undeafened' : 'You are deafened'); if (was.screenshare !== screenshare) { announce(screenshare ? 'You started sharing your screen' : 'You stopped sharing your screen'); } }, [microphone, sound, screenshare, joined, announce]); // --- call ended const wasJoined = useRef(false); useEffect(() => { if (joined) wasJoined.current = true; }, [joined]); useEffect( () => () => { if (pending.current.timer !== undefined) window.clearTimeout(pending.current.timer); if (wasJoined.current) announce('Call ended'); }, [announce], ); }