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

@@ -21,6 +21,7 @@ import {
RectCords,
Badge,
Spinner,
color,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { JoinRule, Room } from 'matrix-js-sdk';
@@ -71,6 +72,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useRoomCall, useRoomCallMembers } from '../call';
import { CallState, CallType } from '../call/types';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
type RoomMenuProps = {
room: Room;
@@ -261,53 +264,106 @@ type CallIndicatorProps = {
};
/**
* Shows when others are in a call in this room
* Shows all users currently in a call in this room (including self)
*/
function CallIndicator({ roomId }: CallIndicatorProps) {
const { othersInCall, isCallActive } = useRoomCallMembers(roomId);
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { callMembers, isCallActive } = useRoomCallMembers(roomId);
const room = mx.getRoom(roomId);
if (!isCallActive || othersInCall.length === 0) {
if (!isCallActive || callMembers.length === 0) {
return null;
}
const userCount = othersInCall.length;
const tooltipText = userCount === 1
? `${othersInCall[0].userId.split(':')[0].slice(1)} is in a call`
: `${userCount} people are in a call`;
const getAvatarUrl = (userId: string): string | undefined => {
if (!room) return undefined;
const mxcUrl = getMemberAvatarMxc(room, userId);
if (!mxcUrl) return undefined;
return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 24, 24, 'crop') ?? undefined;
};
const getDisplayName = (userId: string): string => {
if (!room) return userId.split(':')[0].slice(1);
return getMemberDisplayName(room, userId) ?? userId.split(':')[0].slice(1);
};
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{tooltipText}</Text>
</Tooltip>
}
<Box
alignItems="Center"
gap="100"
style={{
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(12),
backgroundColor: 'var(--mx-positive-container)',
cursor: 'default',
}}
>
{(triggerRef) => (
<Box
ref={triggerRef}
alignItems="Center"
gap="100"
style={{
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(12),
backgroundColor: 'var(--mx-positive-container)',
cursor: 'default',
}}
>
<Icon
size="100"
src={Icons.Phone}
style={{ color: 'var(--mx-positive)' }}
/>
<Box alignItems="Center" gap="100">
{callMembers.slice(0, 5).map((member) => {
const displayName = getDisplayName(member.userId);
const avatarUrl = getAvatarUrl(member.userId);
return (
<TooltipProvider
key={member.userId}
position="Bottom"
offset={8}
tooltip={
<Box
direction="Column"
gap="50"
style={{
padding: `${toRem(8)} ${toRem(12)}`,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderRadius: toRem(8),
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
position: 'relative',
}}
>
{/* Speech bubble arrow */}
<div
style={{
position: 'absolute',
top: toRem(-6),
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: `${toRem(6)} solid transparent`,
borderRight: `${toRem(6)} solid transparent`,
borderBottom: `${toRem(6)} solid ${color.SurfaceVariant.Container}`,
}}
/>
<Text size="T300" style={{ fontWeight: 600, color: color.SurfaceVariant.OnContainer }}>{displayName}</Text>
<Text size="T200" style={{ opacity: 0.7, color: color.SurfaceVariant.OnContainer }}>{member.userId}</Text>
</Box>
}
>
{(triggerRef) => (
<Avatar ref={triggerRef} size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl}
alt={displayName}
renderFallback={() => (
<Text size="T200">
{displayName.charAt(0).toUpperCase()}
</Text>
)}
/>
</Avatar>
)}
</TooltipProvider>
);
})}
{callMembers.length > 5 && (
<Text size="T200" style={{ color: 'var(--mx-positive)' }}>
{userCount} in call
+{callMembers.length - 5}
</Text>
</Box>
)}
</TooltipProvider>
)}
</Box>
</Box>
);
}