dm calls mvp: phase 2: handle ring/decline + timeline render in encrypted DMs
This commit is contained in:
parent
e7c269d49d
commit
6a9096881a
3 changed files with 198 additions and 129 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue