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
1461 lines
53 KiB
TypeScript
1461 lines
53 KiB
TypeScript
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
color,
|
|
config,
|
|
Dialog,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Text,
|
|
toRem,
|
|
} from 'folds';
|
|
import {
|
|
EventTimelineSetHandlerMap,
|
|
EventType,
|
|
JoinRule,
|
|
RelationType,
|
|
Room,
|
|
RoomEvent,
|
|
} from 'matrix-js-sdk';
|
|
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
|
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
|
import {
|
|
CallEmbedContextProvider,
|
|
CallEmbedRefContextProvider,
|
|
useCallHangupEvent,
|
|
useCallJoined,
|
|
useCallThemeSync,
|
|
useCallStart,
|
|
} from '../hooks/useCallEmbed';
|
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
|
import { toastQueueAtom } from '../state/toast';
|
|
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
|
import { hangupCallAndWait } from '../plugins/call/hangup';
|
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
|
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
|
import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
|
import { useCallRejoin } from '../hooks/useCallRejoin';
|
|
import { usePttHaptics } from '../hooks/usePttHaptics';
|
|
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
|
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
|
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
|
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
|
import { SrOnly } from '../features/call-status/styles.css';
|
|
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
|
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
|
import { useCallQuality } from '../hooks/useCallQuality';
|
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
|
import { mDirectAtom } from '../state/mDirectList';
|
|
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
|
import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
|
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
|
import { getChatBg } from '../features/lotus/chatBackground';
|
|
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
|
import { useReducedMotion } from '../hooks/useReducedMotion';
|
|
import { useSetting } from '../state/hooks/settings';
|
|
import { useCallPreferences } from '../state/hooks/callPreferences';
|
|
import { settingsAtom } from '../state/settings';
|
|
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
|
import { StateEvent } from '../../types/matrix/room';
|
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
|
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
|
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
|
import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
|
|
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
|
import { CallAvatarAnimation } from '../styles/Animations.css';
|
|
import { webRTCSupported } from '../utils/rtc';
|
|
import { zIndices } from '../styles/zIndex';
|
|
|
|
const PIP_MIN_W = 200;
|
|
const PIP_MIN_H = 112;
|
|
|
|
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
|
|
|
/** Normalise the element to top/left positioning so resize math is uniform. */
|
|
function normaliseToTopLeft(el: HTMLElement) {
|
|
const rect = el.getBoundingClientRect();
|
|
el.style.left = `${rect.left}px`;
|
|
el.style.top = `${rect.top}px`;
|
|
el.style.width = `${rect.width}px`;
|
|
el.style.height = `${rect.height}px`;
|
|
el.style.right = 'auto';
|
|
el.style.bottom = 'auto';
|
|
}
|
|
|
|
type IncomingCallInfo = {
|
|
room: Room;
|
|
sender: string;
|
|
senderTs: number;
|
|
lifetime: number;
|
|
intent?: string;
|
|
notificationType: RTCNotificationType;
|
|
refEventId: string;
|
|
};
|
|
type IncomingCallProps = {
|
|
dm: boolean;
|
|
info: IncomingCallInfo;
|
|
onIgnore: () => void;
|
|
onAnswer: (room: Room, video: boolean) => void;
|
|
onReject: (room: Room, eventId: string) => void;
|
|
};
|
|
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const livekitSupported = useLivekitSupport();
|
|
const rtcSupported = webRTCSupported();
|
|
const canAnswer = livekitSupported && rtcSupported;
|
|
const { room } = info;
|
|
// [Gitea #135] Answer follows cameraOnJoin: say so, and offer the audio-only
|
|
// alternative, rather than putting the user on video without warning.
|
|
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
|
|
const willAnswerWithCamera = info.intent === 'video' && !!cameraOnJoinPref;
|
|
|
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
|
// Gitea #28 — don't ring during quiet hours / DND / Focus Assist / snooze. The
|
|
// call can still be answered from this overlay; only the audible ring is
|
|
// skipped.
|
|
const quiet = useNotificationsQuiet();
|
|
|
|
const roomName = useRoomName(room);
|
|
const roomAvatar = useRoomAvatar(room, dm);
|
|
const avatarUrl = roomAvatar
|
|
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
|
: undefined;
|
|
|
|
const session = useCallSession(room);
|
|
useCallMembersChange(
|
|
session,
|
|
useCallback(
|
|
(members) => {
|
|
if (members.length === 0) {
|
|
onIgnore();
|
|
}
|
|
},
|
|
[onIgnore],
|
|
),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (info.notificationType !== 'ring' || quiet) return undefined;
|
|
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
|
return stop;
|
|
}, [info.notificationType, ringtoneId, ringtoneVolume, quiet]);
|
|
|
|
useEffect(() => {
|
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
|
if (remaining <= 0) {
|
|
onIgnore();
|
|
return;
|
|
}
|
|
const id = setTimeout(onIgnore, remaining);
|
|
return () => clearTimeout(id);
|
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => onIgnore(),
|
|
clickOutsideDeactivates: false,
|
|
escapeDeactivates: false,
|
|
}}
|
|
>
|
|
<Dialog
|
|
style={{ maxWidth: toRem(324) }}
|
|
// #187: the overlay had no role or name, so a screen reader landed
|
|
// on an unlabelled button with no hint that a call was ringing.
|
|
// Match the in-call banner (role="alert" + label).
|
|
role="alertdialog"
|
|
aria-modal="true"
|
|
aria-label={`Incoming ${info.intent === 'video' ? 'video' : 'voice'} call from ${getMemberName(info.room, info.sender) ?? info.sender}`}
|
|
>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
|
<Text size="T200" align="Center">
|
|
{getMemberName(info.room, info.sender)}
|
|
</Text>
|
|
<Box direction="Column" gap="500" alignItems="Center">
|
|
<Box shrink="No">
|
|
<Avatar size="500" className={CallAvatarAnimation}>
|
|
<RoomAvatar
|
|
roomId={room.roomId}
|
|
src={avatarUrl}
|
|
alt={roomName}
|
|
renderFallback={() => (
|
|
<RoomIcon
|
|
roomType={room.getType()}
|
|
size="400"
|
|
joinRule={room.getJoinRule()}
|
|
filled
|
|
/>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
</Box>
|
|
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
|
<Text size="H3" align="Center" truncate>
|
|
{roomName}
|
|
</Text>
|
|
<Text size="T300" align="Center">
|
|
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
{!livekitSupported && (
|
|
<Text
|
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
|
size="L400"
|
|
align="Center"
|
|
>
|
|
Your homeserver does not support calling.
|
|
</Text>
|
|
)}
|
|
{!webRTCSupported() && (
|
|
<Text
|
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
|
size="L400"
|
|
align="Center"
|
|
>
|
|
Your browser does not support WebRTC, which is required for calling.
|
|
</Text>
|
|
)}
|
|
<Box direction="Column" gap="300">
|
|
{willAnswerWithCamera && (
|
|
<Text size="T200" align="Center" priority="300">
|
|
Answering turns your camera on.
|
|
</Text>
|
|
)}
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
variant="Success"
|
|
size="400"
|
|
radii="400"
|
|
onClick={() => onAnswer(room, info.intent === 'video')}
|
|
before={
|
|
<Icon
|
|
size="200"
|
|
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
|
filled
|
|
/>
|
|
}
|
|
disabled={!canAnswer}
|
|
>
|
|
<Text as="span" size="B400">
|
|
{willAnswerWithCamera ? 'Answer with camera' : 'Answer'}
|
|
</Text>
|
|
</Button>
|
|
{willAnswerWithCamera && (
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
variant="Success"
|
|
fill="Soft"
|
|
size="400"
|
|
radii="400"
|
|
onClick={() => onAnswer(room, false)}
|
|
before={<Icon size="200" src={Icons.Phone} filled />}
|
|
disabled={!canAnswer}
|
|
>
|
|
<Text as="span" size="B400">
|
|
Answer without camera
|
|
</Text>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
variant={dm ? 'Critical' : 'Secondary'}
|
|
fill="Soft"
|
|
size="400"
|
|
radii="400"
|
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
|
before={<Icon size="200" src={Icons.Cross} filled />}
|
|
>
|
|
<Text as="span" size="B400">
|
|
{dm ? 'Reject' : 'Ignore'}
|
|
</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|
|
|
|
type IncomingCallBannerProps = {
|
|
dm: boolean;
|
|
info: IncomingCallInfo;
|
|
onIgnore: () => void;
|
|
onAnswer: (room: Room, video: boolean) => void;
|
|
onReject: (room: Room, eventId: string) => void;
|
|
};
|
|
/**
|
|
* Compact, non-intrusive incoming-call notification shown when the user is
|
|
* ALREADY in a call. Unlike the full-screen `IncomingCall` overlay this is a
|
|
* corner banner that does not take over the screen, and it plays a single
|
|
* soft ping (via the one-shot ringtone preview) rather than the looping ring,
|
|
* so it doesn't talk over the active call.
|
|
*/
|
|
function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallBannerProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const { room } = info;
|
|
const isVideo = info.intent === 'video';
|
|
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
|
|
const bannerCameraOn = isVideo && !!cameraOnJoinPref;
|
|
|
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
|
// Gitea #28 — no ping during quiet hours / DND / Focus Assist / snooze; the
|
|
// banner itself still shows so the call can be answered.
|
|
const quiet = useNotificationsQuiet();
|
|
|
|
const roomName = useRoomName(room);
|
|
const roomAvatar = useRoomAvatar(room, dm);
|
|
const avatarUrl = roomAvatar
|
|
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
|
: undefined;
|
|
|
|
const session = useCallSession(room);
|
|
useCallMembersChange(
|
|
session,
|
|
useCallback(
|
|
(members) => {
|
|
if (members.length === 0) {
|
|
onIgnore();
|
|
}
|
|
},
|
|
[onIgnore],
|
|
),
|
|
);
|
|
|
|
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
|
|
// + volume. We intentionally do NOT loop here — the user is mid-call — and we
|
|
// ping exactly once per incoming call, not again if the user happens to tweak
|
|
// ringtone settings while the banner is showing.
|
|
const pingedRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (info.notificationType !== 'ring' || quiet) return;
|
|
if (pingedRef.current === info.refEventId) return;
|
|
pingedRef.current = info.refEventId;
|
|
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
|
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume, quiet]);
|
|
|
|
useEffect(() => {
|
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
|
if (remaining <= 0) {
|
|
onIgnore();
|
|
return;
|
|
}
|
|
const id = setTimeout(onIgnore, remaining);
|
|
return () => clearTimeout(id);
|
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
|
|
|
const callerName = getMemberName(info.room, info.sender);
|
|
|
|
return (
|
|
<Box
|
|
direction="Column"
|
|
gap="300"
|
|
style={{
|
|
position: 'fixed',
|
|
top: config.space.S400,
|
|
right: config.space.S400,
|
|
zIndex: zIndices.inCallBanner,
|
|
width: toRem(300),
|
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
|
padding: config.space.S300,
|
|
background: color.Surface.Container,
|
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
|
borderRadius: config.radii.R400,
|
|
boxShadow: `0 8px 32px ${color.Other.Shadow}`,
|
|
}}
|
|
role="alert"
|
|
aria-label={`Incoming ${isVideo ? 'video' : 'voice'} call from ${roomName}`}
|
|
>
|
|
<Box gap="300" alignItems="Center">
|
|
<Box shrink="No">
|
|
<Avatar size="300" className={CallAvatarAnimation}>
|
|
<RoomAvatar
|
|
roomId={room.roomId}
|
|
src={avatarUrl}
|
|
alt={roomName}
|
|
renderFallback={() => (
|
|
<RoomIcon
|
|
roomType={room.getType()}
|
|
size="200"
|
|
joinRule={room.getJoinRule()}
|
|
filled
|
|
/>
|
|
)}
|
|
/>
|
|
</Avatar>
|
|
</Box>
|
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
|
|
<Text size="T300" truncate>
|
|
{roomName}
|
|
</Text>
|
|
<Text size="T200" priority="300" truncate>
|
|
{isVideo ? 'Incoming video call' : 'Incoming voice call'}
|
|
{dm ? '' : ` · ${callerName}`}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
<Box gap="200" wrap="Wrap">
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
variant="Success"
|
|
fill="Solid"
|
|
size="300"
|
|
radii="300"
|
|
onClick={() => onAnswer(room, isVideo)}
|
|
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
|
title={bannerCameraOn ? 'Answering turns your camera on' : undefined}
|
|
>
|
|
<Text as="span" size="B300">
|
|
{bannerCameraOn ? 'Answer with camera' : 'Answer'}
|
|
</Text>
|
|
</Button>
|
|
{bannerCameraOn && (
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
variant="Success"
|
|
fill="Soft"
|
|
size="300"
|
|
radii="300"
|
|
onClick={() => onAnswer(room, false)}
|
|
before={<Icon size="100" src={Icons.Phone} filled />}
|
|
>
|
|
<Text as="span" size="B300">
|
|
Answer without camera
|
|
</Text>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
style={{ flexGrow: 1 }}
|
|
variant={dm ? 'Critical' : 'Secondary'}
|
|
fill="Soft"
|
|
size="300"
|
|
radii="300"
|
|
outlined
|
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
|
before={<Icon size="100" src={Icons.Cross} filled />}
|
|
>
|
|
<Text as="span" size="B300">
|
|
{dm ? 'Reject' : 'Ignore'}
|
|
</Text>
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type IncomingCallListenerProps = {
|
|
callEmbed?: CallEmbed;
|
|
joined?: boolean;
|
|
};
|
|
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
|
|
const mx = useMatrixClient();
|
|
const directs = useAtomValue(mDirectAtom);
|
|
const { navigateRoom } = useRoomNavigate();
|
|
const setToast = useSetAtom(toastQueueAtom);
|
|
|
|
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
|
const startCall = useCallStart(dm);
|
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
|
const { microphone, sound } = useCallPreferences();
|
|
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
|
|
|
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
|
|
// against the component unmounting during that await.
|
|
const mountedRef = useRef(true);
|
|
useEffect(
|
|
() => () => {
|
|
mountedRef.current = false;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
|
async (event, room, toStartOfTimeline, removed, data) => {
|
|
// only process rtc notification reference events.
|
|
// we do not want to wait to decrypt all events.
|
|
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
|
if (room?.isCallRoom()) return;
|
|
|
|
if (event.isEncrypted()) {
|
|
if (!event.isBeingDecrypted()) {
|
|
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
|
|
}
|
|
await event.getDecryptionPromise();
|
|
}
|
|
|
|
// C-L6: bail if we unmounted while awaiting decryption above.
|
|
if (!mountedRef.current) return;
|
|
|
|
// Caller-side: a participant declined a call we're hosting in this room.
|
|
// Without this the caller's UI keeps "ringing" until the notification
|
|
// lifetime expires, with no indication the callee said no.
|
|
if (event.getType() === EventType.RTCDecline) {
|
|
const decliner = event.getSender();
|
|
if (
|
|
data.liveEvent &&
|
|
room &&
|
|
decliner &&
|
|
decliner !== mx.getSafeUserId() &&
|
|
callEmbed?.roomId === room.roomId
|
|
) {
|
|
const declinerName = getMemberName(room, decliner);
|
|
setToast({
|
|
id: `rtc-decline-${event.getId() ?? decliner}`,
|
|
displayName: declinerName,
|
|
body: 'Declined your call',
|
|
roomName: room.name,
|
|
roomId: room.roomId,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!room ||
|
|
event.getType() !== EventType.RTCNotification ||
|
|
event.getSender() === mx.getSafeUserId() ||
|
|
!data.liveEvent
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const sender = event.getSender();
|
|
const content = event.getContent<IRTCNotificationContent>();
|
|
// Trust the caller's sender_ts only when it's within 20s of the server's
|
|
// timestamp in EITHER direction. A fast caller clock made a fresh invite
|
|
// look expired-in-the-future; a SLOW one left sender_ts in the past so
|
|
// `Date.now() >= senderTs + lifetime` hid/dismissed the ring for a
|
|
// genuinely fresh invite (COR-3). Fall back to the server ts on large skew.
|
|
const senderTs =
|
|
typeof content.sender_ts === 'number' &&
|
|
Math.abs(content.sender_ts - event.getTs()) <= 20000
|
|
? content.sender_ts
|
|
: event.getTs();
|
|
const lifetime = Math.min(content.lifetime, 120000);
|
|
const notificationType = content.notification_type;
|
|
const relation =
|
|
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
|
const refEventId = relation?.event_id;
|
|
|
|
const mention =
|
|
content['m.mentions']?.room ||
|
|
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
|
|
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
|
|
return;
|
|
}
|
|
|
|
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
|
|
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
|
|
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
|
|
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
|
|
|
const hasCallPermission = permissions.stateEvent(
|
|
StateEvent.GroupCallMemberPrefix,
|
|
mx.getSafeUserId(),
|
|
);
|
|
if (!hasCallPermission) return;
|
|
|
|
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
|
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
|
// restricted rooms, and public rooms must never trigger ringing.
|
|
if (room.isCallRoom()) return;
|
|
const isDirect = directs.has(room.roomId);
|
|
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
|
const joinRule = room.getJoinRule();
|
|
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
|
if (!isDirect && !isPrivateInviteGroup) return;
|
|
|
|
const info: IncomingCallInfo = {
|
|
room,
|
|
sender,
|
|
senderTs,
|
|
lifetime,
|
|
intent:
|
|
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
|
|
? content['m.call.intent']
|
|
: undefined,
|
|
notificationType,
|
|
refEventId,
|
|
};
|
|
|
|
setCallInfo(info);
|
|
},
|
|
[mx, directs, callEmbed, setToast],
|
|
);
|
|
|
|
useEffect(() => {
|
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
|
return () => {
|
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
|
};
|
|
}, [mx, handleTimelineEvent]);
|
|
|
|
const handleIgnore = useCallback(() => {
|
|
setCallInfo(undefined);
|
|
}, []);
|
|
|
|
// [Gitea #161] Stop ringing here when the call was handled ELSEWHERE:
|
|
// - answered on another of our devices (our own m.call.member appears),
|
|
// - declined on another of our devices (our own RTCDecline for this ring),
|
|
// - the caller hung up before we picked up (nobody left in the session).
|
|
// Without this every other device kept ringing for the full lifetime.
|
|
useEffect(() => {
|
|
if (!callInfo) return undefined;
|
|
const { room, refEventId } = callInfo;
|
|
const myUserId = mx.getSafeUserId();
|
|
const dismiss = () =>
|
|
setCallInfo((current) => (current?.refEventId === refEventId ? undefined : current));
|
|
|
|
const session = mx.matrixRTC.getRoomSession(room);
|
|
const checkMemberships = () => {
|
|
const { memberships } = session;
|
|
if (memberships.some((m) => m.sender === myUserId))
|
|
dismiss(); // answered elsewhere
|
|
else if (memberships.length === 0) dismiss(); // caller gone
|
|
};
|
|
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
|
event,
|
|
eventRoom,
|
|
_s,
|
|
_r,
|
|
data,
|
|
) => {
|
|
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
|
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
|
const related = event.getRelation()?.event_id;
|
|
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
|
}
|
|
};
|
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
|
mx.on(RoomEvent.Timeline, onTimeline);
|
|
// The caller's membership may not have arrived yet when the ring starts —
|
|
// only treat "empty" as hung-up after it has had a moment to sync.
|
|
const settle = setTimeout(checkMemberships, 5000);
|
|
return () => {
|
|
session.off(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
|
mx.removeListener(RoomEvent.Timeline, onTimeline);
|
|
clearTimeout(settle);
|
|
};
|
|
}, [mx, callInfo]);
|
|
|
|
const handleReject = useCallback(
|
|
(room: Room, eventId: string) => {
|
|
// Best-effort: the local UI dismisses regardless (below), but a failed
|
|
// decline used to reject uncaught. Log it instead of surfacing — the caller
|
|
// will time the call out on their side even if our decline never lands.
|
|
mx.sendEvent(room.roomId, EventType.RTCDecline, {
|
|
'm.relates_to': {
|
|
rel_type: RelationType.Reference,
|
|
event_id: eventId,
|
|
},
|
|
}).catch((err) => {
|
|
console.error('Failed to send call decline:', err);
|
|
});
|
|
setCallInfo(undefined);
|
|
},
|
|
[mx],
|
|
);
|
|
|
|
const handleAnswer = useCallback(
|
|
async (room: Room, video: boolean) => {
|
|
setCallInfo(undefined);
|
|
// [Gitea #195] Answering from the in-call banner is the one call-to-call
|
|
// switch path. Disposing the current embed alone leaves our ghost
|
|
// `m.call.member` in the old room for ~17 s (until the delayed leave
|
|
// expires) — hang up explicitly and wait for the membership to clear.
|
|
if (callEmbed?.joined) {
|
|
await hangupCallAndWait(mx, callEmbed);
|
|
// Dispose it ourselves now: its HangupCall echo would otherwise land
|
|
// after startCall() and clear the NEW embed from the atom.
|
|
setCallEmbed(undefined);
|
|
}
|
|
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
|
|
// forcing camera+mic+sound on — every other join path does this, and
|
|
// Answer was skipping it, publishing the camera with no prescreen.
|
|
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
|
|
startCall(room, { microphone, video: cameraOnJoin && video, sound });
|
|
navigateRoom(room.roomId);
|
|
},
|
|
[startCall, navigateRoom, microphone, sound, cameraOnJoin, callEmbed, mx, setCallEmbed],
|
|
);
|
|
|
|
if (!callInfo) return null;
|
|
// Already in this room's own call — no notification at all.
|
|
if (callEmbed?.roomId === callInfo.room.roomId) {
|
|
return null;
|
|
}
|
|
// In a different call already: show the compact, non-intrusive banner
|
|
// instead of the full-screen takeover overlay.
|
|
if (joined) {
|
|
return (
|
|
<IncomingCallBanner
|
|
dm={dm}
|
|
info={callInfo}
|
|
onIgnore={handleIgnore}
|
|
onAnswer={handleAnswer}
|
|
onReject={handleReject}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<IncomingCall
|
|
dm={dm}
|
|
info={callInfo}
|
|
onIgnore={handleIgnore}
|
|
onAnswer={handleAnswer}
|
|
onReject={handleReject}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// [Gitea #168] Lives outside the embed so "Call ended" is still announced.
|
|
function CallAnnouncementRegion() {
|
|
const announcement = useAtomValue(callAnnouncementAtom);
|
|
return (
|
|
<span className={SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
|
{announcement}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
|
|
|
// [Gitea #9] PTT/deafen hotkeys and AFK auto-mute are bound here, for the
|
|
// embed's whole lifetime, rather than in CallControls (which only renders
|
|
// while the call room is selected) — so they keep working in PiP and behind
|
|
// the mobile in-call chat. Both are gated on `joined`.
|
|
useCallHotkeys(embed, joined);
|
|
useAfkAutoMute(joined ? embed : undefined);
|
|
useCallJoinLeaveSounds(embed);
|
|
useCallPolicyRevokedToast(embed, joined);
|
|
useCallEndedToast(embed);
|
|
useScreenshareNotices(embed);
|
|
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
|
// as the host bar's (hidden where the server would refuse the share).
|
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
|
useEffect(() => {
|
|
embed.control.setFrameScreenshareAllowed(allowScreenshare);
|
|
}, [embed, allowScreenshare]);
|
|
usePttHaptics();
|
|
useCallAnnouncements(embed, joined);
|
|
useMutedTalkWarning(embed, joined);
|
|
useCallThemeSync(embed);
|
|
useCallQuality(embed);
|
|
useCallHangupEvent(
|
|
embed,
|
|
useCallback(() => {
|
|
setCallEmbed(undefined);
|
|
}, [setCallEmbed]),
|
|
);
|
|
|
|
// [Gitea #58] Warn before an accidental tab close/reload drops the user out
|
|
// of a live call. Only armed while actually joined, and torn down on
|
|
// hangup/dispose since this effect re-runs when `joined` flips back to false.
|
|
useEffect(() => {
|
|
if (!joined) return undefined;
|
|
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
event.preventDefault();
|
|
// Legacy browsers require returnValue to be set to show the prompt.
|
|
event.returnValue = '';
|
|
};
|
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
return () => {
|
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
};
|
|
}, [joined]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* PiP status indicators:
|
|
* - Bottom-left badge: local mic muted (matches Discord/Slack convention — bottom-left = "your" mic)
|
|
* - Top-right badge: all remote participants are muted (quiet room warning)
|
|
*
|
|
* Deliberately separated so users never mistake remote-mute state for their own.
|
|
*/
|
|
function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
|
const mx = useMatrixClient();
|
|
const controlState = useCallControlState(callEmbed.control);
|
|
const allRemoteMuted = useRemoteAllMuted(callEmbed);
|
|
|
|
const localMicMuted = !controlState.microphone;
|
|
const localUserId = mx.getSafeUserId();
|
|
const localDisplayName = getMxIdLocalPart(localUserId) ?? localUserId;
|
|
|
|
// Dark translucent scrim is intentional: these badges overlay arbitrary
|
|
// video, so a theme surface token would not guarantee legibility.
|
|
const badgeStyle: React.CSSProperties = {
|
|
position: 'absolute',
|
|
zIndex: 3,
|
|
background: 'rgba(0,0,0,0.65)',
|
|
backdropFilter: 'blur(4px)',
|
|
borderRadius: config.radii.R300,
|
|
padding: `${config.space.S100} ${config.space.S200}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: config.space.S100,
|
|
pointerEvents: 'none',
|
|
lineHeight: 1,
|
|
userSelect: 'none',
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{localMicMuted && (
|
|
<div
|
|
aria-label={`Your microphone is muted (${localDisplayName})`}
|
|
title="Your microphone is muted"
|
|
style={{ ...badgeStyle, bottom: config.space.S200, left: config.space.S200 }}
|
|
>
|
|
<Icon size="100" src={Icons.MicMute} filled style={{ color: color.Critical.Main }} />
|
|
<Text as="span" size="T200" style={{ color: color.Critical.Main }}>
|
|
You
|
|
</Text>
|
|
</div>
|
|
)}
|
|
{allRemoteMuted && (
|
|
<div
|
|
aria-label="All other participants are muted"
|
|
title="All other participants are muted"
|
|
style={{ ...badgeStyle, top: config.space.S200, right: config.space.S200 }}
|
|
>
|
|
<Icon size="50" src={Icons.MicMute} style={{ color: color.Warning.Main }} />
|
|
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
|
|
All muted
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
type CallEmbedProviderProps = {
|
|
children?: ReactNode;
|
|
};
|
|
/** [Gitea #118] Heartbeat + rejoin after a crash/restart; needs the embed container mounted. */
|
|
function CallRejoin({ callEmbed, joined }: { callEmbed?: CallEmbed; joined: boolean }) {
|
|
useCallRejoin(callEmbed, joined);
|
|
return null;
|
|
}
|
|
|
|
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|
const callEmbed = useAtomValue(callEmbedAtom);
|
|
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
|
const joined = useCallJoined(callEmbed);
|
|
|
|
const selectedRoom = useSelectedRoom();
|
|
const chat = useAtomValue(callChatAtom);
|
|
const screenSize = useScreenSizeContext();
|
|
|
|
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
|
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
|
const callActive = callEmbed && joined;
|
|
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
|
const pipMode = callActive && !inCallRoom;
|
|
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
|
|
const { navigateRoom } = useRoomNavigate();
|
|
|
|
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
|
|
|
|
// C-L3 — prime the ringtone AudioContext on the first user gesture of the
|
|
// session so the first incoming-call ring isn't silent (a fresh context stays
|
|
// suspended until a gesture, and an incoming ring has none of its own).
|
|
useEffect(() => {
|
|
const prime = () => {
|
|
unlockRingtoneAudio();
|
|
window.removeEventListener('pointerdown', prime);
|
|
window.removeEventListener('keydown', prime);
|
|
};
|
|
window.addEventListener('pointerdown', prime, { once: true, passive: true });
|
|
window.addEventListener('keydown', prime, { once: true, passive: true });
|
|
return () => {
|
|
window.removeEventListener('pointerdown', prime);
|
|
window.removeEventListener('keydown', prime);
|
|
};
|
|
}, []);
|
|
|
|
// When entering pip with screenshare active (or screenshare starts while in pip),
|
|
// enable spotlight so the screenshare fills the pip window.
|
|
// When screenshare ends, release the spotlight we auto-enabled.
|
|
const pipAutoSpotlightRef = React.useRef(false);
|
|
useEffect(() => {
|
|
if (!callEmbed) {
|
|
// The embed (and its spotlight) is torn down with the call; drop the latch
|
|
// so a stale ref can't act on the next call's fresh embed.
|
|
pipAutoSpotlightRef.current = false;
|
|
return;
|
|
}
|
|
// Spotlight is wanted only while in pip with an active screenshare. Release
|
|
// it when EITHER ends — including leaving pip (returning to the call room).
|
|
// The release must not sit behind a `!pipMode` early-return, or a
|
|
// screenshare→pip→back sequence leaves the auto-enabled spotlight stuck on
|
|
// with pipAutoSpotlightRef latched true. The ref gates release so we only
|
|
// ever undo a spotlight we turned on (never one the user set).
|
|
// NB: `control.spotlight` is read below but deliberately NOT a dependency —
|
|
// this effect reacts to pip/screenshare *intent*, not to spotlight changes.
|
|
// Adding it as a dep would re-run on every manual spotlight toggle and fight
|
|
// the user.
|
|
const wantSpotlight = pipMode && pipScreenshare;
|
|
if (wantSpotlight) {
|
|
if (!callEmbed.control.spotlight) {
|
|
callEmbed.control.toggleSpotlight();
|
|
pipAutoSpotlightRef.current = true;
|
|
}
|
|
} else if (pipAutoSpotlightRef.current) {
|
|
if (callEmbed.control.spotlight) callEmbed.control.toggleSpotlight();
|
|
pipAutoSpotlightRef.current = false;
|
|
}
|
|
}, [pipMode, pipScreenshare, callEmbed]);
|
|
|
|
const theme = useTheme();
|
|
const isDark = theme.kind === ThemeKind.Dark;
|
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
|
const reduced = useReducedMotion();
|
|
const wallpaperStyle = React.useMemo(
|
|
() => getChatBg(chatBackground, isDark, reduced),
|
|
[chatBackground, isDark, reduced],
|
|
);
|
|
|
|
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
|
useEffect(() => {
|
|
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
|
document.addEventListener('fullscreenchange', onFsChange);
|
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
}, []);
|
|
|
|
const handlePipFullscreen = useCallback(() => {
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
callEmbedRef.current?.requestFullscreen();
|
|
}
|
|
}, [callEmbedRef]);
|
|
|
|
const pipDragRef = React.useRef<{
|
|
startX: number;
|
|
startY: number;
|
|
origLeft: number;
|
|
origTop: number;
|
|
dragged: boolean;
|
|
} | null>(null);
|
|
const activeDragCleanupRef = React.useRef<(() => void) | null>(null);
|
|
React.useEffect(
|
|
() => () => {
|
|
activeDragCleanupRef.current?.();
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Track previous pipMode to only reset position when entering/exiting pip
|
|
const prevPipModeRef = React.useRef(false);
|
|
|
|
React.useEffect(() => {
|
|
const el = callEmbedRef.current;
|
|
if (!el) return;
|
|
const wasInPip = prevPipModeRef.current;
|
|
prevPipModeRef.current = !!pipMode;
|
|
if (pipMode) {
|
|
if (!wasInPip) {
|
|
const saved = localStorage.getItem('pip-position');
|
|
let savedPos: { left: number; top: number } | null = null;
|
|
if (saved) {
|
|
try {
|
|
const raw = JSON.parse(saved) as { left?: unknown; top?: unknown };
|
|
// Validate shape + finiteness: a corrupt value would otherwise feed
|
|
// NaN into Math.min and produce an invalid `NaNpx` CSS value.
|
|
if (
|
|
raw &&
|
|
typeof raw.left === 'number' &&
|
|
Number.isFinite(raw.left) &&
|
|
typeof raw.top === 'number' &&
|
|
Number.isFinite(raw.top)
|
|
) {
|
|
savedPos = { left: raw.left, top: raw.top };
|
|
}
|
|
} catch {
|
|
savedPos = null;
|
|
}
|
|
}
|
|
el.style.right = 'auto';
|
|
el.style.bottom = 'auto';
|
|
if (savedPos) {
|
|
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
|
|
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
|
|
} else {
|
|
el.style.left = `${window.innerWidth - 280 - 16}px`;
|
|
el.style.top = `${window.innerHeight - 158 - 72}px`;
|
|
}
|
|
el.style.width = '280px';
|
|
el.style.height = '158px';
|
|
el.style.borderRadius = '12px';
|
|
el.style.overflow = 'hidden';
|
|
el.style.zIndex = '99';
|
|
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
|
|
el.style.border = '1px solid rgba(255,255,255,0.1)';
|
|
}
|
|
el.style.visibility = 'visible';
|
|
} else {
|
|
if (wasInPip) {
|
|
// Exiting pip: clear all pip styles; syncCallEmbedPlacement will restore correct position
|
|
el.style.top = '';
|
|
el.style.left = '';
|
|
el.style.bottom = '';
|
|
el.style.right = '';
|
|
el.style.width = '';
|
|
el.style.height = '';
|
|
el.style.borderRadius = '';
|
|
el.style.overflow = '';
|
|
el.style.zIndex = '';
|
|
el.style.boxShadow = '';
|
|
el.style.border = '';
|
|
}
|
|
// syncCallEmbedPlacement owns top/left/width/height; don't clear them on visibility changes
|
|
el.style.visibility = callVisible ? '' : 'hidden';
|
|
}
|
|
}, [pipMode, callVisible]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pipMode) return;
|
|
const onPipWindowResize = (): void => {
|
|
const el = callEmbedRef.current;
|
|
if (!el) return;
|
|
// Normalise bottom/right → top/left so clamp math works regardless of initial position.
|
|
if (!el.style.left || el.style.left === 'auto') normaliseToTopLeft(el);
|
|
const l = parseFloat(el.style.left);
|
|
const t = parseFloat(el.style.top);
|
|
if (!isNaN(l))
|
|
el.style.left = `${Math.max(0, Math.min(l, window.innerWidth - el.offsetWidth))}px`;
|
|
if (!isNaN(t))
|
|
el.style.top = `${Math.max(0, Math.min(t, window.innerHeight - el.offsetHeight))}px`;
|
|
};
|
|
window.addEventListener('resize', onPipWindowResize);
|
|
return () => window.removeEventListener('resize', onPipWindowResize);
|
|
}, [pipMode, callEmbedRef]);
|
|
|
|
const handlePipDoubleClick = (e: React.MouseEvent) => {
|
|
const el = callEmbedRef.current;
|
|
if (!el) return;
|
|
e.stopPropagation();
|
|
const margin = 16;
|
|
const w = el.offsetWidth;
|
|
const h = el.offsetHeight;
|
|
const elRect = el.getBoundingClientRect();
|
|
const cx = elRect.left + w / 2;
|
|
const cy = elRect.top + h / 2;
|
|
const snapLeft = cx < window.innerWidth / 2 ? margin : window.innerWidth - w - margin;
|
|
const snapTop = cy < window.innerHeight / 2 ? margin : window.innerHeight - h - margin;
|
|
el.style.left = `${snapLeft}px`;
|
|
el.style.top = `${snapTop}px`;
|
|
el.style.right = 'auto';
|
|
el.style.bottom = 'auto';
|
|
el.style.transition = 'left 0.18s ease, top 0.18s ease';
|
|
setTimeout(() => {
|
|
if (el) el.style.transition = '';
|
|
}, 200);
|
|
localStorage.setItem('pip-position', JSON.stringify({ left: snapLeft, top: snapTop }));
|
|
};
|
|
|
|
const handlePipMouseDown = (e: React.MouseEvent) => {
|
|
const el = callEmbedRef.current;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
pipDragRef.current = {
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
origLeft: rect.left,
|
|
origTop: rect.top,
|
|
dragged: false,
|
|
};
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!pipDragRef.current || !el) return;
|
|
const dx = ev.clientX - pipDragRef.current.startX;
|
|
const dy = ev.clientY - pipDragRef.current.startY;
|
|
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
|
|
pipDragRef.current.dragged = true;
|
|
document.body.style.cursor = 'grabbing';
|
|
document.body.style.userSelect = 'none';
|
|
}
|
|
if (pipDragRef.current.dragged) {
|
|
el.style.left = `${Math.max(
|
|
0,
|
|
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx),
|
|
)}px`;
|
|
el.style.top = `${Math.max(
|
|
0,
|
|
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy),
|
|
)}px`;
|
|
el.style.right = 'auto';
|
|
el.style.bottom = 'auto';
|
|
}
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
activeDragCleanupRef.current = null;
|
|
if (el && pipDragRef.current?.dragged) {
|
|
const savedRect = el.getBoundingClientRect();
|
|
localStorage.setItem(
|
|
'pip-position',
|
|
JSON.stringify({ left: savedRect.left, top: savedRect.top }),
|
|
);
|
|
}
|
|
setTimeout(() => {
|
|
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
|
}, 0);
|
|
};
|
|
activeDragCleanupRef.current = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
};
|
|
|
|
const handlePipTouchStart = (e: React.TouchEvent) => {
|
|
const el = callEmbedRef.current;
|
|
if (!el || e.touches.length !== 1) return;
|
|
const touch = e.touches[0];
|
|
const rect = el.getBoundingClientRect();
|
|
pipDragRef.current = {
|
|
startX: touch.clientX,
|
|
startY: touch.clientY,
|
|
origLeft: rect.left,
|
|
origTop: rect.top,
|
|
dragged: false,
|
|
};
|
|
const onTouchMove = (ev: TouchEvent) => {
|
|
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
|
|
ev.preventDefault();
|
|
const t = ev.touches[0];
|
|
const dx = t.clientX - pipDragRef.current.startX;
|
|
const dy = t.clientY - pipDragRef.current.startY;
|
|
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5)
|
|
pipDragRef.current.dragged = true;
|
|
if (pipDragRef.current.dragged) {
|
|
el.style.left = `${Math.max(
|
|
0,
|
|
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx),
|
|
)}px`;
|
|
el.style.top = `${Math.max(
|
|
0,
|
|
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy),
|
|
)}px`;
|
|
el.style.right = 'auto';
|
|
el.style.bottom = 'auto';
|
|
}
|
|
};
|
|
const onTouchEnd = () => {
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
activeDragCleanupRef.current = null;
|
|
if (el && pipDragRef.current?.dragged) {
|
|
const savedRect = el.getBoundingClientRect();
|
|
localStorage.setItem(
|
|
'pip-position',
|
|
JSON.stringify({ left: savedRect.left, top: savedRect.top }),
|
|
);
|
|
}
|
|
setTimeout(() => {
|
|
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
|
}, 0);
|
|
};
|
|
activeDragCleanupRef.current = () => {
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
};
|
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
document.addEventListener('touchend', onTouchEnd);
|
|
};
|
|
|
|
function applyResize(
|
|
el: HTMLElement,
|
|
corner: Corner,
|
|
sx: number,
|
|
sy: number,
|
|
sw: number,
|
|
sh: number,
|
|
sl: number,
|
|
st: number,
|
|
cx: number,
|
|
cy: number,
|
|
) {
|
|
const dx = cx - sx;
|
|
const dy = cy - sy;
|
|
let w = sw;
|
|
let h = sh;
|
|
let l = sl;
|
|
let t = st;
|
|
if (corner === 'se') {
|
|
w = sw + dx;
|
|
h = sh + dy;
|
|
}
|
|
if (corner === 'sw') {
|
|
w = sw - dx;
|
|
h = sh + dy;
|
|
l = sl + sw - Math.max(PIP_MIN_W, w);
|
|
}
|
|
if (corner === 'ne') {
|
|
w = sw + dx;
|
|
h = sh - dy;
|
|
t = st + sh - Math.max(PIP_MIN_H, h);
|
|
}
|
|
if (corner === 'nw') {
|
|
w = sw - dx;
|
|
h = sh - dy;
|
|
l = sl + sw - Math.max(PIP_MIN_W, w);
|
|
t = st + sh - Math.max(PIP_MIN_H, h);
|
|
}
|
|
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
|
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
|
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
|
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
|
el.style.width = `${w}px`;
|
|
el.style.height = `${h}px`;
|
|
el.style.left = `${l}px`;
|
|
el.style.top = `${t}px`;
|
|
}
|
|
|
|
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const el = callEmbedRef.current;
|
|
if (!el) return;
|
|
normaliseToTopLeft(el);
|
|
const sx = e.clientX;
|
|
const sy = e.clientY;
|
|
const sw = el.offsetWidth;
|
|
const sh = el.offsetHeight;
|
|
const sl = parseFloat(el.style.left);
|
|
const st = parseFloat(el.style.top);
|
|
document.body.style.cursor = `${corner}-resize`;
|
|
document.body.style.userSelect = 'none';
|
|
const onMove = (ev: MouseEvent) => {
|
|
applyResize(el, corner, sx, sy, sw, sh, sl, st, ev.clientX, ev.clientY);
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
activeDragCleanupRef.current = null;
|
|
};
|
|
activeDragCleanupRef.current = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
};
|
|
|
|
const handleResizeTouchStart = (e: React.TouchEvent, corner: Corner) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const el = callEmbedRef.current;
|
|
if (!el || e.touches.length !== 1) return;
|
|
normaliseToTopLeft(el);
|
|
const touch = e.touches[0];
|
|
const sx = touch.clientX;
|
|
const sy = touch.clientY;
|
|
const sw = el.offsetWidth;
|
|
const sh = el.offsetHeight;
|
|
const sl = parseFloat(el.style.left);
|
|
const st = parseFloat(el.style.top);
|
|
const onMove = (ev: TouchEvent) => {
|
|
if (ev.touches.length !== 1) return;
|
|
ev.preventDefault();
|
|
const t = ev.touches[0];
|
|
applyResize(el, corner, sx, sy, sw, sh, sl, st, t.clientX, t.clientY);
|
|
};
|
|
const onEnd = () => {
|
|
document.removeEventListener('touchmove', onMove);
|
|
document.removeEventListener('touchend', onEnd);
|
|
activeDragCleanupRef.current = null;
|
|
};
|
|
activeDragCleanupRef.current = () => {
|
|
document.removeEventListener('touchmove', onMove);
|
|
document.removeEventListener('touchend', onEnd);
|
|
};
|
|
document.addEventListener('touchmove', onMove, { passive: false });
|
|
document.addEventListener('touchend', onEnd);
|
|
};
|
|
|
|
return (
|
|
<CallEmbedContextProvider value={callEmbed}>
|
|
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
|
<CallAnnouncementRegion />
|
|
<CallEmbedRefContextProvider value={callEmbedRef}>
|
|
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
|
<CallRejoin callEmbed={callEmbed} joined={joined} />
|
|
{children}
|
|
</CallEmbedRefContextProvider>
|
|
<div
|
|
data-call-embed-container
|
|
style={{
|
|
visibility: callVisible ? undefined : 'hidden',
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '50%',
|
|
// [Gitea #224] The fork renders EC transparent (lotusTransparent) so the
|
|
// wallpaper shows through the full-size embed; in PiP the thing behind
|
|
// the frame is whatever room you navigated to, so give it an opaque
|
|
// surface instead of letting the timeline bleed through the tiles.
|
|
...(pipMode
|
|
? { background: color.Surface.Container }
|
|
: callVisible
|
|
? wallpaperStyle
|
|
: {}),
|
|
}}
|
|
ref={callEmbedRef}
|
|
>
|
|
{pipMode && callEmbed && (
|
|
<>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="Return to call"
|
|
onMouseDown={handlePipMouseDown}
|
|
onTouchStart={handlePipTouchStart}
|
|
onDoubleClick={handlePipDoubleClick}
|
|
onClick={() => {
|
|
if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId);
|
|
}}
|
|
onKeyDown={(e) => e.key === 'Enter' && navigateRoom(callEmbed.roomId)}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
zIndex: 1,
|
|
background: 'transparent',
|
|
cursor: 'grab',
|
|
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
|
// inside the frame: let the click reach its Share button.
|
|
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
justifyContent: 'flex-end',
|
|
padding: '6px',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
|
{document.fullscreenEnabled && (
|
|
<IconButton
|
|
type="button"
|
|
size="300"
|
|
radii="300"
|
|
variant="Surface"
|
|
fill="None"
|
|
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handlePipFullscreen();
|
|
}}
|
|
style={{
|
|
// Dark scrim is intentional for legibility over arbitrary video.
|
|
background: 'rgba(0,0,0,0.65)',
|
|
backdropFilter: 'blur(4px)',
|
|
color: '#fff',
|
|
}}
|
|
>
|
|
{pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
|
</IconButton>
|
|
)}
|
|
<div
|
|
style={{
|
|
background: 'rgba(0,0,0,0.65)',
|
|
backdropFilter: 'blur(4px)',
|
|
borderRadius: config.radii.R300,
|
|
padding: `${config.space.S100} ${config.space.S200}`,
|
|
color: '#fff',
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
↗ Return to call
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<PipMuteOverlay callEmbed={callEmbed} />
|
|
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
|
const s = corner.includes('s');
|
|
const e2 = corner.includes('e');
|
|
const dots = [
|
|
[3, 3],
|
|
[3, 10],
|
|
[10, 3],
|
|
].map(([a, b]) => ({
|
|
position: 'absolute' as const,
|
|
width: 5,
|
|
height: 5,
|
|
borderRadius: '50%',
|
|
background: 'rgba(255,255,255,0.65)',
|
|
boxShadow: '0 0 3px rgba(0,0,0,0.4)',
|
|
[s ? 'bottom' : 'top']: a,
|
|
[e2 ? 'right' : 'left']: b,
|
|
}));
|
|
return (
|
|
<div
|
|
key={corner}
|
|
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
|
|
onTouchStart={(ev) => handleResizeTouchStart(ev, corner)}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
style={{
|
|
position: 'absolute',
|
|
width: '24px',
|
|
height: '24px',
|
|
[s ? 'bottom' : 'top']: 0,
|
|
[e2 ? 'right' : 'left']: 0,
|
|
cursor: `${corner}-resize`,
|
|
zIndex: 2,
|
|
}}
|
|
>
|
|
{dots.map((style, i) => (
|
|
<div key={i} style={style} />
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
</CallEmbedContextProvider>
|
|
);
|
|
}
|