fix: make call join/leave sounds audible to all participants + server-side hard voice limit docs
CI / Build & Quality Checks (push) Successful in 10m29s
Trigger Desktop Build / trigger (push) Successful in 5s

Sounds (P5-16): browsers block the Web Audio context until a user gesture
starts it, so join/leave sounds — which fire later with no gesture — were
silent. unlockCallSounds() now primes/resumes the shared AudioContext inside
the Join click (centralized in useCallStart so every join path is covered),
making the per-client sounds reliably audible to everyone in the call.

Voice limit (P5-10): the limit is now a hard, cross-client server-side cap
enforced by the voice-limit-guard sidecar (matrix repo) that fronts
lk-jwt-service and refuses LiveKit tokens when a room is full. Updated
LOTUS_FEATURES.md / README.md / LOTUS_TODO.md to reflect that the client
'Channel Full' check is UX only and the server is authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:45:31 -04:00
parent 702e2e00eb
commit 2c5f0b8b28
5 changed files with 30 additions and 8 deletions
+12 -6
View File
@@ -253,19 +253,25 @@ Hook: `src/app/hooks/useAfkAutoMute.ts`
### Voice Channel User Limit (P5-10)
Room admins can cap the number of participants allowed in a room's voice call.
Room admins can cap the number of participants allowed in a room's voice call. The cap is a **hard, server-side limit enforced for every Matrix client** (Element, FluffyChat, …), backed by a client-side UX layer in Lotus Chat.
**Implementation:**
**Client (this repo):**
- Limit is stored in the `io.lotus.voice_limit` room state event with content `{ max_users: N }` (0 / absent = no limit)
- `RoomVoiceLimit` component in Room Settings → General → **Voice** lets admins set the cap with a number input. Editing is gated by `permissions.stateEvent(StateEvent.LotusVoiceLimit, …)`, so only users with `state_default` power (or above) can change it
- `CallPrescreen` (`CallView.tsx`) reads the limit reactively via `useStateEvent` and compares it against the live `useCallMembers` count
- When the call is at capacity, the **Join** button is disabled and a "Channel Full (N/N)" message is shown
- A user who is already a member of the session (rejoining) is never blocked — only new joiners are gated
- Enforcement is local to Lotus Chat clients (no server-side gatekeeping)
- `CallPrescreen` (`CallView.tsx`) reads the limit reactively via `useStateEvent` and compares it against the live `useCallMembers` count; at capacity the **Join** button is disabled and a "Channel Full (N/N)" message is shown
- A user already in the session (rejoining) is never blocked — only new joiners are gated
Files: `src/app/features/common-settings/general/RoomVoiceLimit.tsx`, `src/app/features/call/CallView.tsx`, `StateEvent.LotusVoiceLimit` in `src/types/matrix/room.ts`
**Server (the hard backstop — `matrix` repo `livekit/voice-limit-guard.py`):**
- Every client must fetch a LiveKit JWT from `lk-jwt-service` before joining a call. A fail-open guard sidecar sits in front of it (guard on `:8070`, lk-jwt-service moved to `:8071`)
- On each token request the guard reads the room's `io.lotus.voice_limit` (Synapse admin API), and if the room is at capacity it returns `403` so the client cannot obtain a token and therefore cannot join — regardless of which client they use
- Distinct Matrix users are counted via LiveKit `ListParticipants`; rejoins / extra devices are allowed. Any failure fails open so calls never break
> The client-side "Channel Full" check is UX/early-feedback; the server guard is the actual enforcement.
### Custom Join / Leave Sound Effects (P5-16)
A local sound plays when another participant joins or leaves a call you're in.
+1 -1
View File
@@ -261,7 +261,7 @@ Themes:
**What:** Admins set max participants via custom state event `io.lotus.voice_limit: { max_users: N }`. Show "Channel Full (5/5)" to users over the limit. Local enforcement only.
**[AUDIT REQUIRED]** Check if Element Call has its own participant limit that should be integrated with rather than duplicated.
**Complexity:** Medium.
**Done:** `RoomVoiceLimit` admin control in Room Settings → General → Voice; `CallPrescreen` disables Join + shows "Channel Full (N/N)" when at capacity (rejoiners exempt). State event `StateEvent.LotusVoiceLimit`.
**Done:** `RoomVoiceLimit` admin control in Room Settings → General → Voice; `CallPrescreen` disables Join + shows "Channel Full (N/N)" when at capacity (rejoiners exempt). State event `StateEvent.LotusVoiceLimit`. **Hard enforcement is server-side for ALL clients** via `voice-limit-guard` (matrix repo `livekit/voice-limit-guard.py`) — a fail-open sidecar fronting `lk-jwt-service` (guard `:8070`, lk-jwt `:8071`) that refuses the LiveKit JWT (403) when the room is at capacity. The client check is UX-only. EC has only a global `max_participants` (50), so per-room limits were not duplicating an EC feature.
---
+1 -1
View File
@@ -50,7 +50,7 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
- Dark/light mode inside calls matches your Lotus Chat theme
- Calls are available in DMs and private groups only — no accidental mass rings
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (130 min); a toast confirms the action
- Voice channel user limit: admins can cap how many people can be in a room's call; others see "Channel Full" until a spot opens
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
### Customization & Appearance
+5
View File
@@ -16,6 +16,7 @@ import { useCallMembersChange, useCallSession } from './useCall';
import { CallPreferences } from '../state/callPreferences';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { unlockCallSounds } from '../utils/callSounds';
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
@@ -84,6 +85,10 @@ export const useCallStart = (dm = false) => {
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
// startCall is always invoked from a click/tap handler — unlock the Web
// Audio context now (within the gesture) so join/leave sounds that fire
// later, without any gesture, are audible to everyone in the call.
unlockCallSounds();
const callEmbed = createCallEmbed(
mx,
room,
+11
View File
@@ -12,6 +12,17 @@ const getAudioContext = (): AudioContext | undefined => {
}
};
/**
* Create and resume the shared AudioContext from within a user gesture
* (e.g. clicking "Join"). Browsers block AudioContext playback until it has
* been started by a gesture, so join/leave sounds — which fire later without
* any gesture — would otherwise be silent. Call this on call entry so every
* participant's later membership-change sounds are actually audible.
*/
export const unlockCallSounds = (): void => {
getAudioContext();
};
type Note = {
freq: number;
/** Offset from now, in seconds */