/** * [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), }; 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(); const lastLeft = new Map(); const lastCue: Record = { 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(); }, }; };