feat(call): add sound effects and incoming call notification UI

- Implemented sound management for call events including mute, unmute, deafen, undeafen, call joined, call depart, and dialing sounds.
- Created a new CallSounds class to handle sound playback and state management.
- Added IncomingCallNotification component to display incoming call alerts with caller information and action buttons.
- Styled the incoming call notification using vanilla-extract CSS for animations and layout.
- Integrated sound playback for incoming calls with a 15-second threshold for dialing sound.
This commit is contained in:
2026-02-05 03:24:18 +11:00
parent cd912025f1
commit 1a452f52ca
27 changed files with 1888 additions and 179 deletions

View File

@@ -0,0 +1,316 @@
/* 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());
/**
* 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();
// Find the first room with an incoming call
const incomingRoom = rooms.find((room) => {
// Only monitor DMs and small groups
if (!isDirectOrSmallGroup(room)) return false;
// Skip rooms we've declined
if (declinedRooms.has(room.roomId)) return false;
const { members } = getOtherCallMembersWithTime(room, myUserId);
return members.length > 0;
});
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]);
/**
* Handles state events for call member changes
*/
const handleStateEvent = useCallback((event: MatrixEvent) => {
if (event.getType() === CALL_MEMBER_EVENT_TYPE) {
// Clear declined status if call ended in that room
const roomId = event.getRoomId();
if (roomId && declinedRooms.has(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, remove from declined
setDeclinedRooms((prev) => {
const newSet = new Set(prev);
newSet.delete(roomId);
return newSet;
});
}
}
}
}
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
// Only play sound if call started less than 15 seconds ago
useEffect(() => {
if (incomingCall) {
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();
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();
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];
/**
* 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>
<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>
);
}