From e06d92d88f93417a710c8759ab9dd89b549646d5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:26:07 +0200 Subject: [PATCH] fix: open the photo, camera and file pickers from the compose attach sheet on ios --- src/lib/__tests__/after-modal-dismiss.test.ts | 61 +++++++++++++++++++ src/lib/after-modal-dismiss.ts | 42 +++++++++++++ src/screens/ComposeScreen.tsx | 15 ++++- 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/after-modal-dismiss.test.ts create mode 100644 src/lib/after-modal-dismiss.ts diff --git a/src/lib/__tests__/after-modal-dismiss.test.ts b/src/lib/__tests__/after-modal-dismiss.test.ts new file mode 100644 index 00000000..4f356ded --- /dev/null +++ b/src/lib/__tests__/after-modal-dismiss.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createAfterDismissQueue } from '../after-modal-dismiss'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createAfterDismissQueue', () => { + it('holds the action on iOS until the modal is dismissed', () => { + const queue = createAfterDismissQueue('ios'); + const action = vi.fn(); + queue.run(action); + expect(action).not.toHaveBeenCalled(); + queue.flush(); + expect(action).toHaveBeenCalledTimes(1); + queue.flush(); + expect(action).toHaveBeenCalledTimes(1); + }); + + it('runs the action after the fallback delay when onDismiss never comes', () => { + vi.useFakeTimers(); + const queue = createAfterDismissQueue('ios', 600); + const action = vi.fn(); + queue.run(action); + vi.advanceTimersByTime(599); + expect(action).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(action).toHaveBeenCalledTimes(1); + }); + + it('does not run the action twice when onDismiss and the timer both fire', () => { + vi.useFakeTimers(); + const queue = createAfterDismissQueue('ios', 600); + const action = vi.fn(); + queue.run(action); + queue.flush(); + vi.advanceTimersByTime(1000); + expect(action).toHaveBeenCalledTimes(1); + }); + + it('runs the action right away on Android', () => { + const queue = createAfterDismissQueue('android'); + const action = vi.fn(); + queue.run(action); + expect(action).toHaveBeenCalledTimes(1); + }); +}); + +// The compose "Attach" sheet closed its modal and opened the photo picker, +// camera or file picker in the same tick. On iOS the picker never showed. +describe('compose options sheet', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); + + it('opens the chosen option only after the sheet is dismissed', () => { + expect(source).not.toMatch(/onClose\(\);\s*opt\.onPress\(\)/); + expect(source).toMatch(/onDismiss=\{afterDismiss\.flush\}/); + expect(source).toMatch(/afterDismiss\.run\(opt\.onPress\)/); + }); +}); diff --git a/src/lib/after-modal-dismiss.ts b/src/lib/after-modal-dismiss.ts new file mode 100644 index 00000000..e23ab895 --- /dev/null +++ b/src/lib/after-modal-dismiss.ts @@ -0,0 +1,42 @@ +// iOS can't present a view controller (the photo picker, the camera, the +// document picker, a share sheet) while a React Native Modal is still +// dismissing: UIKit drops the request without an error and the picker never +// shows. A sheet whose option opens a picker has to wait until it is gone. +// +// run() keeps the action until flush(), which the Modal's onDismiss calls. +// A timer flushes too, in case onDismiss doesn't fire. Android has no such +// limit, so the action runs right away there. + +export interface AfterDismissQueue { + run(action: () => void): void; + flush(): void; +} + +export function createAfterDismissQueue( + platform: string, + fallbackMs = 600, +): AfterDismissQueue { + let pending: (() => void) | null = null; + let timer: ReturnType | null = null; + + const flush = () => { + if (timer) clearTimeout(timer); + timer = null; + const action = pending; + pending = null; + action?.(); + }; + + return { + run(action) { + if (platform !== 'ios') { + action(); + return; + } + if (timer) clearTimeout(timer); + pending = action; + timer = setTimeout(flush, fallbackMs); + }, + flush, + }; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index acbf0809..90c6e71b 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -32,6 +32,7 @@ import { getEmailInitials } from '../lib/avatar-utils'; import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store'; import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; import { useLayout } from '../lib/use-layout'; +import { createAfterDismissQueue } from '../lib/after-modal-dismiss'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; import { useHasContacts } from '../lib/capabilities'; @@ -375,8 +376,18 @@ function OptionsSheet({ }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + // An option may open a system picker, which iOS only shows once this + // modal is gone. + const afterDismiss = React.useRef(createAfterDismissQueue(Platform.OS)).current; return ( - + {}}> {!!title && {title}} @@ -384,7 +395,7 @@ function OptionsSheet({ { onClose(); opt.onPress(); }} + onPress={() => { onClose(); afterDismiss.run(opt.onPress); }} > {opt.label}