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:
@@ -62,6 +62,7 @@ experimental_features:
|
|||||||
msc4140_enabled: true
|
msc4140_enabled: true
|
||||||
msc4143_enabled: true
|
msc4143_enabled: true
|
||||||
msc3266_enabled: true
|
msc3266_enabled: true
|
||||||
|
msc4133_enabled: true
|
||||||
matrix_rtc:
|
matrix_rtc:
|
||||||
transports:
|
transports:
|
||||||
- type: livekit
|
- type: livekit
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import { useMatrixClient } from './useMatrixClient';
|
|||||||
import { useCallMembersChange, useCallSession } from './useCall';
|
import { useCallMembersChange, useCallSession } from './useCall';
|
||||||
import { useCallJoined } from './useCallEmbed';
|
import { useCallJoined } from './useCallEmbed';
|
||||||
import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
|
import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
|
||||||
|
import { createCallSoundDebouncer } from '../utils/callSoundDebounce';
|
||||||
|
|
||||||
const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`;
|
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
|
* Plays a local sound effect when another participant joins or leaves
|
||||||
* the call you are in. Style (or off) is configured in Settings → Calls.
|
* 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 {
|
export function useCallJoinLeaveSounds(embed: CallEmbed): void {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -21,6 +27,27 @@ export function useCallJoinLeaveSounds(embed: CallEmbed): void {
|
|||||||
const session = useCallSession(embed.room);
|
const session = useCallSession(embed.room);
|
||||||
|
|
||||||
const prevKeysRef = useRef<Set<string> | null>(null);
|
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
|
// Snapshot current members when the session (re)starts so we never play
|
||||||
// sounds for participants who were already present.
|
// sounds for participants who were already present.
|
||||||
@@ -36,20 +63,29 @@ export function useCallJoinLeaveSounds(embed: CallEmbed): void {
|
|||||||
const prev = prevKeysRef.current ?? next;
|
const prev = prevKeysRef.current ?? next;
|
||||||
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()}|`;
|
const myPrefix = `${mx.getSafeUserId()}|`;
|
||||||
let someoneJoined = false;
|
// Per USER: a device switch (leave on A, join on B) is a flap too.
|
||||||
let someoneLeft = false;
|
const joinedUsers = new Set<string>();
|
||||||
|
const leftUsers = new Set<string>();
|
||||||
next.forEach((key) => {
|
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) => {
|
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],
|
[joined, style, mx],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createCallSoundDebouncer } from './callSoundDebounce';
|
||||||
|
|
||||||
|
// Manual clock: `advance(ms)` moves time and fires due timers in order.
|
||||||
|
const fakeTimers = () => {
|
||||||
|
let now = 0;
|
||||||
|
let seq = 0;
|
||||||
|
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||||
|
const advance = (ms: number) => {
|
||||||
|
const target = now + ms;
|
||||||
|
for (;;) {
|
||||||
|
const due = [...timers.entries()]
|
||||||
|
.filter(([, t]) => t.at <= target)
|
||||||
|
.sort((a, b) => a[1].at - b[1].at)[0];
|
||||||
|
if (!due) break;
|
||||||
|
timers.delete(due[0]);
|
||||||
|
now = due[1].at;
|
||||||
|
due[1].fn();
|
||||||
|
}
|
||||||
|
now = target;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
advance,
|
||||||
|
timers: {
|
||||||
|
now: () => now,
|
||||||
|
setTimeout: (fn: () => void, ms: number) => {
|
||||||
|
seq += 1;
|
||||||
|
timers.set(seq, { at: now + ms, fn });
|
||||||
|
return seq;
|
||||||
|
},
|
||||||
|
clearTimeout: (h: unknown) => {
|
||||||
|
timers.delete(h as number);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
const clock = fakeTimers();
|
||||||
|
const played: string[] = [];
|
||||||
|
const d = createCallSoundDebouncer({
|
||||||
|
play: (k) => played.push(k),
|
||||||
|
timers: clock.timers,
|
||||||
|
leaveGraceMs: 5000,
|
||||||
|
joinSuppressMs: 60000,
|
||||||
|
});
|
||||||
|
return { clock, played, d };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a plain join plays immediately; a plain leave plays after the grace period', () => {
|
||||||
|
const { clock, played, d } = setup();
|
||||||
|
d.joined('@bob');
|
||||||
|
assert.deepEqual(played, ['join']);
|
||||||
|
d.left('@bob');
|
||||||
|
assert.deepEqual(played, ['join']);
|
||||||
|
clock.advance(4999);
|
||||||
|
assert.deepEqual(played, ['join']);
|
||||||
|
clock.advance(1);
|
||||||
|
assert.deepEqual(played, ['join', 'leave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leave + rejoin within the grace period plays nothing at all', () => {
|
||||||
|
const { clock, played, d } = setup();
|
||||||
|
d.joined('@bob');
|
||||||
|
clock.advance(120000);
|
||||||
|
d.left('@bob');
|
||||||
|
clock.advance(3000);
|
||||||
|
d.joined('@bob');
|
||||||
|
clock.advance(10000);
|
||||||
|
assert.deepEqual(played, ['join']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejoin within 60 s of a real leave is silent; after 60 s it chimes again', () => {
|
||||||
|
const { clock, played, d } = setup();
|
||||||
|
d.joined('@bob');
|
||||||
|
clock.advance(120000);
|
||||||
|
d.left('@bob');
|
||||||
|
clock.advance(5000); // leave cue fires
|
||||||
|
assert.deepEqual(played, ['join', 'leave']);
|
||||||
|
clock.advance(30000);
|
||||||
|
d.joined('@bob');
|
||||||
|
assert.deepEqual(played, ['join', 'leave']);
|
||||||
|
d.left('@bob');
|
||||||
|
clock.advance(5000);
|
||||||
|
clock.advance(60000);
|
||||||
|
d.joined('@bob');
|
||||||
|
assert.deepEqual(played, ['join', 'leave', 'leave', 'join']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flap then a later rejoin: the cancelled leave still counts as "left recently"', () => {
|
||||||
|
const { clock, played, d } = setup();
|
||||||
|
d.joined('@bob');
|
||||||
|
clock.advance(120000);
|
||||||
|
d.left('@bob');
|
||||||
|
clock.advance(2000);
|
||||||
|
d.joined('@bob'); // flap: cancelled
|
||||||
|
clock.advance(1000);
|
||||||
|
d.left('@bob');
|
||||||
|
clock.advance(5000); // real leave now → cue
|
||||||
|
d.joined('@bob'); // 8 s after first leave → still suppressed
|
||||||
|
assert.deepEqual(played, ['join', 'leave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('several people leaving together sound once; different users are independent', () => {
|
||||||
|
const { clock, played, d } = setup();
|
||||||
|
d.joined('@a');
|
||||||
|
clock.advance(1000);
|
||||||
|
d.joined('@b');
|
||||||
|
clock.advance(120000);
|
||||||
|
d.left('@a');
|
||||||
|
d.left('@b');
|
||||||
|
clock.advance(5000);
|
||||||
|
assert.deepEqual(played, ['join', 'join', 'leave']);
|
||||||
|
// @a's recent leave must not silence a first-time joiner @c
|
||||||
|
d.joined('@c');
|
||||||
|
assert.deepEqual(played, ['join', 'join', 'leave', 'join']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose cancels pending leave cues', () => {
|
||||||
|
const { clock, played, d } = setup();
|
||||||
|
d.joined('@bob');
|
||||||
|
clock.advance(120000);
|
||||||
|
d.left('@bob');
|
||||||
|
d.dispose();
|
||||||
|
clock.advance(10000);
|
||||||
|
assert.deepEqual(played, ['join']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* [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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user