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:
@@ -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],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user