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 (