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

@@ -127,6 +127,17 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
/** Information about a call member event for grouping */
interface CallEventInfo {
senderId: string;
senderName: string;
isJoining: boolean;
ts: number;
item: number;
mEventId: string;
mEvent: MatrixEvent;
}
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
<Box
@@ -450,6 +461,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
// Track consecutive call member events for collapsing
const pendingCallEventsRef = useRef<CallEventInfo[]>([]);
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
@@ -1468,6 +1482,46 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
[StateEvent.CallMember]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const content = mEvent.getContent();
const prevContent = mEvent.getPrevContent();
// Check current content for active devices
const calls = content['m.calls'];
const hasDevices = Array.isArray(calls) && calls.some((call: { 'm.devices'?: unknown[] }) =>
Array.isArray(call['m.devices']) && call['m.devices'].length > 0
);
// Check previous content for active devices
const prevCalls = prevContent['m.calls'];
const hadDevices = Array.isArray(prevCalls) && prevCalls.some((call: { 'm.devices'?: unknown[] }) =>
Array.isArray(call['m.devices']) && call['m.devices'].length > 0
);
// Determine if this is a join or leave
const isJoining = hasDevices && !hadDevices;
const isLeaving = !hasDevices && hadDevices;
// Skip if no change (refresh events or device updates within same call)
if (!isJoining && !isLeaving) return null;
// Add to pending call events for grouping
// Return a special marker that eventRenderer will recognize
pendingCallEventsRef.current.push({
senderId,
senderName,
isJoining,
ts: mEvent.getTs(),
item,
mEventId,
mEvent,
});
// Return special marker for call event (will be handled by eventRenderer)
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
},
},
(mEventId, mEvent, item) => {
if (!showHiddenEvents) return null;
@@ -1571,7 +1625,128 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false;
let newDivider = false;
let dayDivider = false;
const eventRenderer = (item: number) => {
// Clear pending call events at the start of each render
pendingCallEventsRef.current = [];
/**
* Renders a summary of accumulated call events
*/
const renderCallEventSummary = (callEvents: CallEventInfo[]): React.ReactNode => {
if (callEvents.length === 0) return null;
// Group by user, tracking the sequence of actions to preserve order
const userActions = new Map<string, {
senderName: string;
firstAction: 'join' | 'leave';
lastAction: 'join' | 'leave';
joins: number;
leaves: number;
}>();
callEvents.forEach((evt) => {
const existing = userActions.get(evt.senderId);
const action = evt.isJoining ? 'join' : 'leave';
if (existing) {
existing.lastAction = action;
if (evt.isJoining) {
existing.joins += 1;
} else {
existing.leaves += 1;
}
} else {
userActions.set(evt.senderId, {
senderName: evt.senderName,
firstAction: action,
lastAction: action,
joins: evt.isJoining ? 1 : 0,
leaves: evt.isJoining ? 0 : 1,
});
}
});
// Build summary text
const summaryParts: string[] = [];
userActions.forEach((actions) => {
const { joins, leaves, senderName, firstAction, lastAction } = actions;
const total = joins + leaves;
if (total === 1) {
// Single action
summaryParts.push(`${senderName} ${firstAction === 'join' ? 'joined' : 'left'}`);
} else if (joins > 0 && leaves > 0) {
// Mixed joins and leaves - describe based on first action and cycles
const startedWith = firstAction === 'join' ? 'joined' : 'left';
const opposite = firstAction === 'join' ? 'left' : 'joined';
const cycles = Math.min(joins, leaves);
if (firstAction === lastAction) {
// Ended on same action as started (odd number of same action)
// e.g., join-leave-join or leave-join-leave
if (cycles === 1) {
summaryParts.push(`${senderName} ${startedWith}, ${opposite}, then ${startedWith} again`);
} else {
summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times, then ${startedWith}`);
}
} else {
// Ended on opposite action (equal joins/leaves or started-opposite pattern)
if (cycles === 1) {
summaryParts.push(`${senderName} ${startedWith}, then ${opposite}`);
} else {
summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times`);
}
}
} else if (joins > 1) {
summaryParts.push(`${senderName} joined ${joins} times`);
} else if (leaves > 1) {
summaryParts.push(`${senderName} left ${leaves} times`);
}
});
// Use the first event for rendering metadata
const firstEvent = callEvents[0];
const lastEvent = callEvents[callEvents.length - 1];
const timeJSX = (
<Time
ts={lastEvent.ts}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={`call-summary-${firstEvent.mEventId}`}
data-message-item={firstEvent.item}
data-message-id={firstEvent.mEventId}
room={room}
mEvent={firstEvent.mEvent}
highlight={false}
messageSpacing={messageSpacing}
canDelete={false}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Phone}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
{summaryParts.join(', ')}
</Text>
</Box>
}
/>
</Event>
);
};
const eventRenderer = (item: number, index: number, allItems: number[]) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null;
const timelineSet = eventTimeline?.getTimelineSet();
@@ -1604,7 +1779,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const eventJSX = reactionOrEditEvent(mEvent)
const rawEventJSX = reactionOrEditEvent(mEvent)
? null
: renderMatrixEvent(
mEvent.getType(),
@@ -1615,11 +1790,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
timelineSet,
collapsed
);
// Check if this is a call event that should be grouped
const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
// Determine if we need to flush pending call events
// Flush when: we hit a non-call event, or this is the last item
const isLastItem = index === allItems.length - 1;
const pendingCallEvents = pendingCallEventsRef.current;
const shouldFlushCallEvents = pendingCallEvents.length > 0 && (!isCallEventPending || isLastItem);
let callSummaryJSX: React.ReactNode = null;
if (shouldFlushCallEvents) {
callSummaryJSX = renderCallEventSummary([...pendingCallEvents]);
pendingCallEventsRef.current = []; // Clear array
}
// If this was a call event marker, don't render it directly
const eventJSX = isCallEventPending ? null : rawEventJSX;
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
isPrevRendered = !!eventJSX || !!callSummaryJSX;
const newDividerJSX =
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
newDivider && (eventJSX || callSummaryJSX) && eventSender !== mx.getUserId() ? (
<MessageBase space={messageSpacing}>
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
@@ -1630,7 +1824,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) : null;
const dayDividerJSX =
dayDivider && eventJSX ? (
dayDivider && (eventJSX || callSummaryJSX) ? (
<MessageBase space={messageSpacing}>
<TimelineDivider variant="Surface">
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
@@ -1646,7 +1840,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</MessageBase>
) : null;
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
// Handle various combinations of dividers, call summary, and regular events
const hasContent = eventJSX || callSummaryJSX;
const hasDividers = newDividerJSX || dayDividerJSX;
if (hasContent && hasDividers) {
if (newDividerJSX) newDivider = false;
if (dayDividerJSX) dayDivider = false;
@@ -1654,12 +1852,22 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<React.Fragment key={mEventId}>
{newDividerJSX}
{dayDividerJSX}
{callSummaryJSX}
{eventJSX}
</React.Fragment>
);
}
return eventJSX;
if (callSummaryJSX && eventJSX) {
return (
<React.Fragment key={mEventId}>
{callSummaryJSX}
{eventJSX}
</React.Fragment>
);
}
return callSummaryJSX || eventJSX;
};
return (

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