From 2c5f0b8b2824022662d81ac6603c3fe3edbc3708 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 12 Jun 2026 23:45:31 -0400 Subject: [PATCH] fix: make call join/leave sounds audible to all participants + server-side hard voice limit docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LOTUS_FEATURES.md | 18 ++++++++++++------ LOTUS_TODO.md | 2 +- README.md | 2 +- src/app/hooks/useCallEmbed.ts | 5 +++++ src/app/utils/callSounds.ts | 11 +++++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 9af24e08e..972cbf1e8 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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. diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 9764f8cf8..6b583d3bb 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -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. --- diff --git a/README.md b/README.md index c4eea9ff6..c592c49bf 100644 --- a/README.md +++ b/README.md @@ -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 (1–30 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 diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 25650bec3..04e7f0e41 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -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(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, diff --git a/src/app/utils/callSounds.ts b/src/app/utils/callSounds.ts index 4f2582d9c..d2086b102 100644 --- a/src/app/utils/callSounds.ts +++ b/src/app/utils/callSounds.ts @@ -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 */