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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:28:26 -04:00
co-authored by Claude Opus 4.8
parent 61f1733f50
commit f155a4dc22
4 changed files with 41 additions and 4 deletions
+2
View File
@@ -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).
+6 -2
View File
@@ -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<HTMLDivElement, RoomInputProps>(
({ 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<HTMLDivElement, RoomInputProps>(
)}
<ScheduledMessagesTray roomId={roomId} />
<CustomEditor
editableName="RoomInput"
editableName={editableName}
editor={editor}
placeholder="Send a message..."
onKeyDown={handleKeyDown}
@@ -196,6 +196,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
roomId={room.roomId}
threadRootId={threadId}
editor={editor}
editableName="ThreadInput"
fileDropContainerRef={fileDropContainerRef}
/>
</Box>
@@ -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');