CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m27s
Groundwork for serving the call page from its own origin (call.chat.lotusguild.org). Inert until config.json sets `elementCallUrl`: without it the bundled same-origin page is used exactly as today. - callPageUrl: resolves `elementCallUrl` — absolute https only (http only on localhost for development); anything else, and the desktop app, fall back to the bundled page so a bad value can't break calls. Set once from the loaded client config. - CallEmbed builds the widget URL from it; the widget origin (used by the message guard and Capability Delegation) follows automatically. - Soundboard: a host blob: URL can't be fetched from another origin, so io.lotus.inject_audio now also carries the clip's bytes (`audio`). Forks that predate it ignore the field and use `url`, so this is safe on the released fork. Needs element-call's lotus-call-origin branch (host-origin message check + inject_audio bytes) released and pinned before `elementCallUrl` is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Box,
|
||
Icon,
|
||
IconButton,
|
||
Icons,
|
||
Menu,
|
||
PopOut,
|
||
RectCords,
|
||
Scroll,
|
||
Spinner,
|
||
Switch,
|
||
Text,
|
||
Tooltip,
|
||
TooltipProvider,
|
||
color,
|
||
config,
|
||
toRem,
|
||
} from 'folds';
|
||
import FocusTrap from 'focus-trap-react';
|
||
import { useAtomValue } from 'jotai';
|
||
import { CallEmbed } from '../../plugins/call';
|
||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||
import { useSetting } from '../../state/hooks/settings';
|
||
import { settingsAtom } from '../../state/settings';
|
||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||
import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks';
|
||
import { SoundboardClipReader } from '../../plugins/soundboard';
|
||
import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view';
|
||
import { stopPropagation } from '../../utils/keyboard';
|
||
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
|
||
|
||
type CallSoundboardProps = {
|
||
callEmbed: CallEmbed;
|
||
};
|
||
|
||
type FlatClip = {
|
||
key: string; // packId|shortcode
|
||
packId: string;
|
||
packName: string;
|
||
clip: SoundboardClipReader;
|
||
};
|
||
|
||
/**
|
||
* [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
|
||
* relevant to the call room (the room + parent spaces ∪ the user's personal
|
||
* pack), just like custom emoji. Playing a clip publishes it into the call via
|
||
* the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
|
||
* A management toggle reveals the pack editors (personal + this room, if
|
||
* permitted). Space-wide packs are managed from Space settings.
|
||
*/
|
||
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||
const mx = useMatrixClient();
|
||
const { room } = callEmbed;
|
||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||
const packRooms = useImagePackRooms(room.roomId, roomToParents);
|
||
const packs = useRelevantSoundboardPacks(packRooms);
|
||
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||
|
||
const [cords, setCords] = useState<RectCords>();
|
||
const [manage, setManage] = useState(false);
|
||
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||
const [error, setError] = useState<string>();
|
||
|
||
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
||
// guard those setState calls against the component unmounting first.
|
||
const mountedRef = useRef(true);
|
||
const safetyTimerRef = useRef<number | undefined>(undefined);
|
||
useEffect(
|
||
() => () => {
|
||
mountedRef.current = false;
|
||
if (safetyTimerRef.current !== undefined) window.clearTimeout(safetyTimerRef.current);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const groups = useMemo(
|
||
() =>
|
||
packs
|
||
.map((pack) => ({
|
||
id: pack.id,
|
||
name: pack.meta.name ?? 'Soundboard',
|
||
clips: pack.getClips(),
|
||
}))
|
||
.filter((g) => g.clips.length > 0),
|
||
[packs],
|
||
);
|
||
|
||
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setError(undefined);
|
||
setCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const play = useCallback(
|
||
async (flat: FlatClip) => {
|
||
if (playingKey) return; // one at a time (fork also enforces this)
|
||
setPlayingKey(flat.key);
|
||
setError(undefined);
|
||
// Per-play timer token: `done` clears its OWN timer by identity, so a
|
||
// stale done() from a prior clip can't disarm a newer clip's safety timer
|
||
// (which — since a rejected audio.play() fires neither ended nor error —
|
||
// is sometimes the only thing that unsticks the playingKey guard).
|
||
let myTimer: number | undefined;
|
||
const done = () => {
|
||
if (myTimer !== undefined) {
|
||
window.clearTimeout(myTimer);
|
||
if (safetyTimerRef.current === myTimer) safetyTimerRef.current = undefined;
|
||
myTimer = undefined;
|
||
}
|
||
if (!mountedRef.current) return;
|
||
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||
};
|
||
try {
|
||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||
const vol = (flat.clip.volume / 100) * master;
|
||
// [Gitea #43] Send the bytes too: the call page may be on another
|
||
// origin, where this blob: URL can't be fetched.
|
||
const clipBytes = await fetch(url)
|
||
.then((r) => r.arrayBuffer())
|
||
.catch(() => undefined);
|
||
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
|
||
if (!result.played) {
|
||
// [EC#13] Refused fork-side (only reason today: local mic muted) —
|
||
// don't play it locally either, or the user would think it went out.
|
||
setError(
|
||
result.reason === 'muted'
|
||
? 'Unmute your microphone to play a soundboard clip.'
|
||
: 'Could not play that clip.',
|
||
);
|
||
done();
|
||
return;
|
||
}
|
||
const audio = playClipLocally(url, vol);
|
||
if (audio) {
|
||
audio.addEventListener('ended', done, { once: true });
|
||
audio.addEventListener('error', done, { once: true });
|
||
// Safety: clear the guard even if the audio never signals end.
|
||
myTimer = window.setTimeout(done, 30_000);
|
||
safetyTimerRef.current = myTimer;
|
||
} else {
|
||
done();
|
||
}
|
||
} catch {
|
||
setError('Could not play that clip.');
|
||
done();
|
||
}
|
||
},
|
||
[mx, callEmbed, master, playingKey],
|
||
);
|
||
|
||
return (
|
||
<PopOut
|
||
anchor={cords}
|
||
position="Top"
|
||
align="Center"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu
|
||
style={{
|
||
maxWidth: `min(${manage ? toRem(420) : toRem(340)}, calc(100vw - 2 * ${config.space.S400}))`,
|
||
maxHeight: '70vh',
|
||
}}
|
||
>
|
||
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||
<Box
|
||
shrink="No"
|
||
alignItems="Center"
|
||
justifyContent="SpaceBetween"
|
||
gap="200"
|
||
style={{
|
||
padding: config.space.S200,
|
||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Text size="L400">Soundboard</Text>
|
||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||
<Text size="T200" priority="300">
|
||
Manage
|
||
</Text>
|
||
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||
</Box>
|
||
</Box>
|
||
|
||
<Scroll size="300" hideTrack visibility="Hover">
|
||
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
|
||
{manage ? (
|
||
<>
|
||
<RoomSoundboardPack room={room} stateKey="" />
|
||
<UserSoundboardPack />
|
||
</>
|
||
) : (
|
||
<>
|
||
{groups.length === 0 && (
|
||
<Text size="T200" priority="300">
|
||
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||
a pack in Space settings.
|
||
</Text>
|
||
)}
|
||
{groups.map((g) => (
|
||
<Box key={g.id} direction="Column" gap="100">
|
||
<Text size="L400">{g.name}</Text>
|
||
<Box wrap="Wrap" gap="200">
|
||
{g.clips.map((clip) => {
|
||
const key = `${g.id}|${clip.shortcode}`;
|
||
const flat: FlatClip = {
|
||
key,
|
||
packId: g.id,
|
||
packName: g.name,
|
||
clip,
|
||
};
|
||
return (
|
||
<Box
|
||
key={key}
|
||
as="button"
|
||
direction="Column"
|
||
alignItems="Center"
|
||
justifyContent="Center"
|
||
gap="100"
|
||
disabled={!!playingKey}
|
||
onClick={() => play(flat)}
|
||
aria-label={`Play ${clip.name}`}
|
||
style={{
|
||
width: toRem(76),
|
||
minHeight: toRem(76),
|
||
height: 'auto',
|
||
padding: config.space.S100,
|
||
borderRadius: config.radii.R400,
|
||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
background:
|
||
playingKey === key
|
||
? color.Primary.Container
|
||
: color.SurfaceVariant.Container,
|
||
cursor: playingKey ? 'default' : 'pointer',
|
||
opacity: playingKey && playingKey !== key ? 0.5 : 1,
|
||
}}
|
||
>
|
||
<Text size="H4">
|
||
{playingKey === key ? (
|
||
<Spinner size="200" />
|
||
) : (
|
||
clip.emoji || '🔊'
|
||
)}
|
||
</Text>
|
||
<Text
|
||
size="T200"
|
||
style={{
|
||
maxWidth: '100%',
|
||
textAlign: 'center',
|
||
wordBreak: 'break-word',
|
||
lineHeight: 1.15,
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 3,
|
||
WebkitBoxOrient: 'vertical',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{clip.name}
|
||
</Text>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</>
|
||
)}
|
||
{error && (
|
||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||
{error}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
</Scroll>
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
>
|
||
<TooltipProvider
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text>Soundboard</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(triggerRef) => (
|
||
<IconButton
|
||
ref={triggerRef}
|
||
variant="Surface"
|
||
fill="Soft"
|
||
radii="400"
|
||
size="400"
|
||
onClick={handleOpen}
|
||
outlined
|
||
aria-label="Soundboard"
|
||
aria-expanded={!!cords}
|
||
aria-haspopup="menu"
|
||
>
|
||
<Icon size="400" src={Icons.BellRing} />
|
||
</IconButton>
|
||
)}
|
||
</TooltipProvider>
|
||
</PopOut>
|
||
);
|
||
}
|