Files
cinny/src/app/components/CallEmbedProvider.tsx
T

539 lines
20 KiB
TypeScript
Raw Normal View History

/* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
2026-03-07 18:03:32 +11:00
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';
2026-03-07 18:03:32 +11:00
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
useCallStart,
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';
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';
2026-05-14 22:50:20 -04:00
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';
2026-03-07 18:03:32 +11:00
2026-05-14 23:07:29 -04:00
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(() => {
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 (
<>
<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">
{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">Incoming 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="Success"
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;
}
2026-05-14 23:07:29 -04:00
2026-03-07 18:03:32 +11:00
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
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();
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;
const { navigateRoom } = useRoomNavigate();
2026-03-07 18:03:32 +11:00
const pipDragRef = React.useRef<{
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
2026-05-14 22:50:20 -04:00
} | null>(null);
React.useEffect(() => {
2026-05-14 22:50:20 -04:00
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';
2026-05-14 22:50:20 -04:00
} else {
['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; });
2026-05-14 22:50:20 -04:00
el.style.visibility = callVisible ? '' : 'hidden';
}
}, [pipMode, callVisible]);
const handlePipMouseDown = (e: React.MouseEvent) => {
const el = callEmbedRef.current; if (!el) return;
2026-05-14 22:50:20 -04:00
const rect = el.getBoundingClientRect();
pipDragRef.current = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
const onMove = (ev: MouseEvent) => {
2026-05-14 22:50:20 -04:00
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'; }
2026-05-14 22:50:20 -04:00
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';
2026-05-14 22:50:20 -04:00
}
};
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);
2026-05-14 22:50:20 -04:00
};
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);
};
2026-05-14 23:07:29 -04:00
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation(); e.preventDefault();
const el = callEmbedRef.current; if (!el) return;
2026-05-14 23:07:29 -04:00
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`;
2026-05-14 23:07:29 -04:00
};
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);
2026-05-14 23:07:29 -04:00
};
2026-03-07 18:03:32 +11:00
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
2026-03-07 18:03:32 +11:00
<div
data-call-embed-container
style={{
visibility: callVisible ? undefined : 'hidden',
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '50%',
}}
ref={callEmbedRef}
2026-05-14 22:50:20 -04:00
>
{pipMode && callEmbed && (
2026-05-14 23:07:29 -04:00
<>
2026-05-14 22:50:20 -04:00
<div
2026-05-14 23:07:29 -04:00
role="button"
tabIndex={0}
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onTouchStart={handlePipTouchStart}
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' }}
2026-05-14 22:50:20 -04:00
>
<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' }}>
2026-05-14 23:07:29 -04:00
Return to call
</div>
2026-05-14 22:50:20 -04:00
</div>
{(['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 (
<div key={corner} onMouseDown={(ev) => 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) => <div key={i} style={style} />)}
</div>
);
})}
2026-05-14 23:07:29 -04:00
</>
2026-05-14 22:50:20 -04:00
)}
</div>
2026-03-07 18:03:32 +11:00
</CallEmbedContextProvider>
);
}