diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx
index ad55d0fdc..cb3cd904b 100644
--- a/src/app/features/room/ThreadBrowser.tsx
+++ b/src/app/features/room/ThreadBrowser.tsx
@@ -14,7 +14,7 @@ import {
toRem,
} from 'folds';
import type { EventTimelineSet, MatrixEvent, Room, Thread } from '$types/matrix-sdk';
-import { MatrixEventEvent, NotificationCountType, RoomEvent, ThreadEvent } from '$types/matrix-sdk';
+import { EventType, NotificationCountType, RoomEvent, ThreadEvent } from '$types/matrix-sdk';
import { useAtomValue } from 'jotai';
import type { HTMLReactParserOptions } from 'html-react-parser';
import type { Opts as LinkifyOpts } from 'linkifyjs';
@@ -26,6 +26,7 @@ import { nicknamesAtom } from '$state/nicknames';
import { getMemberAvatarMxc, getMemberDisplayName, reactionOrEditEvent } from '$utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
import { UserAvatar } from '$components/user-avatar';
+import { MessageNotDecryptedContent, MessageBadEncryptedContent } from '$components/message';
import {
Chats,
chipIcon,
@@ -241,6 +242,15 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) {
return ;
}
+ const type = rootEvent.getType();
+ if (type === (EventType.RoomMessageEncrypted as string)) {
+ return ;
+ }
+
+ if (rootEvent.isDecryptionFailure()) {
+ return ;
+ }
+
return (
{
const onUpdate = () => forceUpdate((n) => n + 1);
- const onDecrypted = (mEvent: MatrixEvent) => {
- if (mEvent.getRoomId() !== room.roomId) return;
- const relation = mEvent.getRelation();
- const isThreadRelation = relation?.rel_type === 'm.thread';
- const threadId = isThreadRelation ? relation.event_id : mEvent.getId();
- if (threadId && room.getThread(threadId)) {
- onUpdate();
- }
- };
room.on(ThreadEvent.New, onUpdate);
room.on(ThreadEvent.Update, onUpdate);
room.on(ThreadEvent.NewReply, onUpdate);
- mx.on(MatrixEventEvent.Decrypted, onDecrypted);
let cancelled = false;
const loadThreads = async () => {
@@ -391,7 +391,6 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr
room.off(ThreadEvent.New, onUpdate);
room.off(ThreadEvent.Update, onUpdate);
room.off(ThreadEvent.NewReply, onUpdate);
- mx.off(MatrixEventEvent.Decrypted, onDecrypted);
};
}, [room, mx]);
diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx
index e956c744c..5f680cce5 100644
--- a/src/app/features/room/ThreadDrawer.tsx
+++ b/src/app/features/room/ThreadDrawer.tsx
@@ -2,7 +2,7 @@ import type { MouseEventHandler } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Header, IconButton, Scroll, Spinner, Text, config, toRem } from 'folds';
import { Chats, composerIcon, X } from '$components/icons/phosphor';
-import type { IEvent, Room } from '$types/matrix-sdk';
+import type { IEvent, Room, CryptoBackend } from '$types/matrix-sdk';
import {
Direction,
MatrixEvent,
@@ -390,32 +390,67 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
forceUpdate((n) => n + 1);
}
};
- const onDecrypted = (mEvent: MatrixEvent) => {
+ const onRedaction = (mEvent: MatrixEvent) => {
+ // Redactions (removing reactions/messages) should also trigger updates
if (isEventInThread(mEvent)) {
forceUpdate((n) => n + 1);
}
};
- const onRedaction = (mEvent: MatrixEvent) => {
- // Redactions (removing reactions/messages) should also trigger updates
+ const onDecrypted = (mEvent: MatrixEvent) => {
if (isEventInThread(mEvent)) {
+ const currThread = room.getThread(threadRootId);
+ if (currThread && !currThread.events.includes(mEvent)) {
+ currThread.addEvents([mEvent], false);
+ }
forceUpdate((n) => n + 1);
}
};
const onThreadUpdate = () => forceUpdate((n) => n + 1);
mx.on(RoomEvent.Timeline, onTimeline);
- mx.on(MatrixEventEvent.Decrypted, onDecrypted);
room.on(RoomEvent.Redaction, onRedaction);
room.on(ThreadEvent.Update, onThreadUpdate);
room.on(ThreadEvent.NewReply, onThreadUpdate);
+ mx.on(MatrixEventEvent.Decrypted, onDecrypted);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
- mx.off(MatrixEventEvent.Decrypted, onDecrypted);
room.removeListener(RoomEvent.Redaction, onRedaction);
room.removeListener(ThreadEvent.Update, onThreadUpdate);
room.removeListener(ThreadEvent.NewReply, onThreadUpdate);
+ mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
};
}, [mx, room, threadRootId]);
+ // Retry decryption for thread root events that failed because they were fetched before keys arrived
+ useEffect(() => {
+ if (!rootEvent?.isEncrypted() || !rootEvent.isDecryptionFailure()) return undefined;
+ const crypto = mx.getCrypto();
+ if (!crypto) return undefined;
+
+ const retryDecrypt = async () => {
+ try {
+ await rootEvent.attemptDecryption(crypto as CryptoBackend);
+ if (!rootEvent.isDecryptionFailure()) forceUpdate((n) => n + 1);
+ } catch {
+ // ignore
+ }
+ };
+
+ // Piggyback on other decryptions as a proxy signal for key arrival
+ const sentinels = room
+ .getLiveTimeline()
+ .getEvents()
+ .slice(-50)
+ .filter((e) => e.isEncrypted());
+ sentinels.forEach((e) => e.on(MatrixEventEvent.Decrypted, retryDecrypt));
+
+ // Attempt immediately in case keys arrived since the initial failure
+ retryDecrypt();
+
+ return () => {
+ sentinels.forEach((e) => e.off(MatrixEventEvent.Decrypted, retryDecrypt));
+ };
+ }, [rootEvent, room, mx]);
+
// Mark thread as read when viewing it
useEffect(() => {
const markThreadAsRead = async () => {