From the 6-agent deep per-feature audit. Mobile-gated / consistency fixes; desktop unchanged except two intentional dialog-width normalizations noted below. - In-call control bar: wrap="Wrap" on the SequenceCard so the compact two-group row wraps on the narrowest phones (<=390px) instead of pushing End off-screen (M1 fixed the 500-750px band; this covers narrower). Desktop stays one row. - In-call soundboard popout: clamp maxWidth to the viewport (like M5's screenshare popover) so it can't overflow a narrow phone. - Report-Message dialog + "Seen by" (EventReaders) modals (Message.tsx x2 + RoomViewFollowing): add useModalStyle so they go full-screen on mobile like their sibling report/receipt modals (they floated as fixed cards before). - In-app toast container: full-width toasts inset from both edges on mobile (ScreenSize.Mobile); a fixed 280-340px card previously overflowed a narrow phone. Desktop byte-identical (bottom-right floating card). - Policy-list tabs + audio-controls rows: wrap="Wrap" (inert on desktop). Intentional desktop deltas (normalizing to existing sibling modals, verified by two review passes as consistent, not regressions): Report dialog max-width 380->480px; EventReaders modals 460->360px. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useSetAtom } from 'jotai';
|
||
import {
|
||
Box,
|
||
Button,
|
||
Chip,
|
||
color,
|
||
config,
|
||
Icon,
|
||
IconButton,
|
||
Icons,
|
||
Menu,
|
||
MenuItem,
|
||
PopOut,
|
||
RectCords,
|
||
Spinner,
|
||
Text,
|
||
toRem,
|
||
} from 'folds';
|
||
import FocusTrap from 'focus-trap-react';
|
||
import { SequenceCard } from '../../components/sequence-card';
|
||
import * as css from './styles.css';
|
||
import {
|
||
ChatButton,
|
||
ControlDivider,
|
||
FullscreenButton,
|
||
MicrophoneButton,
|
||
ScreenShareButton,
|
||
ScreenshareAudioButton,
|
||
SoundButton,
|
||
VideoButton,
|
||
} from './Controls';
|
||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||
import { useSetting } from '../../state/hooks/settings';
|
||
import { settingsAtom } from '../../state/settings';
|
||
import { callEmbedAtom } from '../../state/callEmbed';
|
||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||
import { stopPropagation } from '../../utils/keyboard';
|
||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||
import { CallSoundboard } from './CallSoundboard';
|
||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||
import { StateEvent } from '../../../types/matrix/room';
|
||
import { RoomQualityContent } from '../../utils/callQuality';
|
||
|
||
type CallControlsProps = {
|
||
callEmbed: CallEmbed;
|
||
};
|
||
export function CallControls({ callEmbed }: CallControlsProps) {
|
||
const controlRef = useRef<HTMLDivElement>(null);
|
||
const callEmbedRef = useCallEmbedRef();
|
||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||
const screenSize = useScreenSize();
|
||
const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500);
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
|
||
useResizeObserver(
|
||
useCallback(() => {
|
||
const element = controlRef.current;
|
||
if (!element) return;
|
||
setNarrowContainer(element.clientWidth < 500);
|
||
}, []),
|
||
useCallback(() => controlRef.current, []),
|
||
);
|
||
|
||
// Collapse to the stacked/compact layout whenever the bar's own container is
|
||
// narrow (a small desktop call window) OR the viewport is a phone. The old
|
||
// element-only `< 500` check left the ~11-control row overflowing off-screen
|
||
// in the 500–750px band (landscape phones / small tablets).
|
||
const compact = narrowContainer || screenSize === ScreenSize.Mobile;
|
||
|
||
useEffect(() => {
|
||
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||
}, []);
|
||
|
||
const handleFullscreen = useCallback(() => {
|
||
if (document.fullscreenElement) {
|
||
document.exitFullscreen();
|
||
} else {
|
||
callEmbedRef.current?.requestFullscreen();
|
||
}
|
||
}, [callEmbedRef]);
|
||
|
||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||
useCallControlState(callEmbed.control);
|
||
|
||
useAfkAutoMute(callEmbed);
|
||
|
||
const [cords, setCords] = useState<RectCords>();
|
||
const [shareConfirm, setShareConfirm] = useState(false);
|
||
useEffect(() => {
|
||
if (!shareConfirm) return;
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') setShareConfirm(false);
|
||
};
|
||
window.addEventListener('keydown', onKeyDown);
|
||
return () => window.removeEventListener('keydown', onKeyDown);
|
||
}, [shareConfirm]);
|
||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||
|
||
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||
// users don't click dead buttons. Absent/true = allowed.
|
||
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
||
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||
const cameraAllowed = roomQuality?.allow_camera !== false;
|
||
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
||
// Keep a forbidden control visible while its track is still live (so the user
|
||
// can stop it); otherwise hide it entirely.
|
||
const showCamera = cameraAllowed || video;
|
||
const showScreenshare = screenshareAllowed || screenshare;
|
||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||
const [pttActive, setPttActive] = useState(false);
|
||
|
||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||
const microphoneRef = useRef(microphone);
|
||
useEffect(() => {
|
||
microphoneRef.current = microphone;
|
||
}, [microphone]);
|
||
|
||
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
|
||
const pttModeRef = useRef(pttMode);
|
||
const micBeforePTTRef = useRef<boolean | null>(null);
|
||
useEffect(() => {
|
||
if (pttMode && !pttModeRef.current) {
|
||
micBeforePTTRef.current = microphoneRef.current;
|
||
callEmbed.control.setMicrophone(false);
|
||
} else if (!pttMode && pttModeRef.current) {
|
||
callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true);
|
||
micBeforePTTRef.current = null;
|
||
}
|
||
pttModeRef.current = pttMode;
|
||
}, [pttMode, callEmbed]);
|
||
|
||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleSpotlightClick = () => {
|
||
callEmbed.control.toggleSpotlight();
|
||
setCords(undefined);
|
||
};
|
||
|
||
const handleReactionsClick = () => {
|
||
callEmbed.control.toggleReactions();
|
||
setCords(undefined);
|
||
};
|
||
|
||
const handleSettingsClick = () => {
|
||
callEmbed.control.toggleSettings();
|
||
setCords(undefined);
|
||
};
|
||
|
||
const handleMicrophoneToggle = useCallback(
|
||
() => callEmbed.control.toggleMicrophone(),
|
||
[callEmbed],
|
||
);
|
||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||
|
||
const pttActiveRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (!pttMode) return;
|
||
const iframeWindow = callEmbed.iframe.contentWindow;
|
||
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.code !== pttKey || e.repeat) return;
|
||
const target = e.target as HTMLElement;
|
||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||
const isEditable = (el: HTMLElement): boolean => {
|
||
const tag = el.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||
let node: HTMLElement | null = el;
|
||
while (node && node !== el.ownerDocument.body) {
|
||
if (node.contentEditable === 'true') return true;
|
||
if (node.contentEditable === 'false') return false;
|
||
node = node.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
if (isEditable(target)) return;
|
||
e.preventDefault();
|
||
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||
callEmbed.control.pttActive = true;
|
||
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
||
pttActiveRef.current = true;
|
||
setPttActive(true);
|
||
};
|
||
const onKeyUp = (e: KeyboardEvent) => {
|
||
if (e.code !== pttKey) return;
|
||
callEmbed.control.pttActive = false;
|
||
callEmbed.control.setMicrophone(false);
|
||
pttActiveRef.current = false;
|
||
setPttActive(false);
|
||
};
|
||
const onBlur = () => {
|
||
callEmbed.control.pttActive = false;
|
||
callEmbed.control.setMicrophone(false);
|
||
pttActiveRef.current = false;
|
||
setPttActive(false);
|
||
};
|
||
const onFocus = () => {
|
||
callEmbed.control.pttActive = false;
|
||
callEmbed.control.setMicrophone(false);
|
||
pttActiveRef.current = false;
|
||
setPttActive(false);
|
||
};
|
||
window.addEventListener('keydown', onKeyDown);
|
||
window.addEventListener('keyup', onKeyUp);
|
||
window.addEventListener('blur', onBlur);
|
||
window.addEventListener('focus', onFocus);
|
||
// BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe
|
||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||
iframeWindow?.addEventListener('keyup', onKeyUp);
|
||
iframeWindow?.addEventListener('blur', onBlur);
|
||
iframeWindow?.addEventListener('focus', onFocus);
|
||
return () => {
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
window.removeEventListener('keyup', onKeyUp);
|
||
window.removeEventListener('blur', onBlur);
|
||
window.removeEventListener('focus', onFocus);
|
||
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||
iframeWindow?.removeEventListener('keyup', onKeyUp);
|
||
iframeWindow?.removeEventListener('blur', onBlur);
|
||
iframeWindow?.removeEventListener('focus', onFocus);
|
||
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
||
if (pttActiveRef.current) {
|
||
callEmbed.control.pttActive = false;
|
||
callEmbed.control.setMicrophone(false);
|
||
pttActiveRef.current = false;
|
||
setPttActive(false);
|
||
}
|
||
};
|
||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||
}, [pttMode, pttKey, callEmbed]);
|
||
|
||
useEffect(() => {
|
||
const isEditable = (el: HTMLElement): boolean => {
|
||
const tag = el.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||
let node: HTMLElement | null = el;
|
||
while (node && node !== el.ownerDocument.body) {
|
||
if (node.contentEditable === 'true') return true;
|
||
if (node.contentEditable === 'false') return false;
|
||
node = node.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.code !== deafenKey) return;
|
||
if (e.repeat) return;
|
||
if (isEditable(e.target as HTMLElement)) return;
|
||
e.preventDefault();
|
||
callEmbed.control.toggleSound();
|
||
};
|
||
// C-L4: also bind the EC iframe window so the deafen key works when focus is
|
||
// inside the iframe (mirrors the PTT binding above).
|
||
const iframeWindow = callEmbed.iframe.contentWindow;
|
||
window.addEventListener('keydown', onKeyDown);
|
||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||
return () => {
|
||
window.removeEventListener('keydown', onKeyDown);
|
||
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||
};
|
||
}, [callEmbed, deafenKey]);
|
||
|
||
const [hangupState, hangup] = useAsyncCallback(
|
||
useCallback(() => callEmbed.hangup(), [callEmbed]),
|
||
);
|
||
const exiting =
|
||
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||
|
||
// C-M4: the normal teardown relies on EC echoing a Close/Hangup action after
|
||
// it ACKs HangupCall (useCallHangupEvent -> clears callEmbedAtom -> dispose).
|
||
// If EC ACKs but never echoes, the End button would spin forever. Fall back to
|
||
// disposing the embed a few seconds after a successful hangup send, unless it
|
||
// was already torn down by the normal path.
|
||
useEffect(() => {
|
||
if (hangupState.status !== AsyncStatus.Success) return undefined;
|
||
const id = setTimeout(() => {
|
||
if (!callEmbed.disposed) setCallEmbed(undefined);
|
||
}, 4000);
|
||
return () => clearTimeout(id);
|
||
}, [hangupState.status, callEmbed, setCallEmbed]);
|
||
|
||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||
|
||
return (
|
||
<Box
|
||
ref={controlRef}
|
||
className={css.CallControlContainer}
|
||
justifyContent="Center"
|
||
alignItems="Center"
|
||
>
|
||
{pttMode && (
|
||
<Chip
|
||
variant={pttActive ? 'Success' : 'Warning'}
|
||
fill="Soft"
|
||
radii="400"
|
||
style={{
|
||
position: 'absolute',
|
||
top: '-2.2rem',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
pointerEvents: 'none',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
outlined
|
||
>
|
||
<Text size="T200" style={{ fontWeight: 700 }}>
|
||
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
|
||
</Text>
|
||
</Chip>
|
||
)}
|
||
{shareConfirm && (
|
||
<>
|
||
<div
|
||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||
onClick={() => setShareConfirm(false)}
|
||
aria-hidden="true"
|
||
/>
|
||
<Box
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: '110%',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
background: color.Surface.Container,
|
||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
borderRadius: '0.75rem',
|
||
padding: '1rem 1.25rem',
|
||
zIndex: 100,
|
||
minWidth: '260px',
|
||
// Don't run past the screen edges on a narrow phone (centered via
|
||
// translateX(-50%)); clamp to the viewport minus a small margin.
|
||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.75rem',
|
||
}}
|
||
>
|
||
<Text size="T300" style={{ fontWeight: 600 }}>
|
||
Share your screen?
|
||
</Text>
|
||
<Text size="T200" style={{ opacity: 0.75 }}>
|
||
Your screen will be visible to all participants in this call.
|
||
</Text>
|
||
<Box gap="200">
|
||
<Button
|
||
size="300"
|
||
variant="Success"
|
||
fill="Solid"
|
||
radii="300"
|
||
onClick={() => {
|
||
callEmbed.control.toggleScreenshare();
|
||
setShareConfirm(false);
|
||
}}
|
||
>
|
||
<Text size="B300">Share</Text>
|
||
</Button>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
radii="300"
|
||
outlined
|
||
onClick={() => setShareConfirm(false)}
|
||
>
|
||
<Text size="B300">Cancel</Text>
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
</>
|
||
)}
|
||
<SequenceCard
|
||
className={css.ControlCard}
|
||
variant="SurfaceVariant"
|
||
gap="400"
|
||
radii="500"
|
||
alignItems="Center"
|
||
justifyContent="SpaceBetween"
|
||
wrap="Wrap"
|
||
>
|
||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||
</Box>
|
||
{!compact && showVideoGroup && <ControlDivider />}
|
||
{showVideoGroup && (
|
||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||
{/* Show a forbidden control while its track is still live so the
|
||
user can stop it; once stopped it hides and can't be restarted. */}
|
||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||
{showScreenshare && (
|
||
<>
|
||
<ScreenShareButton
|
||
enabled={screenshare}
|
||
onToggle={() =>
|
||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||
}
|
||
/>
|
||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||
control since they're the same concern. */}
|
||
<ScreenshareAudioButton
|
||
muted={screenshareAudioMuted}
|
||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||
/>
|
||
</>
|
||
)}
|
||
{!!document.fullscreenEnabled && (
|
||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||
)}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
{!compact && <ControlDivider />}
|
||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||
<ChatButton />
|
||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||
<PopOut
|
||
anchor={cords}
|
||
position="Top"
|
||
align="Center"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||
<MenuItem
|
||
size="300"
|
||
variant="Surface"
|
||
radii="300"
|
||
onClick={handleSpotlightClick}
|
||
>
|
||
<Text size="B300" truncate>
|
||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||
</Text>
|
||
</MenuItem>
|
||
<MenuItem
|
||
size="300"
|
||
variant="Surface"
|
||
radii="300"
|
||
onClick={handleReactionsClick}
|
||
>
|
||
<Text size="B300" truncate>
|
||
Reactions
|
||
</Text>
|
||
</MenuItem>
|
||
<MenuItem
|
||
size="300"
|
||
variant="Surface"
|
||
radii="300"
|
||
onClick={handleSettingsClick}
|
||
>
|
||
<Text size="B300" truncate>
|
||
Settings
|
||
</Text>
|
||
</MenuItem>
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
>
|
||
<IconButton
|
||
variant="Surface"
|
||
fill="Soft"
|
||
radii="400"
|
||
size="400"
|
||
onClick={handleOpenMenu}
|
||
outlined
|
||
aria-label="More options"
|
||
aria-expanded={!!cords}
|
||
aria-haspopup="menu"
|
||
>
|
||
<Icon size="400" src={Icons.VerticalDots} />
|
||
</IconButton>
|
||
</PopOut>
|
||
</Box>
|
||
<Box shrink="No" direction="Column">
|
||
<Button
|
||
style={{ minWidth: toRem(88) }}
|
||
variant="Critical"
|
||
fill="Solid"
|
||
onClick={hangup}
|
||
before={
|
||
exiting ? (
|
||
<Spinner variant="Critical" fill="Solid" size="200" />
|
||
) : (
|
||
<Icon src={Icons.PhoneDown} size="200" filled />
|
||
)
|
||
}
|
||
disabled={exiting}
|
||
>
|
||
<Text size="B400">End</Text>
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|