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/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
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user