From 4acb7c7c204bba1d2578c6c13403f09f19f3049f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 May 2026 23:37:07 -0400 Subject: [PATCH] feat: incoming DM call notification with ring tone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When another user starts a call in a DM room, show a fixed-position notification with caller avatar, name, and Answer/Decline buttons. A Web Audio API double-pulse ring tone plays until answered, declined, or the 30-second auto-dismiss fires. - useIncomingDmCall hook: listens to MatrixRTC SessionStarted/SessionEnded, filters DM rooms (encrypted, ≤2 members), auto-dismisses after 30s, stops if caller leaves or user joins another call - IncomingCallNotification component: ring tone, caller info, themed UI for LotusGuild Terminal TDS (navy bg, orange border, neon-green Answer) and standard Cinny dark/light (CSS vars, folds Button Success/Critical) - Router.tsx: mount IncomingCallNotification inside CallEmbedProvider --- .../components/IncomingCallNotification.tsx | 307 ++++++++++++++++++ src/app/hooks/useIncomingDmCall.ts | 87 +++++ src/app/pages/Router.tsx | 2 + 3 files changed, 396 insertions(+) create mode 100644 src/app/components/IncomingCallNotification.tsx create mode 100644 src/app/hooks/useIncomingDmCall.ts 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) +