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
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:
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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>('');
|
||||
Reference in New Issue
Block a user