From 51e25435a2090a423dc79db614daafcc40704ce6 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:06:02 -0700 Subject: [PATCH] Focus composer input when editable (#10982) --- src/components/ContextMenu/index.tsx | 2 ++ src/components/ContextMenu/index.web.tsx | 4 +++- src/components/dms/MessageContextMenu.tsx | 19 +++++++++++++++++-- .../Messages/components/MessageComposer.tsx | 10 +++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 258e2898e4..4a6f453a8a 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -615,6 +615,8 @@ export function Outer({ label?: string style?: StyleProp align?: 'left' | 'right' + /** Web only. Native restores focus differently. */ + onCloseAutoFocus?: (event: Event) => void }) { const t = useTheme() const context = useContextMenuContext() diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx index 9554e7e1ae..3dfc39ffaa 100644 --- a/src/components/ContextMenu/index.web.tsx +++ b/src/components/ContextMenu/index.web.tsx @@ -34,14 +34,16 @@ export function Outer({ children, label, style, + onCloseAutoFocus, }: { children: React.ReactNode label?: string style?: StyleProp + onCloseAutoFocus?: (event: Event) => void }) { const t = useTheme() return ( - + {label ? ( { + setReply(message) + }, [setReply, message]) + // On web, the menu is a Radix dropdown that restores focus to the trigger on + // close. When Reply moves focus to the composer, don't let Radix steal it + // back. Checking activeElement (rather than tracking reply intent) also + // handles re-replying to the same message, where the composer's focus effect + // bails on an unchanged reply target and focus should stay on the trigger. + const onCloseAutoFocus = useCallback((event: Event) => { + if (document.activeElement && document.activeElement !== document.body) { + event.preventDefault() + } + }, []) + const isFromSelf = message.sender?.did === currentAccount?.did const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable) @@ -161,11 +175,12 @@ export let MessageContextMenu = ({ label={l`Sent at ${i18n.date(new Date(message.sentAt), { timeStyle: 'short', })}`} - style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> + style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]} + onCloseAutoFocus={onCloseAutoFocus}> setReply(message)}> + onPress={onReply}> {l`Reply`} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 09a15ca2b5..dd2197b247 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -77,6 +77,15 @@ export function MessageComposer({ composerInternalApiRef.current?.input?.focus() }, [replyTo, composerInternalApiRef]) + // On web, focus the input once the conversation is ready. The composer also + // mounts during the loading state (when it isn't editable), so a mount-time + // autoFocus would fire too early to land focus. + useEffect(() => { + if (IS_WEB && editable) { + composerInternalApiRef.current?.input?.focus() + } + }, [editable, composerInternalApiRef]) + // Android interactive dismiss sometimes doesn't blur the input const blur = useNonReactiveCallback(() => { composerInternalApiRef.current?.input?.blur() @@ -249,7 +258,6 @@ export function MessageComposer({ internalApiRef={composerInternalApiRef} defaultValue={text} editable={editable} - autoFocus={IS_WEB} maxRows={12} outerStyle={[a.flex_1]} contentTextStyle={[a.text_md, a.leading_snug]}