199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|