CI / Build & Quality Checks (pull_request) Successful in 4m18s
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 11m27s
With a fork that reports `screensharePrompt` (element-call lotus-screenshare- prompt), the call bar and status bar keep their screenshare button on every engine. Where the click can't be delegated, starting asks the fork to show "Share your screen?" inside the call frame (io.lotus.prompt_screenshare) instead of our own confirm; its Share click starts the share. Stopping works from the bar as before (no click needed in the frame). Chromium is unchanged. - useScreenshareMode: hidden (older fork's corner button) | prompt | direct. - The room's call policy is also pushed to the fork in prompt mode, so the prompt never opens where sharing is forbidden. - Picture-in-picture: the "Return to call" overlay covers the frame; while the fork reports the prompt open it lets clicks through to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
294 lines
8.9 KiB
TypeScript
294 lines
8.9 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,
|
||
useScreenshareMode,
|
||
} 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;
|
||
// where the click can't be delegated, starting asks for the in-frame prompt.
|
||
const screenshareMode = useScreenshareMode(callEmbed.control);
|
||
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
|
||
const [shareConfirm, setShareConfirm] = useState(false);
|
||
const handleScreenshareToggle = () => {
|
||
if (screenshare) callEmbed.control.toggleScreenshare();
|
||
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
|
||
else setShareConfirm(true);
|
||
};
|
||
|
||
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={() => handleScreenshareToggle()}
|
||
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>
|
||
);
|
||
}
|