2026-05-14 19:41:12 +10:00
|
|
|
/* eslint-disable jsx-a11y/media-has-caption */
|
|
|
|
|
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 { MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
|
|
|
|
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';
|
|
|
|
|
import { CallEmbed } from '../plugins/call';
|
|
|
|
|
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';
|
|
|
|
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
|
|
|
|
import { mDirectAtom } from '../state/mDirectList';
|
|
|
|
|
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
|
|
|
|
import { mxcUrlToHttp } from '../utils/matrix';
|
|
|
|
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
2026-05-14 22:50:20 -04:00
|
|
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
2026-05-14 19:41:12 +10:00
|
|
|
import { getStateEvent } 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 { 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
|
|
|
|
|
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
const session = useCallSession(room);
|
|
|
|
|
useCallMembersChange(
|
|
|
|
|
session,
|
|
|
|
|
useCallback(() => {
|
|
|
|
|
const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription);
|
|
|
|
|
if (members.length === 0) {
|
|
|
|
|
onIgnore();
|
|
|
|
|
}
|
|
|
|
|
}, [room, session, onIgnore])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const playSound = useCallback(() => {
|
|
|
|
|
const audioElement = audioRef.current;
|
|
|
|
|
audioElement?.play();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (info.notificationType === 'ring') {
|
|
|
|
|
playSound();
|
|
|
|
|
}
|
|
|
|
|
}, [playSound, info.notificationType]);
|
|
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
{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">
|
|
|
|
|
<Text size="H3" align="Center" truncate>
|
|
|
|
|
{roomName}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text size="T300">Incoming Call</Text>
|
|
|
|
|
</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 }}
|
|
|
|
|
variant="Success"
|
|
|
|
|
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 =
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
[mx]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleAnswer = useCallback(
|
|
|
|
|
(room: Room, video: boolean) => {
|
|
|
|
|
startCall(room, { microphone: true, video, sound: true });
|
|
|
|
|
setCallInfo(undefined);
|
|
|
|
|
navigateRoom(room.roomId);
|
|
|
|
|
},
|
|
|
|
|
[startCall, navigateRoom]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}, [setCallEmbed])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CallEmbedProviderProps = {
|
|
|
|
|
children?: ReactNode;
|
|
|
|
|
};
|
|
|
|
|
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|
|
|
|
const callEmbed = useAtomValue(callEmbedAtom);
|
|
|
|
|
const callEmbedRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
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-15 13:41:38 -04:00
|
|
|
const pipDragRef = React.useRef<{
|
|
|
|
|
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
|
2026-05-14 22:50:20 -04:00
|
|
|
} | null>(null);
|
|
|
|
|
|
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;
|
|
|
|
|
if (pipMode) {
|
2026-05-15 13:41:38 -04:00
|
|
|
el.style.top = 'auto'; el.style.left = 'auto';
|
|
|
|
|
el.style.bottom = '72px'; el.style.right = '16px';
|
|
|
|
|
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';
|
2026-05-14 22:50:20 -04:00
|
|
|
} else {
|
2026-05-15 13:41:38 -04:00
|
|
|
['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; });
|
2026-05-14 22:50:20 -04:00
|
|
|
el.style.visibility = callVisible ? '' : 'hidden';
|
|
|
|
|
}
|
|
|
|
|
}, [pipMode, callVisible]);
|
|
|
|
|
|
|
|
|
|
const handlePipMouseDown = (e: React.MouseEvent) => {
|
2026-05-15 13:41:38 -04:00
|
|
|
const el = callEmbedRef.current; if (!el) return;
|
2026-05-14 22:50:20 -04:00
|
|
|
const rect = el.getBoundingClientRect();
|
2026-05-15 13:41:38 -04:00
|
|
|
pipDragRef.current = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
|
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
2026-05-14 22:50:20 -04:00
|
|
|
if (!pipDragRef.current || !el) return;
|
2026-05-15 13:41:38 -04:00
|
|
|
const dx = ev.clientX - pipDragRef.current.startX, 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'; }
|
2026-05-14 22:50:20 -04:00
|
|
|
if (pipDragRef.current.dragged) {
|
2026-05-15 13:41:38 -04:00
|
|
|
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';
|
2026-05-14 22:50:20 -04:00
|
|
|
}
|
|
|
|
|
};
|
2026-05-15 13:41:38 -04:00
|
|
|
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); };
|
|
|
|
|
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();
|
|
|
|
|
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);
|
|
|
|
|
setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0);
|
|
|
|
|
};
|
|
|
|
|
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-15 13:41:38 -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-15 13:41:38 -04:00
|
|
|
const sx = e.clientX, sy = e.clientY, sw = el.offsetWidth, sh = el.offsetHeight;
|
|
|
|
|
const sl = parseFloat(el.style.left), st = parseFloat(el.style.top);
|
|
|
|
|
document.body.style.cursor = `${corner}-resize`; document.body.style.userSelect = 'none';
|
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
|
|
|
const dx = ev.clientX-sx, dy = ev.clientY-sy;
|
|
|
|
|
let w = sw, h = sh, l = sl, 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`;
|
2026-05-14 23:07:29 -04:00
|
|
|
};
|
2026-05-15 13:41:38 -04:00
|
|
|
const onUp = () => { 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 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%',
|
|
|
|
|
}}
|
|
|
|
|
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-15 13:41:38 -04:00
|
|
|
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', display:'flex', alignItems:'flex-start', justifyContent:'flex-end', padding:'6px' }}
|
2026-05-14 22:50:20 -04:00
|
|
|
>
|
2026-05-15 13:41:38 -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-05-15 14:13:41 -04:00
|
|
|
{(['se','sw','ne','nw'] as Corner[]).map((corner) => {
|
|
|
|
|
const s = corner.includes('s'); const e2 = corner.includes('e');
|
|
|
|
|
const dots = [[2,2],[2,7],[7,2]].map(([a,b]) => ({
|
|
|
|
|
position:'absolute' as const, width:4, height:4, borderRadius:'50%',
|
|
|
|
|
background:'rgba(255,255,255,0.45)',
|
|
|
|
|
[s?'bottom':'top']:a, [e2?'right':'left']:b,
|
|
|
|
|
}));
|
|
|
|
|
return (
|
|
|
|
|
<div key={corner} onMouseDown={(ev) => handleResizeMouseDown(ev, corner)} onClick={(ev) => ev.stopPropagation()}
|
|
|
|
|
style={{ position:'absolute', width:'18px', height:'18px', [s?'bottom':'top']:0, [e2?'right':'left']:0, cursor:`${corner}-resize`, zIndex:2 }}>
|
|
|
|
|
{dots.map((style, i) => <div key={i} style={style} />)}
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|