feat(desktop): keyboard shortcuts — ⌘⇧N new channel + ↑-to-edit last message (#809)

Signed-off-by: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-01 09:12:45 -07:00
committed by GitHub
co-authored by npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm
parent 39911e4285
commit d810608d85
9 changed files with 292 additions and 4 deletions
+2 -1
View File
@@ -43,7 +43,8 @@ const overrides = new Map([
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 800], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
["src/features/messages/lib/useRichTextEditor.ts", 560], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up).
["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
+14
View File
@@ -175,6 +175,7 @@ export function AppShell() {
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const location = useLocation();
const queryClient = useQueryClient();
const {
@@ -522,6 +523,10 @@ export function AppShell() {
setIsNewDmOpen(true);
}, []);
const handleOpenCreateChannel = React.useCallback(() => {
setIsCreateChannelOpen(true);
}, []);
React.useLayoutEffect(() => {
if (settingsOpen) {
return;
@@ -545,6 +550,12 @@ export function AppShell() {
return;
}
if (key === "n" && event.shiftKey) {
event.preventDefault();
handleOpenCreateChannel();
return;
}
if (key === "o" && event.shiftKey) {
event.preventDefault();
handleOpenBrowseChannels();
@@ -565,6 +576,7 @@ export function AppShell() {
}, [
handleOpenBrowseChannels,
handleOpenNewDm,
handleOpenCreateChannel,
handleOpenSearch,
goHome,
settingsOpen,
@@ -688,6 +700,7 @@ export function AppShell() {
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isCreateChannelOpen={isCreateChannelOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
@@ -695,6 +708,7 @@ export function AppShell() {
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
@@ -175,6 +175,50 @@ export const ChannelPane = React.memo(function ChannelPane({
const mainEditTarget = editTarget && !isEditInThread ? editTarget : null;
const threadEditTarget = editTarget && isEditInThread ? editTarget : null;
// ↑-to-edit resolvers. Find the most recent message authored by the current
// user in the relevant scope and enter edit mode via `onEdit`. Editability
// mirrors the action bar's gate (`message.pubkey === currentPubkey`); we
// also skip optimistic `pending` messages, which have no persisted event id
// to target. Both scopes are passed in chronological (oldest→newest) order,
// so we select by newest `createdAt` and break ties toward the later array
// position (`>=`) — `createdAt` is second-granularity, so a reply sent in
// the same second as the message before it must still win. Returns true when
// a target was found so MessageComposer can swallow the ArrowUp.
const findLastOwnEditable = React.useCallback(
(candidates: TimelineMessage[]): TimelineMessage | null => {
if (!onEdit || !currentPubkey) return null;
let best: TimelineMessage | null = null;
for (const message of candidates) {
if (message.pubkey !== currentPubkey || message.pending) continue;
if (!best || message.createdAt >= best.createdAt) {
best = message;
}
}
return best;
},
[onEdit, currentPubkey],
);
const handleEditLastOwnMainMessage = React.useCallback((): boolean => {
const target = findLastOwnEditable(messages);
if (!target || !onEdit) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, messages, onEdit]);
const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
if (!onEdit) return false;
// Thread scope = the open thread head plus its replies, in chronological
// order. The head is oldest, so append it first.
const scope: TimelineMessage[] = [];
if (threadHeadMessage) scope.push(threadHeadMessage);
for (const entry of threadMessages) scope.push(entry.message);
const target = findLastOwnEditable(scope);
if (!target) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
@@ -330,6 +374,7 @@ export const ChannelPane = React.memo(function ChannelPane({
editTarget={mainEditTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
@@ -391,6 +436,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
@@ -41,6 +41,20 @@ export type RichTextEditorOptions = {
/** Called on plain Enter (submit). Handled inside Tiptap's extension system
* so it fires *before* ProseMirror's default splitBlock behaviour. */
onSubmit?: () => void;
/**
* Called on ArrowUp in an empty composer (Slack parity: edit your last
* message). Handled inside ProseMirror's `editorProps.handleKeyDown` — the
* raw DOM keydown hook that runs before any command/caret logic — so it
* fires deterministically even immediately after a send while the editor
* still holds DOM focus (where the keymap plugin and a wrapper-level
* `onKeyDown` both fail to see the event because the WebView's
* vertical-arrow handling consumes it first). The owner should locate the
* most recent message authored by the current user within this composer's
* scope and enter edit mode. Return `true` if a target was found and edit
* mode was entered, so the keystroke is swallowed; return `false` to let
* ArrowUp fall through to normal caret movement.
*/
onEditLastOwnMessage?: () => boolean;
/** When true, plain Enter is passed through (e.g. to select an autocomplete item). */
isAutocompleteOpen?: React.RefObject<boolean>;
};
@@ -61,6 +75,7 @@ export function useRichTextEditor({
mentionNames,
channelNames,
onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen,
}: RichTextEditorOptions) {
const onUpdateRef = React.useRef(onUpdate);
@@ -69,6 +84,9 @@ export function useRichTextEditor({
const onSubmitRef = React.useRef(onSubmit);
onSubmitRef.current = onSubmit;
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
onEditLastOwnMessageRef.current = onEditLastOwnMessage;
const placeholderRef = React.useRef(placeholder);
placeholderRef.current = placeholder;
@@ -286,6 +304,44 @@ export function useRichTextEditor({
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
"data-testid": "message-input",
},
// ArrowUp in an empty composer → edit your last message (Slack
// parity). Handled here in ProseMirror's own DOM `keydown` hook —
// NOT via `addKeyboardShortcuts` (the keymap plugin) and NOT via a
// wrapper-level React `onKeyDown`.
//
// Why this layer specifically: immediately after a send the editor
// still holds DOM focus and the doc was just cleared. In the app's
// WebView, ProseMirror's keymap/vertical-arrow path does not reliably
// route ArrowUp to our binding in that state — the keystroke is
// effectively swallowed until the user clicks out and back (which is
// exactly the reported bug). `handleKeyDown` is the first, lowest hook
// ProseMirror exposes: it runs on the raw DOM keydown before any
// command/caret logic, fires regardless of selection state, and works
// the same across browser engines. Returning `true` consumes the key.
handleKeyDown: (view, event) => {
if (event.key !== "ArrowUp") return false;
// Respect the same guards as before: no modifiers (let ⌥↑/⇧↑/etc.
// through), autocomplete closed, a handler exists, and the composer
// is empty (never steal the arrow from drafted text or an in-flight
// edit, whose loaded body makes the doc non-empty).
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
return false;
if (isAutocompleteOpen?.current) return false;
const handler = onEditLastOwnMessageRef.current;
if (!handler) return false;
// Emptiness is read straight off the live ProseMirror doc rather
// than a captured `editor` ref — the `editor` instance isn't in
// scope at config time (useEditor deps are `[]`), and the view's
// state is always current. Empty = a single empty textblock with
// no text content (mirrors Tiptap's `editor.isEmpty`).
const { doc } = view.state;
const isEmptyDoc =
doc.childCount <= 1 && doc.textContent.length === 0;
if (!isEmptyDoc) return false;
// Consume only if a target was found and edit mode was entered;
// otherwise let ArrowUp fall through to normal caret movement.
return handler();
},
},
onUpdate: ({ editor: ed }) => {
const markdown = getMarkdownFromEditor(ed);
@@ -301,9 +357,33 @@ export function useRichTextEditor({
);
// Toggle editable without destroying the editor instance.
//
// When the composer is disabled mid-send (`isSending` flips the `disabled`
// prop true), ProseMirror sets the underlying element `contenteditable=false`
// and the browser BLURS it — focus jumps to `document.body`. When the send
// completes and the editor becomes editable again, focus does NOT return on
// its own. That left the just-emptied composer focus-less, so the very next
// ArrowUp (edit-last-message) never reached the editor's keydown hook and
// did nothing until the user clicked back in. We restore focus here, scoped
// to *this* editor instance (we only refocus if this editor was the one that
// lost focus to the disable), so it can't steal focus from another composer.
const hadFocusBeforeDisableRef = React.useRef(false);
React.useEffect(() => {
if (editor && editor.isEditable !== editable) {
editor.setEditable(editable);
if (!editor || editor.isEditable === editable) return;
if (!editable) {
// About to disable: remember whether we currently hold focus so we know
// whether to restore it when re-enabled.
hadFocusBeforeDisableRef.current = editor.isFocused;
editor.setEditable(false);
} else {
editor.setEditable(true);
// Re-enabled: if we owned focus before the disable blurred us, take it
// back (preserving the current selection — `focus()` with no arg keeps
// the existing selection rather than jumping to the end).
if (hadFocusBeforeDisableRef.current) {
hadFocusBeforeDisableRef.current = false;
editor.commands.focus();
}
}
}, [editor, editable]);
@@ -63,6 +63,15 @@ type MessageComposerProps = {
isSending?: boolean;
onCancelEdit?: () => void;
onCancelReply?: () => void;
/**
* Invoked when the user presses in an empty composer that is not already
* in edit mode. The owner should locate the most recent message authored by
* the current user within this composer's scope (main timeline, DM, or
* thread) and enter edit mode for it. Return `true` if a target was found
* and edit mode was entered, so the composer can swallow the keystroke;
* return `false` to let the arrow key fall through normally.
*/
onEditLastOwnMessage?: () => boolean;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onSend: (
content: string,
@@ -92,6 +101,7 @@ export function MessageComposer({
isSending = false,
onCancelEdit,
onCancelReply,
onEditLastOwnMessage,
onEditSave,
onSend,
placeholder,
@@ -145,12 +155,14 @@ export function MessageComposer({
const isUploadingRef = React.useRef(media.isUploading);
const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave);
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
const editTargetRef = React.useRef(editTarget);
disabledRef.current = disabled;
isSendingRef.current = isSending;
isUploadingRef.current = media.isUploading;
onSendRef.current = onSend;
onEditSaveRef.current = onEditSave;
onEditLastOwnMessageRef.current = onEditLastOwnMessage;
editTargetRef.current = editTarget;
const isAutocompleteOpenRef = React.useRef(false);
@@ -183,6 +195,13 @@ export function MessageComposer({
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
onSubmit: () => submitMessageRef.current(),
onEditLastOwnMessage: () => {
// Never re-enter edit from an empty edit (e.g. image-only edit whose
// text body is empty) — `editTarget` means we're already editing.
if (editTargetRef.current) return false;
const handler = onEditLastOwnMessageRef.current;
return handler ? handler() : false;
},
isAutocompleteOpen: isAutocompleteOpenRef,
onUpdate: ({ markdown, text }) => {
setContent(markdown);
@@ -41,6 +41,7 @@ type MessageThreadPanelProps = {
onClose: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditLastOwnMessage?: () => boolean;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onMarkUnread?: (message: TimelineMessage) => void;
onExpandReplies: (message: TimelineMessage) => void;
@@ -98,6 +99,7 @@ export function MessageThreadPanel({
onClose,
onDelete,
onEdit,
onEditLastOwnMessage,
onEditSave,
onFollowThread,
onMarkUnread,
@@ -353,6 +355,7 @@ export function MessageThreadPanel({
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditLastOwnMessage={onEditLastOwnMessage}
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
+23 -1
View File
@@ -161,6 +161,8 @@ type AppSidebarProps = {
isPresencePending?: boolean;
isNewDmOpen?: boolean;
onNewDmOpenChange?: (open: boolean) => void;
isCreateChannelOpen?: boolean;
onCreateChannelOpenChange?: (open: boolean) => void;
};
// ---------------------------------------------------------------------------
@@ -410,6 +412,8 @@ export function AppSidebar({
isPresencePending,
isNewDmOpen: isNewDmOpenProp,
onNewDmOpenChange,
isCreateChannelOpen: isCreateChannelOpenProp,
onCreateChannelOpenChange,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
@@ -420,6 +424,17 @@ export function AppSidebar({
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);
// Allow the create-channel dialog to be opened from outside (e.g. the
// ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift.
// When the external flag flips on, open the "stream" create dialog; the
// close direction is reported back via `onCreateChannelOpenChange` in the
// dialog's `onOpenChange` below.
React.useEffect(() => {
if (isCreateChannelOpenProp) {
setCreateDialogKind("stream");
}
}, [isCreateChannelOpenProp]);
const [collapsedGroups, setCollapsedGroups] = React.useState<
Record<CollapsibleSidebarGroup, boolean>
>({
@@ -808,7 +823,14 @@ export function AppSidebar({
channelKind={createDialogKind}
isCreating={isCreatingAny}
onOpenChange={(open) => {
if (!open) setCreateDialogKind(null);
if (!open) {
// If a "stream" dialog driven by the external controller is
// closing, report it back so AppShell's open state resets.
if (createDialogKind === "stream") {
onCreateChannelOpenChange?.(false);
}
setCreateDialogKind(null);
}
}}
onCreate={handleCreateFromDialog}
/>
@@ -41,6 +41,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
keysWindows: "Shift+Ctrl+K",
category: "Navigation",
},
{
id: "new-channel",
label: "New channel",
description: "Open the create channel dialog",
keys: "⇧⌘N",
keysWindows: "Shift+Ctrl+N",
category: "Navigation",
},
{
id: "open-settings",
label: "Settings",
+95
View File
@@ -718,3 +718,98 @@ test("thread composer keeps focus after sending a thread reply", async ({
await expect(threadInput).toBeFocused();
});
test("ArrowUp in an empty composer edits your last message right after sending", async ({
page,
}) => {
// Regression: after a send, the composer keeps DOM focus and ProseMirror
// would consume ArrowUp before it reached the edit-last-message handler,
// so ↑ did nothing until you clicked out and back. The handler now lives
// in the editor keymap, so ↑ must work with no intermediate click.
const message = `Edit-last via arrow up ${Date.now()}`;
const input = page.getByTestId("message-input");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await input.fill(message);
await input.press("Enter");
await expect(page.getByTestId("message-timeline")).toContainText(message);
// Composer stays focused after send — no click, just press ↑.
await expect(input).toBeFocused();
await page.keyboard.press("ArrowUp");
// Edit mode is entered for the just-sent message.
const editBanner = page.getByTestId("edit-target");
await expect(editBanner).toBeVisible();
await expect(editBanner).toContainText(message);
await expect(input).toHaveText(message);
});
test("ArrowUp does not edit when the composer has draft text", async ({
page,
}) => {
// Guard: ↑ must only hijack to edit when the composer is empty, so it
// never steals the arrow key from someone navigating drafted text.
const sent = `Sent before draft ${Date.now()}`;
const draft = `Half-typed draft ${Date.now()}`;
const input = page.getByTestId("message-input");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await input.fill(sent);
await input.press("Enter");
await expect(page.getByTestId("message-timeline")).toContainText(sent);
await input.fill(draft);
await expect(input).toHaveText(draft);
await page.keyboard.press("ArrowUp");
// No edit mode; the draft is untouched.
await expect(page.getByTestId("edit-target")).toHaveCount(0);
await expect(input).toHaveText(draft);
});
test("ArrowUp edits your last thread reply right after sending it", async ({
page,
}) => {
// Same fix must hold in the thread composer (shares MessageComposer).
const seed = `Thread arrow-up seed ${Date.now()}`;
const reply = `Thread reply to edit ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill(seed);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(seed);
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadInput = threadPanel.getByTestId("message-input");
await expect(threadInput).toBeFocused();
await page.keyboard.type(reply);
await page.keyboard.press("Enter");
await expect(threadPanel).toContainText(reply);
// No click — press ↑ in the still-focused thread composer.
await page.keyboard.press("ArrowUp");
const editBanner = threadPanel.getByTestId("edit-target");
await expect(editBanner).toBeVisible();
await expect(editBanner).toContainText(reply);
await expect(threadInput).toHaveText(reply);
});