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

@ -1127,116 +1127,131 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
return ( return (
<Message <EncryptedContent mEvent={mEvent}>
key={mEvent.getId()} {() => {
data-message-item={item} // §5.9: after decrypt, DM-call service events still route through
data-message-id={mEventId} // this branch (outer typeToRenderer dispatched on the pre-decrypt
room={room} // 'm.room.encrypted' type). Drop the whole row instead of falling
mEvent={mEvent} // through to MessageUnsupportedContent. Keys mirror the hardcoded
messageSpacing={messageSpacing} // literals in the outer filter — migrate together (§5.19).
messageLayout={messageLayout} const decryptedType = mEvent.getType();
collapse={collapse} if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null;
highlight={highlighted} if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null;
edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} return (
canSendReaction={canSendReaction} <Message
canPinEvent={canPinEvent} key={mEvent.getId()}
imagePackRooms={imagePackRooms} data-message-item={item}
relations={hasReactions ? reactionRelations : undefined} data-message-id={mEventId}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room} room={room}
timelineSet={timelineSet} mEvent={mEvent}
replyEventId={replyEventId} messageSpacing={messageSpacing}
threadRootId={threadRootId} messageLayout={messageLayout}
onClick={handleOpenReply} collapse={collapse}
getMemberPowerTag={getMemberPowerTag} highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
/>
)
}
reactions={
reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={mEventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)
}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors} accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct} legacyUsernameColor={legacyUsernameColor || direct}
/> hour24Clock={hour24Clock}
) dateFormatString={dateFormatString}
} >
reactions={ {(() => {
reactionRelations && ( if (mEvent.isRedacted()) return <RedactedContent />;
<Reactions if (decryptedType === MessageEvent.Sticker)
style={{ marginTop: config.space.S200 }} return (
room={room} <MSticker
relations={reactionRelations} content={mEvent.getContent()}
mEventId={mEventId} renderImageContent={(props) => (
canSendReaction={canSendReaction} <ImageContent
onReactionToggle={handleReactionToggle} {...props}
/> autoPlay={mediaAutoLoad}
) renderImage={(p) => <Image {...p} loading="lazy" />}
} renderViewer={(p) => <ImageViewer {...p} />}
hideReadReceipts={hideActivity} />
showDeveloperTools={showDeveloperTools} )}
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
<EncryptedContent mEvent={mEvent}>
{() => {
if (mEvent.isRedacted()) return <RedactedContent />;
if (mEvent.getType() === MessageEvent.Sticker)
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/> />
)} );
/> if (decryptedType === MessageEvent.RoomMessage) {
); const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
if (mEvent.getType() === MessageEvent.RoomMessage) { const getContent = (() =>
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); editedEvent?.getContent()['m.new_content'] ??
const getContent = (() => mEvent.getContent()) as GetContentCallback;
editedEvent?.getContent()['m.new_content'] ??
mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ??
return ( getMxIdLocalPart(senderId) ??
<RenderMessageContent senderId;
displayName={senderDisplayName} return (
msgType={mEvent.getContent().msgtype ?? ''} <RenderMessageContent
ts={mEvent.getTs()} displayName={senderDisplayName}
edited={!!editedEvent} msgType={mEvent.getContent().msgtype ?? ''}
getContent={getContent} ts={mEvent.getTs()}
mediaAutoLoad={mediaAutoLoad} edited={!!editedEvent}
urlPreview={showUrlPreview} getContent={getContent}
htmlReactParserOptions={htmlReactParserOptions} mediaAutoLoad={mediaAutoLoad}
linkifyOpts={linkifyOpts} urlPreview={showUrlPreview}
outlineAttachment={messageLayout === MessageLayout.Bubble} htmlReactParserOptions={htmlReactParserOptions}
/> linkifyOpts={linkifyOpts}
); outlineAttachment={messageLayout === MessageLayout.Bubble}
} />
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) );
return ( }
<Text> if (decryptedType === MessageEvent.RoomMessageEncrypted)
<MessageNotDecryptedContent /> return (
</Text> <Text>
); <MessageNotDecryptedContent />
return ( </Text>
<Text> );
<MessageUnsupportedContent /> return (
</Text> <Text>
); <MessageUnsupportedContent />
}} </Text>
</EncryptedContent> );
</Message> })()}
</Message>
);
}}
</EncryptedContent>
); );
}, },
[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) => {