diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md
index 22aecc8de..9af24e08e 100644
--- a/LOTUS_FEATURES.md
+++ b/LOTUS_FEATURES.md
@@ -251,6 +251,39 @@ Automatically mutes the microphone after a configurable period of microphone-on
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.
+
+**Implementation:**
+
+- 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)
+
+Files: `src/app/features/common-settings/general/RoomVoiceLimit.tsx`, `src/app/features/call/CallView.tsx`, `StateEvent.LotusVoiceLimit` in `src/types/matrix/room.ts`
+
+### Custom Join / Leave Sound Effects (P5-16)
+
+A local sound plays when another participant joins or leaves a call you're in.
+
+**Implementation:**
+
+- `useCallJoinLeaveSounds(embed)` hook (wired in `CallUtils` inside `CallEmbedProvider`) listens to `MatrixRTCSession` membership changes via `useCallMembersChange`
+- Membership identity is tracked by `sender|deviceId`; a snapshot is taken when the session (re)starts so participants already present never trigger a sound
+- Your own membership is filtered out (`mx.getSafeUserId()` prefix), and sounds fire only while you are actually joined (`useCallJoined`)
+- Sounds are synthesized in-browser with the Web Audio API (`OscillatorNode` + envelope) — no audio assets to bundle. Join uses a rising motif, leave a falling one
+- Three styles: **Chime** (sine), **Soft** (triangle), **Retro** (square arpeggio), plus **Off**
+
+**Settings (Settings → Calls):**
+
+- **Join & Leave Sounds** dropdown — Off / Chime / Soft / Retro (default: Chime). Selecting a style previews the join sound immediately
+
+Files: `src/app/utils/callSounds.ts`, `src/app/hooks/useCallJoinLeaveSounds.ts`
+
### Noise Suppression Toggle
A `noiseSuppression` URL parameter is passed to the Element Call widget URL, allowing the noise suppression feature to be toggled from within Lotus settings.
diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md
index b628d8987..9764f8cf8 100644
--- a/LOTUS_TODO.md
+++ b/LOTUS_TODO.md
@@ -256,11 +256,12 @@ Themes:
---
-### [ ] P5-10 · Voice Channel User Limit
+### [x] P5-10 · Voice Channel User Limit
**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.
+**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`.
---
@@ -310,11 +311,12 @@ Themes:
---
-### [ ] P5-16 · Custom Join / Leave Sound Effects
+### [x] P5-16 · Custom Join / Leave Sound Effects
**What:** Local-only sounds when participants join/leave a call you're in. Built-in options + per-user settable. Detect via Element Call participant list change events.
**[AUDIT REQUIRED]** Find how Element Call exposes join/leave participant events to the parent window via postMessage bridge.
-**Complexity:** Medium.
+**Complexity:** Medium.
+**Done:** Detected via `MatrixRTCSession` membership changes (`useCallMembersChange`) rather than the EC postMessage bridge — more reliable, identity tracked by `sender|deviceId`. Sounds synthesized with Web Audio (no assets). Styles Off/Chime/Soft/Retro in Settings → Calls. Hook `useCallJoinLeaveSounds`, util `callSounds.ts`.
---
diff --git a/README.md b/README.md
index 9115e8aba..c4eea9ff6 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,8 @@ 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
+- 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/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index 196700417..f45bbfc20 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -42,6 +42,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
+import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -406,6 +407,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
useCallMemberSoundSync(embed);
+ useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
useCallHangupEvent(
embed,
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index d9ec08ed0..9c8117020 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -10,6 +10,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
+import { useStateEvent } from '../../hooks/useStateEvent';
+import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
@@ -74,6 +76,14 @@ function AlreadyInCallMessage() {
);
}
+function ChannelFullMessage({ current, max }: { current: number; max: number }) {
+ return (
+
+ Channel Full ({current}/{max}) — Wait for someone to leave before joining.
+
+ );
+}
+
function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
@@ -96,7 +106,14 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
- const canJoin = hasPermission && livekitSupported && rtcSupported;
+ // Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
+ const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
+ 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;
+
+ const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
return (
@@ -117,16 +134,17 @@ function CallPrescreen() {
- {!inOtherCall &&
- (hasPermission ? (
-
- ) : (
-
- ))}
+ {!inOtherCall && !hasPermission && }
+ {!inOtherCall && hasPermission && channelFull && (
+
+ )}
+ {!inOtherCall && hasPermission && !channelFull && (
+
+ )}
{inOtherCall && }
diff --git a/src/app/features/common-settings/general/RoomVoiceLimit.tsx b/src/app/features/common-settings/general/RoomVoiceLimit.tsx
new file mode 100644
index 000000000..6cc743bd4
--- /dev/null
+++ b/src/app/features/common-settings/general/RoomVoiceLimit.tsx
@@ -0,0 +1,98 @@
+import React, { FormEventHandler, useCallback } from 'react';
+import { Box, Button, color, Input, Spinner, Text } from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../../room-settings/styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import { StateEvent } from '../../../../types/matrix/room';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
+
+export type VoiceLimitContent = {
+ max_users?: number;
+};
+
+type RoomVoiceLimitProps = {
+ permissions: RoomPermissionsAPI;
+};
+export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+
+ const canEdit = permissions.stateEvent(StateEvent.LotusVoiceLimit, mx.getSafeUserId());
+
+ const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
+ const maxUsers = limitEvent?.getContent().max_users ?? 0;
+
+ const [submitState, submit] = useAsyncCallback(
+ useCallback(
+ async (value: number) => {
+ const content: VoiceLimitContent = value > 0 ? { max_users: value } : {};
+ await mx.sendStateEvent(room.roomId, StateEvent.LotusVoiceLimit as any, content);
+ },
+ [mx, room.roomId],
+ ),
+ );
+ const submitting = submitState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ const target = evt.target as HTMLFormElement;
+ const limitInput = target.elements.namedItem('limitInput') as HTMLInputElement | null;
+ if (!limitInput) return;
+ const parsed = parseInt(limitInput.value, 10);
+ const value = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
+ submit(value);
+ };
+
+ return (
+
+
+
+
+
+
+ : undefined}
+ >
+ Save
+
+
+ {submitState.status === AsyncStatus.Error && (
+
+ {(submitState.error as MatrixError).message}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts
index a23a85d0e..6b8bb0a99 100644
--- a/src/app/features/common-settings/general/index.ts
+++ b/src/app/features/common-settings/general/index.ts
@@ -6,3 +6,4 @@ export * from './RoomProfile';
export * from './RoomPublish';
export * from './RoomShareInvite';
export * from './RoomUpgrade';
+export * from './RoomVoiceLimit';
diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx
index c12dd8683..c6d29d07f 100644
--- a/src/app/features/room-settings/general/General.tsx
+++ b/src/app/features/room-settings/general/General.tsx
@@ -13,6 +13,7 @@ import {
RoomPublish,
RoomShareInvite,
RoomUpgrade,
+ RoomVoiceLimit,
} from '../../common-settings/general';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
@@ -54,6 +55,10 @@ export function General({ requestClose }: GeneralProps) {
+
+ Voice
+
+
Addresses
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index d4155682e..e770dcebd 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -65,6 +65,7 @@ import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
+import { playCallJoinSound } from '../../../utils/callSounds';
type ThemeSelectorProps = {
themeNames: Record;
@@ -1113,6 +1114,15 @@ function Calls() {
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
+ const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
+ settingsAtom,
+ 'callJoinLeaveSound',
+ );
+
+ const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
+ setCallJoinLeaveSound(value);
+ if (value !== 'off') playCallJoinSound(value);
+ };
const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey);
@@ -1227,6 +1237,34 @@ function Calls() {
/>
)}
+
+
+ handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro')
+ }
+ style={{
+ background: 'var(--bg-surface)',
+ color: 'inherit',
+ border: '1px solid var(--border-interactive-normal)',
+ borderRadius: '6px',
+ padding: '4px 8px',
+ fontSize: 'inherit',
+ cursor: 'pointer',
+ }}
+ >
+
+
+
+
+
+ }
+ />
+
);
}
diff --git a/src/app/hooks/useCallJoinLeaveSounds.ts b/src/app/hooks/useCallJoinLeaveSounds.ts
new file mode 100644
index 000000000..184c318f9
--- /dev/null
+++ b/src/app/hooks/useCallJoinLeaveSounds.ts
@@ -0,0 +1,57 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
+import { CallEmbed } from '../plugins/call';
+import { useSetting } from '../state/hooks/settings';
+import { settingsAtom } from '../state/settings';
+import { useMatrixClient } from './useMatrixClient';
+import { useCallMembersChange, useCallSession } from './useCall';
+import { useCallJoined } from './useCallEmbed';
+import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
+
+const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`;
+
+/**
+ * Plays a local sound effect when another participant joins or leaves
+ * the call you are in. Style (or off) is configured in Settings → Calls.
+ */
+export function useCallJoinLeaveSounds(embed: CallEmbed): void {
+ const mx = useMatrixClient();
+ const [style] = useSetting(settingsAtom, 'callJoinLeaveSound');
+ const joined = useCallJoined(embed);
+ const session = useCallSession(embed.room);
+
+ const prevKeysRef = useRef | null>(null);
+
+ // Snapshot current members when the session (re)starts so we never play
+ // sounds for participants who were already present.
+ useEffect(() => {
+ prevKeysRef.current = new Set(session.memberships.map(membershipKey));
+ }, [session]);
+
+ useCallMembersChange(
+ session,
+ useCallback(
+ (members: CallMembership[]) => {
+ const next = new Set(members.map(membershipKey));
+ const prev = prevKeysRef.current ?? next;
+ prevKeysRef.current = next;
+
+ if (!joined || style === 'off') return;
+
+ const myPrefix = `${mx.getSafeUserId()}|`;
+ let someoneJoined = false;
+ let someoneLeft = false;
+ next.forEach((key) => {
+ if (!prev.has(key) && !key.startsWith(myPrefix)) someoneJoined = true;
+ });
+ prev.forEach((key) => {
+ if (!next.has(key) && !key.startsWith(myPrefix)) someoneLeft = true;
+ });
+
+ if (someoneJoined) playCallJoinSound(style);
+ if (someoneLeft) playCallLeaveSound(style);
+ },
+ [joined, style, mx],
+ ),
+ );
+}
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 487dc02fb..22e566439 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -131,6 +131,8 @@ export interface Settings {
afkAutoMute: boolean;
afkTimeoutMinutes: number;
+
+ callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
}
const defaultSettings: Settings = {
@@ -204,6 +206,8 @@ const defaultSettings: Settings = {
afkAutoMute: false,
afkTimeoutMinutes: 10,
+
+ callJoinLeaveSound: 'chime',
};
export const getSettings = (): Settings => {
diff --git a/src/app/utils/callSounds.ts b/src/app/utils/callSounds.ts
new file mode 100644
index 000000000..4f2582d9c
--- /dev/null
+++ b/src/app/utils/callSounds.ts
@@ -0,0 +1,95 @@
+export type CallSoundStyle = 'chime' | 'soft' | 'retro';
+
+let sharedCtx: AudioContext | undefined;
+
+const getAudioContext = (): AudioContext | undefined => {
+ try {
+ if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
+ if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
+ return sharedCtx;
+ } catch {
+ return undefined;
+ }
+};
+
+type Note = {
+ freq: number;
+ /** Offset from now, in seconds */
+ at: number;
+ /** Duration in seconds */
+ dur: number;
+};
+
+const playNotes = (notes: Note[], type: OscillatorType, peakGain: number): void => {
+ const ctx = getAudioContext();
+ if (!ctx) return;
+ const now = ctx.currentTime;
+ notes.forEach(({ freq, at, dur }) => {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = type;
+ osc.frequency.value = freq;
+ const start = now + at;
+ // Short attack/decay envelope to avoid clicks
+ gain.gain.setValueAtTime(0, start);
+ gain.gain.linearRampToValueAtTime(peakGain, start + 0.015);
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.start(start);
+ osc.stop(start + dur + 0.02);
+ });
+};
+
+const SOUNDS: Record void; leave: () => void }> = {
+ chime: {
+ join: () =>
+ playNotes(
+ [
+ { freq: 587.33, at: 0, dur: 0.12 },
+ { freq: 880, at: 0.1, dur: 0.2 },
+ ],
+ 'sine',
+ 0.25,
+ ),
+ leave: () =>
+ playNotes(
+ [
+ { freq: 880, at: 0, dur: 0.12 },
+ { freq: 587.33, at: 0.1, dur: 0.2 },
+ ],
+ 'sine',
+ 0.25,
+ ),
+ },
+ soft: {
+ join: () => playNotes([{ freq: 523.25, at: 0, dur: 0.4 }], 'triangle', 0.18),
+ leave: () => playNotes([{ freq: 392, at: 0, dur: 0.4 }], 'triangle', 0.18),
+ },
+ retro: {
+ join: () =>
+ playNotes(
+ [
+ { freq: 440, at: 0, dur: 0.07 },
+ { freq: 554.37, at: 0.07, dur: 0.07 },
+ { freq: 659.25, at: 0.14, dur: 0.14 },
+ ],
+ 'square',
+ 0.1,
+ ),
+ leave: () =>
+ playNotes(
+ [
+ { freq: 659.25, at: 0, dur: 0.07 },
+ { freq: 554.37, at: 0.07, dur: 0.07 },
+ { freq: 440, at: 0.14, dur: 0.14 },
+ ],
+ 'square',
+ 0.1,
+ ),
+ },
+};
+
+export const playCallJoinSound = (style: CallSoundStyle): void => SOUNDS[style]?.join();
+
+export const playCallLeaveSound = (style: CallSoundStyle): void => SOUNDS[style]?.leave();
diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts
index c73c0f0b5..79042672f 100644
--- a/src/types/matrix/room.ts
+++ b/src/types/matrix/room.ts
@@ -40,6 +40,7 @@ export enum StateEvent {
PoniesRoomEmotes = 'im.ponies.room_emotes',
PowerLevelTags = 'in.cinny.room.power_level_tags',
+ LotusVoiceLimit = 'io.lotus.voice_limit',
}
export enum MessageEvent {