Files
cinny/src/app/features/call/IncomingCallNotification.tsx

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>
);
}