mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
fix: open the photo, camera and file pickers from the compose attach sheet on ios
This commit is contained in:
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user