diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index ee9a61992..22a6e8cb0 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1004,6 +1004,9 @@ function ChannelWorkspace({ scope={scope} channelId={current.id} channelName={current.name} + autoFocus={ + !current.readOnly && !requestedMessage && !requestedThread + } onOpenLink={openLink} canOpenLink={canOpenLink} label={ diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 480a5caf3..e11043535 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -379,6 +379,64 @@ function mount( }; } +it("autofocuses each selected conversation once without stealing focus on updates", () => { + const h = mount({ autoFocus: true }); + expect(h.input()).toHaveFocus(); + const other = document.createElement("button"); + document.body.append(other); + try { + other.focus(); + h.retarget({ channelName: "Renamed", autoFocus: false }); + h.retarget({ autoFocus: true }); + expect(other).toHaveFocus(); + h.retarget({ channelId: "another-channel" }); + expect(h.input()).toHaveFocus(); + h.retarget({ channelId: "keyboard-navigation" }); + expect(h.input()).toHaveFocus(); + } finally { + other.remove(); + } +}); + +it("lets an explicit focus restoration in the mount commit win", () => { + const h = mount(); + h.unmount(); + const target = document.createElement("button"); + document.body.append(target); + function RestoreFocus() { + useLayoutEffect(() => target.focus(), []); + return null; + } + try { + render( + <> + + + , + ); + expect(target).toHaveFocus(); + } finally { + target.remove(); + } +}); + +it("leaves focus alone unless an enabled composer opts into mount focus", () => { + const h = mount(); + expect(h.input()).not.toHaveFocus(); + h.retarget({ + channelId: "disabled-channel", + disabled: true, + autoFocus: true, + }); + expect(h.input()).not.toHaveFocus(); +}); + it("keeps unpublished completions invisible but lets Escape revoke pending work", () => { const h = mount(); const input = h.input(); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 629d5c510..dddcbbb16 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -92,6 +92,8 @@ export type MessageComposerProps = { replyContext?: ReactNode; mediaTimeSeconds?: number; clearMediaTime?(): void; + /** Focus once when this conversation mounts, not when overlays close. */ + autoFocus?: boolean; focusRequest?: number; hideMediaTimeIndicator?: boolean; disabled?: boolean; @@ -138,6 +140,7 @@ function Composer({ replyContext, mediaTimeSeconds, clearMediaTime, + autoFocus = false, focusRequest, hideMediaTimeIndicator = false, disabled: requestedDisabled = false, @@ -202,6 +205,27 @@ function Composer({ const valueRef = useRef(value); const caret = useRef(undefined); const input = useRef(null); + const focusOnMount = useRef( + autoFocus && !disabled && typeof document !== "undefined" + ? document.activeElement + : undefined, + ); + useEffect(() => { + // A navigation/dialog owner may restore focus during this commit. Let that + // explicit handoff win over the conversation's default initial focus. + const previous = focusOnMount.current; + if ( + previous && + (previous === document.activeElement || + (!previous.isConnected && document.activeElement === document.body)) + ) { + const editor = input.current; + if (!editor) return; + const end = editor.value.length; + editor.setSelectionRange(end, end); + editor.focus(); + } + }, []); const nonmembers = useNonmemberMentions(session, channelId, () => input.current?.focus(), ); diff --git a/tests/browser/composer-focus.spec.mjs b/tests/browser/composer-focus.spec.mjs new file mode 100644 index 000000000..cc9007c63 --- /dev/null +++ b/tests/browser/composer-focus.spec.mjs @@ -0,0 +1,41 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; + +test.use({ productionBroker: true, dmLabels: true }); + +// Browser boundary: real sidebar navigation must hand focus to the mounted +// ProseMirror editor, including after a warm return; typing must need no click. +test("selecting a channel or DM focuses its composer and retains drafts", async ({ + page, + app, +}) => { + await open(page, app); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const conversation = page.getByRole("article", { name: "Conversation" }); + const input = conversation.getByRole("textbox"); + for (const [index, name] of [ + "Beta", + "Alice Fixture", + "Beta", + "Alice Fixture", + ].entries()) { + await sidebar.getByRole("button", { name, exact: true }).click(); + await expect(input).toBeFocused(); + if (index < 2) { + await page.keyboard.type(`Draft for ${name}`); + await expect(input).toHaveText(`Draft for ${name}`); + } else { + await expect(input).toHaveText(`Draft for ${name}`); + await page.keyboard.type(" continued"); + await expect(input).toHaveText(`Draft for ${name} continued`); + } + } + const settings = page.getByRole("button", { + name: "Channel settings", + exact: true, + }); + await settings.click(); + await expect( + page.getByRole("button", { name: "Close channel settings" }), + ).toBeFocused(); +});