import { useCallback, useEffect, useRef } from 'react'; import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import { useSetAtom } from 'jotai'; import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { useMatrixClient } from './useMatrixClient'; import { useCallMembersChange, useCallSession } from './useCall'; import { useCallJoined } from './useCallEmbed'; import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds'; import { createCallSoundDebouncer } from '../utils/callSoundDebounce'; import { toastQueueAtom } from '../state/toast'; import { DEAFEN_CATCHUP_MIN_MS, deafenCatchUpText } from '../utils/deafenCatchUp'; import { getMemberName } from '../utils/room'; const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`; const userOfKey = (key: string): string => key.slice(0, key.indexOf('|')); /** * Plays a local sound effect when another participant joins or leaves * the call you are in. Style (or off) is configured in Settings → Calls. * * [Gitea #145] Cues go through `createCallSoundDebouncer` so a participant * whose connection flaps (leave + rejoin within seconds) is not announced * twice; membership UI is unaffected. */ export function useCallJoinLeaveSounds(embed: CallEmbed): void { const mx = useMatrixClient(); const [style] = useSetting(settingsAtom, 'callJoinLeaveSound'); const joined = useCallJoined(embed); const session = useCallSession(embed.room); const prevKeysRef = useRef | null>(null); const styleRef = useRef(style); styleRef.current = style; // [Gitea #128] Undeafen catch-up: snapshot who is here when you deafen, diff // when you undeafen, one toast if it changed and you were deafened ≥ 10 s. // Rides the same membership stream as the sounds — no new subscriptions. const { sound } = useCallControlState(embed.control); const setToast = useSetAtom(toastQueueAtom); const usersNow = useRef>(new Set()); const deafenedAt = useRef<{ at: number; users: Set } | null>(null); useEffect(() => { if (!joined) { deafenedAt.current = null; return; } if (!sound) { if (!deafenedAt.current) deafenedAt.current = { at: Date.now(), users: new Set(usersNow.current) }; return; } const snap = deafenedAt.current; deafenedAt.current = null; if (!snap || Date.now() - snap.at < DEAFEN_CATCHUP_MIN_MS) return; const text = deafenCatchUpText(snap.users, usersNow.current, (u) => getMemberName(embed.room, u), ); if (text) { setToast({ id: `deafen-catchup-${Date.now()}`, displayName: 'Lotus Chat', body: text, roomName: embed.room.name ?? 'Voice call', roomId: embed.roomId, }); } }, [sound, joined, embed, setToast]); // One debouncer per joined call, so pending leave cues die with the call. const debouncerRef = useRef | null>(null); useEffect(() => { if (!joined) return undefined; const debouncer = createCallSoundDebouncer({ play: (kind) => { const current = styleRef.current; if (current === 'off') return; if (kind === 'join') playCallJoinSound(current); else playCallLeaveSound(current); }, }); debouncerRef.current = debouncer; return () => { debouncer.dispose(); if (debouncerRef.current === debouncer) debouncerRef.current = null; }; }, [joined]); // Snapshot current members when the session (re)starts so we never play // sounds for participants who were already present. useEffect(() => { prevKeysRef.current = new Set(session.memberships.map(membershipKey)); }, [session]); useCallMembersChange( session, useCallback( (members: CallMembership[]) => { const next = new Set(members.map(membershipKey)); const prev = prevKeysRef.current ?? next; prevKeysRef.current = next; usersNow.current = new Set( Array.from(next, userOfKey).filter((u) => u !== mx.getSafeUserId()), ); const debouncer = debouncerRef.current; if (!joined || style === 'off' || !debouncer) return; const myPrefix = `${mx.getSafeUserId()}|`; // Per USER: a device switch (leave on A, join on B) is a flap too. const joinedUsers = new Set(); const leftUsers = new Set(); next.forEach((key) => { if (!prev.has(key) && !key.startsWith(myPrefix)) joinedUsers.add(userOfKey(key)); }); prev.forEach((key) => { if (!next.has(key) && !key.startsWith(myPrefix)) leftUsers.add(userOfKey(key)); }); // A user still present on another device neither joined nor left, and // a second device of someone already here is not a new arrival. const stillPresent = new Set(Array.from(next, userOfKey)); const wasPresent = new Set(Array.from(prev, userOfKey)); leftUsers.forEach((u) => { if (!stillPresent.has(u)) debouncer.left(u); }); joinedUsers.forEach((u) => { if (!wasPresent.has(u)) debouncer.joined(u); }); }, [joined, style, mx], ), ); }