2026-05-14 22:50:20 -04:00
|
|
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
2026-03-07 18:03:32 +11:00
|
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
2026-03-09 14:04:48 +11:00
|
|
|
import { config } from 'folds';
|
2026-03-07 18:03:32 +11:00
|
|
|
import {
|
|
|
|
|
CallEmbedContextProvider,
|
|
|
|
|
CallEmbedRefContextProvider,
|
|
|
|
|
useCallHangupEvent,
|
|
|
|
|
useCallJoined,
|
|
|
|
|
useCallThemeSync,
|
2026-03-08 14:22:11 +11:00
|
|
|
useCallMemberSoundSync,
|
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 22:50:20 -04:00
|
|
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
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();
|
2026-05-14 22:50:20 -04:00
|
|
|
const { navigateRoom } = useRoomNavigate();
|
2026-03-07 18:03:32 +11:00
|
|
|
|
|
|
|
|
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-03-07 18:03:32 +11:00
|
|
|
|
2026-05-14 22:50:20 -04:00
|
|
|
// Tracks whether the current pointer-down turned into a drag vs a tap/click.
|
|
|
|
|
const pipDragRef = useRef<{
|
|
|
|
|
startX: number;
|
|
|
|
|
startY: number;
|
|
|
|
|
origLeft: number;
|
|
|
|
|
origTop: number;
|
|
|
|
|
dragged: boolean;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
|
|
|
|
// useCallEmbedPlacementSync writes top/left/width/height as inline styles directly on this
|
|
|
|
|
// element. Override those when in PiP mode; let them be restored when returning to the call room.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = callEmbedRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
if (pipMode) {
|
|
|
|
|
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';
|
|
|
|
|
} else {
|
|
|
|
|
// Clear all PiP overrides so useCallEmbedPlacementSync can take back control.
|
|
|
|
|
el.style.top = '';
|
|
|
|
|
el.style.left = '';
|
|
|
|
|
el.style.bottom = '';
|
|
|
|
|
el.style.right = '';
|
|
|
|
|
el.style.borderRadius = '';
|
|
|
|
|
el.style.overflow = '';
|
|
|
|
|
el.style.zIndex = '';
|
|
|
|
|
el.style.boxShadow = '';
|
|
|
|
|
el.style.border = '';
|
|
|
|
|
el.style.visibility = callVisible ? '' : 'hidden';
|
|
|
|
|
}
|
|
|
|
|
}, [pipMode, callVisible]);
|
|
|
|
|
|
|
|
|
|
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 onMouseMove = (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) {
|
|
|
|
|
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
|
|
|
|
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
|
|
|
|
el.style.left = `${newLeft}px`;
|
|
|
|
|
el.style.top = `${newTop}px`;
|
|
|
|
|
el.style.right = 'auto';
|
|
|
|
|
el.style.bottom = 'auto';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseUp = () => {
|
|
|
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
|
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
|
|
|
document.body.style.cursor = '';
|
|
|
|
|
document.body.style.userSelect = '';
|
|
|
|
|
// Defer clearing the dragged flag so the onClick handler fires first.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (pipDragRef.current) pipDragRef.current.dragged = false;
|
|
|
|
|
}, 0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
|
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
|
|
|
|
|
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
|
|
|
|
|
el.style.left = `${newLeft}px`;
|
|
|
|
|
el.style.top = `${newTop}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);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePipClick = (roomId: string) => {
|
|
|
|
|
if (pipDragRef.current?.dragged) return;
|
|
|
|
|
navigateRoom(roomId);
|
|
|
|
|
};
|
2026-03-07 18:03:32 +11:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<CallEmbedContextProvider value={callEmbed}>
|
|
|
|
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
|
|
|
|
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
|
|
|
|
|
<div
|
|
|
|
|
data-call-embed-container
|
2026-05-14 22:50:20 -04:00
|
|
|
ref={callEmbedRef}
|
2026-03-07 18:03:32 +11:00
|
|
|
style={{
|
|
|
|
|
visibility: callVisible ? undefined : 'hidden',
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '50%',
|
|
|
|
|
}}
|
2026-05-14 22:50:20 -04:00
|
|
|
>
|
|
|
|
|
{pipMode && callEmbed && (
|
|
|
|
|
<div
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-label="Return to call"
|
|
|
|
|
onMouseDown={handlePipMouseDown}
|
|
|
|
|
onTouchStart={handlePipTouchStart}
|
|
|
|
|
onClick={() => handlePipClick(callEmbed.roomId)}
|
|
|
|
|
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-07 18:03:32 +11:00
|
|
|
</CallEmbedContextProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|