mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm
parent
39911e4285
commit
d810608d85
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user