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:
2026-09-12 19:46:06 -04:00
co-authored by Claude Opus 5
parent fb14db6d50
commit fc68e0a769
2 changed files with 128 additions and 7 deletions
+100 -7
View File
@@ -1,11 +1,25 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
import {
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 { StatusDivider } from './components';
import { CallEmbed, useCallControlState } 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';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -177,6 +191,23 @@ export function CallControl({
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
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(
() => callEmbed.control.toggleMicrophone(),
[callEmbed],
@@ -198,7 +229,65 @@ export function CallControl({
};
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">
<MicrophoneButton
enabled={microphone}
@@ -210,12 +299,16 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && <StatusDivider />}
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
{!compact && (
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
{showCamera && (
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
)}
{!compact && showScreenshare && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
disabled={!callJoined}
/>
)}
+28
View File
@@ -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]);
};