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 }) {