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
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
/**
|
|
* [Gitea #145] Debounce join/leave cues for a flapping participant.
|
|
*
|
|
* A participant whose connection drops and comes back within seconds used to
|
|
* play the leave cue AND the join cue every time (verified on the local calls
|
|
* stack: leave+join within ~3 s → "leave", "join"). Rules, applied per USER
|
|
* (not per device, so a device switch is also quiet):
|
|
*
|
|
* - a leave cue is delayed by `leaveGraceMs`; 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 less than `joinSuppressMs`
|
|
* ago (their reconnect is not news);
|
|
* - cues of the same kind fired within `coalesceMs` collapse into one, so a
|
|
* batch of leaves still sounds once, as before.
|
|
*
|
|
* Only the SOUND is debounced — membership UI stays live. Timers are injected
|
|
* so the rules are unit-testable without real time.
|
|
*/
|
|
|
|
export type CallCueKind = 'join' | 'leave';
|
|
|
|
type Timers = {
|
|
now: () => number;
|
|
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
clearTimeout: (handle: unknown) => void;
|
|
};
|
|
|
|
export type CallSoundDebouncerOptions = {
|
|
play: (kind: CallCueKind) => void;
|
|
leaveGraceMs?: number;
|
|
joinSuppressMs?: number;
|
|
coalesceMs?: number;
|
|
timers?: Timers;
|
|
};
|
|
|
|
export type CallSoundDebouncer = {
|
|
joined: (userId: string) => void;
|
|
left: (userId: string) => void;
|
|
/** Cancel pending leave cues (call on unmount / when leaving the call). */
|
|
dispose: () => void;
|
|
};
|
|
|
|
export const DEFAULT_LEAVE_GRACE_MS = 5_000;
|
|
export const DEFAULT_JOIN_SUPPRESS_MS = 60_000;
|
|
const DEFAULT_COALESCE_MS = 250;
|
|
|
|
const realTimers: Timers = {
|
|
now: () => Date.now(),
|
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
};
|
|
|
|
export const createCallSoundDebouncer = ({
|
|
play,
|
|
leaveGraceMs = DEFAULT_LEAVE_GRACE_MS,
|
|
joinSuppressMs = DEFAULT_JOIN_SUPPRESS_MS,
|
|
coalesceMs = DEFAULT_COALESCE_MS,
|
|
timers = realTimers,
|
|
}: CallSoundDebouncerOptions): CallSoundDebouncer => {
|
|
const pendingLeave = new Map<string, { handle: unknown; leftAt: number }>();
|
|
const lastLeft = new Map<string, number>();
|
|
const lastCue: Record<CallCueKind, number> = { join: -Infinity, leave: -Infinity };
|
|
|
|
const cue = (kind: CallCueKind) => {
|
|
const now = timers.now();
|
|
if (now - lastCue[kind] < coalesceMs) return;
|
|
lastCue[kind] = now;
|
|
play(kind);
|
|
};
|
|
|
|
return {
|
|
joined: (userId) => {
|
|
const pending = pendingLeave.get(userId);
|
|
if (pending) {
|
|
// Flap: back before the leave cue fired — nobody needs to hear either.
|
|
timers.clearTimeout(pending.handle);
|
|
pendingLeave.delete(userId);
|
|
lastLeft.set(userId, pending.leftAt);
|
|
return;
|
|
}
|
|
const leftAt = lastLeft.get(userId);
|
|
if (leftAt !== undefined && timers.now() - leftAt < joinSuppressMs) return;
|
|
cue('join');
|
|
},
|
|
left: (userId) => {
|
|
if (pendingLeave.has(userId)) return;
|
|
const leftAt = timers.now();
|
|
const handle = timers.setTimeout(() => {
|
|
pendingLeave.delete(userId);
|
|
lastLeft.set(userId, leftAt);
|
|
cue('leave');
|
|
}, leaveGraceMs);
|
|
pendingLeave.set(userId, { handle, leftAt });
|
|
},
|
|
dispose: () => {
|
|
pendingLeave.forEach(({ handle }) => timers.clearTimeout(handle));
|
|
pendingLeave.clear();
|
|
},
|
|
};
|
|
};
|