fix(calls): app-wide call bar honours room camera/screenshare policy
The persistent call-status bar exposed Video and ScreenShare with no io.lotus.room_quality check and no share confirmation, bypassing the in-room bar's gating. Add useRoomCallPolicy and apply the same hiding plus a "Share your screen?" confirm. Fixes #26 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,11 +1,25 @@
|
|||||||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
import {
|
||||||
import React, { useCallback } from 'react';
|
Box,
|
||||||
|
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';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
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';
|
||||||
|
|
||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -177,6 +191,23 @@ export function CallControl({
|
|||||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
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;
|
||||||
|
const showScreenshare = allowScreenshare || screenshare;
|
||||||
|
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(),
|
||||||
[callEmbed],
|
[callEmbed],
|
||||||
@@ -198,7 +229,65 @@ export function CallControl({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" alignItems="Center" gap="300">
|
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
|
||||||
|
{shareConfirm && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||||||
|
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();
|
||||||
|
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}
|
||||||
@@ -210,12 +299,16 @@ export function CallControl({
|
|||||||
onToggle={() => callEmbed.control.toggleSound()}
|
onToggle={() => callEmbed.control.toggleSound()}
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
/>
|
/>
|
||||||
{!compact && <StatusDivider />}
|
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
||||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
{showCamera && (
|
||||||
{!compact && (
|
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||||||
|
)}
|
||||||
|
{!compact && showScreenshare && (
|
||||||
<ScreenShareButton
|
<ScreenShareButton
|
||||||
enabled={screenshare}
|
enabled={screenshare}
|
||||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
onToggle={() =>
|
||||||
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
|
}
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useStateEvent } from './useStateEvent';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import { RoomQualityContent } from '../utils/callQuality';
|
||||||
|
|
||||||
|
export type RoomCallPolicy = {
|
||||||
|
allowCamera: boolean;
|
||||||
|
allowScreenshare: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #26] Shared room-level camera/screenshare policy, read from the
|
||||||
|
* `io.lotus.room_quality` state event. Absent/true = allowed; only an explicit
|
||||||
|
* `false` forbids. Hoisted out of `CallControls` so other call surfaces (e.g.
|
||||||
|
* the app-wide `CallStatus` bar) can apply the same gating.
|
||||||
|
*/
|
||||||
|
export const useRoomCallPolicy = (room: Room): RoomCallPolicy => {
|
||||||
|
const roomQualityEvent = useStateEvent(room, StateEvent.LotusRoomQuality);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||||
|
return {
|
||||||
|
allowCamera: roomQuality?.allow_camera !== false,
|
||||||
|
allowScreenshare: roomQuality?.allow_screenshare !== false,
|
||||||
|
};
|
||||||
|
}, [roomQualityEvent]);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user