From 861293243c3da297f0cbdfb66f9a6bd2fc64def7 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 11 Oct 2026 22:07:18 -0400 Subject: [PATCH] fix(calls): "Channel Full" counts people, not devices (#342) useVoiceChannelFull compared the number of MatrixRTC memberships (one per device) with io.lotus.voice_limit, so one person on desktop + phone, or a crashed tab whose membership hadn't expired, filled two slots and locked others out, while the server-side guard (which counts distinct users) would have admitted them. utils/voiceChannelFull.ts (unit-tested) counts distinct senders; the header/sidebar counter shows people too. Verified on the local stack: limit 2, @aud_main in the call on two devices; @aud_bob's prescreen now shows 1/2 with Join enabled (was "Channel Full (2/2)"), matching the guard's 200. Fixes #342 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/hooks/useVoiceChannelFull.ts | 11 ++++++---- src/app/utils/voiceChannelFull.test.ts | 28 ++++++++++++++++++++++++++ src/app/utils/voiceChannelFull.ts | 25 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/app/utils/voiceChannelFull.test.ts create mode 100644 src/app/utils/voiceChannelFull.ts diff --git a/src/app/hooks/useVoiceChannelFull.ts b/src/app/hooks/useVoiceChannelFull.ts index 638ae1d67..319169419 100644 --- a/src/app/hooks/useVoiceChannelFull.ts +++ b/src/app/hooks/useVoiceChannelFull.ts @@ -5,6 +5,7 @@ import { StateEvent } from '../../types/matrix/room'; import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit'; import { useCallMembers, useCallSession } from './useCall'; import { useMatrixClient } from './useMatrixClient'; +import { voiceChannelOccupancy } from '../utils/voiceChannelFull'; export type VoiceChannelFull = { channelFull: boolean; @@ -26,9 +27,11 @@ export const useVoiceChannelFull = (room: Room): VoiceChannelFull => { return useMemo(() => { const maxUsers = limitEvent?.getContent().max_users ?? 0; - // A user already counted in the session is rejoining and should not be blocked. - const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId()); - const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers; - return { channelFull, current: callMembers.length, max: maxUsers }; + // Distinct people, not per-device memberships (Gitea #342). + return voiceChannelOccupancy( + callMembers.map((m) => m.sender), + mx.getSafeUserId(), + maxUsers, + ); }, [limitEvent, callMembers, mx]); }; diff --git a/src/app/utils/voiceChannelFull.test.ts b/src/app/utils/voiceChannelFull.test.ts new file mode 100644 index 000000000..75cca8a98 --- /dev/null +++ b/src/app/utils/voiceChannelFull.test.ts @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { voiceChannelOccupancy } from './voiceChannelFull'; + +test('voiceChannelOccupancy: one user on two devices fills one slot, not two', () => { + assert.deepEqual(voiceChannelOccupancy(['@a:x', '@a:x'], '@b:x', 2), { + channelFull: false, + current: 1, + max: 2, + }); +}); + +test('voiceChannelOccupancy: full when distinct users reach the limit', () => { + assert.deepEqual(voiceChannelOccupancy(['@a:x', '@c:x', '@a:x'], '@b:x', 2), { + channelFull: true, + current: 2, + max: 2, + }); +}); + +test('voiceChannelOccupancy: someone already in the call can always join another device', () => { + assert.equal(voiceChannelOccupancy(['@a:x', '@b:x'], '@b:x', 2).channelFull, false); +}); + +test('voiceChannelOccupancy: no limit (0) is never full; unknown senders are ignored', () => { + assert.equal(voiceChannelOccupancy(['@a:x', '@c:x'], '@b:x', 0).channelFull, false); + assert.equal(voiceChannelOccupancy([undefined, '@a:x'], '@b:x', 2).current, 1); +}); diff --git a/src/app/utils/voiceChannelFull.ts b/src/app/utils/voiceChannelFull.ts new file mode 100644 index 000000000..ef746195a --- /dev/null +++ b/src/app/utils/voiceChannelFull.ts @@ -0,0 +1,25 @@ +/** + * [Gitea #342] Whether a voice channel with an `io.lotus.voice_limit` is full. + * Counts distinct PEOPLE, not call memberships: a MatrixRTC membership is per + * device, so one user on desktop + phone (or with a crashed tab's membership + * not yet expired) used to fill two slots. This matches the server-side guard + * (matrix `voice-limit-guard.py`, which reduces LiveKit identities to users). + */ +export type VoiceChannelOccupancy = { + channelFull: boolean; + /** Distinct users in the call. */ + current: number; + max: number; +}; + +export const voiceChannelOccupancy = ( + memberSenders: Array, + myUserId: string, + maxUsers: number, +): VoiceChannelOccupancy => { + const users = new Set(memberSenders.filter((s): s is string => !!s)); + // A user already in the call (another device, or rejoining) is never blocked. + const alreadyMember = users.has(myUserId); + const channelFull = maxUsers > 0 && !alreadyMember && users.size >= maxUsers; + return { channelFull, current: users.size, max: maxUsers }; +};