From f155a4dc22e866d2ad5999a4cd03f4271d429c8f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 20:28:26 -0400 Subject: [PATCH] feat(threads): up-arrow edits your last thread reply (+ fix cross-fire) The thread composer reused RoomInput's hardcoded editableName="RoomInput", so the main timeline's global up-arrow "edit last message" handler fired while focused in a thread composer and targeted the MAIN timeline's last message (wrong), and there was no up-arrow edit for the thread itself. - Make editableName a RoomInput prop (default "RoomInput"); the thread composer passes "ThreadInput", so the two up-arrow handlers never cross-fire. - Add an up-arrow-edit handler to ThreadTimeline (parity with RoomTimeline): empty thread composer + Up -> edit the latest editable reply in that thread, using thread.liveTimeline + canEditEvent + setEditId. Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 2 ++ src/app/features/room/RoomInput.tsx | 8 +++-- src/app/features/room/thread/ThreadPanel.tsx | 1 + .../features/room/thread/ThreadTimeline.tsx | 34 +++++++++++++++++-- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 3895db28b..488e67281 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -901,6 +901,8 @@ Root messages in the main timeline show a **"N replies · time"** chip (server-a The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1). +**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply *in that thread* — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message). + ### Notifications (Slack-style, P4-1) By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it). diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 9ce1d7349..050875d0b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -151,9 +151,13 @@ interface RoomInputProps { roomId: string; room: Room; threadRootId?: string; + // Identifies this composer to global key handlers (e.g. the up-arrow "edit last + // message" handler). Threads pass a distinct name so the main timeline's handler + // doesn't fire for the thread composer, and vice-versa. + editableName?: string; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => { + ({ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); @@ -981,7 +985,7 @@ export const RoomInput = forwardRef( )} diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index 48c2a5423..e684f0a44 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -32,11 +32,12 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds'; import classNames from 'classnames'; import { Opts as LinkifyOpts } from 'linkifyjs'; +import { isKeyHotkey } from 'is-hotkey'; import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator'; import { useAlive } from '../../../hooks/useAlive'; -import { scrollToBottom } from '../../../utils/dom'; +import { editableActiveElement, scrollToBottom } from '../../../utils/dom'; import { DefaultPlaceholder, MessageBase, @@ -55,9 +56,11 @@ import { renderMatrixMention, } from '../../../plugins/react-custom-html-parser'; import { + canEditEvent, decryptAllTimelineEvent, getEditedEvent, getEventReactions, + getLatestEditableEvt, getMemberName, getReactionContent, reactionOrEditEvent, @@ -76,7 +79,8 @@ import { today, yesterday, } from '../../../utils/time'; -import { createMentionElement, moveCursor } from '../../../components/editor'; +import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor'; +import { useKeyDown } from '../../../hooks/useKeyDown'; import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts'; import { usePowerLevelsContext } from '../../../hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room'; @@ -580,6 +584,32 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { [editor], ); + // Up-arrow in the (empty) thread composer edits your last editable reply in + // this thread — parity with the main timeline. Gated on the thread composer's + // distinct editableName so it and the main handler never cross-fire. + useKeyDown( + window, + useCallback( + (evt) => { + if ( + isKeyHotkey('arrowup', evt) && + editableActiveElement() && + document.activeElement?.getAttribute('data-editable-name') === 'ThreadInput' && + isEmptyEditor(editor) + ) { + const editableEvt = getLatestEditableEvt(thread.liveTimeline, (mEvt) => + canEditEvent(mx, mEvt), + ); + const editableEvtId = editableEvt?.getId(); + if (!editableEvtId) return; + setEditId(editableEvtId); + evt.preventDefault(); + } + }, + [mx, thread, editor], + ), + ); + const handleOpenReply: MouseEventHandler = useCallback( (evt) => { const targetId = evt.currentTarget.getAttribute('data-event-id');