diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index 679592398..b7cd42367 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -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 (
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+ {roomName}
+
+
+ {isVideo ? 'Incoming video call' : 'Incoming voice call'}
+ {dm ? '' : ` · ${callerName}`}
+
+
+
+
+
+
+
+
+ );
+}
+
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 (
+
+ );
+ }
+ return (
- ) : null;
+ );
}
function CallUtils({ embed }: { embed: CallEmbed }) {