129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
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],
|
||
|
|
);
|
||
|
|
}
|