fix: open the photo, camera and file pickers from the compose attach sheet on ios

This commit is contained in:
Linus Rath
2026-10-22 22:04:28 +02:00
parent 42e89122bd
commit e06d92d88f
3 changed files with 116 additions and 2 deletions
@@ -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\)/);
});
});
+42
View File
@@ -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<typeof setTimeout> | 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,
};
}
+13 -2
View File
@@ -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 (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Modal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
onDismiss={afterDismiss.flush}
>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
@@ -384,7 +395,7 @@ function OptionsSheet({
<Pressable
key={opt.label}
style={styles.scheduleRow}
onPress={() => { onClose(); opt.onPress(); }}
onPress={() => { onClose(); afterDismiss.run(opt.onPress); }}
>
<Text style={[styles.scheduleRowLabel, opt.destructive && { color: c.error }]}>{opt.label}</Text>
</Pressable>