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 ( + + + + + + + + + {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 {