Files
cinny/src/app/features/call/CallSoundboard.tsx
T
jared ee6bdd8241 fix(call): Wave-1 audit fixes (calls host side)
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers
  (resendForkState — deafen+quality only) instead of clobbering live mic/video/
  deafen back to the join-time snapshot.
- C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL
  published track instead of getUserMedia on the browser DEFAULT mic (which could
  measure silence while the user spoke on another device → auto-mute an active
  speaker). Fails safe (never mutes) when call_state is null OR empty.
- C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms
  debounce) with an early-return so unchanged state doesn't re-render.
- C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent);
  C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on
  stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards.

Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier
clean, build OK, 677 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:20:07 -04:00

268 lines
9.6 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.
*/
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);
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
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);
const done = () => {
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;
callEmbed.control.injectAudio(url, vol);
const audio = playClipLocally(url, vol);
if (audio) {
audio.addEventListener('ended', done, { once: true });
audio.addEventListener('error', done, { once: true });
} else {
done();
}
// Safety: clear the guard even if the audio never signals end.
window.setTimeout(done, 30_000);
} 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: manage ? toRem(420) : toRem(340), 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),
height: toRem(76),
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" truncate style={{ maxWidth: '100%' }}>
{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>
);
}