diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 680c9cf0bab..e0157b55f74 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -104,6 +104,7 @@ import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, } from "./ThreadSettingsSheet"; +import { composerSettingsToolbarLayout } from "./composer-draft-expansion"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, @@ -415,6 +416,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; const showsCompactDictation = isVoiceInputPresented && !isExpanded; const isToolbarVisible = isExpanded || isVoiceInputPresented; + const settingsToolbar = composerSettingsToolbarLayout({ + isExpanded, + isVoicePresented: isVoiceInputPresented, + }); const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, attachments: props.draftAttachments, @@ -726,7 +731,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} : null} + {settingsToolbar.mount ? ( + ) : null} diff --git a/apps/mobile/src/features/threads/composer-draft-expansion.test.ts b/apps/mobile/src/features/threads/composer-draft-expansion.test.ts new file mode 100644 index 00000000000..0ad3facee9c --- /dev/null +++ b/apps/mobile/src/features/threads/composer-draft-expansion.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { composerSettingsToolbarLayout } from "./composer-draft-expansion"; + +describe("composer settings toolbar layout", () => { + it("never overlays the native editor", () => { + expect( + composerSettingsToolbarLayout({ isExpanded: false, isVoicePresented: false }).overlayEditor, + ).toBe(false); + expect( + composerSettingsToolbarLayout({ isExpanded: true, isVoicePresented: false }).overlayEditor, + ).toBe(false); + expect( + composerSettingsToolbarLayout({ isExpanded: false, isVoicePresented: true }).overlayEditor, + ).toBe(false); + }); + + it("mounts the row only when the draft is expanded or dictation is showing", () => { + expect( + composerSettingsToolbarLayout({ isExpanded: false, isVoicePresented: false }).mount, + ).toBe(false); + expect( + composerSettingsToolbarLayout({ isExpanded: true, isVoicePresented: false }).mount, + ).toBe(true); + expect( + composerSettingsToolbarLayout({ isExpanded: false, isVoicePresented: true }).mount, + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/composer-draft-expansion.ts b/apps/mobile/src/features/threads/composer-draft-expansion.ts new file mode 100644 index 00000000000..e2017253abc --- /dev/null +++ b/apps/mobile/src/features/threads/composer-draft-expansion.ts @@ -0,0 +1,15 @@ +/** + * The model chip is a RN Pressable. The composer is a UITextView. iOS native + * views win hit testing against overlapping RN siblings, so a chip laid out + * over the editor selects text / shows Paste instead of opening settings. + * Never overlay the settings row on the editor. Mount it in flow, or not at all. + */ +export function composerSettingsToolbarLayout(input: { + readonly isExpanded: boolean; + readonly isVoicePresented: boolean; +}): { readonly overlayEditor: boolean; readonly mount: boolean } { + return { + overlayEditor: false, + mount: input.isExpanded || input.isVoicePresented, + }; +}