From f5f3637d01880d7b5c19535d80ef33b3c657b9ee Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:15:35 +0200 Subject: [PATCH] fix: keep full-screen modal headers below the status bar on iPhones with a notch --- src/components/PasteSignInLinkModal.tsx | 6 +- src/components/QrScanModal.tsx | 7 ++- src/components/SafeAreaModal.tsx | 19 ++++++ .../__tests__/modal-safe-area.test.ts | 60 +++++++++++++++++++ src/components/calendar/EventModal.tsx | 6 +- .../email/AttachmentPreviewModal.tsx | 7 ++- src/components/filters/FilterRuleModal.tsx | 7 ++- src/components/filters/SieveEditorSheet.tsx | 7 ++- 8 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 src/components/SafeAreaModal.tsx create mode 100644 src/components/__tests__/modal-safe-area.test.ts diff --git a/src/components/PasteSignInLinkModal.tsx b/src/components/PasteSignInLinkModal.tsx index 3f27698b..4c5cc66f 100644 --- a/src/components/PasteSignInLinkModal.tsx +++ b/src/components/PasteSignInLinkModal.tsx @@ -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 ( - + @@ -145,7 +145,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn - + ); } diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index c9a98a00..f058e710 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -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 ( - + {permission?.granted ? ( - + ); } diff --git a/src/components/SafeAreaModal.tsx b/src/components/SafeAreaModal.tsx new file mode 100644 index 00000000..adad46e0 --- /dev/null +++ b/src/components/SafeAreaModal.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/__tests__/modal-safe-area.test.ts b/src/components/__tests__/modal-safe-area.test.ts new file mode 100644 index 00000000..1eb8eda2 --- /dev/null +++ b/src/components/__tests__/modal-safe-area.test.ts @@ -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 elements, attributes included. */ +function modalTags(source: string): string[] { + const tags: string[] = []; + let at = source.indexOf('= 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(' { + 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(' close()} animationType="slide">\n'); + expect(tags).toHaveLength(1); + expect(tags[0]).toContain('animationType="slide"'); + }); +}); diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 896a0d2a..c5027581 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -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 ( - )} - + ); } diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index 8948d40c..9ca523d7 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -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 ( - + {content} - + ); } diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index ffe96e30..616b5dd1 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -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 ( - + - + ); } diff --git a/src/components/filters/SieveEditorSheet.tsx b/src/components/filters/SieveEditorSheet.tsx index 116610a3..a62e1445 100644 --- a/src/components/filters/SieveEditorSheet.tsx +++ b/src/components/filters/SieveEditorSheet.tsx @@ -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 ( - + - + ); }