From dca51a41ef019b3bce747934b3fbaa98e7756835 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 1 Jul 2026 23:19:01 -0400 Subject: [PATCH] fix(forward): full-width search + deep-audit fixes for message forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of ForwardMessageDialog, fixes: - Search input was intrinsic-width (sat in a default Row Box with no grow) — now a Column Box stretches it full-width, matching every other search input. - Search field is auto-focused on open (FocusTrap initialFocus; was nothing). - Edited messages now forward the LATEST edit (m.new_content via getEditedEvent) instead of the stale pre-edit body. - Reply fallbacks stripped (trimReplyFromBody + block) along with m.relates_to, so forwards stand alone instead of quoting the old room. - Undecryptable events are refused with an inline error (previously forwarded m.bad.encrypted junk); send failures now show an error instead of silently resetting. - sendEvent uses the typed threadId-aware overload (explicit null) instead of an untyped (mx as any) call relying on the SDK's legacy arg-sniffing. - Room list + filter memoized (was re-sorting all rooms every keystroke). Co-Authored-By: Claude Opus 4.8 --- .../room/message/ForwardMessageDialog.tsx | 91 ++++++++++++++++--- 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/src/app/features/room/message/ForwardMessageDialog.tsx b/src/app/features/room/message/ForwardMessageDialog.tsx index bef10aad0..b2a4d84a8 100644 --- a/src/app/features/room/message/ForwardMessageDialog.tsx +++ b/src/app/features/room/message/ForwardMessageDialog.tsx @@ -1,8 +1,9 @@ -import React, { ChangeEvent, useCallback, useState } from 'react'; +import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Avatar, Box, + color, config, Header, Icon, @@ -28,6 +29,7 @@ import { mDirectAtom } from '../../../state/mDirectList'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; +import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room'; type RoomRowProps = { room: Room; @@ -86,35 +88,83 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { const modalStyle = useModalStyle(400); const directs = useAtomValue(mDirectAtom); const useAuthentication = useMediaAuthentication(); + const searchInputRef = useRef(null); const [query, setQuery] = useState(''); const [sending, setSending] = useState(false); const [sentTo, setSentTo] = useState(null); + const [error, setError] = useState(null); - const allRooms = mx - .getRooms() - .filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom()) - .sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)); + const allRooms = useMemo( + () => + mx + .getRooms() + .filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom()) + .sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)), + [mx], + ); - const filtered = query - ? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase())) - : allRooms; + const filtered = useMemo(() => { + if (!query) return allRooms; + const q = query.toLowerCase(); + return allRooms.filter((r) => r.name.toLowerCase().includes(q)); + }, [allRooms, query]); + + /** + * Build the content to forward: + * - undecryptable events are refused (would forward `m.bad.encrypted` junk) + * - edited messages forward the LATEST edit (`m.new_content`), not the + * original pre-edit body + * - reply fallbacks (`> <@user> …` quote + `` block) are stripped + * along with the `m.relates_to` reply/thread relation, so the forwarded + * message stands alone in the target room + */ + const buildForwardContent = useCallback((): Record | undefined => { + if (mEvent.isDecryptionFailure()) return undefined; + + let content = { ...mEvent.getContent() }; + + const eventId = mEvent.getId(); + const room = mx.getRoom(mEvent.getRoomId()); + if (eventId && room) { + const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet()); + const newContent = editedEvent?.getContent()['m.new_content']; + if (newContent && typeof newContent === 'object') { + content = { ...(newContent as Record) }; + } + } + + delete content['m.relates_to']; + if (typeof content.body === 'string') { + content.body = trimReplyFromBody(content.body); + } + if (typeof content.formatted_body === 'string') { + content.formatted_body = trimReplyFromFormattedBody(content.formatted_body); + } + return content; + }, [mx, mEvent]); const forward = useCallback( async (room: Room) => { if (sending) return; + const fwdContent = buildForwardContent(); + if (!fwdContent) { + setError('This message could not be decrypted, so it cannot be forwarded.'); + return; + } setSending(true); - const fwdContent: Record = { ...mEvent.getContent() }; - delete fwdContent['m.relates_to']; + setError(null); try { + // threadId-aware overload (P3-8): explicit null = send to the main timeline. // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent); + await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent); setSentTo(room.name); setTimeout(onClose, 1400); } catch { setSending(false); + setError(`Failed to forward to ${room.name}. Try again.`); } }, - [mx, mEvent, onClose, sending], + [mx, mEvent, onClose, sending, buildForwardContent], ); return ( @@ -122,7 +172,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { searchInputRef.current ?? false, onDeactivate: onClose, clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, @@ -153,8 +203,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { {!sentTo && ( - + ) => setQuery(e.target.value)} /> + {error && ( + + {error} + + )} )}