diff --git a/scripts/dev-homeserver.sh b/scripts/dev-homeserver.sh index 1eb0e3150..ab32e72f3 100755 --- a/scripts/dev-homeserver.sh +++ b/scripts/dev-homeserver.sh @@ -62,6 +62,7 @@ experimental_features: msc4140_enabled: true msc4143_enabled: true msc3266_enabled: true + msc4133_enabled: true matrix_rtc: transports: - type: livekit diff --git a/src/app/hooks/useCallJoinLeaveSounds.ts b/src/app/hooks/useCallJoinLeaveSounds.ts index 184c318f9..0c54dc311 100644 --- a/src/app/hooks/useCallJoinLeaveSounds.ts +++ b/src/app/hooks/useCallJoinLeaveSounds.ts @@ -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 | 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 | 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(); + const leftUsers = new Set(); 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], ), diff --git a/src/app/utils/callSoundDebounce.test.ts b/src/app/utils/callSoundDebounce.test.ts new file mode 100644 index 000000000..39f2094cd --- /dev/null +++ b/src/app/utils/callSoundDebounce.test.ts @@ -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 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']); +}); diff --git a/src/app/utils/callSoundDebounce.ts b/src/app/utils/callSoundDebounce.ts new file mode 100644 index 000000000..a95a31424 --- /dev/null +++ b/src/app/utils/callSoundDebounce.ts @@ -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), +}; + +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(); + }, + }; +};