2026-05-14 19:41:12 +10:00
|
|
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
2026-03-07 18:03:32 +11:00
|
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
2026-05-14 19:41:12 +10:00
|
|
|
import FocusTrap from 'focus-trap-react';
|
|
|
|
|
import {
|
|
|
|
|
Avatar,
|
|
|
|
|
Box,
|
|
|
|
|
Button,
|
|
|
|
|
color,
|
|
|
|
|
config,
|
|
|
|
|
Dialog,
|
|
|
|
|
Icon,
|
|
|
|
|
Icons,
|
|
|
|
|
Overlay,
|
|
|
|
|
OverlayBackdrop,
|
|
|
|
|
OverlayCenter,
|
|
|
|
|
Text,
|
|
|
|
|
toRem,
|
|
|
|
|
} from 'folds';
|
|
|
|
|
import {
|
|
|
|
|
EventTimelineSetHandlerMap,
|
|
|
|
|
EventType,
|
|
|
|
|
RelationType,
|
|
|
|
|
Room,
|
|
|
|
|
RoomEvent,
|
|
|
|
|
} from 'matrix-js-sdk';
|
|
|
|
|
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
|
|
|
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
2026-03-07 18:03:32 +11:00
|
|
|
import {
|
|
|
|
|
CallEmbedContextProvider,
|
|
|
|
|
CallEmbedRefContextProvider,
|
|
|
|
|
useCallHangupEvent,
|
|
|
|
|
useCallJoined,
|
|
|
|
|
useCallThemeSync,
|
2026-03-08 14:22:11 +11:00
|
|
|
useCallMemberSoundSync,
|
2026-05-14 19:41:12 +10:00
|
|
|
useCallStart,
|
2026-03-07 18:03:32 +11:00
|
|
|
} from '../hooks/useCallEmbed';
|
|
|
|
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
2026-05-24 23:16:43 -04:00
|
|
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
2026-03-07 18:03:32 +11:00
|
|
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
|
|
|
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
2026-05-14 19:41:12 +10:00
|
|
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
|
|
|
import CallSound from '../../../public/sound/call.ogg';
|
|
|
|
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
2026-06-03 22:13:22 -04:00
|
|
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
2026-05-14 19:41:12 +10:00
|
|
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
|
|
|
|
import { mDirectAtom } from '../state/mDirectList';
|
|
|
|
|
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
2026-05-22 12:08:50 -04:00
|
|
|
import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
|
2026-05-14 19:41:12 +10:00
|
|
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
2026-05-14 22:50:20 -04:00
|
|
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
2026-05-23 00:28:37 -04:00
|
|
|
import { getChatBg } from '../features/lotus/chatBackground';
|
|
|
|
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
|
|
|
|
import { useSetting } from '../state/hooks/settings';
|
|
|
|
|
import { settingsAtom } from '../state/settings';
|
2026-05-16 01:34:20 -04:00
|
|
|
import { getStateEvent, getMemberDisplayName } from '../utils/room';
|
2026-05-14 19:41:12 +10:00
|
|
|
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 { CallAvatarAnimation } from '../styles/Animations.css';
|
|
|
|
|
import { webRTCSupported } from '../utils/rtc';
|
2026-03-07 18:03:32 +11:00
|
|
|
|
2026-05-14 23:07:29 -04:00
|
|
|
const PIP_MIN_W = 200;
|
2026-05-15 13:41:38 -04:00
|
|
|
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`;
|
2026-05-15 14:13:41 -04:00
|
|
|
el.style.width = `${rect.width}px`;
|
|
|
|
|
el.style.height = `${rect.height}px`;
|
2026-05-15 13:41:38 -04:00
|
|
|
el.style.right = 'auto';
|
|
|
|
|
el.style.bottom = 'auto';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:41:12 +10:00
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
|
|
|
|
|
|
|
const roomName = useRoomName(room);
|
|
|
|
|
const roomAvatar = useRoomAvatar(room, dm);
|
|
|
|
|
const avatarUrl = roomAvatar
|
2026-05-21 23:30:50 -04:00
|
|
|
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
2026-05-14 19:41:12 +10:00
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
const session = useCallSession(room);
|
|
|
|
|
useCallMembersChange(
|
|
|
|
|
session,
|
2026-05-23 17:20:41 +05:30
|
|
|
useCallback(
|
|
|
|
|
(members) => {
|
|
|
|
|
if (members.length === 0) {
|
|
|
|
|
onIgnore();
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-23 11:26:45 -04:00
|
|
|
[onIgnore],
|
|
|
|
|
),
|
2026-05-14 19:41:12 +10:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const playSound = useCallback(() => {
|
|
|
|
|
const audioElement = audioRef.current;
|
2026-05-16 01:34:20 -04:00
|
|
|
audioElement?.play().catch(() => undefined);
|
2026-05-14 19:41:12 +10:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-22 17:17:26 -04:00
|
|
|
const audioEl = audioRef.current;
|
2026-05-14 19:41:12 +10:00
|
|
|
if (info.notificationType === 'ring') {
|
|
|
|
|
playSound();
|
|
|
|
|
}
|
2026-05-16 01:34:20 -04:00
|
|
|
return () => {
|
2026-05-22 17:17:26 -04:00
|
|
|
if (audioEl) {
|
|
|
|
|
audioEl.pause();
|
|
|
|
|
audioEl.currentTime = 0;
|
2026-05-16 01:34:20 -04:00
|
|
|
}
|
|
|
|
|
};
|
2026-05-14 19:41:12 +10:00
|
|
|
}, [playSound, info.notificationType]);
|
|
|
|
|
|
2026-05-15 16:00:17 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
2026-05-21 20:49:33 -04:00
|
|
|
if (remaining <= 0) {
|
|
|
|
|
onIgnore();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-15 16:00:17 -04:00
|
|
|
const id = setTimeout(onIgnore, remaining);
|
|
|
|
|
return () => clearTimeout(id);
|
|
|
|
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
|
|
|
|
|
2026-05-14 19:41:12 +10:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
|
|
|
<OverlayCenter>
|
|
|
|
|
<FocusTrap
|
|
|
|
|
focusTrapOptions={{
|
|
|
|
|
initialFocus: false,
|
|
|
|
|
onDeactivate: () => onIgnore(),
|
|
|
|
|
clickOutsideDeactivates: false,
|
|
|
|
|
escapeDeactivates: false,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Dialog style={{ maxWidth: toRem(324) }}>
|
|
|
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
|
|
|
|
<Text size="T200" align="Center">
|
2026-05-21 20:49:33 -04:00
|
|
|
{getMemberDisplayName(info.room, info.sender) ??
|
|
|
|
|
getMxIdLocalPart(info.sender) ??
|
|
|
|
|
info.sender}
|
2026-05-14 19:41:12 +10:00
|
|
|
</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">
|
|
|
|
|
<Text size="H3" align="Center" truncate>
|
|
|
|
|
{roomName}
|
|
|
|
|
</Text>
|
2026-05-23 22:51:56 -04:00
|
|
|
<Text size="T300">
|
|
|
|
|
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
|
|
|
|
</Text>
|
2026-05-14 19:41:12 +10:00
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
{!livekitSupported && (
|
|
|
|
|
<Text
|
|
|
|
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
|
|
|
|
size="L400"
|
|
|
|
|
align="Center"
|
|
|
|
|
>
|
|
|
|
|
Your homeserver does not support calling.
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
2026-05-15 14:56:30 -04:00
|
|
|
{!webRTCSupported() && (
|
2026-05-14 19:41:12 +10:00
|
|
|
<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">
|
|
|
|
|
<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">
|
|
|
|
|
Answer
|
|
|
|
|
</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
style={{ flexGrow: 1 }}
|
2026-05-24 00:34:55 -04:00
|
|
|
variant={dm ? 'Critical' : 'Secondary'}
|
2026-05-14 19:41:12 +10:00
|
|
|
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>
|
|
|
|
|
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
|
|
|
|
<source src={CallSound} type="audio/ogg" />
|
|
|
|
|
</audio>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type IncomingCallListenerProps = {
|
|
|
|
|
callEmbed?: CallEmbed;
|
|
|
|
|
joined?: boolean;
|
|
|
|
|
};
|
|
|
|
|
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
|
|
|
|
|
const mx = useMatrixClient();
|
|
|
|
|
const directs = useAtomValue(mDirectAtom);
|
|
|
|
|
const { navigateRoom } = useRoomNavigate();
|
|
|
|
|
|
|
|
|
|
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
|
|
|
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
|
|
|
|
const startCall = useCallStart(dm);
|
|
|
|
|
|
|
|
|
|
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 (event.isEncrypted()) {
|
|
|
|
|
if (!event.isBeingDecrypted()) {
|
|
|
|
|
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
|
|
|
|
|
}
|
|
|
|
|
await event.getDecryptionPromise();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!room ||
|
|
|
|
|
event.getType() !== EventType.RTCNotification ||
|
|
|
|
|
event.getSender() === mx.getSafeUserId() ||
|
|
|
|
|
!data.liveEvent
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sender = event.getSender();
|
|
|
|
|
const content = event.getContent<IRTCNotificationContent>();
|
|
|
|
|
const senderTs =
|
|
|
|
|
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
|
|
|
|
|
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 =
|
2026-05-21 20:49:33 -04:00
|
|
|
content['m.mentions']?.room ||
|
|
|
|
|
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
|
2026-05-14 19:41:12 +10:00
|
|
|
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,
|
2026-05-21 23:30:50 -04:00
|
|
|
mx.getSafeUserId(),
|
2026-05-14 19:41:12 +10:00
|
|
|
);
|
|
|
|
|
if (!hasCallPermission) 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);
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
[mx],
|
2026-05-14 19:41:12 +10:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
|
|
|
|
return () => {
|
|
|
|
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
|
|
|
|
};
|
|
|
|
|
}, [mx, handleTimelineEvent]);
|
|
|
|
|
|
|
|
|
|
const handleIgnore = useCallback(() => {
|
|
|
|
|
setCallInfo(undefined);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleReject = useCallback(
|
|
|
|
|
(room: Room, eventId: string) => {
|
|
|
|
|
mx.sendEvent(room.roomId, EventType.RTCDecline, {
|
|
|
|
|
'm.relates_to': {
|
|
|
|
|
rel_type: RelationType.Reference,
|
|
|
|
|
event_id: eventId,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
setCallInfo(undefined);
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
[mx],
|
2026-05-14 19:41:12 +10:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleAnswer = useCallback(
|
|
|
|
|
(room: Room, video: boolean) => {
|
|
|
|
|
startCall(room, { microphone: true, video, sound: true });
|
|
|
|
|
setCallInfo(undefined);
|
|
|
|
|
navigateRoom(room.roomId);
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
[startCall, navigateRoom],
|
2026-05-14 19:41:12 +10:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return !joined && callInfo ? (
|
|
|
|
|
<IncomingCall
|
|
|
|
|
dm={dm}
|
|
|
|
|
info={callInfo}
|
|
|
|
|
onIgnore={handleIgnore}
|
|
|
|
|
onAnswer={handleAnswer}
|
|
|
|
|
onReject={handleReject}
|
|
|
|
|
/>
|
|
|
|
|
) : null;
|
|
|
|
|
}
|
2026-05-14 23:07:29 -04:00
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
|
|
|
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
|
|
|
|
|
2026-03-08 14:22:11 +11:00
|
|
|
useCallMemberSoundSync(embed);
|
2026-03-07 18:03:32 +11:00
|
|
|
useCallThemeSync(embed);
|
|
|
|
|
useCallHangupEvent(
|
|
|
|
|
embed,
|
|
|
|
|
useCallback(() => {
|
|
|
|
|
setCallEmbed(undefined);
|
2026-05-21 23:30:50 -04:00
|
|
|
}, [setCallEmbed]),
|
2026-03-07 18:03:32 +11:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 22:13:22 -04:00
|
|
|
/** Shown inside the PiP window when the local microphone is muted. */
|
|
|
|
|
function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
|
|
|
|
const allMuted = useRemoteAllMuted(callEmbed);
|
|
|
|
|
if (!allMuted) return null;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
aria-label="Microphone muted"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: '8px',
|
|
|
|
|
left: '8px',
|
|
|
|
|
zIndex: 3,
|
|
|
|
|
background: 'rgba(0,0,0,0.60)',
|
|
|
|
|
backdropFilter: 'blur(4px)',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
padding: '3px 7px',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '4px',
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
color: color.Critical.Main,
|
|
|
|
|
fontSize: '13px',
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
userSelect: 'none',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon size="100" src={Icons.MicMute} filled />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
type CallEmbedProviderProps = {
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
};
|
|
|
|
|
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|
|
|
|
const callEmbed = useAtomValue(callEmbedAtom);
|
2026-05-22 13:24:07 -04:00
|
|
|
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
2026-03-07 18:03:32 +11:00
|
|
|
const joined = useCallJoined(callEmbed);
|
|
|
|
|
|
|
|
|
|
const selectedRoom = useSelectedRoom();
|
|
|
|
|
const chat = useAtomValue(callChatAtom);
|
|
|
|
|
const screenSize = useScreenSizeContext();
|
|
|
|
|
|
|
|
|
|
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
|
2026-05-14 22:50:20 -04:00
|
|
|
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
|
|
|
|
const callActive = callEmbed && joined;
|
|
|
|
|
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
|
|
|
|
const pipMode = callActive && !inCallRoom;
|
2026-05-15 13:41:38 -04:00
|
|
|
const { navigateRoom } = useRoomNavigate();
|
2026-03-07 18:03:32 +11:00
|
|
|
|
2026-05-24 23:16:43 -04:00
|
|
|
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
|
|
|
|
|
|
|
|
|
|
// Sync pip mode into CallControl so it can adjust behavior accordingly
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!callEmbed) return;
|
|
|
|
|
callEmbed.control.setPipMode(!!pipMode);
|
|
|
|
|
}, [pipMode, callEmbed]);
|
|
|
|
|
|
|
|
|
|
// When entering pip with screenshare active (or screenshare starts while in pip),
|
2026-05-30 17:13:54 -04:00
|
|
|
// enable spotlight so the screenshare fills the pip window.
|
|
|
|
|
// When screenshare ends, release the spotlight we auto-enabled.
|
|
|
|
|
const pipAutoSpotlightRef = React.useRef(false);
|
2026-05-24 23:16:43 -04:00
|
|
|
useEffect(() => {
|
2026-05-30 17:13:54 -04:00
|
|
|
if (!pipMode || !callEmbed) return;
|
|
|
|
|
if (pipScreenshare) {
|
|
|
|
|
if (!callEmbed.control.spotlight) {
|
|
|
|
|
callEmbed.control.toggleSpotlight();
|
|
|
|
|
pipAutoSpotlightRef.current = true;
|
|
|
|
|
}
|
|
|
|
|
} else if (pipAutoSpotlightRef.current) {
|
|
|
|
|
if (callEmbed.control.spotlight) callEmbed.control.toggleSpotlight();
|
|
|
|
|
pipAutoSpotlightRef.current = false;
|
2026-05-24 23:16:43 -04:00
|
|
|
}
|
|
|
|
|
}, [pipMode, pipScreenshare, callEmbed]);
|
|
|
|
|
|
2026-05-23 00:28:37 -04:00
|
|
|
const theme = useTheme();
|
|
|
|
|
const isDark = theme.kind === ThemeKind.Dark;
|
|
|
|
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
|
|
|
|
const wallpaperStyle = React.useMemo(
|
|
|
|
|
() => getChatBg(chatBackground, isDark),
|
|
|
|
|
[chatBackground, isDark],
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-15 13:41:38 -04:00
|
|
|
const pipDragRef = React.useRef<{
|
2026-05-21 20:49:33 -04:00
|
|
|
startX: number;
|
|
|
|
|
startY: number;
|
|
|
|
|
origLeft: number;
|
|
|
|
|
origTop: number;
|
|
|
|
|
dragged: boolean;
|
2026-05-14 22:50:20 -04:00
|
|
|
} | null>(null);
|
2026-05-15 15:56:43 -04:00
|
|
|
const activeDragCleanupRef = React.useRef<(() => void) | null>(null);
|
2026-05-21 20:49:33 -04:00
|
|
|
React.useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
activeDragCleanupRef.current?.();
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
[],
|
2026-05-21 20:49:33 -04:00
|
|
|
);
|
2026-05-14 22:50:20 -04:00
|
|
|
|
2026-05-22 22:48:39 -04:00
|
|
|
// Track previous pipMode to only reset position when entering/exiting pip
|
2026-05-19 16:45:02 -04:00
|
|
|
const prevPipModeRef = React.useRef(false);
|
2026-05-22 22:48:39 -04:00
|
|
|
|
2026-05-15 13:41:38 -04:00
|
|
|
React.useEffect(() => {
|
2026-05-14 22:50:20 -04:00
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
2026-05-22 22:48:39 -04:00
|
|
|
const wasInPip = prevPipModeRef.current;
|
|
|
|
|
prevPipModeRef.current = !!pipMode;
|
2026-05-14 22:50:20 -04:00
|
|
|
if (pipMode) {
|
2026-05-22 22:48:39 -04:00
|
|
|
if (!wasInPip) {
|
2026-05-30 17:13:54 -04:00
|
|
|
const saved = localStorage.getItem('pip-position');
|
|
|
|
|
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : 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`;
|
|
|
|
|
}
|
2026-05-21 20:49:33 -04:00
|
|
|
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)';
|
2026-05-19 16:45:02 -04:00
|
|
|
el.style.border = '1px solid rgba(255,255,255,0.1)';
|
|
|
|
|
}
|
|
|
|
|
el.style.visibility = 'visible';
|
2026-05-14 22:50:20 -04:00
|
|
|
} else {
|
2026-05-22 22:48:39 -04:00
|
|
|
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
|
2026-05-14 22:50:20 -04:00
|
|
|
el.style.visibility = callVisible ? '' : 'hidden';
|
|
|
|
|
}
|
|
|
|
|
}, [pipMode, callVisible]);
|
|
|
|
|
|
2026-05-19 16:26:25 -04:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!pipMode) return;
|
|
|
|
|
const onPipWindowResize = (): void => {
|
|
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
2026-05-23 22:51:56 -04:00
|
|
|
// Normalise bottom/right → top/left so clamp math works regardless of initial position.
|
|
|
|
|
if (!el.style.left || el.style.left === 'auto') normaliseToTopLeft(el);
|
2026-05-19 16:26:25 -04:00
|
|
|
const l = parseFloat(el.style.left);
|
|
|
|
|
const t = parseFloat(el.style.top);
|
2026-05-21 20:49:33 -04:00
|
|
|
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`;
|
2026-05-19 16:26:25 -04:00
|
|
|
};
|
|
|
|
|
window.addEventListener('resize', onPipWindowResize);
|
|
|
|
|
return () => window.removeEventListener('resize', onPipWindowResize);
|
|
|
|
|
}, [pipMode, callEmbedRef]);
|
|
|
|
|
|
2026-05-30 17:13:54 -04:00
|
|
|
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;
|
2026-05-30 17:31:38 -04:00
|
|
|
const elRect = el.getBoundingClientRect();
|
|
|
|
|
const cx = elRect.left + w / 2;
|
|
|
|
|
const cy = elRect.top + h / 2;
|
2026-05-30 17:13:54 -04:00
|
|
|
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 }));
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 22:50:20 -04:00
|
|
|
const handlePipMouseDown = (e: React.MouseEvent) => {
|
2026-05-21 20:49:33 -04:00
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
2026-05-14 22:50:20 -04:00
|
|
|
const rect = el.getBoundingClientRect();
|
2026-05-21 20:49:33 -04:00
|
|
|
pipDragRef.current = {
|
|
|
|
|
startX: e.clientX,
|
|
|
|
|
startY: e.clientY,
|
|
|
|
|
origLeft: rect.left,
|
|
|
|
|
origTop: rect.top,
|
|
|
|
|
dragged: false,
|
|
|
|
|
};
|
2026-05-15 13:41:38 -04:00
|
|
|
const onMove = (ev: MouseEvent) => {
|
2026-05-14 22:50:20 -04:00
|
|
|
if (!pipDragRef.current || !el) return;
|
2026-05-22 12:08:50 -04:00
|
|
|
const dx = ev.clientX - pipDragRef.current.startX;
|
|
|
|
|
const dy = ev.clientY - pipDragRef.current.startY;
|
2026-05-21 20:49:33 -04:00
|
|
|
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';
|
|
|
|
|
}
|
2026-05-14 22:50:20 -04:00
|
|
|
if (pipDragRef.current.dragged) {
|
2026-05-21 20:49:33 -04:00
|
|
|
el.style.left = `${Math.max(
|
|
|
|
|
0,
|
2026-05-21 23:30:50 -04:00
|
|
|
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx),
|
2026-05-21 20:49:33 -04:00
|
|
|
)}px`;
|
|
|
|
|
el.style.top = `${Math.max(
|
|
|
|
|
0,
|
2026-05-21 23:30:50 -04:00
|
|
|
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy),
|
2026-05-21 20:49:33 -04:00
|
|
|
)}px`;
|
|
|
|
|
el.style.right = 'auto';
|
|
|
|
|
el.style.bottom = 'auto';
|
2026-05-14 22:50:20 -04:00
|
|
|
}
|
|
|
|
|
};
|
2026-05-21 20:49:33 -04:00
|
|
|
const onUp = () => {
|
|
|
|
|
document.removeEventListener('mousemove', onMove);
|
|
|
|
|
document.removeEventListener('mouseup', onUp);
|
|
|
|
|
document.body.style.cursor = '';
|
|
|
|
|
document.body.style.userSelect = '';
|
|
|
|
|
activeDragCleanupRef.current = null;
|
2026-05-30 17:13:54 -04:00
|
|
|
if (el && pipDragRef.current?.dragged) {
|
2026-05-30 17:31:38 -04:00
|
|
|
const savedRect = el.getBoundingClientRect();
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
'pip-position',
|
|
|
|
|
JSON.stringify({ left: savedRect.left, top: savedRect.top }),
|
|
|
|
|
);
|
2026-05-30 17:13:54 -04:00
|
|
|
}
|
2026-05-21 20:49:33 -04:00
|
|
|
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);
|
2026-05-14 22:50:20 -04:00
|
|
|
};
|
|
|
|
|
|
2026-05-15 14:13:41 -04:00
|
|
|
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();
|
2026-05-21 20:49:33 -04:00
|
|
|
pipDragRef.current = {
|
|
|
|
|
startX: touch.clientX,
|
|
|
|
|
startY: touch.clientY,
|
|
|
|
|
origLeft: rect.left,
|
|
|
|
|
origTop: rect.top,
|
|
|
|
|
dragged: false,
|
|
|
|
|
};
|
2026-05-15 14:13:41 -04:00
|
|
|
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;
|
2026-05-21 20:49:33 -04:00
|
|
|
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5)
|
|
|
|
|
pipDragRef.current.dragged = true;
|
2026-05-15 14:13:41 -04:00
|
|
|
if (pipDragRef.current.dragged) {
|
2026-05-21 20:49:33 -04:00
|
|
|
el.style.left = `${Math.max(
|
|
|
|
|
0,
|
2026-05-21 23:30:50 -04:00
|
|
|
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx),
|
2026-05-21 20:49:33 -04:00
|
|
|
)}px`;
|
|
|
|
|
el.style.top = `${Math.max(
|
|
|
|
|
0,
|
2026-05-21 23:30:50 -04:00
|
|
|
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy),
|
2026-05-21 20:49:33 -04:00
|
|
|
)}px`;
|
|
|
|
|
el.style.right = 'auto';
|
|
|
|
|
el.style.bottom = 'auto';
|
2026-05-15 14:13:41 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const onTouchEnd = () => {
|
|
|
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
|
|
|
document.removeEventListener('touchend', onTouchEnd);
|
2026-05-15 15:56:43 -04:00
|
|
|
activeDragCleanupRef.current = null;
|
2026-05-30 17:13:54 -04:00
|
|
|
if (el && pipDragRef.current?.dragged) {
|
2026-05-30 17:31:38 -04:00
|
|
|
const savedRect = el.getBoundingClientRect();
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
'pip-position',
|
|
|
|
|
JSON.stringify({ left: savedRect.left, top: savedRect.top }),
|
|
|
|
|
);
|
2026-05-30 17:13:54 -04:00
|
|
|
}
|
2026-05-21 20:49:33 -04:00
|
|
|
setTimeout(() => {
|
|
|
|
|
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
|
|
|
|
}, 0);
|
|
|
|
|
};
|
|
|
|
|
activeDragCleanupRef.current = () => {
|
|
|
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
|
|
|
document.removeEventListener('touchend', onTouchEnd);
|
2026-05-15 14:13:41 -04:00
|
|
|
};
|
|
|
|
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
|
|
|
document.addEventListener('touchend', onTouchEnd);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 23:07:29 -04:00
|
|
|
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
2026-05-21 20:49:33 -04:00
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
2026-05-14 23:07:29 -04:00
|
|
|
normaliseToTopLeft(el);
|
2026-05-22 12:08:50 -04:00
|
|
|
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);
|
2026-05-21 20:49:33 -04:00
|
|
|
document.body.style.cursor = `${corner}-resize`;
|
|
|
|
|
document.body.style.userSelect = 'none';
|
2026-05-15 13:41:38 -04:00
|
|
|
const onMove = (ev: MouseEvent) => {
|
2026-05-22 12:08:50 -04:00
|
|
|
const dx = ev.clientX - sx;
|
|
|
|
|
const dy = ev.clientY - sy;
|
|
|
|
|
let w = sw;
|
|
|
|
|
let h = sh;
|
|
|
|
|
let l = sl;
|
|
|
|
|
let t = st;
|
2026-05-21 20:49:33 -04:00
|
|
|
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 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 = '';
|
2026-05-14 23:07:29 -04:00
|
|
|
};
|
2026-05-21 20:49:33 -04:00
|
|
|
document.addEventListener('mousemove', onMove);
|
|
|
|
|
document.addEventListener('mouseup', onUp);
|
2026-05-14 23:07:29 -04:00
|
|
|
};
|
|
|
|
|
|
2026-03-07 18:03:32 +11:00
|
|
|
return (
|
|
|
|
|
<CallEmbedContextProvider value={callEmbed}>
|
|
|
|
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
2026-05-14 19:41:12 +10:00
|
|
|
<CallEmbedRefContextProvider value={callEmbedRef}>
|
|
|
|
|
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
|
|
|
|
{children}
|
|
|
|
|
</CallEmbedRefContextProvider>
|
2026-03-07 18:03:32 +11:00
|
|
|
<div
|
|
|
|
|
data-call-embed-container
|
|
|
|
|
style={{
|
|
|
|
|
visibility: callVisible ? undefined : 'hidden',
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '50%',
|
2026-05-23 00:28:37 -04:00
|
|
|
...(callVisible && !pipMode ? wallpaperStyle : {}),
|
2026-03-07 18:03:32 +11:00
|
|
|
}}
|
|
|
|
|
ref={callEmbedRef}
|
2026-05-14 22:50:20 -04:00
|
|
|
>
|
|
|
|
|
{pipMode && callEmbed && (
|
2026-05-14 23:07:29 -04:00
|
|
|
<>
|
2026-05-14 22:50:20 -04:00
|
|
|
<div
|
2026-05-14 23:07:29 -04:00
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-label="Return to call"
|
|
|
|
|
onMouseDown={handlePipMouseDown}
|
2026-05-15 14:13:41 -04:00
|
|
|
onTouchStart={handlePipTouchStart}
|
2026-05-30 17:13:54 -04:00
|
|
|
onDoubleClick={handlePipDoubleClick}
|
2026-05-21 20:49:33 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId);
|
|
|
|
|
}}
|
2026-05-15 13:41:38 -04:00
|
|
|
onKeyDown={(e) => e.key === 'Enter' && navigateRoom(callEmbed.roomId)}
|
2026-05-21 20:49:33 -04:00
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
inset: 0,
|
|
|
|
|
zIndex: 1,
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
cursor: 'grab',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
justifyContent: 'flex-end',
|
|
|
|
|
padding: '6px',
|
|
|
|
|
}}
|
2026-05-14 22:50:20 -04:00
|
|
|
>
|
2026-05-21 20:49:33 -04:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
background: 'rgba(0,0,0,0.65)',
|
|
|
|
|
backdropFilter: 'blur(4px)',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
padding: '3px 8px',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-05-14 23:07:29 -04:00
|
|
|
↗ Return to call
|
|
|
|
|
</div>
|
2026-05-14 22:50:20 -04:00
|
|
|
</div>
|
2026-06-03 22:13:22 -04:00
|
|
|
<PipMuteOverlay callEmbed={callEmbed} />
|
2026-05-21 20:49:33 -04:00
|
|
|
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
|
|
|
|
const s = corner.includes('s');
|
|
|
|
|
const e2 = corner.includes('e');
|
|
|
|
|
const dots = [
|
2026-05-23 23:52:58 -04:00
|
|
|
[3, 3],
|
|
|
|
|
[3, 10],
|
|
|
|
|
[10, 3],
|
2026-05-21 20:49:33 -04:00
|
|
|
].map(([a, b]) => ({
|
|
|
|
|
position: 'absolute' as const,
|
2026-05-23 23:52:58 -04:00
|
|
|
width: 5,
|
|
|
|
|
height: 5,
|
2026-05-21 20:49:33 -04:00
|
|
|
borderRadius: '50%',
|
2026-05-23 23:52:58 -04:00
|
|
|
background: 'rgba(255,255,255,0.65)',
|
|
|
|
|
boxShadow: '0 0 3px rgba(0,0,0,0.4)',
|
2026-05-21 20:49:33 -04:00
|
|
|
[s ? 'bottom' : 'top']: a,
|
|
|
|
|
[e2 ? 'right' : 'left']: b,
|
2026-05-15 14:13:41 -04:00
|
|
|
}));
|
|
|
|
|
return (
|
2026-05-21 20:49:33 -04:00
|
|
|
<div
|
|
|
|
|
key={corner}
|
|
|
|
|
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
|
|
|
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
2026-05-23 23:52:58 -04:00
|
|
|
width: '24px',
|
|
|
|
|
height: '24px',
|
2026-05-21 20:49:33 -04:00
|
|
|
[s ? 'bottom' : 'top']: 0,
|
|
|
|
|
[e2 ? 'right' : 'left']: 0,
|
|
|
|
|
cursor: `${corner}-resize`,
|
|
|
|
|
zIndex: 2,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{dots.map((style, i) => (
|
|
|
|
|
<div key={i} style={style} />
|
|
|
|
|
))}
|
2026-05-15 14:13:41 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-05-14 23:07:29 -04:00
|
|
|
</>
|
2026-05-14 22:50:20 -04:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-07 18:03:32 +11:00
|
|
|
</CallEmbedContextProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|