diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index c92fe33ad..6c8e6520e 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -47,6 +47,9 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast'; +import { useCallAnnouncements } from '../hooks/useCallAnnouncements'; +import { callAnnouncementAtom } from '../state/callAnnouncement'; +import { SrOnly } from '../features/call-status/styles.css'; import { useCallHotkeys } from '../hooks/useCallHotkeys'; import { useAfkAutoMute } from '../hooks/useAfkAutoMute'; import { useCallQuality } from '../hooks/useCallQuality'; @@ -676,6 +679,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) ); } +// [Gitea #168] Lives outside the embed so "Call ended" is still announced. +function CallAnnouncementRegion() { + const announcement = useAtomValue(callAnnouncementAtom); + return ( + + {announcement} + + ); +} + function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { const setCallEmbed = useSetAtom(callEmbedAtom); @@ -687,6 +700,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { useAfkAutoMute(joined ? embed : undefined); useCallJoinLeaveSounds(embed); useCallPolicyRevokedToast(embed, joined); + useCallAnnouncements(embed, joined); useCallThemeSync(embed); useCallQuality(embed); useCallHangupEvent( @@ -1230,6 +1244,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { return ( {callEmbed && } + {children} diff --git a/src/app/hooks/useCallAnnouncements.ts b/src/app/hooks/useCallAnnouncements.ts new file mode 100644 index 000000000..fa96576e9 --- /dev/null +++ b/src/app/hooks/useCallAnnouncements.ts @@ -0,0 +1,128 @@ +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], + ); +} diff --git a/src/app/state/callAnnouncement.ts b/src/app/state/callAnnouncement.ts new file mode 100644 index 000000000..03586c38b --- /dev/null +++ b/src/app/state/callAnnouncement.ts @@ -0,0 +1,8 @@ +import { atom } from 'jotai'; + +/** + * [Gitea #168] Latest screen-reader-only call announcement ("alice joined", + * "You are muted", "Call ended"). Rendered by CallEmbedProvider in an + * aria-live region that outlives the call embed, so "Call ended" is heard. + */ +export const callAnnouncementAtom = atom('');