32384e9820
CI / Build & Quality Checks (push) Successful in 10m24s
The rect variable in the onUp and onTouchEnd closures was shadowing the outer rect declaration in handlePipMouseDown and handlePipTouchStart. Renamed inner declarations to savedRect. Also renamed rect → elRect in handlePipDoubleClick for the same reason. Removed unused eslint-disable-next-line comment in MessageSearch.tsx. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
850 lines
28 KiB
TypeScript
850 lines
28 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,
|
|
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';
|
|
import {
|
|
CallEmbedContextProvider,
|
|
CallEmbedRefContextProvider,
|
|
useCallHangupEvent,
|
|
useCallJoined,
|
|
useCallThemeSync,
|
|
useCallMemberSoundSync,
|
|
useCallStart,
|
|
} from '../hooks/useCallEmbed';
|
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
|
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, getMxIdLocalPart } from '../utils/matrix';
|
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
|
import { getChatBg } from '../features/lotus/chatBackground';
|
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
|
import { useSetting } from '../state/hooks/settings';
|
|
import { settingsAtom } from '../state/settings';
|
|
import { getStateEvent, getMemberDisplayName } 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';
|
|
|
|
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;
|
|
|
|
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(
|
|
(members) => {
|
|
if (members.length === 0) {
|
|
onIgnore();
|
|
}
|
|
},
|
|
[onIgnore],
|
|
),
|
|
);
|
|
|
|
const playSound = useCallback(() => {
|
|
const audioElement = audioRef.current;
|
|
audioElement?.play().catch(() => undefined);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const audioEl = audioRef.current;
|
|
if (info.notificationType === 'ring') {
|
|
playSound();
|
|
}
|
|
return () => {
|
|
if (audioEl) {
|
|
audioEl.pause();
|
|
audioEl.currentTime = 0;
|
|
}
|
|
};
|
|
}, [playSound, info.notificationType]);
|
|
|
|
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) }}>
|
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
|
<Text size="T200" align="Center">
|
|
{getMemberDisplayName(info.room, info.sender) ??
|
|
getMxIdLocalPart(info.sender) ??
|
|
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">
|
|
{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">
|
|
<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={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>
|
|
<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;
|
|
}
|
|
|
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
|
|
|
useCallMemberSoundSync(embed);
|
|
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) 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 { navigateRoom } = useRoomNavigate();
|
|
|
|
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),
|
|
// 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 (!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;
|
|
}
|
|
}, [pipMode, pipScreenshare, callEmbed]);
|
|
|
|
const theme = useTheme();
|
|
const isDark = theme.kind === ThemeKind.Dark;
|
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
|
const wallpaperStyle = React.useMemo(
|
|
() => getChatBg(chatBackground, isDark),
|
|
[chatBackground, isDark],
|
|
);
|
|
|
|
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');
|
|
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`;
|
|
}
|
|
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);
|
|
};
|
|
|
|
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) => {
|
|
const dx = ev.clientX - sx;
|
|
const dy = ev.clientY - 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 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);
|
|
};
|
|
|
|
return (
|
|
<CallEmbedContextProvider value={callEmbed}>
|
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
|
<CallEmbedRefContextProvider value={callEmbedRef}>
|
|
<IncomingCallListener 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%',
|
|
...(callVisible && !pipMode ? 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',
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
justifyContent: 'flex-end',
|
|
padding: '6px',
|
|
}}
|
|
>
|
|
<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',
|
|
}}
|
|
>
|
|
↗ Return to call
|
|
</div>
|
|
</div>
|
|
{(['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)}
|
|
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>
|
|
);
|
|
}
|