feat(calls): debounce join/leave cues for a flapping participant (#145)

Verified first on the local calls stack: a participant who left and rejoined
~3 s later played "leave" then "join" every time. Cues now go through
createCallSoundDebouncer (per USER, not per device):

- a leave cue waits 5 s; if the same user is back before it fires, the leave is
  cancelled and no join cue is played either;
- a join cue is suppressed for a user who left < 60 s ago;
- same-kind cues within 250 ms collapse, so a batch of leaves still sounds once;
- a second device of someone already present is not a new arrival, and a
  device switch is quiet.

Only the sound is debounced; membership UI is unchanged. Sound style, volume
and PTT interplay untouched (the style is read at play time). Timers are
injected — unit-tested with a manual clock. Re-ran the flap scenario headless:
join → flap → (silence) → real leave → one "leave" 5 s later → rejoin within
60 s → silence.

Also enables msc4133 (custom profile fields → in-call avatar decorations) on
the dev Synapse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 00:15:34 -04:00
co-authored by Claude Opus 5
parent 30fd22a5c5
commit 4e455ae42e
4 changed files with 273 additions and 8 deletions
+44 -8
View File
@@ -7,12 +7,18 @@ import { useMatrixClient } from './useMatrixClient';
import { useCallMembersChange, useCallSession } from './useCall';
import { useCallJoined } from './useCallEmbed';
import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
import { createCallSoundDebouncer } from '../utils/callSoundDebounce';
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();
@@ -21,6 +27,27 @@ export function useCallJoinLeaveSounds(embed: CallEmbed): void {
const session = useCallSession(embed.room);
const prevKeysRef = useRef<Set<string> | null>(null);
const styleRef = useRef(style);
styleRef.current = style;
// One debouncer per joined call, so pending leave cues die with the call.
const debouncerRef = useRef<ReturnType<typeof createCallSoundDebouncer> | 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.
@@ -36,20 +63,29 @@ export function useCallJoinLeaveSounds(embed: CallEmbed): void {
const prev = prevKeysRef.current ?? next;
prevKeysRef.current = next;
if (!joined || style === 'off') return;
const debouncer = debouncerRef.current;
if (!joined || style === 'off' || !debouncer) return;
const myPrefix = `${mx.getSafeUserId()}|`;
let someoneJoined = false;
let someoneLeft = false;
// Per USER: a device switch (leave on A, join on B) is a flap too.
const joinedUsers = new Set<string>();
const leftUsers = new Set<string>();
next.forEach((key) => {
if (!prev.has(key) && !key.startsWith(myPrefix)) someoneJoined = true;
if (!prev.has(key) && !key.startsWith(myPrefix)) joinedUsers.add(userOfKey(key));
});
prev.forEach((key) => {
if (!next.has(key) && !key.startsWith(myPrefix)) someoneLeft = true;
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);
});
if (someoneJoined) playCallJoinSound(style);
if (someoneLeft) playCallLeaveSound(style);
},
[joined, style, mx],
),