mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep full-screen modal headers below the status bar on iPhones with a notch
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
Platform,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
@@ -19,6 +18,7 @@ import Button from './Button';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
||||
import { describeLoginError } from '../lib/login-errors';
|
||||
import { SafeAreaModal } from './SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
|
||||
interface PasteSignInLinkModalProps {
|
||||
@@ -81,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
|
||||
const title = t('login.mobile.paste_title', 'Paste sign-in link');
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
|
||||
<View style={styles.header}>
|
||||
@@ -145,7 +145,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
|
||||
import { View, Text, Pressable, StyleSheet, Platform, Linking } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from './icons';
|
||||
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { SafeAreaModal } from './SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
|
||||
interface QrScanModalProps {
|
||||
@@ -43,7 +44,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<View style={styles.container}>
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
@@ -105,7 +106,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Modal, type ModalProps } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
/**
|
||||
* A Modal with its own safe-area provider. A Modal is a separate native
|
||||
* window, and a SafeAreaView inside a slide-up Modal measured no insets: the
|
||||
* view starts off screen when the slide begins, so on iPhones with a notch or
|
||||
* Dynamic Island the header (and its close button) ended up under the status
|
||||
* bar, where taps don't reach it. Use this for full-screen modals with a
|
||||
* SafeAreaView header.
|
||||
*/
|
||||
export function SafeAreaModal({ children, ...props }: ModalProps) {
|
||||
return (
|
||||
<Modal {...props}>
|
||||
<SafeAreaProvider>{children}</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// A SafeAreaView inside a slide-up react-native Modal measured no insets on
|
||||
// iOS: the Modal is its own window and its content starts off screen, so on
|
||||
// iPhones with a notch or Dynamic Island the header and its close button sat
|
||||
// under the status bar, out of reach (attachment preview, QR scanner, paste
|
||||
// sign-in link, filter and event editors). Such modals use SafeAreaModal,
|
||||
// which gives the window its own SafeAreaProvider.
|
||||
|
||||
const SRC = path.resolve(__dirname, '../..');
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
|
||||
return e.name.endsWith('.tsx') ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Opening tags of plain <Modal> elements, attributes included. */
|
||||
function modalTags(source: string): string[] {
|
||||
const tags: string[] = [];
|
||||
let at = source.indexOf('<Modal');
|
||||
while (at >= 0) {
|
||||
if (/[\s>]/.test(source[at + 6] ?? '')) {
|
||||
let depth = 0;
|
||||
let i = at;
|
||||
for (; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
|
||||
}
|
||||
tags.push(source.slice(at, i + 1));
|
||||
}
|
||||
at = source.indexOf('<Modal', at + 6);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
describe('slide-up modals with a SafeAreaView', () => {
|
||||
it('use SafeAreaModal so their header clears the status bar', () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
if (!source.includes('SafeAreaView')) return false;
|
||||
return modalTags(source).some((tag) => /animationType="slide"/.test(tag));
|
||||
})
|
||||
.map((file) => path.relative(SRC, file));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds the tags it is looking for', () => {
|
||||
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]).toContain('animationType="slide"');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
@@ -69,6 +68,7 @@ import { ParticipantInput } from './ParticipantInput';
|
||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
@@ -419,7 +419,7 @@ export function EventModal({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="fullScreen"
|
||||
@@ -838,7 +838,7 @@ export function EventModal({
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -21,6 +21,7 @@ import { formatFullDateTime } from '../../lib/email-date';
|
||||
import type { EmlPreview } from './use-body-override';
|
||||
import { useFilePreview } from './use-file-preview';
|
||||
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
export interface PreviewItem {
|
||||
@@ -432,9 +433,9 @@ export function AttachmentPreviewModal({
|
||||
);
|
||||
if (docked) return item || loading ? content : null;
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||
{content}
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Alert,
|
||||
KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
FilterComparator,
|
||||
FilterActionType,
|
||||
} from '../../lib/sieve/types';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
|
||||
@@ -227,7 +228,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
@@ -490,7 +491,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
</Button>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, TextInput,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
||||
import { useColors } from '../../theme/colors';
|
||||
import Button from '../Button';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
interface SieveEditorSheetProps {
|
||||
@@ -63,7 +64,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
|
||||
}, [script, showSaveWarning, onSave]);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
@@ -174,7 +175,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user