feat: voice channel user limit (P5-10) + call join/leave sounds (P5-16)
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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user