/* 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(null); const [declinedRooms, setDeclinedRooms] = useState>(new Set()); const [seenCalls, setSeenCalls] = useState>(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 (
( {callerName.charAt(0).toUpperCase()} )} />
{callerName} {otherCallersCount > 0 && ( {` +${otherCallersCount} other${otherCallersCount > 1 ? 's' : ''}`} )} {!isGroupCall && ( Calling from {incomingCall.roomName} )}
); }