feat(calls): non-intrusive incoming-call banner while already in a call (#4b)

Previously a second incoming call was dropped from the UI entirely when the
user was already in a call (`!joined && callInfo`). Now, when joined to a
different call, a compact corner banner (caller avatar + name + Answer/Reject)
is shown instead of the full-screen IncomingCall overlay, with a single soft
ping (one-shot ringtone) rather than the looping ring so it doesn't talk over
the active call. The full overlay still shows when not in any call; being in
the ringing room's own call still shows nothing.

Built with folds primitives + TDS tokens (no hardcoded colors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:45:18 -04:00
parent 66cc51d6d0
commit c67aed01dc
+162 -4
View File
@@ -40,7 +40,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { useMatrixClient } from '../hooks/useMatrixClient';
import { startRingtone } from '../utils/ringtones'; import { previewRingtone, startRingtone } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers'; import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
@@ -246,6 +246,149 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
); );
} }
type IncomingCallBannerProps = {
dm: boolean;
info: IncomingCallInfo;
onIgnore: () => void;
onAnswer: (room: Room, video: boolean) => void;
onReject: (room: Room, eventId: string) => void;
};
/**
* Compact, non-intrusive incoming-call notification shown when the user is
* ALREADY in a call. Unlike the full-screen `IncomingCall` overlay this is a
* corner banner that does not take over the screen, and it plays a single
* soft ping (via the one-shot ringtone preview) rather than the looping ring,
* so it doesn't talk over the active call.
*/
function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallBannerProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { room } = info;
const isVideo = info.intent === 'video';
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
const avatarUrl = roomAvatar
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const session = useCallSession(room);
useCallMembersChange(
session,
useCallback(
(members) => {
if (members.length === 0) {
onIgnore();
}
},
[onIgnore],
),
);
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
// + volume. We intentionally do NOT loop here — the user is mid-call.
useEffect(() => {
if (info.notificationType !== 'ring') return;
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
}, [info.notificationType, ringtoneId, ringtoneVolume]);
useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now();
if (remaining <= 0) {
onIgnore();
return;
}
const id = setTimeout(onIgnore, remaining);
return () => clearTimeout(id);
}, [info.senderTs, info.lifetime, onIgnore]);
const callerName =
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
return (
<Box
direction="Column"
gap="300"
style={{
position: 'fixed',
top: config.space.S400,
right: config.space.S400,
zIndex: 9990,
width: toRem(300),
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
padding: config.space.S300,
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
boxShadow: `0 8px 32px ${color.Other.Shadow}`,
}}
role="alert"
aria-label={`Incoming ${isVideo ? 'video' : 'voice'} call from ${roomName}`}
>
<Box gap="300" alignItems="Center">
<Box shrink="No">
<Avatar size="300" className={CallAvatarAnimation}>
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="200"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
</Box>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{roomName}
</Text>
<Text size="T200" priority="300" truncate>
{isVideo ? 'Incoming video call' : 'Incoming voice call'}
{dm ? '' : ` · ${callerName}`}
</Text>
</Box>
</Box>
<Box gap="200">
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Solid"
size="300"
radii="300"
onClick={() => onAnswer(room, isVideo)}
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
>
<Text as="span" size="B300">
Answer
</Text>
</Button>
<Button
style={{ flexGrow: 1 }}
variant={dm ? 'Critical' : 'Secondary'}
fill="Soft"
size="300"
radii="300"
outlined
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="100" src={Icons.Cross} filled />}
>
<Text as="span" size="B300">
{dm ? 'Reject' : 'Ignore'}
</Text>
</Button>
</Box>
</Box>
);
}
type IncomingCallListenerProps = { type IncomingCallListenerProps = {
callEmbed?: CallEmbed; callEmbed?: CallEmbed;
joined?: boolean; joined?: boolean;
@@ -371,10 +514,25 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
[startCall, navigateRoom], [startCall, navigateRoom],
); );
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) { if (!callInfo) return null;
// Already in this room's own call — no notification at all.
if (callEmbed?.roomId === callInfo.room.roomId) {
return null; return null;
} }
return !joined && callInfo ? ( // In a different call already: show the compact, non-intrusive banner
// instead of the full-screen takeover overlay.
if (joined) {
return (
<IncomingCallBanner
dm={dm}
info={callInfo}
onIgnore={handleIgnore}
onAnswer={handleAnswer}
onReject={handleReject}
/>
);
}
return (
<IncomingCall <IncomingCall
dm={dm} dm={dm}
info={callInfo} info={callInfo}
@@ -382,7 +540,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
onAnswer={handleAnswer} onAnswer={handleAnswer}
onReject={handleReject} onReject={handleReject}
/> />
) : null; );
} }
function CallUtils({ embed }: { embed: CallEmbed }) { function CallUtils({ embed }: { embed: CallEmbed }) {