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:
@@ -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).
|
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)
|
### 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).
|
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).
|
||||||
|
|||||||
@@ -151,9 +151,13 @@ interface RoomInputProps {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
room: Room;
|
room: Room;
|
||||||
threadRootId?: string;
|
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>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
|
({ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
@@ -981,7 +985,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
)}
|
)}
|
||||||
<ScheduledMessagesTray roomId={roomId} />
|
<ScheduledMessagesTray roomId={roomId} />
|
||||||
<CustomEditor
|
<CustomEditor
|
||||||
editableName="RoomInput"
|
editableName={editableName}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
|||||||
roomId={room.roomId}
|
roomId={room.roomId}
|
||||||
threadRootId={threadId}
|
threadRootId={threadId}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
editableName="ThreadInput"
|
||||||
fileDropContainerRef={fileDropContainerRef}
|
fileDropContainerRef={fileDropContainerRef}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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 { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
|
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { scrollToBottom } from '../../../utils/dom';
|
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
MessageBase,
|
MessageBase,
|
||||||
@@ -55,9 +56,11 @@ import {
|
|||||||
renderMatrixMention,
|
renderMatrixMention,
|
||||||
} from '../../../plugins/react-custom-html-parser';
|
} from '../../../plugins/react-custom-html-parser';
|
||||||
import {
|
import {
|
||||||
|
canEditEvent,
|
||||||
decryptAllTimelineEvent,
|
decryptAllTimelineEvent,
|
||||||
getEditedEvent,
|
getEditedEvent,
|
||||||
getEventReactions,
|
getEventReactions,
|
||||||
|
getLatestEditableEvt,
|
||||||
getMemberName,
|
getMemberName,
|
||||||
getReactionContent,
|
getReactionContent,
|
||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
@@ -76,7 +79,8 @@ import {
|
|||||||
today,
|
today,
|
||||||
yesterday,
|
yesterday,
|
||||||
} from '../../../utils/time';
|
} 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 { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||||
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||||
@@ -580,6 +584,32 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
[editor],
|
[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(
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
|||||||
Reference in New Issue
Block a user