refactor(calls): one ScreenshareConfirm and one room-policy hook for both call bars
Fixes #101 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,18 +1,5 @@
|
|||||||
import {
|
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
Box,
|
import React, { useCallback, useState } from 'react';
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
color,
|
|
||||||
config,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Icons,
|
|
||||||
Spinner,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
TooltipProvider,
|
|
||||||
} from 'folds';
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { StatusDivider } from './components';
|
import { StatusDivider } from './components';
|
||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
@@ -20,6 +7,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||||
|
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
|
||||||
|
|
||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -199,14 +187,6 @@ export function CallControl({
|
|||||||
const showCamera = allowCamera || video;
|
const showCamera = allowCamera || video;
|
||||||
const showScreenshare = allowScreenshare || screenshare;
|
const showScreenshare = allowScreenshare || screenshare;
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
if (!shareConfirm) return undefined;
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') setShareConfirm(false);
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
|
||||||
}, [shareConfirm]);
|
|
||||||
|
|
||||||
const handleMicrophoneToggle = useCallback(
|
const handleMicrophoneToggle = useCallback(
|
||||||
() => callEmbed.control.toggleMicrophone(),
|
() => callEmbed.control.toggleMicrophone(),
|
||||||
@@ -230,64 +210,15 @@ export function CallControl({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
|
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
|
||||||
{shareConfirm && (
|
<ScreenshareConfirm
|
||||||
<>
|
open={shareConfirm}
|
||||||
<div
|
align="Start"
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
onConfirm={() => {
|
||||||
onClick={() => setShareConfirm(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '110%',
|
|
||||||
left: 0,
|
|
||||||
background: color.Surface.Container,
|
|
||||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
|
||||||
borderRadius: '0.75rem',
|
|
||||||
padding: '1rem 1.25rem',
|
|
||||||
zIndex: 100,
|
|
||||||
minWidth: '260px',
|
|
||||||
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();
|
callEmbed.control.toggleScreenshare();
|
||||||
setShareConfirm(false);
|
setShareConfirm(false);
|
||||||
}}
|
}}
|
||||||
>
|
onCancel={() => 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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Box alignItems="Inherit" gap="200">
|
<Box alignItems="Inherit" gap="200">
|
||||||
<MicrophoneButton
|
<MicrophoneButton
|
||||||
enabled={microphone}
|
enabled={microphone}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useRef, useState, useEffect } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
color,
|
|
||||||
config,
|
config,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -41,9 +40,8 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
||||||
import { CallSoundboard } from './CallSoundboard';
|
import { CallSoundboard } from './CallSoundboard';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { ScreenshareConfirm } from './ScreenshareConfirm';
|
||||||
import { RoomQualityContent } from '../../utils/callQuality';
|
|
||||||
|
|
||||||
type CallControlsProps = {
|
type CallControlsProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
@@ -90,14 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
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 [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||||
@@ -107,16 +97,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
// visual PTT chip remains here.
|
// visual PTT chip remains here.
|
||||||
const pttActive = useAtomValue(pttActiveAtom);
|
const pttActive = useAtomValue(pttActiveAtom);
|
||||||
|
|
||||||
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
// [P5-31 / Gitea #101] Hard room publish policy — hide controls the server
|
||||||
// users don't click dead buttons. Absent/true = allowed.
|
// will refuse so users don't click dead buttons. Absent/true = allowed.
|
||||||
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
// Shared with the app-wide CallStatus bar's CallControl via useRoomCallPolicy
|
||||||
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
// so both surfaces apply the same gating.
|
||||||
const cameraAllowed = roomQuality?.allow_camera !== false;
|
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
|
||||||
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
|
||||||
// Keep a forbidden control visible while its track is still live (so the user
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
// can stop it); otherwise hide it entirely.
|
// can stop it); otherwise hide it entirely.
|
||||||
const showCamera = cameraAllowed || video;
|
const showCamera = allowCamera || video;
|
||||||
const showScreenshare = screenshareAllowed || screenshare;
|
const showScreenshare = allowScreenshare || screenshare;
|
||||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
@@ -191,67 +180,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{shareConfirm && (
|
<ScreenshareConfirm
|
||||||
<>
|
open={shareConfirm}
|
||||||
<div
|
align="Center"
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
onConfirm={() => {
|
||||||
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();
|
callEmbed.control.toggleScreenshare();
|
||||||
setShareConfirm(false);
|
setShareConfirm(false);
|
||||||
}}
|
}}
|
||||||
>
|
onCancel={() => 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
|
<SequenceCard
|
||||||
className={css.ControlCard}
|
className={css.ControlCard}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Box, Button, Text, color, config } from 'folds';
|
||||||
|
|
||||||
|
export type ScreenshareConfirmProps = {
|
||||||
|
open: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
/**
|
||||||
|
* Horizontal placement relative to the trigger button. `Center` (the
|
||||||
|
* in-call bar's centered layout) transforms to center itself over the
|
||||||
|
* anchor; `Start` (the app-wide status bar, anchored to its own left edge)
|
||||||
|
* hugs the anchor's left edge instead.
|
||||||
|
*/
|
||||||
|
align?: 'Center' | 'Start';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #101] Shared "Share your screen?" confirmation popover, used by both
|
||||||
|
* the in-call `CallControls` bar and the app-wide `CallStatus` bar's
|
||||||
|
* `CallControl`. Previously each bar carried its own near-identical copy;
|
||||||
|
* hoisted here so their behaviour (Escape / click-outside to close, confirm
|
||||||
|
* starts the share) can't drift apart.
|
||||||
|
*/
|
||||||
|
export function ScreenshareConfirm({
|
||||||
|
open,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
align = 'Center',
|
||||||
|
}: ScreenshareConfirmProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [open, onCancel]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '110%',
|
||||||
|
...(align === 'Center' ? { left: '50%', transform: 'translateX(-50%)' } : { left: 0 }),
|
||||||
|
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={onConfirm}>
|
||||||
|
<Text size="B300">Share</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user