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 { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
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 { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||||
import { useCallQuality } from '../hooks/useCallQuality';
|
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 }) {
|
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
@@ -687,6 +700,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useAfkAutoMute(joined ? embed : undefined);
|
useAfkAutoMute(joined ? embed : undefined);
|
||||||
useCallJoinLeaveSounds(embed);
|
useCallJoinLeaveSounds(embed);
|
||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
|
useCallAnnouncements(embed, joined);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallQuality(embed);
|
useCallQuality(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
@@ -1230,6 +1244,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
return (
|
return (
|
||||||
<CallEmbedContextProvider value={callEmbed}>
|
<CallEmbedContextProvider value={callEmbed}>
|
||||||
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
||||||
|
<CallAnnouncementRegion />
|
||||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||||
{children}
|
{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