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/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/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/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/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["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 ["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] = const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null); React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const location = useLocation(); const location = useLocation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
@@ -522,6 +523,10 @@ export function AppShell() {
setIsNewDmOpen(true); setIsNewDmOpen(true);
}, []); }, []);
const handleOpenCreateChannel = React.useCallback(() => {
setIsCreateChannelOpen(true);
}, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (settingsOpen) { if (settingsOpen) {
return; return;
@@ -545,6 +550,12 @@ export function AppShell() {
return; return;
} }
if (key === "n" && event.shiftKey) {
event.preventDefault();
handleOpenCreateChannel();
return;
}
if (key === "o" && event.shiftKey) { if (key === "o" && event.shiftKey) {
event.preventDefault(); event.preventDefault();
handleOpenBrowseChannels(); handleOpenBrowseChannels();
@@ -565,6 +576,7 @@ export function AppShell() {
}, [ }, [
handleOpenBrowseChannels, handleOpenBrowseChannels,
handleOpenNewDm, handleOpenNewDm,
handleOpenCreateChannel,
handleOpenSearch, handleOpenSearch,
goHome, goHome,
settingsOpen, settingsOpen,
@@ -688,6 +700,7 @@ export function AppShell() {
isLoading={channelsQuery.isLoading} isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending} isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen} isNewDmOpen={isNewDmOpen}
isCreateChannelOpen={isCreateChannelOpen}
isPresencePending={presenceSession.isPending} isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => { onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace); const id = workspacesHook.addWorkspace(workspace);
@@ -695,6 +708,7 @@ export function AppShell() {
}} }}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen} onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen} onNewDmOpenChange={setIsNewDmOpen}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)} onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace} onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace} onRemoveWorkspace={workspacesHook.removeWorkspace}
@@ -175,6 +175,50 @@ export const ChannelPane = React.memo(function ChannelPane({
const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const mainEditTarget = editTarget && !isEditInThread ? editTarget : null;
const threadEditTarget = 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 = const isNonMemberView =
activeChannel !== null && activeChannel !== null &&
!activeChannel.isMember && !activeChannel.isMember &&
@@ -330,6 +374,7 @@ export const ChannelPane = React.memo(function ChannelPane({
editTarget={mainEditTarget} editTarget={mainEditTarget}
isSending={isSending} isSending={isSending}
onCancelEdit={onCancelEdit} onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave} onEditSave={onEditSave}
onSend={onSendMessage} onSend={onSendMessage}
profiles={profiles} profiles={profiles}
@@ -391,6 +436,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onClose={onCloseThread} onClose={onCloseThread}
onDelete={onDelete} onDelete={onDelete}
onEdit={onEdit} onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave} onEditSave={onEditSave}
onFollowThread={onFollowThread} onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread} onMarkUnread={onMarkUnread}
@@ -41,6 +41,20 @@ export type RichTextEditorOptions = {
/** Called on plain Enter (submit). Handled inside Tiptap's extension system /** Called on plain Enter (submit). Handled inside Tiptap's extension system
* so it fires *before* ProseMirror's default splitBlock behaviour. */ * so it fires *before* ProseMirror's default splitBlock behaviour. */
onSubmit?: () => void; 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). */ /** When true, plain Enter is passed through (e.g. to select an autocomplete item). */
isAutocompleteOpen?: React.RefObject<boolean>; isAutocompleteOpen?: React.RefObject<boolean>;
}; };
@@ -61,6 +75,7 @@ export function useRichTextEditor({
mentionNames, mentionNames,
channelNames, channelNames,
onSubmit, onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen, isAutocompleteOpen,
}: RichTextEditorOptions) { }: RichTextEditorOptions) {
const onUpdateRef = React.useRef(onUpdate); const onUpdateRef = React.useRef(onUpdate);
@@ -69,6 +84,9 @@ export function useRichTextEditor({
const onSubmitRef = React.useRef(onSubmit); const onSubmitRef = React.useRef(onSubmit);
onSubmitRef.current = onSubmit; onSubmitRef.current = onSubmit;
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
onEditLastOwnMessageRef.current = onEditLastOwnMessage;
const placeholderRef = React.useRef(placeholder); const placeholderRef = React.useRef(placeholder);
placeholderRef.current = 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", "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", "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 }) => { onUpdate: ({ editor: ed }) => {
const markdown = getMarkdownFromEditor(ed); const markdown = getMarkdownFromEditor(ed);
@@ -301,9 +357,33 @@ export function useRichTextEditor({
); );
// Toggle editable without destroying the editor instance. // 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(() => { React.useEffect(() => {
if (editor && editor.isEditable !== editable) { if (!editor || editor.isEditable === editable) return;
editor.setEditable(editable); 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]); }, [editor, editable]);
@@ -63,6 +63,15 @@ type MessageComposerProps = {
isSending?: boolean; isSending?: boolean;
onCancelEdit?: () => void; onCancelEdit?: () => void;
onCancelReply?: () => 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>; onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onSend: ( onSend: (
content: string, content: string,
@@ -92,6 +101,7 @@ export function MessageComposer({
isSending = false, isSending = false,
onCancelEdit, onCancelEdit,
onCancelReply, onCancelReply,
onEditLastOwnMessage,
onEditSave, onEditSave,
onSend, onSend,
placeholder, placeholder,
@@ -145,12 +155,14 @@ export function MessageComposer({
const isUploadingRef = React.useRef(media.isUploading); const isUploadingRef = React.useRef(media.isUploading);
const onSendRef = React.useRef(onSend); const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave); const onEditSaveRef = React.useRef(onEditSave);
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
const editTargetRef = React.useRef(editTarget); const editTargetRef = React.useRef(editTarget);
disabledRef.current = disabled; disabledRef.current = disabled;
isSendingRef.current = isSending; isSendingRef.current = isSending;
isUploadingRef.current = media.isUploading; isUploadingRef.current = media.isUploading;
onSendRef.current = onSend; onSendRef.current = onSend;
onEditSaveRef.current = onEditSave; onEditSaveRef.current = onEditSave;
onEditLastOwnMessageRef.current = onEditLastOwnMessage;
editTargetRef.current = editTarget; editTargetRef.current = editTarget;
const isAutocompleteOpenRef = React.useRef(false); const isAutocompleteOpenRef = React.useRef(false);
@@ -183,6 +195,13 @@ export function MessageComposer({
mentionNames: mentions.knownNames, mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames, channelNames: channelLinks.knownChannelNames,
onSubmit: () => submitMessageRef.current(), 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, isAutocompleteOpen: isAutocompleteOpenRef,
onUpdate: ({ markdown, text }) => { onUpdate: ({ markdown, text }) => {
setContent(markdown); setContent(markdown);
@@ -41,6 +41,7 @@ type MessageThreadPanelProps = {
onClose: () => void; onClose: () => void;
onDelete?: (message: TimelineMessage) => void; onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void;
onEditLastOwnMessage?: () => boolean;
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>; onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
onMarkUnread?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void;
onExpandReplies: (message: TimelineMessage) => void; onExpandReplies: (message: TimelineMessage) => void;
@@ -98,6 +99,7 @@ export function MessageThreadPanel({
onClose, onClose,
onDelete, onDelete,
onEdit, onEdit,
onEditLastOwnMessage,
onEditSave, onEditSave,
onFollowThread, onFollowThread,
onMarkUnread, onMarkUnread,
@@ -353,6 +355,7 @@ export function MessageThreadPanel({
isSending={isSending} isSending={isSending}
onCancelEdit={onCancelEdit} onCancelEdit={onCancelEdit}
onCancelReply={composerReplyTarget ? onCancelReply : undefined} onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditLastOwnMessage={onEditLastOwnMessage}
onEditSave={onEditSave} onEditSave={onEditSave}
onSend={onSend} onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`} placeholder={`Reply in thread to ${threadHead.author}`}
+23 -1
View File
@@ -161,6 +161,8 @@ type AppSidebarProps = {
isPresencePending?: boolean; isPresencePending?: boolean;
isNewDmOpen?: boolean; isNewDmOpen?: boolean;
onNewDmOpenChange?: (open: boolean) => void; onNewDmOpenChange?: (open: boolean) => void;
isCreateChannelOpen?: boolean;
onCreateChannelOpenChange?: (open: boolean) => void;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -410,6 +412,8 @@ export function AppSidebar({
isPresencePending, isPresencePending,
isNewDmOpen: isNewDmOpenProp, isNewDmOpen: isNewDmOpenProp,
onNewDmOpenChange, onNewDmOpenChange,
isCreateChannelOpen: isCreateChannelOpenProp,
onCreateChannelOpenChange,
}: AppSidebarProps) { }: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false); const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
@@ -420,6 +424,17 @@ export function AppSidebar({
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] = const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null); 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< const [collapsedGroups, setCollapsedGroups] = React.useState<
Record<CollapsibleSidebarGroup, boolean> Record<CollapsibleSidebarGroup, boolean>
>({ >({
@@ -808,7 +823,14 @@ export function AppSidebar({
channelKind={createDialogKind} channelKind={createDialogKind}
isCreating={isCreatingAny} isCreating={isCreatingAny}
onOpenChange={(open) => { 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} onCreate={handleCreateFromDialog}
/> />
@@ -41,6 +41,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
keysWindows: "Shift+Ctrl+K", keysWindows: "Shift+Ctrl+K",
category: "Navigation", 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", id: "open-settings",
label: "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(); 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);
});