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:
@@ -40,7 +40,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { startRingtone } from '../utils/ringtones';
|
||||
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
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 = {
|
||||
callEmbed?: CallEmbed;
|
||||
joined?: boolean;
|
||||
@@ -371,10 +514,25 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
[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 !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
|
||||
dm={dm}
|
||||
info={callInfo}
|
||||
@@ -382,7 +540,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
onAnswer={handleAnswer}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
|
||||
Reference in New Issue
Block a user