diff --git a/src/app/components/IncomingCallNotification.tsx b/src/app/components/IncomingCallNotification.tsx
new file mode 100644
index 000000000..ac5c2246e
--- /dev/null
+++ b/src/app/components/IncomingCallNotification.tsx
@@ -0,0 +1,307 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import { Avatar, Box, Button, Text } from 'folds';
+import { UserAvatar } from './user-avatar';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { useCallStart } from '../hooks/useCallEmbed';
+import { useSetting } from '../state/hooks/settings';
+import { settingsAtom } from '../state/settings';
+import { useIncomingDmCall } from '../hooks/useIncomingDmCall';
+import { mxcUrlToHttp } from '../utils/matrix';
+
+function useRingTone(active: boolean) {
+ const stopRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => {
+ if (!active) {
+ stopRef.current?.();
+ stopRef.current = null;
+ return;
+ }
+
+ let ctx: AudioContext;
+ try {
+ ctx = new AudioContext();
+ } catch {
+ return;
+ }
+
+ let cancelled = false;
+
+ const pulse = (t: number, freq: number, dur: number) => {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.frequency.value = freq;
+ osc.type = 'sine';
+ gain.gain.setValueAtTime(0, t);
+ gain.gain.linearRampToValueAtTime(0.22, t + 0.04);
+ gain.gain.setValueAtTime(0.22, t + dur - 0.04);
+ gain.gain.linearRampToValueAtTime(0, t + dur);
+ osc.start(t);
+ osc.stop(t + dur);
+ };
+
+ const ring = () => {
+ if (cancelled) return;
+ const now = ctx.currentTime;
+ pulse(now, 880, 0.18);
+ pulse(now + 0.28, 880, 0.18);
+ setTimeout(ring, 2200);
+ };
+
+ ring();
+
+ stopRef.current = () => {
+ cancelled = true;
+ ctx.close();
+ };
+
+ return () => {
+ cancelled = true;
+ ctx.close();
+ stopRef.current = null;
+ };
+ }, [active]);
+
+ return stopRef;
+}
+
+function IncomingCallCard() {
+ const mx = useMatrixClient();
+ const startCall = useCallStart(true);
+ const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
+ const [incoming, dismiss] = useIncomingDmCall();
+ const stopRingRef = useRingTone(!!incoming);
+
+ const stopAndDismiss = useCallback(() => {
+ stopRingRef.current?.();
+ dismiss();
+ }, [dismiss, stopRingRef]);
+
+ const handleAnswer = useCallback(() => {
+ if (!incoming) return;
+ stopRingRef.current?.();
+ startCall(incoming.room);
+ dismiss();
+ }, [incoming, startCall, dismiss, stopRingRef]);
+
+ if (!incoming) return null;
+
+ const callerUser = mx.getUser(incoming.callerId);
+ const callerName = callerUser?.displayName ?? incoming.callerId;
+ const avatarMxc = callerUser?.avatarUrl;
+ const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, undefined, 64, 64, 'crop') ?? undefined : undefined;
+
+ if (lotusTerminal) {
+ return (
+
+
+
+ INCOMING CALL
+
+
+
+ (
+
+ {callerName[0]?.toUpperCase() ?? '?'}
+
+ )}
+ />
+
+
+
{callerName}
+
+ {incoming.callerId}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Incoming call
+
+
+
+ (
+ {callerName[0]?.toUpperCase() ?? '?'}
+ )}
+ />
+
+
+ {callerName}
+ {incoming.callerId}
+
+
+
+
+
+
+
+
+ );
+}
+
+export function IncomingCallNotification() {
+ return ;
+}
diff --git a/src/app/hooks/useIncomingDmCall.ts b/src/app/hooks/useIncomingDmCall.ts
new file mode 100644
index 000000000..7e5eac3b9
--- /dev/null
+++ b/src/app/hooks/useIncomingDmCall.ts
@@ -0,0 +1,87 @@
+import { Room } from 'matrix-js-sdk';
+import {
+ MatrixRTCSession,
+ MatrixRTCSessionEvent,
+} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
+import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
+import { useEffect, useRef, useState } from 'react';
+import { useAtomValue } from 'jotai';
+import { useMatrixClient } from './useMatrixClient';
+import { callEmbedAtom } from '../state/callEmbed';
+
+export type IncomingDmCall = {
+ room: Room;
+ callerId: string;
+};
+
+const isDmRoom = (room: Room): boolean =>
+ room.hasEncryptionStateEvent() && room.getMembers().length <= 2;
+
+export const useIncomingDmCall = (): [IncomingDmCall | null, () => void] => {
+ const mx = useMatrixClient();
+ const callEmbed = useAtomValue(callEmbedAtom);
+ const [incoming, setIncoming] = useState(null);
+ const sessionRef = useRef(null);
+
+ useEffect(() => {
+ const myUserId = mx.getUserId();
+
+ const handleSessionStarted = (roomId: string, session: MatrixRTCSession) => {
+ if (callEmbed) return;
+ const room = mx.getRoom(roomId);
+ if (!room || !isDmRoom(room)) return;
+ const memberships = session.memberships ?? [];
+ if (memberships.length === 0) return;
+ const callerMembership = memberships.find((m) => m.sender !== myUserId);
+ if (!callerMembership) return;
+ if (memberships.find((m) => m.sender === myUserId)) return;
+ sessionRef.current = session;
+ setIncoming({ room, callerId: callerMembership.sender ?? callerMembership.deviceId });
+ };
+
+ const handleSessionEnded = (roomId: string) => {
+ setIncoming((prev) => (prev?.room.roomId === roomId ? null : prev));
+ };
+
+ mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, handleSessionStarted);
+ mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
+ return () => {
+ mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleSessionStarted);
+ mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
+ };
+ }, [mx, callEmbed]);
+
+ // Dismiss if caller leaves before answered
+ useEffect(() => {
+ const session = sessionRef.current;
+ if (!session || !incoming) return;
+ const myUserId = mx.getUserId();
+ const check = () => {
+ const memberships = session.memberships ?? [];
+ if (!memberships.some((m) => m.sender !== myUserId)) setIncoming(null);
+ };
+ session.on(MatrixRTCSessionEvent.MembershipsChanged, check);
+ return () => {
+ session.off(MatrixRTCSessionEvent.MembershipsChanged, check);
+ };
+ }, [incoming, mx]);
+
+ // Auto-dismiss after 30 seconds
+ useEffect(() => {
+ if (!incoming) return;
+ const t = setTimeout(() => setIncoming(null), 30_000);
+ return () => clearTimeout(t);
+ }, [incoming]);
+
+ // Dismiss when user joins a call
+ useEffect(() => {
+ if (callEmbed) setIncoming(null);
+ }, [callEmbed]);
+
+ const dismiss = () => {
+ sessionRef.current = null;
+ setIncoming(null);
+ };
+
+ return [incoming, dismiss];
+};
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 4de42081f..66883b452 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -69,6 +69,7 @@ import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search';
import { getFallbackSession } from '../state/sessions';
import { CallStatusRenderer } from './CallStatusRenderer';
+import { IncomingCallNotification } from '../components/IncomingCallNotification';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
@@ -137,6 +138,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+