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 () => {