diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index 8963a4039d5f..93ec96ca1b19 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -60,7 +60,12 @@ export function ComposerInlineControl(props: { accessibilityState={ props.static ? undefined : { disabled: props.disabled, selected: props.selected } } - className="h-[44px] flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle" + // Icon-only, the control sits right after the composer's text, where + // leading padding would take taps meant for the text field. + className={cn( + "h-[44px] flex-row items-center gap-2 rounded-xl pr-2 active:bg-subtle", + props.iconOnly ? "pl-1" : "pl-2", + )} disabled={props.disabled || props.static} onPress={props.onPress} style={{ opacity: props.disabled ? 0.45 : 1 }} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 4f0b76d6865a..9907f4cb579a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -32,7 +32,7 @@ import { import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "react"; -import { I18nManager, Keyboard, Platform, View, type ViewStyle } from "react-native"; +import { I18nManager, Keyboard, Platform, Pressable, View, type ViewStyle } from "react-native"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -124,6 +124,9 @@ export const COMPOSER_COLLAPSED_CHROME = 60; const NO_MACHINES: ReadonlyArray = []; const COLLAPSED_EDITOR_DEFAULT_INSET = 6; +// Space between the compact editor and the icon-only model control. It belongs +// to the editor slot, so a tap that lands short of the icon focuses the editor. +const COMPACT_EDITOR_TRAILING_GAP = 12; // iOS puts line-height slack above glyphs. DM Sans's lowercase midline is // descender (0.31em) + half x-height (0.504em) above the line bottom. const LOWERCASE_MIDLINE_EM = 0.562; @@ -170,7 +173,9 @@ function useCompactComposerEditor(text: ScaledTextRole, value: string) { slotWidth: editorWidth.value, rowWidth: rowWidth.value, isRTL, + trailingGap: COMPACT_EDITOR_TRAILING_GAP, })), + // The full-width editor must move above the controls in the same frame it widens. bottom: isExpanded ? 52 : (compactRowHeight - minimum) / 2, })); @@ -187,7 +192,9 @@ function useCompactComposerEditor(text: ScaledTextRole, value: string) { if (!isExpanded) { editorX.set(event.nativeEvent.layout.x); editorWidth.set(event.nativeEvent.layout.width); - setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8)); + setCompactWidth( + Math.max(0, event.nativeEvent.layout.width - 4 - COMPACT_EDITOR_TRAILING_GAP), + ); } }, editorProps: { @@ -913,7 +920,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps compactEditor.containerStyle, compactEditor.isExpanded ? { flex: 0, width: 0 } : undefined, ]} - > + > + {/* The editor overlay is inset from this slot; taps on the inset still focus it. */} + inputRef.current?.focus()} + /> + {!isExpanded && stripAttachments.length > 0 ? ( {stripAttachments.slice(0, compactAttachmentLimit).map((attachment) => ( diff --git a/apps/mobile/src/features/threads/compactEditorInsets.test.ts b/apps/mobile/src/features/threads/compactEditorInsets.test.ts index ebb7a39f85a2..b909d20923c5 100644 --- a/apps/mobile/src/features/threads/compactEditorInsets.test.ts +++ b/apps/mobile/src/features/threads/compactEditorInsets.test.ts @@ -4,14 +4,26 @@ import { compactEditorInsets } from "./compactEditorInsets"; describe("compact editor insets", () => { it("keeps the measured slot position in left-to-right layouts", () => { - expect(compactEditorInsets({ slotX: 48, slotWidth: 200, rowWidth: 360, isRTL: false })).toEqual( - { left: 52, right: 116 }, - ); + expect( + compactEditorInsets({ + slotX: 48, + slotWidth: 200, + rowWidth: 360, + isRTL: false, + trailingGap: 12, + }), + ).toEqual({ left: 52, right: 124 }); }); it("swaps the insets in right-to-left layouts so the editor stays over its slot", () => { - expect(compactEditorInsets({ slotX: 112, slotWidth: 200, rowWidth: 360, isRTL: true })).toEqual( - { left: 52, right: 116 }, - ); + expect( + compactEditorInsets({ + slotX: 112, + slotWidth: 200, + rowWidth: 360, + isRTL: true, + trailingGap: 12, + }), + ).toEqual({ left: 52, right: 124 }); }); }); diff --git a/apps/mobile/src/features/threads/compactEditorInsets.ts b/apps/mobile/src/features/threads/compactEditorInsets.ts index 46cb521b9b30..083a4d77eedc 100644 --- a/apps/mobile/src/features/threads/compactEditorInsets.ts +++ b/apps/mobile/src/features/threads/compactEditorInsets.ts @@ -9,9 +9,15 @@ export function compactEditorInsets(input: { readonly slotWidth: number; readonly rowWidth: number; readonly isRTL: boolean; + readonly trailingGap?: number; }): { left: number; right: number } { "worklet"; - const fromLeft = input.slotX + COMPACT_EDITOR_SLOT_INSET; - const fromRight = input.rowWidth - input.slotX - input.slotWidth + COMPACT_EDITOR_SLOT_INSET; + const trailingGap = input.trailingGap ?? COMPACT_EDITOR_SLOT_INSET; + const fromLeft = input.slotX + (input.isRTL ? trailingGap : COMPACT_EDITOR_SLOT_INSET); + const fromRight = + input.rowWidth - + input.slotX - + input.slotWidth + + (input.isRTL ? COMPACT_EDITOR_SLOT_INSET : trailingGap); return input.isRTL ? { left: fromRight, right: fromLeft } : { left: fromLeft, right: fromRight }; }