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('');