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 }; +};