feat: voice channel user limit (P5-10) + call join/leave sounds (P5-16)
CI / Build & Quality Checks (push) Successful in 10m54s
Trigger Desktop Build / trigger (push) Successful in 6s

P5-10 Voice Channel User Limit:
- New StateEvent.LotusVoiceLimit (io.lotus.voice_limit) with { max_users }
- RoomVoiceLimit admin control in Room Settings > General > Voice
  (power-level gated via permissions.stateEvent)
- CallPrescreen reads the limit reactively and disables Join with a
  'Channel Full (N/N)' message at capacity; existing members can rejoin

P5-16 Custom Join/Leave Sound Effects:
- useCallJoinLeaveSounds hook wired into CallUtils; detects participant
  join/leave via MatrixRTCSession membership changes (sender|deviceId),
  filters out self, only fires while joined
- Sounds synthesized in-browser with Web Audio (callSounds.ts) - no
  assets bundled; styles Off/Chime/Soft/Retro
- 'Join & Leave Sounds' selector in Settings > Calls (previews on change)

Docs: LOTUS_FEATURES.md, README.md, LOTUS_TODO.md (P5-10/P5-16 marked done)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 22:20:22 -04:00
parent 2b1c3256b6
commit 702e2e00eb
13 changed files with 371 additions and 15 deletions
+33
View File
@@ -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.
+6 -4
View File
@@ -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`.
---
+2
View File
@@ -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 (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
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
### Customization & Appearance
+2
View File
@@ -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,
+29 -11
View File
@@ -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 (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Channel Full ({current}/{max}) Wait for someone to leave before joining.
</Text>
);
}
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<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;
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
return (
<Scroll variant="Surface" hideTrack>
@@ -117,16 +134,17 @@ function CallPrescreen() {
<CallMemberRenderer members={callMembers} />
<PrescreenControls canJoin={canJoin} />
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
) : (
<NoPermissionMessage />
))}
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
{!inOtherCall && hasPermission && channelFull && (
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
)}
{inOtherCall && <AlreadyInCallMessage />}
</Box>
</Box>
@@ -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<VoiceLimitContent>().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<HTMLFormElement> = (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 (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Voice Channel Limit"
description="Set the maximum number of participants allowed in this room's voice call. Set to 0 for no limit. Enforced locally by Lotus Chat clients."
>
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
<Box style={{ maxWidth: '100px' }} grow="Yes">
<Input
key={maxUsers}
name="limitInput"
defaultValue={maxUsers}
type="number"
min={0}
max={99}
size="300"
variant="Secondary"
radii="300"
readOnly={!canEdit}
disabled={!canEdit}
/>
</Box>
<Button
type="submit"
size="300"
variant="Primary"
fill="Solid"
radii="300"
disabled={!canEdit || submitting}
before={submitting ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined}
>
<Text size="B300">Save</Text>
</Button>
</Box>
{submitState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(submitState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}
@@ -6,3 +6,4 @@ export * from './RoomProfile';
export * from './RoomPublish';
export * from './RoomShareInvite';
export * from './RoomUpgrade';
export * from './RoomVoiceLimit';
@@ -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) {
<RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
<RoomPublishedAddresses permissions={permissions} />
@@ -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<string, string>;
@@ -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() {
/>
)}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join & Leave Sounds"
description="Play a sound when someone joins or leaves a call you are in."
after={
<select
value={callJoinLeaveSound}
onChange={(e) =>
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',
}}
>
<option value="off">Off</option>
<option value="chime">Chime</option>
<option value="soft">Soft</option>
<option value="retro">Retro</option>
</select>
}
/>
</SequenceCard>
</Box>
);
}
+57
View File
@@ -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<Set<string> | 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],
),
);
}
+4
View File
@@ -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 => {
+95
View File
@@ -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<CallSoundStyle, { join: () => 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();
+1
View File
@@ -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 {