From e7c269d49d82124efef863997b5ad3376f4f7397 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sun, 19 Apr 2026 13:59:33 +0300 Subject: [PATCH] dm calls mvp: phase 2: incoming call strip + A-side auto-hangup on decline, peer-leave, no-answer --- .../call-status/IncomingCallStrip.tsx | 146 ++++++++++++++ src/app/features/call-status/index.ts | 1 + src/app/features/call/IncomingCallToast.tsx | 178 ------------------ src/app/features/room/RoomTimeline.tsx | 7 + src/app/hooks/useCallerAutoHangup.ts | 121 ++++++++++++ src/app/hooks/useIncomingRtcNotifications.ts | 38 +++- src/app/pages/IncomingCallStripRenderer.tsx | 69 +++++++ src/app/pages/Router.tsx | 7 +- 8 files changed, 386 insertions(+), 181 deletions(-) create mode 100644 src/app/features/call-status/IncomingCallStrip.tsx delete mode 100644 src/app/features/call/IncomingCallToast.tsx create mode 100644 src/app/hooks/useCallerAutoHangup.ts create mode 100644 src/app/pages/IncomingCallStripRenderer.tsx diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx new file mode 100644 index 00000000..f11c36b0 --- /dev/null +++ b/src/app/features/call-status/IncomingCallStrip.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { Avatar, Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { Room } from 'matrix-js-sdk'; +import { useSetAtom } from 'jotai'; +import { useNavigate } from 'react-router-dom'; +import * as css from './styles.css'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { UserAvatar } from '../../components/user-avatar'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; +import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { useDmCallStart } from '../../hooks/useDmCallStart'; +import { getDirectRoomPath } from '../../pages/pathUtils'; +import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls'; +import { getIncomingCallKey } from '../../utils/rtcNotification'; + +const DECLINE_RETRY_DELAY_MS = 500; + +type IncomingCallStripProps = { + call: IncomingCall; + room: Room; +}; +export function IncomingCallStrip({ call, room }: IncomingCallStripProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const navigate = useNavigate(); + const useAuthentication = useMediaAuthentication(); + const startDmCall = useDmCallStart(); + const setIncoming = useSetAtom(incomingCallsAtom); + + const senderId = call.notifEvent.getSender(); + const displayName = + (senderId && getMemberDisplayName(room, senderId)) || + (senderId && getMxIdLocalPart(senderId)) || + senderId || + t('Call.unknown_caller'); + const avatarMxc = senderId ? getMemberAvatarMxc(room, senderId) : undefined; + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; + + const callKey = getIncomingCallKey(call.callId, call.roomId); + + const handleAnswer = () => { + setIncoming({ type: 'REMOVE', key: callKey }); + startDmCall(call.roomId); + navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId))); + }; + + const handleDecline = async () => { + setIncoming({ type: 'REMOVE', key: callKey }); + const evId = call.notifEvent.getId(); + if (!evId) return; + try { + await mx.sendRtcDecline(call.roomId, evId); + } catch (err) { + await new Promise((resolve) => { + setTimeout(resolve, DECLINE_RETRY_DELAY_MS); + }); + try { + await mx.sendRtcDecline(call.roomId, evId); + } catch (retryErr) { + // eslint-disable-next-line no-console + console.warn('[call] sendRtcDecline failed after retry', retryErr); + } + } + }; + + return ( + + + + } + /> + + + + {displayName} + + + {t('Call.incoming')} + + + + + + {t('Call.decline')} + + } + > + {(anchorRef) => ( + + + + )} + + + {t('Call.answer')} + + } + > + {(anchorRef) => ( + + + + )} + + + + ); +} diff --git a/src/app/features/call-status/index.ts b/src/app/features/call-status/index.ts index 99accf8b..49da8655 100644 --- a/src/app/features/call-status/index.ts +++ b/src/app/features/call-status/index.ts @@ -1 +1,2 @@ export * from './CallStatus'; +export * from './IncomingCallStrip'; diff --git a/src/app/features/call/IncomingCallToast.tsx b/src/app/features/call/IncomingCallToast.tsx deleted file mode 100644 index 41be94f4..00000000 --- a/src/app/features/call/IncomingCallToast.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { useAtomValue, useSetAtom } from 'jotai'; -import { Avatar, Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { Room } from 'matrix-js-sdk'; -import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; -import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { UserAvatar } from '../../components/user-avatar'; -import { ContainerColor } from '../../styles/ContainerColor.css'; -import { useDmCallStart } from '../../hooks/useDmCallStart'; -import { getDirectRoomPath } from '../../pages/pathUtils'; -// eslint-disable-next-line import/no-relative-packages -import RingSoundOgg from '../../../../public/sound/ring.ogg'; -// eslint-disable-next-line import/no-relative-packages -import RingSoundMp3 from '../../../../public/sound/ring.mp3'; - -type IncomingCallToastProps = { - call: IncomingCall; - room: Room; -}; - -function IncomingCallToast({ call, room }: IncomingCallToastProps) { - const { t } = useTranslation(); - const mx = useMatrixClient(); - const navigate = useNavigate(); - const useAuthentication = useMediaAuthentication(); - const startDmCall = useDmCallStart(); - const setIncoming = useSetAtom(incomingCallsAtom); - - const senderId = call.notifEvent.getSender(); - const displayName = - (senderId && getMemberDisplayName(room, senderId)) || - (senderId && getMxIdLocalPart(senderId)) || - senderId || - t('Call.unknown_caller'); - const avatarMxc = senderId ? getMemberAvatarMxc(room, senderId) : undefined; - const avatarUrl = avatarMxc - ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined - : undefined; - - const callKey = `call_${call.callId}_${call.roomId}`; - - const handleAnswer = () => { - setIncoming({ type: 'REMOVE', key: callKey }); - startDmCall(call.roomId); - navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId))); - }; - - const handleDecline = async () => { - setIncoming({ type: 'REMOVE', key: callKey }); - const evId = call.notifEvent.getId(); - if (!evId) return; - try { - await mx.sendRtcDecline(call.roomId, evId); - } catch (err) { - // best-effort — toast is gone either way - // eslint-disable-next-line no-console - console.warn('[call] sendRtcDecline failed', err); - } - }; - - return ( - - - } - /> - - - - {displayName} - - - {t('Call.incoming')} - - - - - - - - ); -} - -export function IncomingCallStack() { - const mx = useMatrixClient(); - const incoming = useAtomValue(incomingCallsAtom); - const audioRef = useRef(null); - - const hasIncoming = incoming.size > 0; - - useEffect(() => { - const audio = audioRef.current; - if (!audio) return; - if (hasIncoming) { - audio.currentTime = 0; - audio.play().catch(() => { - // autoplay blocked — toast UI still visible - }); - } else { - audio.pause(); - audio.currentTime = 0; - } - }, [hasIncoming]); - - const entries = Array.from(incoming.values()); - - return ( - <> - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - - {hasIncoming && ( - - {entries.map((call) => { - const room = mx.getRoom(call.roomId); - if (!room) return null; - return ( - - ); - })} - - )} - - ); -} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d651dd11..bacf1a10 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1022,6 +1022,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli [string, MatrixEvent, number, EventTimelineSet, boolean] >( { + // Suppress DM-call service events from the timeline (§5.8). In encrypted + // DMs this takes effect after per-event decryption re-render via + // EncryptedContent; the first render shows the "not decrypted" placeholder. + // Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline + // when MSC4075/MSC4310 stabilize (§5.19). + 'org.matrix.msc4075.rtc.notification': () => null, + 'org.matrix.msc4310.rtc.decline': () => null, [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); diff --git a/src/app/hooks/useCallerAutoHangup.ts b/src/app/hooks/useCallerAutoHangup.ts new file mode 100644 index 00000000..7a24e6ba --- /dev/null +++ b/src/app/hooks/useCallerAutoHangup.ts @@ -0,0 +1,121 @@ +// DM call auto-hangup. +// +// Scope despite the name: fires on any DM callEmbed, including when we're the +// B-side (answered a ring). Logic is symmetric: "leave if peer left or never +// arrived". Rename pending — see dm_calls_techdebt.md §5.15. +// +// Covers four cases (all §5.5): +// 1. Peer declines — RTCDecline timeline event → hangup immediately. +// 2. Peer never joins — no-answer timer fires (lifetime + grace). +// 3. Peer joins then leaves — memberships go empty → hangup after grace. +// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip. +// +// KNOWN GAPS (also in dm_calls_techdebt.md): +// §5.9 Does NOT handle encrypted DMs. `ev.getType()` returns +// 'm.room.encrypted' on first fire; we early-exit. Needs +// `MatrixEventEvent.Decrypted` listener + `decryptEventIfNeeded`, +// pattern authoritative in CallEmbed.ts:233-234. +// §5.16 Decline is not tied to our specific ring event id. Any RTCDecline +// from peer in this room kills our call. +// §5.18 Grace constants are empirically chosen, may need tuning with +// real-world /sync + LiveKit reconnect metrics. + +import { useEffect } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { EventType, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; +import { useMatrixClient } from './useMatrixClient'; +import { callEmbedAtom } from '../state/callEmbed'; +import { mDirectAtom } from '../state/mDirectList'; +import { RTC_NOTIFICATION_DEFAULT_LIFETIME } from '../utils/rtcNotification'; + +// Grace beyond the ring lifetime before giving up on no-answer. Covers /sync +// latency between B joining and A seeing the membership state event. +const NO_ANSWER_GRACE_MS = 10_000; + +// Wait after the peer's last membership drops before tearing down. Matrix RTC +// memberships can flap on LiveKit reconnect / short network hiccups; without a +// grace we'd kill a live call on every blip. +const PEER_LEAVE_GRACE_MS = 8_000; + +export const useCallerAutoHangup = (): void => { + const mx = useMatrixClient(); + const callEmbed = useAtomValue(callEmbedAtom); + const setCallEmbed = useSetAtom(callEmbedAtom); + const mDirect = useAtomValue(mDirectAtom); + + useEffect(() => { + if (!callEmbed) return undefined; + const roomId = callEmbed.roomId; + if (!mDirect.has(roomId)) return undefined; + + const selfId = mx.getUserId(); + if (!selfId) return undefined; + const selfDeviceId = mx.getDeviceId(); + + const isSelf = (m: CallMembership): boolean => + m.sender === selfId && (!selfDeviceId || m.deviceId === selfDeviceId); + + const session = mx.matrixRTC.getRoomSession(callEmbed.room); + + let disposed = false; + let peerSeen = session.memberships.some((m) => !isSelf(m)); + let peerLeaveTimer: ReturnType | undefined; + + const performHangup = () => { + if (disposed) return; + disposed = true; + if (callEmbed.joined) { + callEmbed.hangup().catch(() => setCallEmbed(undefined)); + } else { + setCallEmbed(undefined); + } + }; + + const clearPeerLeaveTimer = () => { + if (peerLeaveTimer) { + clearTimeout(peerLeaveTimer); + peerLeaveTimer = undefined; + } + }; + + const noAnswerTimer: ReturnType | undefined = peerSeen + ? undefined + : setTimeout(performHangup, RTC_NOTIFICATION_DEFAULT_LIFETIME + NO_ANSWER_GRACE_MS); + + const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => { + const peerPresent = next.some((m) => !isSelf(m)); + if (peerPresent) { + clearPeerLeaveTimer(); + if (!peerSeen) { + peerSeen = true; + if (noAnswerTimer) clearTimeout(noAnswerTimer); + } + return; + } + if (peerSeen && !peerLeaveTimer) { + peerLeaveTimer = setTimeout(performHangup, PEER_LEAVE_GRACE_MS); + } + }; + + const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => { + if (!data.liveEvent || !room) return; + if (room.roomId !== roomId) return; + if (ev.getType() !== EventType.RTCDecline) return; + if (ev.getSender() === selfId) return; + performHangup(); + }; + + session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); + mx.on(RoomEvent.Timeline, onTimeline); + + return () => { + disposed = true; + if (noAnswerTimer) clearTimeout(noAnswerTimer); + clearPeerLeaveTimer(); + session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); + mx.removeListener(RoomEvent.Timeline, onTimeline); + }; + }, [mx, callEmbed, mDirect, setCallEmbed]); +}; diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index bfdabfb5..0562c0d9 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -1,3 +1,19 @@ +// Incoming DM ring: watches `m.rtc.notification` in the live timeline and +// populates `incomingCallsAtom` so the bottom strip can render. +// +// KNOWN GAP §5.9 (blocks Phase 2 commit): encrypted DMs. +// `RoomEvent.Timeline` fires once with `ev.getType() === 'm.room.encrypted'` +// (verified in matrix-js-sdk 38.2 event-timeline-set.js:563, no re-emit post +// decrypt). Our `!== EventType.RTCNotification` filter early-exits and the +// ring never reaches the strip. Authoritative fix pattern: also +// `mx.on(MatrixEventEvent.Decrypted, h)` and `mx.decryptEventIfNeeded(ev)` — +// see CallEmbed.ts:233-234 for the reference implementation. +// +// The `registryRef` sync-effect below handles an asymmetry §5.6: REMOVE on the +// atom can come from outside the hook (strip buttons), and we need to drop +// timers/listeners for those keys to avoid leaks and to let a fresh ring with +// the same dedup key re-trigger. + import { useEffect, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { @@ -108,6 +124,7 @@ export const useIncomingRtcNotifications = (): void => { const mx = useMatrixClient(); const mDirect = useAtomValue(mDirectAtom); const callEmbed = useAtomValue(callEmbedAtom); + const incoming = useAtomValue(incomingCallsAtom); const setIncoming = useSetAtom(incomingCallsAtom); const mDirectRef = useRef(mDirect); @@ -115,6 +132,8 @@ export const useIncomingRtcNotifications = (): void => { const inCallRef = useRef(callEmbed !== undefined); inCallRef.current = callEmbed !== undefined; + const registryRef = useRef>(new Map()); + // When local user joins any call (via header / other UI), drop any toast for that room. useEffect(() => { if (callEmbed) { @@ -122,8 +141,25 @@ export const useIncomingRtcNotifications = (): void => { } }, [callEmbed, setIncoming]); + // Any key dropped from the atom (external REMOVE via strip accept/decline, etc.) + // must also drop the matching registry entry — otherwise its expiry timer and + // memberships listener leak, and a fresh ring for the same dedup key would be + // swallowed by `registry.has(key)` until the timer finally fires. useEffect(() => { - const registry = new Map(); + const registry = registryRef.current; + Array.from(registry.keys()).forEach((key) => { + if (!incoming.has(key)) { + const entry = registry.get(key); + if (!entry) return; + clearTimeout(entry.timer); + entry.unsubMemberships?.(); + registry.delete(key); + } + }); + }, [incoming]); + + useEffect(() => { + const registry = registryRef.current; const removeByKey = (key: string) => { const entry = registry.get(key); diff --git a/src/app/pages/IncomingCallStripRenderer.tsx b/src/app/pages/IncomingCallStripRenderer.tsx new file mode 100644 index 00000000..7cd3e9b0 --- /dev/null +++ b/src/app/pages/IncomingCallStripRenderer.tsx @@ -0,0 +1,69 @@ +// Top-level renderer for incoming DM call strips + ringtone audio. +// +// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before +// `CallStatusRenderer` so the strip stacks above the in-call pill. +// +// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no +// user gesture yet), the ring is silent — strip is still visible but user may +// miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is +// Phase 3 polish. + +import React, { useEffect, useRef } from 'react'; +import { useAtomValue } from 'jotai'; +import { Box } from 'folds'; +import { incomingCallsAtom } from '../state/incomingCalls'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { IncomingCallStrip } from '../features/call-status'; +// eslint-disable-next-line import/no-relative-packages +import RingSoundOgg from '../../../public/sound/ring.ogg'; +// eslint-disable-next-line import/no-relative-packages +import RingSoundMp3 from '../../../public/sound/ring.mp3'; + +export function IncomingCallStripRenderer() { + const mx = useMatrixClient(); + const incoming = useAtomValue(incomingCallsAtom); + const audioRef = useRef(null); + + const hasIncoming = incoming.size > 0; + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + if (hasIncoming) { + audio.currentTime = 0; + audio.play().catch(() => { + // autoplay blocked — strip UI still visible + }); + } else { + audio.pause(); + audio.currentTime = 0; + } + }, [hasIncoming]); + + const entries = Array.from(incoming.values()); + + return ( + <> + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + + {hasIncoming && ( + + {entries.map((call) => { + const room = mx.getRoom(call.roomId); + if (!room) return null; + return ( + + ); + })} + + )} + + ); +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 477ca2e9..c47c06e4 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -71,11 +71,13 @@ import { getFallbackSession } from '../state/sessions'; import { CallStatusRenderer } from './CallStatusRenderer'; import { CallEmbedProvider } from '../components/CallEmbedProvider'; import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications'; -import { IncomingCallStack } from '../features/call/IncomingCallToast'; +import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup'; +import { IncomingCallStripRenderer } from './IncomingCallStripRenderer'; function IncomingCallsFeature() { useIncomingRtcNotifications(); - return ; + useCallerAutoHangup(); + return null; } export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { @@ -143,6 +145,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > +