feat(a11y): screen-reader announcements for call events (#168)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

A visually hidden aria-live=polite region (rendered by CallEmbedProvider so it
outlives the embed) announces joins/leaves — batched over 1.5 s: 'alice
joined', 'alice and bob joined', '3 people joined' — your own mute/unmute,
deafen/undeafen and screenshare start/stop, and 'Call ended'. Nothing visible,
nothing audible for anyone else, no setting. Verified headless by observing
the region: bob joined → You are muted → … → bob left → Call ended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 12:55:01 -04:00
co-authored by Claude Opus 5
parent 84c906fe33
commit 2e7915d086
3 changed files with 151 additions and 0 deletions
+15
View File
@@ -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 (
<span className={SrOnly} role="status" aria-live="polite" aria-atomic="true">
{announcement}
</span>
);
}
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 (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
<CallAnnouncementRegion />
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
+128
View File
@@ -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<Set<string> | null>(null);
const pending = useRef<{ joined: Set<string>; left: Set<string>; 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],
);
}
+8
View File
@@ -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<string>('');