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. diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx index 7246219a4..29438d7f4 100644 --- a/src/app/components/GlobalKeyboardShortcuts.tsx +++ b/src/app/components/GlobalKeyboardShortcuts.tsx @@ -2,14 +2,14 @@ * 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'; -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'; @@ -20,9 +20,17 @@ 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, + 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(); @@ -52,6 +60,11 @@ export function GlobalKeyboardShortcuts() { const replyDraft = useAtomValue(replyDraftAtomFamily); const setReplyDraft = useSetAtom(replyDraftAtomFamily); + 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. */ const navigateToRoom = useCallback( (roomId: string, remaining: number) => { @@ -151,9 +164,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..6101a714e 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'; @@ -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,49 @@ 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, setEditNavRequest] = useAtom( + roomIdToEditNavRequestAtomFamily(room.roomId) + ); + + useEffect(() => { + if (!editNavRequest) return; + const editableEvents = processedEventsRef.current.filter( + (e) => !e.mEvent.isRedacted() && canEditEvent(mx, e.mEvent) + ); + if (editableEvents.length === 0) { + setEditNavRequest(undefined); + 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); + setEditNavRequest(undefined); + return; + } + + const currentIdx = editableEvents.findIndex((e) => e.mEvent.getId() === currentEditId); + const next = + 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, setEditNavRequest]); + 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() +);