Files
cinny/src/app/features/call/CallSoundboard.tsx
T
Lotus CIandClaude Opus 5.5 f8efbfe38b
CI / Build & Quality Checks (pull_request) Successful in 2m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m47s
fix(calls): explain when the soundboard is not allowed in a voice channel (EC #47)
The fork now refuses a clip it cannot publish (audio-only rooms grant
only the microphone source) with reason "not_permitted". Say "The
soundboard isn't available in this voice channel" instead of the
generic error. Older forks are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:22:50 -04:00

320 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
*/
/** [EC#13, EC#47] Why the fork refused to play a clip, for the user. */
export const soundboardRefusalText = (reason: string | undefined): string => {
if (reason === 'muted') return 'Unmute your microphone to play a soundboard clip.';
if (reason === 'not_permitted')
return "The soundboard isn't available in this voice channel (only microphone audio is allowed here).";
return 'Could not play that clip.';
};
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, EC#47] Refused fork-side (mic muted, or the room's token
// only allows the microphone source, as in audio-only rooms) —
// don't play it locally either, or the user would think it went out.
setError(soundboardRefusalText(result.reason));
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>
);
}