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 { useRemoteAllMuted } from '../hooks/useCallSpeakers'; import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; import { mDirectAtom } from '../state/mDirectList'; import { useMediaAuthentication } from '../hooks/useMediaAuthentication'; import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix'; import { RoomAvatar, RoomIcon } from './room-avatar'; import { useRoomNavigate } from '../hooks/useRoomNavigate'; import { getChatBg } from '../features/lotus/chatBackground'; import { 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(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 ( <> }> onIgnore(), clickOutsideDeactivates: false, escapeDeactivates: false, }} > {getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender} ( )} /> {roomName} {info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'} {!livekitSupported && ( Your homeserver does not support calling. )} {!webRTCSupported() && ( Your browser does not support WebRTC, which is required for calling. )} ); } 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(); 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(); 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 ? ( ) : null; } function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); useCallMemberSoundSync(embed); useCallThemeSync(embed); useCallHangupEvent( embed, useCallback(() => { setCallEmbed(undefined); }, [setCallEmbed]), ); return null; } /** 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 (
); } type CallEmbedProviderProps = { children?: ReactNode; }; export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const callEmbed = useAtomValue(callEmbedAtom); const callEmbedRef = useRef(null) as React.RefObject; 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 ( {callEmbed && } {children}
{pipMode && callEmbed && ( <>
{ 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', }} >
↗ Return to call
{(['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 (
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) => (
))}
); })} )}
); }