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;
return (
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
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
<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 (
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
onClick={handleOpenReply}
getMemberPowerTag={getMemberPowerTag}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
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}
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}
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} />}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
>
{(() => {
if (mEvent.isRedacted()) return <RedactedContent />;
if (decryptedType === MessageEvent.Sticker)
return (
<MSticker
content={mEvent.getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...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 (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
);
}
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
}}
</EncryptedContent>
</Message>
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ??
getMxIdLocalPart(senderId) ??
senderId;
return (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
);
}
if (decryptedType === MessageEvent.RoomMessageEncrypted)
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
})()}
</Message>
);
}}
</EncryptedContent>
);
},
[MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {

View file

@ -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]);
};

View file

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