Files
cinny/src/app/utils/callSoundDebounce.ts
T

101 lines
3.3 KiB
TypeScript
Raw Normal View History

/**
* [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();
},
};
};