CI / Build & Quality Checks (push) Successful in 4m32s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 16m32s
Firefox, Safari and the WebKitGTK desktop app can't hand the user's click to the call frame (no Capability Delegation), and getDisplayMedia needs it. The host used to click EC's hidden footer button through the DOM instead, which dies with same-origin. Now (pins element-call-embedded 0.25.0-lotus.20): - those engines get `lotusFrameScreenshare`, the fork shows EC's own screenshare button in the frame, and the call bar and status bar hide theirs once controls_state reports `frameScreenshare`; the screenshare-audio mute stays; - the room's call policy is pushed with io.lotus.set_frame_screenshare, so the frame button hides where the server would refuse a share, like ours; - Chromium keeps the delegated io.lotus.set_screenshare from the host bar. Removed the fallbacks for forks older than lotus.14, which read or clicked EC's DOM: the screenshare/layout/settings/reactions/leave button lookups and their MutationObservers, the frame-window hotkey binding, and the speaking/muted tile scrape in useCallSpeakers (io.lotus.call_state is the only source now). getCallDocument is gone; the host's only handle on the frame is postMessage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
290 lines
8.6 KiB
TypeScript
290 lines
8.6 KiB
TypeScript
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||
import React, { useCallback, useState } from 'react';
|
||
import { useSetAtom } from 'jotai';
|
||
import { MicLevelBars } from '../call/MicLevelBars';
|
||
import { StatusDivider } from './components';
|
||
import {
|
||
CallEmbed,
|
||
useCallControlState,
|
||
useCallMicLevel,
|
||
useFrameScreenshare,
|
||
} from '../../plugins/call';
|
||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||
import { callEmbedAtom } from '../../state/callEmbed';
|
||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
|
||
import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
|
||
|
||
type MicrophoneButtonProps = {
|
||
enabled: boolean;
|
||
onToggle: () => Promise<unknown>;
|
||
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
|
||
level?: number;
|
||
disabled?: boolean;
|
||
};
|
||
function MicrophoneButton({ enabled, onToggle, disabled, level = 0 }: MicrophoneButtonProps) {
|
||
const [micState, toggleMic] = useAsyncCallback(onToggle);
|
||
const loading = micState.status === AsyncStatus.Loading;
|
||
|
||
return (
|
||
<TooltipProvider
|
||
position="Top"
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(anchorRef) => (
|
||
<IconButton
|
||
ref={anchorRef}
|
||
variant={enabled ? 'Surface' : 'Warning'}
|
||
fill="Soft"
|
||
radii="300"
|
||
size="300"
|
||
className={MobileTouchTarget}
|
||
onClick={toggleMic}
|
||
outlined
|
||
disabled={disabled || loading}
|
||
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
|
||
>
|
||
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
||
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||
{enabled && <MicLevelBars level={level} />}
|
||
</span>
|
||
</IconButton>
|
||
)}
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
|
||
type SoundButtonProps = {
|
||
enabled: boolean;
|
||
onToggle: () => void;
|
||
disabled?: boolean;
|
||
};
|
||
function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||
return (
|
||
<TooltipProvider
|
||
position="Top"
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(anchorRef) => (
|
||
<IconButton
|
||
ref={anchorRef}
|
||
variant={enabled ? 'Surface' : 'Warning'}
|
||
fill="Soft"
|
||
radii="300"
|
||
size="300"
|
||
className={MobileTouchTarget}
|
||
onClick={() => onToggle()}
|
||
// No aria-pressed on these call toggles (#187 DP7/8): their labels already
|
||
// name the action ("Deafen" / "Undeafen"), and pressed-state on top
|
||
// read as "Deafen, pressed" while NOT deafened.
|
||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||
outlined
|
||
disabled={disabled}
|
||
>
|
||
<Icon
|
||
size="100"
|
||
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
|
||
filled={!enabled}
|
||
/>
|
||
</IconButton>
|
||
)}
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
|
||
type VideoButtonProps = {
|
||
enabled: boolean;
|
||
onToggle: () => Promise<unknown>;
|
||
disabled?: boolean;
|
||
};
|
||
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||
const [videoState, toggleVideo] = useAsyncCallback(onToggle);
|
||
const loading = videoState.status === AsyncStatus.Loading;
|
||
|
||
return (
|
||
<TooltipProvider
|
||
position="Top"
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(anchorRef) => (
|
||
<IconButton
|
||
ref={anchorRef}
|
||
variant={enabled ? 'Success' : 'Surface'}
|
||
fill="Soft"
|
||
radii="300"
|
||
size="300"
|
||
className={MobileTouchTarget}
|
||
onClick={toggleVideo}
|
||
aria-label={enabled ? 'Stop Video' : 'Start Video'}
|
||
outlined
|
||
disabled={disabled || loading}
|
||
>
|
||
<Icon
|
||
size="100"
|
||
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
|
||
filled={enabled}
|
||
/>
|
||
</IconButton>
|
||
)}
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
|
||
type ScreenShareButtonProps = {
|
||
enabled: boolean;
|
||
onToggle: () => void;
|
||
disabled?: boolean;
|
||
};
|
||
function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
|
||
return (
|
||
<TooltipProvider
|
||
position="Top"
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(anchorRef) => (
|
||
<IconButton
|
||
ref={anchorRef}
|
||
variant={enabled ? 'Success' : 'Surface'}
|
||
fill="Soft"
|
||
radii="300"
|
||
size="300"
|
||
className={MobileTouchTarget}
|
||
onClick={onToggle}
|
||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||
outlined
|
||
disabled={disabled}
|
||
>
|
||
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
|
||
</IconButton>
|
||
)}
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
|
||
export function CallControl({
|
||
callEmbed,
|
||
compact,
|
||
callJoined,
|
||
}: {
|
||
callEmbed: CallEmbed;
|
||
compact: boolean;
|
||
callJoined: boolean;
|
||
}) {
|
||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||
const micLevel = useCallMicLevel(callEmbed);
|
||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||
|
||
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
|
||
// in-room CallControls bar, so the status bar can't be used to bypass it.
|
||
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
|
||
// Keep a forbidden control visible while its track is still live (so the user
|
||
// can stop it); otherwise hide it entirely.
|
||
const showCamera = allowCamera || video;
|
||
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame.
|
||
const frameScreenshare = useFrameScreenshare(callEmbed.control);
|
||
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare);
|
||
const [shareConfirm, setShareConfirm] = useState(false);
|
||
|
||
const handleMicrophoneToggle = useCallback(
|
||
() => callEmbed.control.toggleMicrophone(),
|
||
[callEmbed],
|
||
);
|
||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||
|
||
const [hangupState, hangup] = useAsyncCallback(
|
||
useCallback(() => callEmbed.hangup(), [callEmbed]),
|
||
);
|
||
const exiting =
|
||
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||
|
||
const handleHangup = () => {
|
||
if (!callJoined) {
|
||
setCallEmbed(undefined);
|
||
return;
|
||
}
|
||
hangup();
|
||
};
|
||
|
||
return (
|
||
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
|
||
<ScreenshareConfirm
|
||
open={shareConfirm}
|
||
align="Start"
|
||
onConfirm={() => {
|
||
callEmbed.control.toggleScreenshare();
|
||
setShareConfirm(false);
|
||
}}
|
||
onCancel={() => setShareConfirm(false)}
|
||
/>
|
||
<Box alignItems="Inherit" gap="200">
|
||
<MicrophoneButton
|
||
enabled={microphone}
|
||
onToggle={handleMicrophoneToggle}
|
||
disabled={!callJoined}
|
||
level={micLevel}
|
||
/>
|
||
<SoundButton
|
||
enabled={sound}
|
||
onToggle={() => callEmbed.control.toggleSound()}
|
||
disabled={!callJoined}
|
||
/>
|
||
{!compact && audioOutputSelectable() && (
|
||
<AudioOutputButton embed={callEmbed} disabled={!callJoined} />
|
||
)}
|
||
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
||
{showCamera && (
|
||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||
)}
|
||
{!compact && showScreenshare && (
|
||
<ScreenShareButton
|
||
enabled={screenshare}
|
||
onToggle={() =>
|
||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||
}
|
||
disabled={!callJoined}
|
||
/>
|
||
)}
|
||
</Box>
|
||
<StatusDivider />
|
||
<Chip
|
||
variant="Critical"
|
||
radii="Pill"
|
||
fill="Soft"
|
||
before={
|
||
exiting ? (
|
||
<Spinner variant="Critical" fill="Soft" size="50" />
|
||
) : (
|
||
<Icon size="50" src={Icons.PhoneDown} filled />
|
||
)
|
||
}
|
||
disabled={exiting}
|
||
outlined
|
||
onClick={handleHangup}
|
||
>
|
||
{!compact && (
|
||
<Text as="span" size="L400">
|
||
End
|
||
</Text>
|
||
)}
|
||
</Chip>
|
||
</Box>
|
||
);
|
||
}
|