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:
316
src/app/features/call/IncomingCallNotification.tsx
Normal file
316
src/app/features/call/IncomingCallNotification.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user