dm calls mvp: phase 2: handle ring/decline + timeline render in encrypted DMs

This commit is contained in:
v.lagerev 2026-04-19 15:26:37 +03:00
parent e7c269d49d
commit 6a9096881a
3 changed files with 198 additions and 129 deletions

View file

@ -1126,6 +1126,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const { replyEventId, threadRootId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
return (
<EncryptedContent mEvent={mEvent}>
{() => {
// §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 ( return (
<Message <Message
key={mEvent.getId()} key={mEvent.getId()}
@ -1182,10 +1194,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
hour24Clock={hour24Clock} hour24Clock={hour24Clock}
dateFormatString={dateFormatString} dateFormatString={dateFormatString}
> >
<EncryptedContent mEvent={mEvent}> {(() => {
{() => {
if (mEvent.isRedacted()) return <RedactedContent />; if (mEvent.isRedacted()) return <RedactedContent />;
if (mEvent.getType() === MessageEvent.Sticker) if (decryptedType === MessageEvent.Sticker)
return ( return (
<MSticker <MSticker
content={mEvent.getContent()} content={mEvent.getContent()}
@ -1199,7 +1210,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)} )}
/> />
); );
if (mEvent.getType() === MessageEvent.RoomMessage) { if (decryptedType === MessageEvent.RoomMessage) {
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
const getContent = (() => const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ?? editedEvent?.getContent()['m.new_content'] ??
@ -1207,7 +1218,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ??
getMxIdLocalPart(senderId) ??
senderId;
return ( return (
<RenderMessageContent <RenderMessageContent
displayName={senderDisplayName} displayName={senderDisplayName}
@ -1223,7 +1236,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
/> />
); );
} }
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) if (decryptedType === MessageEvent.RoomMessageEncrypted)
return ( return (
<Text> <Text>
<MessageNotDecryptedContent /> <MessageNotDecryptedContent />
@ -1234,9 +1247,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<MessageUnsupportedContent /> <MessageUnsupportedContent />
</Text> </Text>
); );
})()}
</Message>
);
}} }}
</EncryptedContent> </EncryptedContent>
</Message>
); );
}, },
[MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {

View file

@ -11,18 +11,27 @@
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip. // 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
// //
// KNOWN GAPS (also in dm_calls_techdebt.md): // 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 // §5.16 Decline is not tied to our specific ring event id. Any RTCDecline
// from peer in this room kills our call. // from peer in this room kills our call.
// §5.18 Grace constants are empirically chosen, may need tuning with // §5.18 Grace constants are empirically chosen, may need tuning with
// real-world /sync + LiveKit reconnect metrics. // 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 { useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; 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 { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
@ -99,16 +108,35 @@ export const useCallerAutoHangup = (): void => {
} }
}; };
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => { const maybeHangupOnDecline = (ev: MatrixEvent): void => {
if (!data.liveEvent || !room) return; // onDecrypted fires for every decrypted event client-wide; gate on room
if (room.roomId !== roomId) return; // first to avoid walking type/sender on unrelated rooms.
if (ev.getRoomId() !== roomId) return;
if (ev.getType() !== EventType.RTCDecline) return; if (ev.getType() !== EventType.RTCDecline) return;
if (ev.getSender() === selfId) return; if (ev.getSender() === selfId) return;
performHangup(); 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); session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.on(RoomEvent.Timeline, onTimeline); mx.on(RoomEvent.Timeline, onTimeline);
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
return () => { return () => {
disposed = true; disposed = true;
@ -116,6 +144,7 @@ export const useCallerAutoHangup = (): void => {
clearPeerLeaveTimer(); clearPeerLeaveTimer();
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline); mx.removeListener(RoomEvent.Timeline, onTimeline);
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
}; };
}, [mx, callEmbed, mDirect, setCallEmbed]); }, [mx, callEmbed, mDirect, setCallEmbed]);
}; };

View file

@ -1,13 +1,13 @@
// Incoming DM ring: watches `m.rtc.notification` in the live timeline and // Incoming DM ring: watches `m.rtc.notification` in the live timeline and
// populates `incomingCallsAtom` so the bottom strip can render. // populates `incomingCallsAtom` so the bottom strip can render.
// //
// KNOWN GAP §5.9 (blocks Phase 2 commit): encrypted DMs. // Encrypted DMs (§5.9): `RoomEvent.Timeline` fires once with
// `RoomEvent.Timeline` fires once with `ev.getType() === 'm.room.encrypted'` // `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt
// (verified in matrix-js-sdk 38.2 event-timeline-set.js:563, no re-emit post // (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline
// decrypt). Our `!== EventType.RTCNotification` filter early-exits and the // and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline
// ring never reaches the strip. Authoritative fix pattern: also // handler — mirrors CallEmbed.ts:233-234. Dedup relies on `registry.has(key)`
// `mx.on(MatrixEventEvent.Decrypted, h)` and `mx.decryptEventIfNeeded(ev)` — // for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so
// see CallEmbed.ts:233-234 for the reference implementation. // double delivery (Timeline for cleartext + Decrypted for encrypted) is safe.
// //
// The `registryRef` sync-effect below handles an asymmetry §5.6: REMOVE on the // 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 // atom can come from outside the hook (strip buttons), and we need to drop
@ -20,6 +20,8 @@ import {
EventType, EventType,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
MatrixEventEvent,
MatrixEventHandlerMap,
RelationType, RelationType,
Room, Room,
RoomEvent, RoomEvent,
@ -204,15 +206,7 @@ export const useIncomingRtcNotifications = (): void => {
return setTimeout(() => removeByKey(key), delay); return setTimeout(() => removeByKey(key), delay);
}; };
const handleTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = async ( const processEvent = async (ev: MatrixEvent, room: Room): Promise<void> => {
ev,
room,
_toStartOfTimeline,
_removed,
data
) => {
if (!data.liveEvent || !room) return;
if (ev.getType() === EventType.RTCDecline) { if (ev.getType() === EventType.RTCDecline) {
const rel = ev.getRelation(); const rel = ev.getRelation();
if (rel?.event_id) removeByNotifId(rel.event_id); 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 handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => {
const redacted = ev.event.redacts; const redacted = ev.event.redacts;
if (redacted) removeByNotifId(redacted); if (redacted) removeByNotifId(redacted);
@ -279,11 +302,13 @@ export const useIncomingRtcNotifications = (): void => {
}; };
mx.on(RoomEvent.Timeline, handleTimeline); mx.on(RoomEvent.Timeline, handleTimeline);
mx.on(MatrixEventEvent.Decrypted, handleDecrypted);
mx.on(RoomEvent.Redaction, handleRedaction); mx.on(RoomEvent.Redaction, handleRedaction);
mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
return () => { return () => {
mx.removeListener(RoomEvent.Timeline, handleTimeline); mx.removeListener(RoomEvent.Timeline, handleTimeline);
mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
mx.removeListener(RoomEvent.Redaction, handleRedaction); mx.removeListener(RoomEvent.Redaction, handleRedaction);
mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded);
registry.forEach((entry) => { registry.forEach((entry) => {