fix(calls): "Channel Full" counts people, not devices (#342)
CI / Build & Quality Checks (pull_request) Successful in 3m9s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m44s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-11 22:07:18 -04:00
co-authored by Claude Opus 5.5
parent 022e5e6987
commit 861293243c
3 changed files with 60 additions and 4 deletions
+7 -4
View File
@@ -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<VoiceLimitContent>().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]);
};
+28
View File
@@ -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);
});
+25
View File
@@ -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<string | undefined>,
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 };
};