feat(call): in-call soundboard, quality controls, room call-permissions
CI / Build & Quality Checks (push) Successful in 10m49s
CI / Trigger Desktop Build (push) Successful in 8s

Element Call is now consumed as our self-built fork
(@lotusguild/element-call-embedded); wire up its previously-dormant
capabilities and document the fork as live.

Soundboard (P5-15): a call-bar button plays user-uploaded audio clips into the
call as a real published track (io.lotus.inject_audio) plus local playback.
Clips are uploadable like emoji/sticker packs, stored in io.lotus.soundboard
account data (synced across devices). Gated by a Settings toggle + volume.

Quality controls (P5-31): per-user mic/screenshare bitrate + screenshare
framerate (Settings -> Calls), applied via io.lotus.set_quality clamped to any
room cap. Room admins set caps and hard call-permissions (allow_screenshare /
allow_camera) in Room Settings -> Voice; the call bar hides blocked buttons.

- New: CallSoundboard, useSoundboard, soundboardClips; RoomQuality,
  useCallQuality, callQuality (+ unit tests).
- Optimistic-write RoomQuality admin UI (no stale-state clobber).
- Docs: mark EC fork live across README/FEATURES/TODO/BUGS/TESTING; add D2
  manual-test steps.

Numeric quality caps are client-cooperative; screenshare/camera permissions are
hard-enforced server-side (see LotusGuild/matrix voice-limit-guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 22:34:17 -04:00
parent 02b2ce8109
commit 7c06b27c73
22 changed files with 1259 additions and 120 deletions
@@ -0,0 +1,198 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Switch, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import {
AUDIO_BITRATE_OPTIONS,
RoomQualityContent,
SCREENSHARE_BITRATE_OPTIONS,
SCREENSHARE_FRAMERATE_OPTIONS,
} from '../../../utils/callQuality';
// Only the numeric cap keys are edited via `update`; the boolean policy keys
// are handled by `setAllow`.
type CapKey = 'audio_max_kbps' | 'screenshare_max_kbps' | 'screenshare_max_fps';
// String <-> numeric bridge for SettingsSelect (which needs string values).
const toValue = (n?: number): string => (typeof n === 'number' ? String(n) : 'auto');
const CAP_KEYS: (keyof RoomQualityContent)[] = [
'audio_max_kbps',
'screenshare_max_kbps',
'screenshare_max_fps',
'allow_screenshare',
'allow_camera',
];
const capsEqual = (a: RoomQualityContent, b: RoomQualityContent): boolean =>
CAP_KEYS.every((k) => a[k] === b[k]);
type RoomQualityProps = {
permissions: RoomPermissionsAPI;
};
/**
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
*/
export function RoomQuality({ permissions }: RoomQualityProps) {
const mx = useMatrixClient();
const room = useRoom();
const canEdit = permissions.stateEvent(StateEvent.LotusRoomQuality, mx.getSafeUserId());
const event = useStateEvent(room, StateEvent.LotusRoomQuality);
const caps = useMemo<RoomQualityContent>(() => event?.getContent() ?? {}, [event]);
const [submitState, submit] = useAsyncCallback(
useCallback(
async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
},
[mx, room.roomId],
),
);
const submitting = submitState.status === AsyncStatus.Loading;
// Optimistic mirror: `useStateEvent` only refreshes when the write echoes
// back via /sync (not when sendStateEvent resolves), so consecutive edits
// must build on the pending write — otherwise a second edit spreads a stale
// `caps` and silently drops the first. `effective` is what the UI shows and
// what each edit merges into; it's reconciled below once the echo lands.
const [pending, setPending] = useState<RoomQualityContent | null>(null);
const effective = pending ?? caps;
useEffect(() => {
if (!pending) return;
// Revert the optimistic view if the write failed…
if (submitState.status === AsyncStatus.Error) {
setPending(null);
return;
}
// …or drop it once the synced state actually reflects it.
if (capsEqual(caps, pending)) setPending(null);
}, [caps, pending, submitState.status]);
const commit = (next: RoomQualityContent) => {
setPending(next);
submit(next);
};
const update = (key: CapKey, value: string) => {
const next: RoomQualityContent = { ...effective };
if (value === 'auto') delete next[key];
else next[key] = parseInt(value, 10);
commit(next);
};
const setAllow = (key: 'allow_screenshare' | 'allow_camera', allowed: boolean) => {
const next: RoomQualityContent = { ...effective };
// Absent = allowed, so only persist the key when forbidding.
if (allowed) delete next[key];
else next[key] = false;
commit(next);
};
// Absent/true = allowed.
const screenshareAllowed = effective.allow_screenshare !== false;
const cameraAllowed = effective.allow_camera !== false;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Call Permissions"
description={
<Text size="T200" priority="300">
Control what participants may share in this room. These are enforced on the server for
every Matrix client (Element, FluffyChat, Lotus Chat, ).
</Text>
}
/>
<Box direction="Column" gap="300">
<SettingTile
title="Allow Screen Sharing"
description="When off, no one can share their screen in this room."
after={
<Switch
variant="Primary"
value={screenshareAllowed}
onChange={(v) => setAllow('allow_screenshare', v)}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Allow Camera"
description="When off, this is an audio-only room — no one can turn on their camera. Microphones are always allowed."
after={
<Switch
variant="Primary"
value={cameraAllowed}
onChange={(v) => setAllow('allow_camera', v)}
disabled={!canEdit || submitting}
/>
}
/>
</Box>
<SettingTile
title="Call Quality Caps"
description={
<Text size="T200" priority="300">
Set a maximum microphone bitrate, screenshare bitrate, and screenshare framerate for
this room. Lotus Chat clamps each participant to these ceilings (best-effort applies
to Lotus Chat clients). Auto = no cap.
</Text>
}
/>
<Box direction="Column" gap="300">
<SettingTile
title="Max Microphone Bitrate"
after={
<SettingsSelect
value={toValue(effective.audio_max_kbps)}
onChange={(v) => update('audio_max_kbps', v)}
options={AUDIO_BITRATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Max Screenshare Bitrate"
after={
<SettingsSelect
value={toValue(effective.screenshare_max_kbps)}
onChange={(v) => update('screenshare_max_kbps', v)}
options={SCREENSHARE_BITRATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
<SettingTile
title="Max Screenshare Framerate"
after={
<SettingsSelect
value={toValue(effective.screenshare_max_fps)}
onChange={(v) => update('screenshare_max_fps', v)}
options={SCREENSHARE_FRAMERATE_OPTIONS}
disabled={!canEdit || submitting}
/>
}
/>
</Box>
</SequenceCard>
);
}