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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user