From 4b910a54c2ff09b5cd40f994df46952a583aca2c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 23:18:27 -0400 Subject: [PATCH 1/4] feat(keyboard): Ctrl+Alt+Up/Down to cycle editable messages Adds Ctrl+Alt+Up / Ctrl+Alt+Down keyboard shortcuts to navigate through the current user's own editable messages, mirroring the existing Ctrl+Up / Ctrl+Down reply navigation. Implementation: - roomInputDrafts.ts: new IEditNavRequest atom family (roomIdToEditNavRequestAtomFamily) used as a one-shot signal between GlobalKeyboardShortcuts and RoomTimeline. - GlobalKeyboardShortcuts.tsx: intercepts mod+alt+up/down, bumps a nonce and writes a { dir, nonce } request to the atom. - RoomTimeline.tsx: watches the atom; on change navigates editId to the prev/next editable message in the processed timeline (or starts at the most recent if none is active). --- .../components/GlobalKeyboardShortcuts.tsx | 31 ++++++++++--- src/app/features/room/RoomTimeline.tsx | 45 ++++++++++++++++++- src/app/state/room/roomInputDrafts.ts | 8 ++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx index 7246219a4..2f0b4f799 100644 --- a/src/app/components/GlobalKeyboardShortcuts.tsx +++ b/src/app/components/GlobalKeyboardShortcuts.tsx @@ -2,10 +2,10 @@ * Global keyboard shortcuts for navigation and accessibility. * * Shortcuts provided: - * Alt+N — jump to the highest-priority unread room - * Alt+Shift+Down — cycle forward through unread rooms - * Alt+Shift+Up — cycle backward through unread rooms - * Ctrl+Down / Ctrl+Up: cycle through messages to reply to + * Alt+N — jump to the highest-priority unread room + * Alt+Shift+Down/Up — cycle forward/backward through unread rooms + * Ctrl+Down / Ctrl+Up — cycle through messages to reply to + * Ctrl+Alt+Down/Up — cycle through your own messages to edit */ import { useCallback, useRef } from 'react'; import { useNavigate, useLocation, matchPath } from 'react-router-dom'; @@ -20,7 +20,10 @@ import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '$pages/pat import { HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { announce } from '$utils/announce'; -import { roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts'; +import { + roomIdToReplyDraftAtomFamily, + roomIdToEditNavRequestAtomFamily, +} from '$state/room/roomInputDrafts'; import type { Room } from '$types/matrix-sdk'; export function GlobalKeyboardShortcuts() { @@ -52,6 +55,9 @@ export function GlobalKeyboardShortcuts() { const replyDraft = useAtomValue(replyDraftAtomFamily); const setReplyDraft = useSetAtom(replyDraftAtomFamily); + const setEditNavRequest = useSetAtom(roomIdToEditNavRequestAtomFamily(currentRoom?.roomId ?? '')); + const editNavNonceRef = useRef(0); + /** Navigate to a room by ID and announce it to screen readers. */ const navigateToRoom = useCallback( (roomId: string, remaining: number) => { @@ -151,9 +157,24 @@ export function GlobalKeyboardShortcuts() { [currentRoom, replyDraft, setReplyDraft] ); + /** Ctrl+Alt+Down / Ctrl+Alt+Up: cycle through the current user's editable messages. */ + const handleEditKeyDown = useCallback( + (evt: KeyboardEvent) => { + const isDown = isKeyHotkey('mod+alt+down', evt); + const isUp = isKeyHotkey('mod+alt+up', evt); + if (!isDown && !isUp) return; + if (currentRoom === null) return; + evt.preventDefault(); + editNavNonceRef.current += 1; + setEditNavRequest({ dir: isDown ? 'next' : 'prev', nonce: editNavNonceRef.current }); + }, + [currentRoom, setEditNavRequest] + ); + useKeyDown(window, handleNextUnreadKeyDown); useKeyDown(window, handleUnreadNavKeyDown); useKeyDown(window, handleReplyKeyDown); + useKeyDown(window, handleEditKeyDown); return null; } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 56bf1cca5..d6d054030 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -67,7 +67,10 @@ import { useRoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; import { buildAbbrReplaceTextNode } from '$components/message/RenderBody'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { roomToParentsAtom } from '$state/room/roomToParents'; -import { roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts'; +import { + roomIdToReplyDraftAtomFamily, + roomIdToEditNavRequestAtomFamily, +} from '$state/room/roomInputDrafts'; import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; import { getRoomUnreadInfo, @@ -816,6 +819,46 @@ export function RoomTimeline({ }; }, [onEditLastMessageRef, mx, actions]); + // Keep stable refs so the edit-nav effect below doesn't stale-close over them. + const editIdRef = useRef(editId); + editIdRef.current = editId; + const handleEditRef = useRef(handleEdit); + handleEditRef.current = handleEdit; + + const editNavRequest = useAtomValue(roomIdToEditNavRequestAtomFamily(room.roomId)); + + useEffect(() => { + if (!editNavRequest) return; + const myUserId = mx.getUserId(); + const editableEvents = processedEventsRef.current.filter( + (e) => + e.mEvent.getSender() === myUserId && + e.mEvent.getType() === 'm.room.message' && + !e.mEvent.isRedacted() + ); + if (editableEvents.length === 0) return; + + const currentEditId = editIdRef.current; + const doHandleEdit = handleEditRef.current; + + if (currentEditId === undefined) { + // No active edit — start at the most recent editable message. + const latest = editableEvents.at(-1)!; + const id = latest.mEvent.getId(); + if (id) doHandleEdit(id); + return; + } + + const currentIdx = editableEvents.findIndex((e) => e.mEvent.getId() === currentEditId); + const next = + editNavRequest.dir === 'prev' + ? editableEvents[currentIdx - 1] + : editableEvents[currentIdx + 1]; + if (!next) return; + const id = next.mEvent.getId(); + if (id) doHandleEdit(id); + }, [editNavRequest, mx]); + useEffect(() => { const v = vListRef.current; if (!v) return; diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index e79abbe0f..6aab478e9 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -58,3 +58,11 @@ export type TReplyDraftAtom = ReturnType; export const roomIdToReplyDraftAtomFamily = atomFamily(() => createReplyDraftAtom() ); + +/** Navigation request written by GlobalKeyboardShortcuts, consumed by RoomTimeline. */ +export type IEditNavRequest = { dir: 'prev' | 'next'; nonce: number }; +const createEditNavRequestAtom = () => atom(undefined); +export type TEditNavRequestAtom = ReturnType; +export const roomIdToEditNavRequestAtomFamily = atomFamily(() => + createEditNavRequestAtom() +); From 2343408a63547aa58d451d239a28a3e50c94db61 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:01 -0400 Subject: [PATCH 2/4] chore: add changeset --- .changeset/edit-message-keybind.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/edit-message-keybind.md diff --git a/.changeset/edit-message-keybind.md b/.changeset/edit-message-keybind.md new file mode 100644 index 000000000..52245431d --- /dev/null +++ b/.changeset/edit-message-keybind.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add Ctrl+Alt+Up/Down keyboard shortcuts to cycle through editable messages. From e46c7b04b6ba9206ee8ce67f7d0980c9524bb0c4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 20:02:58 -0400 Subject: [PATCH 3/4] fix(edit-keybind): address Copilot review feedback - Reset editNavRequest atom to undefined after consuming it so repeat keystrokes don't no-op - Use canEditEvent() helper instead of manual sender/type check to correctly filter by msgtype and relation type - Replace '' sentinel key with a stable no-op atom in GlobalKeyboardShortcuts to avoid polluting roomIdToEditNavRequestAtomFamily with a spurious empty-string entry --- .../components/GlobalKeyboardShortcuts.tsx | 13 ++++++++++-- src/app/features/room/RoomTimeline.tsx | 21 ++++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx index 2f0b4f799..ec678779c 100644 --- a/src/app/components/GlobalKeyboardShortcuts.tsx +++ b/src/app/components/GlobalKeyboardShortcuts.tsx @@ -9,7 +9,7 @@ */ import { useCallback, useRef } from 'react'; import { useNavigate, useLocation, matchPath } from 'react-router-dom'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom, atom } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { roomToParentsAtom } from '$state/room/roomToParents'; @@ -23,9 +23,14 @@ import { announce } from '$utils/announce'; import { roomIdToReplyDraftAtomFamily, roomIdToEditNavRequestAtomFamily, + type IEditNavRequest, } from '$state/room/roomInputDrafts'; import type { Room } from '$types/matrix-sdk'; +// Stable fallback atom used when no room is active — prevents atomFamily from +// creating a spurious entry under the empty-string key ''. +const _noopEditNavAtom = atom(undefined); + export function GlobalKeyboardShortcuts() { const navigate = useNavigate(); const location = useLocation(); @@ -55,7 +60,11 @@ export function GlobalKeyboardShortcuts() { const replyDraft = useAtomValue(replyDraftAtomFamily); const setReplyDraft = useSetAtom(replyDraftAtomFamily); - const setEditNavRequest = useSetAtom(roomIdToEditNavRequestAtomFamily(currentRoom?.roomId ?? '')); + const setEditNavRequest = useSetAtom( + currentRoom?.roomId + ? roomIdToEditNavRequestAtomFamily(currentRoom.roomId) + : _noopEditNavAtom + ); const editNavNonceRef = useRef(0); /** Navigate to a room by ID and announce it to screen readers. */ diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d6d054030..35b46743d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -9,7 +9,7 @@ import { useState, } from 'react'; import type { Editor } from 'slate'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useAtom, useSetAtom } from 'jotai'; import type { Room } from '$types/matrix-sdk'; import { PushProcessor, Direction } from '$types/matrix-sdk'; import classNames from 'classnames'; @@ -45,7 +45,7 @@ import { factoryRenderLinkifyWithMention, } from '$plugins/react-custom-html-parser'; import { today, yesterday, timeDayMonthYear } from '$utils/time'; -import { unwrapRelationJumpTarget } from '$utils/room'; +import { unwrapRelationJumpTarget, canEditEvent } from '$utils/room'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; @@ -825,18 +825,17 @@ export function RoomTimeline({ const handleEditRef = useRef(handleEdit); handleEditRef.current = handleEdit; - const editNavRequest = useAtomValue(roomIdToEditNavRequestAtomFamily(room.roomId)); + const [editNavRequest, setEditNavRequest] = useAtom(roomIdToEditNavRequestAtomFamily(room.roomId)); useEffect(() => { if (!editNavRequest) return; - const myUserId = mx.getUserId(); const editableEvents = processedEventsRef.current.filter( - (e) => - e.mEvent.getSender() === myUserId && - e.mEvent.getType() === 'm.room.message' && - !e.mEvent.isRedacted() + (e) => !e.mEvent.isRedacted() && canEditEvent(mx, e.mEvent) ); - if (editableEvents.length === 0) return; + if (editableEvents.length === 0) { + setEditNavRequest(undefined); + return; + } const currentEditId = editIdRef.current; const doHandleEdit = handleEditRef.current; @@ -846,6 +845,7 @@ export function RoomTimeline({ const latest = editableEvents.at(-1)!; const id = latest.mEvent.getId(); if (id) doHandleEdit(id); + setEditNavRequest(undefined); return; } @@ -854,10 +854,11 @@ export function RoomTimeline({ editNavRequest.dir === 'prev' ? editableEvents[currentIdx - 1] : editableEvents[currentIdx + 1]; + setEditNavRequest(undefined); if (!next) return; const id = next.mEvent.getId(); if (id) doHandleEdit(id); - }, [editNavRequest, mx]); + }, [editNavRequest, mx, setEditNavRequest]); useEffect(() => { const v = vListRef.current; From 7a7577d073038ca7c56ce1454913a6999d1eaabd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 13:30:44 -0400 Subject: [PATCH 4/4] style: fix formatting --- src/app/components/GlobalKeyboardShortcuts.tsx | 4 +--- src/app/features/room/RoomTimeline.tsx | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx index ec678779c..29438d7f4 100644 --- a/src/app/components/GlobalKeyboardShortcuts.tsx +++ b/src/app/components/GlobalKeyboardShortcuts.tsx @@ -61,9 +61,7 @@ export function GlobalKeyboardShortcuts() { const setReplyDraft = useSetAtom(replyDraftAtomFamily); const setEditNavRequest = useSetAtom( - currentRoom?.roomId - ? roomIdToEditNavRequestAtomFamily(currentRoom.roomId) - : _noopEditNavAtom + currentRoom?.roomId ? roomIdToEditNavRequestAtomFamily(currentRoom.roomId) : _noopEditNavAtom ); const editNavNonceRef = useRef(0); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 35b46743d..6101a714e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -825,7 +825,9 @@ export function RoomTimeline({ const handleEditRef = useRef(handleEdit); handleEditRef.current = handleEdit; - const [editNavRequest, setEditNavRequest] = useAtom(roomIdToEditNavRequestAtomFamily(room.roomId)); + const [editNavRequest, setEditNavRequest] = useAtom( + roomIdToEditNavRequestAtomFamily(room.roomId) + ); useEffect(() => { if (!editNavRequest) return;