From 6a9096881ae81d9c2dfc85c4fca5fc0802af684e Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sun, 19 Apr 2026 15:26:37 +0300 Subject: [PATCH] dm calls mvp: phase 2: handle ring/decline + timeline render in encrypted DMs --- src/app/features/room/RoomTimeline.tsx | 225 ++++++++++--------- src/app/hooks/useCallerAutoHangup.ts | 45 +++- src/app/hooks/useIncomingRtcNotifications.ts | 57 +++-- 3 files changed, 198 insertions(+), 129 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index bacf1a10..444352ae 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1127,116 +1127,131 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const highlighted = focusItem?.index === item && focusItem.highlight; return ( - + {() => { + // §5.9: after decrypt, DM-call service events still route through + // this branch (outer typeToRenderer dispatched on the pre-decrypt + // 'm.room.encrypted' type). Drop the whole row instead of falling + // through to MessageUnsupportedContent. Keys mirror the hardcoded + // literals in the outer filter — migrate together (§5.19). + const decryptedType = mEvent.getType(); + if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null; + if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null; + + return ( + + ) + } + reactions={ + reactionRelations && ( + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={legacyUsernameColor || direct} - /> - ) - } - reactions={ - reactionRelations && ( - - ) - } - hideReadReceipts={hideActivity} - showDeveloperTools={showDeveloperTools} - memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} - accessibleTagColors={accessiblePowerTagColors} - legacyUsernameColor={legacyUsernameColor || direct} - hour24Clock={hour24Clock} - dateFormatString={dateFormatString} - > - - {() => { - if (mEvent.isRedacted()) return ; - if (mEvent.getType() === MessageEvent.Sticker) - return ( - ( - } - renderViewer={(p) => } + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {(() => { + if (mEvent.isRedacted()) return ; + if (decryptedType === MessageEvent.Sticker) + return ( + ( + } + renderViewer={(p) => } + /> + )} /> - )} - /> - ); - if (mEvent.getType() === MessageEvent.RoomMessage) { - const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); - const getContent = (() => - editedEvent?.getContent()['m.new_content'] ?? - mEvent.getContent()) as GetContentCallback; + ); + if (decryptedType === MessageEvent.RoomMessage) { + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? + mEvent.getContent()) as GetContentCallback; - const senderId = mEvent.getSender() ?? ''; - const senderDisplayName = - getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; - return ( - - ); - } - if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) - return ( - - - - ); - return ( - - - - ); - }} - - + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? + getMxIdLocalPart(senderId) ?? + senderId; + return ( + + ); + } + if (decryptedType === MessageEvent.RoomMessageEncrypted) + return ( + + + + ); + return ( + + + + ); + })()} + + ); + }} + ); }, [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { diff --git a/src/app/hooks/useCallerAutoHangup.ts b/src/app/hooks/useCallerAutoHangup.ts index 7a24e6ba..38697fce 100644 --- a/src/app/hooks/useCallerAutoHangup.ts +++ b/src/app/hooks/useCallerAutoHangup.ts @@ -11,18 +11,27 @@ // 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. +// +// Encrypted DMs: RTCDecline arrives as m.room.encrypted first and Timeline +// does not re-emit post-decrypt (matrix-js-sdk 38.2). We mirror the +// CallEmbed.ts:233-234 pattern — listen to both Timeline (kick decryption) +// and MatrixEventEvent.Decrypted. `performHangup` is guarded by `disposed`, +// so double delivery for cleartext rooms is a no-op. import { useEffect } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; -import { EventType, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; +import { + EventType, + MatrixEvent, + MatrixEventEvent, + MatrixEventHandlerMap, + 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'; @@ -99,16 +108,35 @@ export const useCallerAutoHangup = (): void => { } }; - const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => { - if (!data.liveEvent || !room) return; - if (room.roomId !== roomId) return; + const maybeHangupOnDecline = (ev: MatrixEvent): void => { + // onDecrypted fires for every decrypted event client-wide; gate on room + // first to avoid walking type/sender on unrelated rooms. + if (ev.getRoomId() !== roomId) return; if (ev.getType() !== EventType.RTCDecline) return; if (ev.getSender() === selfId) return; performHangup(); }; + const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => { + if (!data.liveEvent || !room) return; + if (room.roomId !== roomId) return; + // Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted + // handler see it once cleartext is available. + if (ev.isEncrypted()) { + mx.decryptEventIfNeeded(ev).catch(() => {}); + return; + } + maybeHangupOnDecline(ev); + }; + + const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => { + if (err) return; + maybeHangupOnDecline(ev); + }; + session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); mx.on(RoomEvent.Timeline, onTimeline); + mx.on(MatrixEventEvent.Decrypted, onDecrypted); return () => { disposed = true; @@ -116,6 +144,7 @@ export const useCallerAutoHangup = (): void => { clearPeerLeaveTimer(); session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); mx.removeListener(RoomEvent.Timeline, onTimeline); + mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted); }; }, [mx, callEmbed, mDirect, setCallEmbed]); }; diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index 0562c0d9..5c431a4c 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -1,13 +1,13 @@ // 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. +// Encrypted DMs (§5.9): `RoomEvent.Timeline` fires once with +// `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt +// (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline +// and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline +// handler — mirrors CallEmbed.ts:233-234. Dedup relies on `registry.has(key)` +// for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so +// double delivery (Timeline for cleartext + Decrypted for encrypted) is safe. // // 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 @@ -20,6 +20,8 @@ import { EventType, MatrixClient, MatrixEvent, + MatrixEventEvent, + MatrixEventHandlerMap, RelationType, Room, RoomEvent, @@ -204,15 +206,7 @@ export const useIncomingRtcNotifications = (): void => { return setTimeout(() => removeByKey(key), delay); }; - const handleTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = async ( - ev, - room, - _toStartOfTimeline, - _removed, - data - ) => { - if (!data.liveEvent || !room) return; - + const processEvent = async (ev: MatrixEvent, room: Room): Promise => { if (ev.getType() === EventType.RTCDecline) { const rel = ev.getRelation(); if (rel?.event_id) removeByNotifId(rel.event_id); @@ -269,6 +263,35 @@ export const useIncomingRtcNotifications = (): void => { }); }; + const handleTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = ( + ev, + room, + _toStartOfTimeline, + _removed, + data + ) => { + if (!data.liveEvent || !room) return; + // Encrypted events land here as m.room.encrypted; kick decryption and let + // the Decrypted handler pick them up once the cleartext is available. + if (ev.isEncrypted()) { + mx.decryptEventIfNeeded(ev).catch(() => {}); + return; + } + processEvent(ev, room); + }; + + const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => { + if (err) return; + const roomId = ev.getRoomId(); + if (!roomId) return; + const room = mx.getRoom(roomId); + if (!room) return; + // No liveEvent flag on Decrypted. Backfill safety relies on + // `isRtcNotificationExpired` (ring branch) and `registry.has(key)` dedup; + // a stale decline just no-ops via `removeByNotifId`. + processEvent(ev, room); + }; + const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => { const redacted = ev.event.redacts; if (redacted) removeByNotifId(redacted); @@ -279,11 +302,13 @@ export const useIncomingRtcNotifications = (): void => { }; mx.on(RoomEvent.Timeline, handleTimeline); + mx.on(MatrixEventEvent.Decrypted, handleDecrypted); mx.on(RoomEvent.Redaction, handleRedaction); mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); return () => { mx.removeListener(RoomEvent.Timeline, handleTimeline); + mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted); mx.removeListener(RoomEvent.Redaction, handleRedaction); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); registry.forEach((entry) => {