/* 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 } 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(() => { 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 ( <> }> onIgnore(), clickOutsideDeactivates: false, escapeDeactivates: false, }} > {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); React.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 { ['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; }); 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 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 = ''; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); }; 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); setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); }; 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=''; }; 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) =>
)}
); })} )}
); }