/* eslint-disable jsx-a11y/media-has-caption */ import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; 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'; import { CallEmbedContextProvider, CallEmbedRefContextProvider, useCallHangupEvent, useCallJoined, useCallThemeSync, useCallMemberSoundSync, useCallStart, } 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'; 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'; import { useRoomNavigate } from '../hooks/useRoomNavigate'; import { getStateEvent, getMemberDisplayName } from '../utils/room'; import { getMxIdLocalPart } from '../utils/matrix'; 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(() => { const members = MatrixRTCSession.sessionMembershipsForRoom(room, session.sessionDescription); if (members.length === 0) { onIgnore(); } }, [room, session, onIgnore]), ); const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play().catch(() => undefined); }, []); useEffect(() => { if (info.notificationType === 'ring') { playSound(); } return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current.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} Incoming 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; } type CallEmbedProviderProps = { children?: ReactNode; }; export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const callEmbed = useAtomValue(callEmbedAtom); const callEmbedRef = useRef(null); 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 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 first entering pip (not on callVisible changes) const prevPipModeRef = React.useRef(false); React.useEffect(() => { const el = callEmbedRef.current; if (!el) return; if (pipMode) { if (!prevPipModeRef.current) { 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 { [ 'top', 'left', 'bottom', 'right', 'width', 'height', 'borderRadius', 'overflow', 'zIndex', 'boxShadow', 'border', ].forEach((p) => { (el.style as any)[p] = ''; }); el.style.visibility = callVisible ? '' : 'hidden'; } prevPipModeRef.current = pipMode; }, [pipMode, callVisible]); React.useEffect(() => { if (!pipMode) return; const onPipWindowResize = (): void => { const el = callEmbedRef.current; if (!el) return; 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 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, 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; 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; 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, 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`; }; 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 = [ [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 (
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) => (
))}
); })} )}
); }