341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
/* eslint-disable no-console */
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
|
import { Box, Icon, IconButton, Icons, Text, color } from 'folds';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useCall } from './useCall';
|
|
import { CallType } from './types';
|
|
import { getCallSounds, CallSoundType } from './CallSounds';
|
|
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { UserAvatar } from '../../components/user-avatar';
|
|
import * as css from './IncomingCallNotification.css';
|
|
|
|
/** The Matrix state event type for call membership (MSC3401) */
|
|
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member';
|
|
|
|
/** Represents an incoming call */
|
|
interface IncomingCall {
|
|
roomId: string;
|
|
roomName: string;
|
|
callerIds: string[];
|
|
startTime: number;
|
|
}
|
|
|
|
/**
|
|
* Checks if a room is a DM or small group (not a server/space room)
|
|
* @param room - The Matrix room to check
|
|
* @returns true if the room is a DM or small group
|
|
*/
|
|
function isDirectOrSmallGroup(room: Room): boolean {
|
|
if (room.isSpaceRoom()) {
|
|
return false;
|
|
}
|
|
const memberCount = room.getJoinedMemberCount();
|
|
return memberCount <= 10;
|
|
}
|
|
|
|
/**
|
|
* Gets active call members from a room who are NOT the current user
|
|
* Also returns the earliest call start time
|
|
* @param room - The Matrix room
|
|
* @param myUserId - The current user's ID
|
|
* @returns Object with member IDs and earliest call start time
|
|
*/
|
|
function getOtherCallMembersWithTime(room: Room, myUserId: string): { members: string[]; earliestTime: number } {
|
|
const members: string[] = [];
|
|
const now = Date.now();
|
|
let earliestTime = now;
|
|
|
|
try {
|
|
const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE);
|
|
|
|
stateEvents.forEach((event) => {
|
|
const content = event.getContent();
|
|
const userId = event.getStateKey();
|
|
|
|
if (!userId || userId === myUserId || !content['m.calls']) return;
|
|
|
|
const calls = content['m.calls'];
|
|
if (!Array.isArray(calls)) return;
|
|
|
|
calls.forEach((call) => {
|
|
const devices = call['m.devices'];
|
|
if (!Array.isArray(devices)) return;
|
|
|
|
devices.forEach((device) => {
|
|
const expiresTs = device.expires_ts || 0;
|
|
|
|
if (expiresTs > now && !members.includes(userId)) {
|
|
members.push(userId);
|
|
// Track event origin time to determine call age
|
|
const eventTime = event.getTs() || now;
|
|
if (eventTime < earliestTime) {
|
|
earliestTime = eventTime;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
} catch (e) {
|
|
console.error('Error reading call member events:', e);
|
|
}
|
|
|
|
return { members, earliestTime };
|
|
}
|
|
|
|
/**
|
|
* Wrapper for backward compatibility
|
|
*/
|
|
function getOtherCallMembers(room: Room, myUserId: string): string[] {
|
|
return getOtherCallMembersWithTime(room, myUserId).members;
|
|
}
|
|
|
|
/**
|
|
* Component that monitors for incoming calls and displays a notification UI
|
|
* with room info, caller details, and accept/decline buttons.
|
|
*/
|
|
export function IncomingCallNotification() {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const { activeCall, startCall } = useCall();
|
|
const [incomingCall, setIncomingCall] = useState<IncomingCall | null>(null);
|
|
const [declinedRooms, setDeclinedRooms] = useState<Set<string>>(new Set());
|
|
const [seenCalls, setSeenCalls] = useState<Map<string, number>>(new Map());
|
|
|
|
/**
|
|
* Checks all rooms for incoming calls
|
|
*/
|
|
const checkForIncomingCalls = useCallback(() => {
|
|
const myUserId = mx.getUserId();
|
|
if (!myUserId) return;
|
|
|
|
// If we're already in a call, don't show incoming call notification
|
|
if (activeCall) {
|
|
setIncomingCall(null);
|
|
return;
|
|
}
|
|
|
|
// Check all joined rooms for calls
|
|
const rooms = mx.getRooms();
|
|
const now = Date.now();
|
|
const MAX_CALL_AGE = 10000; // 10 seconds
|
|
|
|
// Find the first room with an incoming call
|
|
const incomingRoom = rooms.find((room) => {
|
|
// Only monitor 1-1 DMs (exactly 2 members)
|
|
if (room.getJoinedMemberCount() !== 2) return false;
|
|
// Skip rooms we've declined
|
|
if (declinedRooms.has(room.roomId)) return false;
|
|
const { members, earliestTime } = getOtherCallMembersWithTime(room, myUserId);
|
|
if (members.length === 0) return false;
|
|
|
|
// Skip if we've already seen this exact call session
|
|
const lastSeenTime = seenCalls.get(room.roomId);
|
|
if (lastSeenTime && lastSeenTime === earliestTime) return false;
|
|
|
|
// Only show if the call is fresh (less than 10 seconds old)
|
|
const callAge = now - earliestTime;
|
|
return callAge < MAX_CALL_AGE;
|
|
});
|
|
|
|
if (incomingRoom) {
|
|
const { members, earliestTime } = getOtherCallMembersWithTime(incomingRoom, myUserId);
|
|
setIncomingCall({
|
|
roomId: incomingRoom.roomId,
|
|
roomName: incomingRoom.name || 'Unknown Room',
|
|
callerIds: members,
|
|
startTime: earliestTime,
|
|
});
|
|
} else {
|
|
setIncomingCall(null);
|
|
}
|
|
}, [mx, activeCall, declinedRooms, seenCalls]);
|
|
|
|
/**
|
|
* Handles state events for call member changes
|
|
*/
|
|
const handleStateEvent = useCallback((event: MatrixEvent) => {
|
|
if (event.getType() === CALL_MEMBER_EVENT_TYPE) {
|
|
// Clear declined status and seen calls if call ended in that room
|
|
const roomId = event.getRoomId();
|
|
if (roomId) {
|
|
const room = mx.getRoom(roomId);
|
|
if (room) {
|
|
const myUserId = mx.getUserId();
|
|
if (myUserId) {
|
|
const members = getOtherCallMembers(room, myUserId);
|
|
if (members.length === 0) {
|
|
// Call ended, clear tracking
|
|
setDeclinedRooms((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(roomId);
|
|
return newSet;
|
|
});
|
|
setSeenCalls((prev) => {
|
|
const newMap = new Map(prev);
|
|
newMap.delete(roomId);
|
|
return newMap;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
checkForIncomingCalls();
|
|
}
|
|
}, [checkForIncomingCalls, declinedRooms, mx]);
|
|
|
|
// Set up event listeners
|
|
useEffect(() => {
|
|
mx.on(RoomStateEvent.Events, handleStateEvent);
|
|
checkForIncomingCalls();
|
|
const interval = setInterval(checkForIncomingCalls, 10000);
|
|
|
|
return () => {
|
|
mx.off(RoomStateEvent.Events, handleStateEvent);
|
|
clearInterval(interval);
|
|
};
|
|
}, [mx, handleStateEvent, checkForIncomingCalls]);
|
|
|
|
// Play/stop dialing sound based on incoming call state
|
|
// Mark call as seen when displayed
|
|
useEffect(() => {
|
|
if (incomingCall) {
|
|
// Mark this call as seen so we don't notify again
|
|
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
|
|
|
|
const callAge = Date.now() - incomingCall.startTime;
|
|
const MAX_CALL_AGE_FOR_SOUND = 15000; // 15 seconds
|
|
|
|
if (callAge < MAX_CALL_AGE_FOR_SOUND) {
|
|
getCallSounds().playSound(CallSoundType.CallDialing, true);
|
|
console.log('🔔 Incoming call from room:', incomingCall.roomName, '(age:', Math.round(callAge / 1000), 's)');
|
|
} else {
|
|
console.log('📞 Incoming call too old for sound:', incomingCall.roomName, '(age:', Math.round(callAge / 1000), 's)');
|
|
getCallSounds().stopDialingLoop();
|
|
}
|
|
} else {
|
|
getCallSounds().stopDialingLoop();
|
|
}
|
|
|
|
return () => {
|
|
getCallSounds().stopDialingLoop();
|
|
};
|
|
}, [incomingCall]);
|
|
|
|
/**
|
|
* Handles accepting the incoming call
|
|
*/
|
|
const handleAccept = useCallback(async () => {
|
|
if (!incomingCall) return;
|
|
|
|
getCallSounds().stopDialingLoop();
|
|
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
|
|
try {
|
|
await startCall(incomingCall.roomId, CallType.Voice);
|
|
} catch (error) {
|
|
console.error('Failed to join call:', error);
|
|
}
|
|
}, [incomingCall, startCall]);
|
|
|
|
/**
|
|
* Handles declining the incoming call
|
|
*/
|
|
const handleDecline = useCallback(() => {
|
|
if (!incomingCall) return;
|
|
|
|
getCallSounds().stopDialingLoop();
|
|
setSeenCalls((prev) => new Map(prev).set(incomingCall.roomId, incomingCall.startTime));
|
|
setDeclinedRooms((prev) => new Set(prev).add(incomingCall.roomId));
|
|
setIncomingCall(null);
|
|
}, [incomingCall]);
|
|
|
|
// Don't render if no incoming call
|
|
if (!incomingCall) {
|
|
return null;
|
|
}
|
|
|
|
const room = mx.getRoom(incomingCall.roomId);
|
|
const firstCaller = incomingCall.callerIds[0];
|
|
const isGroupCall = room ? room.getJoinedMemberCount() > 2 : false;
|
|
|
|
/**
|
|
* Gets the display name for a user
|
|
*/
|
|
const getDisplayName = (userId: string): string => {
|
|
if (!room) return getMxIdLocalPart(userId) ?? userId;
|
|
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
|
};
|
|
|
|
/**
|
|
* Gets the avatar URL for a user
|
|
*/
|
|
const getAvatarUrl = (userId: string): string | undefined => {
|
|
if (!room) return undefined;
|
|
const mxcUrl = getMemberAvatarMxc(room, userId);
|
|
if (!mxcUrl) return undefined;
|
|
return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 48, 48, 'crop') ?? undefined;
|
|
};
|
|
|
|
const callerName = getDisplayName(firstCaller);
|
|
const avatarUrl = getAvatarUrl(firstCaller);
|
|
const otherCallersCount = incomingCall.callerIds.length - 1;
|
|
|
|
return (
|
|
<div className={css.IncomingCallContainer}>
|
|
<div className={css.IncomingCallContent}>
|
|
<div className={css.CallerInfo}>
|
|
<div className={css.CallerAvatarWrapper}>
|
|
<UserAvatar
|
|
className={css.CallerAvatar}
|
|
userId={firstCaller}
|
|
src={avatarUrl}
|
|
alt={callerName}
|
|
renderFallback={() => (
|
|
<Text size="H5" style={{ color: color.Surface.Container }}>
|
|
{callerName.charAt(0).toUpperCase()}
|
|
</Text>
|
|
)}
|
|
/>
|
|
<div className={css.CallerAvatarRing} />
|
|
</div>
|
|
<Box direction="Column" gap="100">
|
|
<Text size="T300" className={css.CallerName}>
|
|
{callerName}
|
|
{otherCallersCount > 0 && (
|
|
<span className={css.OtherCallers}>
|
|
{` +${otherCallersCount} other${otherCallersCount > 1 ? 's' : ''}`}
|
|
</span>
|
|
)}
|
|
</Text>
|
|
{!isGroupCall && (
|
|
<Text size="T200" className={css.RoomName}>
|
|
<Icon size="100" src={Icons.Phone} />
|
|
<span>Calling from {incomingCall.roomName}</span>
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</div>
|
|
|
|
<Box alignItems="Center" gap="200" className={css.CallActions}>
|
|
<IconButton
|
|
onClick={handleDecline}
|
|
variant="Critical"
|
|
className={css.DeclineButton}
|
|
>
|
|
<Icon size="300" src={Icons.Cross} />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={handleAccept}
|
|
variant="Success"
|
|
className={css.AcceptButton}
|
|
>
|
|
<Icon size="300" src={Icons.Phone} />
|
|
</IconButton>
|
|
</Box>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|