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 (
-
+
-
+
);
}