diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 53b09cfd1dd..03fb365ebdd 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -5,6 +5,7 @@ import { ChevronDown } from "lucide-react"; import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; +import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext"; import { useMentions } from "@/features/messages/lib/useMentions"; @@ -101,6 +102,8 @@ export function ForumComposer({ mentions.isMentionOpen || channelLinks.isChannelOpen; const submitMessageRef = React.useRef<() => void>(() => {}); + const formRef = React.useRef(null); + const composerOwnsFocus = useComposerFocusOwnership(formRef); // Set after `useLinkEditor` exists; the editor's link-click handler // delegates through this ref to break the hook ordering cycle. @@ -500,6 +503,7 @@ export function ForumComposer({ }} onFocusCapture={expandCompactComposer} onSubmit={handleSubmit} + ref={formRef} > {media.isDragOver && } {isCompactLayout ? ( @@ -526,6 +530,7 @@ export function ForumComposer({ ? channelLinks.channelSuggestions : [] } + composerOwnsFocus={composerOwnsFocus} mentionSelectedIndex={mentions.mentionSelectedIndex} mentionSuggestions={ mentions.isMentionOpen ? mentions.suggestions : [] diff --git a/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx index 149eec7b91c..e3a63e7dd58 100644 --- a/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx +++ b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx @@ -8,6 +8,7 @@ import { type ForumComposerAutocompletesProps = { channelSelectedIndex: number; channelSuggestions: ChannelSuggestion[]; + composerOwnsFocus: boolean; mentionSelectedIndex: number; mentionSuggestions: MentionSuggestion[]; onChannelSelect: (suggestion: ChannelSuggestion) => void; @@ -20,6 +21,7 @@ type ForumComposerAutocompletesProps = { export function ForumComposerAutocompletes({ channelSelectedIndex, channelSuggestions, + composerOwnsFocus, mentionSelectedIndex, mentionSuggestions, onChannelSelect, @@ -31,12 +33,14 @@ export function ForumComposerAutocompletes({ return ( <> ", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + Element: dom.window.Element, + Event: dom.window.Event, + FocusEvent: dom.window.FocusEvent, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +async function renderHarness() { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { useComposerFocusOwnership } = await import( + "./useComposerFocusOwnership.ts" + ); + + function Harness() { + const formRef = React.useRef(null); + const ownsFocus = useComposerFocusOwnership(formRef); + return React.createElement( + React.Fragment, + null, + React.createElement( + "form", + { "data-testid": "composer", ref: formRef }, + React.createElement("input", { "aria-label": "Editor" }), + React.createElement("button", { type: "button" }, "Overlay control"), + React.createElement("output", { + "data-testid": "owned", + "data-owned": String(ownsFocus), + }), + ), + React.createElement("input", { "aria-label": "Elsewhere" }), + ); + } + + const view = render(React.createElement(Harness)); + return { + view, + ownership: () => view.getByTestId("owned").getAttribute("data-owned"), + }; +} + +test("tracks focus entering, moving within, and leaving the composer", async () => { + const { act } = await import("react"); + const { view, ownership } = await renderHarness(); + + assert.equal(ownership(), "false"); + + const editor = view.getByRole("textbox", { name: "Editor" }); + await act(async () => editor.focus()); + assert.equal(ownership(), "true"); + + // Focus handed from the editor to an overlay control stays owned — this is + // the transition an editor-focus gate got wrong, unmounting the overlay + // before the control it was handing focus to could receive it. + const control = view.getByRole("button", { name: "Overlay control" }); + await act(async () => control.focus()); + assert.equal(ownership(), "true"); + + const elsewhere = view.getByRole("textbox", { name: "Elsewhere" }); + await act(async () => elsewhere.focus()); + assert.equal(ownership(), "false"); +}); + +test("an internal focus move never reports an unowned intermediate state", async () => { + const React = await import("react"); + const { act } = React; + const { render } = await import("@testing-library/react"); + const { useComposerFocusOwnership } = await import( + "./useComposerFocusOwnership.ts" + ); + + const observed = []; + function Harness() { + const formRef = React.useRef(null); + const ownsFocus = useComposerFocusOwnership(formRef); + observed.push(ownsFocus); + return React.createElement( + "form", + { ref: formRef }, + React.createElement("input", { "aria-label": "Editor" }), + React.createElement("button", { type: "button" }, "Overlay control"), + ); + } + + const view = render(React.createElement(Harness)); + const editor = view.getByRole("textbox", { name: "Editor" }); + const control = view.getByRole("button", { name: "Overlay control" }); + await act(async () => editor.focus()); + observed.length = 0; + + // relatedTarget mirrors a browser handing focus editor → overlay control. + // The focusout handler must read it instead of assuming focus left. + const { fireEvent } = await import("@testing-library/react"); + fireEvent.focusOut(editor, { relatedTarget: control }); + fireEvent.focusIn(control); + + assert.equal(observed.includes(false), false); +}); diff --git a/desktop/src/features/messages/lib/useComposerFocusOwnership.ts b/desktop/src/features/messages/lib/useComposerFocusOwnership.ts new file mode 100644 index 00000000000..f39d618dc03 --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerFocusOwnership.ts @@ -0,0 +1,44 @@ +import * as React from "react"; + +/** + * Tracks whether a composer owns document focus: true while the focused + * element lives anywhere inside `containerRef` (the composer form) — the + * editor or the focusable controls of its suggestion overlays. + * + * This is the value the autocomplete overlays gate their rendering on. It is + * deliberately not the editor's own focus state: an overlay gated on editor + * focus alone unmounts the moment keyboard focus moves from the editor into + * the overlay's controls, which makes those controls unreachable. Ownership + * is tracked with `focusout` + `relatedTarget` containment rather than + * blur/focus pairs so an internal focus move never passes through a false + * state — a false flicker would unmount the overlay before the control it + * is handing focus to receives it. Each composer form has its own instance, + * so focus in one composer never keeps a sibling composer's overlays alive. + */ +export function useComposerFocusOwnership( + containerRef: React.RefObject, +): boolean { + const [ownsFocus, setOwnsFocus] = React.useState(false); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + setOwnsFocus(container.contains(document.activeElement)); + const handleFocusIn = () => setOwnsFocus(true); + const handleFocusOut = (event: FocusEvent) => { + setOwnsFocus( + event.relatedTarget instanceof Node && + container.contains(event.relatedTarget), + ); + }; + container.addEventListener("focusin", handleFocusIn); + container.addEventListener("focusout", handleFocusOut); + return () => { + container.removeEventListener("focusin", handleFocusIn); + container.removeEventListener("focusout", handleFocusOut); + }; + }, [containerRef]); + + return ownsFocus; +} diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index 57e458bc911..21a0a102350 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -238,8 +238,11 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { return { handled: true }; } + // Forward Tab selects; Shift+Tab deliberately does not. The reverse + // move stays the browser's, so this overlay can't swallow a keyboard + // user's way back out (see useMentions for the same split). if ( - event.key === "Tab" || + (event.key === "Tab" && !event.shiftKey) || (event.key === "Enter" && !event.ctrlKey && !event.metaKey && diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index de6e6da6c8f..f846b545d6c 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -749,9 +749,13 @@ export function useMentions( ); return { handled: true }; } + // Shift+Tab is deliberately not a select: it is the keyboard route out + // of the editor — into this overlay's Options controls where the + // composer offers them, otherwise the browser's own backward focus + // move — so those controls stay reachable. if ( exactMentionSpace || - event.key === "Tab" || + (event.key === "Tab" && !event.shiftKey) || (event.key === "Enter" && !event.ctrlKey && !event.metaKey && diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index e3e17071fad..2f00531820d 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -167,16 +167,12 @@ export function useRichTextEditor({ const addressedAgentMentionNamesRef = React.useRef([]); const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; - const onSubmitRef = React.useRef(onSubmit); onSubmitRef.current = onSubmit; - const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); onEditLastOwnMessageRef.current = onEditLastOwnMessage; - const onEditLinkRef = React.useRef(onEditLink); onEditLinkRef.current = onEditLink; - const onLinkSelectionChangeRef = React.useRef(onLinkSelectionChange); onLinkSelectionChangeRef.current = onLinkSelectionChange; @@ -618,13 +614,19 @@ export function useRichTextEditor({ const hadFocusBeforeDisableRef = React.useRef(false); React.useEffect(() => { if (!editor || editor.isEditable === editable) return; + // `emitUpdate: false` on both toggles — the doc hasn't changed, so the + // default synthetic `update` event would replay `onUpdate` with stale + // text/cursor and resurrect consumer state derived from it (e.g. reopen + // a mention menu the user dismissed with Escape, or re-fire a typing + // notification for an untouched draft). Real content changes (typing, + // clearContent) dispatch real transactions that emit their own updates. 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); + editor.setEditable(false, false); } else { - editor.setEditable(true); + editor.setEditable(true, false); // 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). diff --git a/desktop/src/features/messages/ui/ChannelAutocomplete.tsx b/desktop/src/features/messages/ui/ChannelAutocomplete.tsx index 1305919a1c9..5c759a8a693 100644 --- a/desktop/src/features/messages/ui/ChannelAutocomplete.tsx +++ b/desktop/src/features/messages/ui/ChannelAutocomplete.tsx @@ -12,6 +12,11 @@ import { type ChannelAutocompleteProps = { suggestions: ChannelSuggestion[]; selectedIndex: number; + /** + * Whether the owning composer owns document focus. Composers that don't + * must not render suggestions — see MentionAutocomplete for the rationale. + */ + composerOwnsFocus: boolean; onSelect: (suggestion: ChannelSuggestion) => void; position?: "above" | "below"; }; @@ -19,6 +24,7 @@ type ChannelAutocompleteProps = { export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({ suggestions, selectedIndex, + composerOwnsFocus, onSelect, position = "above", }: ChannelAutocompleteProps) { @@ -31,7 +37,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({ activeItem?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); - if (suggestions.length === 0) { + if (!composerOwnsFocus || suggestions.length === 0) { return null; } @@ -42,6 +48,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({ position === "below" ? "top-full mt-1" : "bottom-full mb-1", )} > + {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, no behavior of its own — an unprevented mousedown here (scrollbar, padding ring) blurs the editor, and the focus gate above would unmount the overlay mid-press. */}
event.preventDefault()} ref={listRef} style={POPOVER_SHADOW_STYLE} > diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index ae39931334e..30bfe196e3f 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -195,7 +195,10 @@ export function ComposerMentionButton({ data-testid="message-insert-mention" disabled={disabled} onClick={onOpen} - onMouseDown={onCaptureSelection} + onMouseDown={(event) => { + onCaptureSelection(); + event.preventDefault(); + }} type="button" >