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