diff --git a/src/components/SafeAreaModal.tsx b/src/components/SafeAreaModal.tsx
index adad46e0..13750bb0 100644
--- a/src/components/SafeAreaModal.tsx
+++ b/src/components/SafeAreaModal.tsx
@@ -4,11 +4,12 @@ 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.
+ * window, and a SafeAreaView inside a Modal whose content slides in (a
+ * slide-up Modal, or a drawer or sheet the app animates itself) measured no
+ * insets: the view starts off screen. On iPhones with a notch or Dynamic
+ * Island headers (and their close buttons) ended up under the status bar,
+ * where taps don't reach them, and bottom rows on the home indicator. Use
+ * this for any Modal with a SafeAreaView inside.
*/
export function SafeAreaModal({ children, ...props }: ModalProps) {
return (
diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx
index f0e929e7..85ef2b9c 100644
--- a/src/components/SidebarDrawer.tsx
+++ b/src/components/SidebarDrawer.tsx
@@ -39,6 +39,7 @@ import type { RootStackParamList } from '../navigation/types';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
+import { SafeAreaModal } from './SafeAreaModal';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
import {
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
@@ -1240,8 +1241,11 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
);
}
+ // Its own safe-area provider: the panel's SafeAreaView starts off screen
+ // (it slides in), and without one it measured no insets on iOS, so the
+ // account header sat under the status bar.
return (
- setPrompt(null)}
/>
)}
-
+
);
}
diff --git a/src/components/__tests__/modal-safe-area.test.ts b/src/components/__tests__/modal-safe-area.test.ts
index 1eb8eda2..74e287b8 100644
--- a/src/components/__tests__/modal-safe-area.test.ts
+++ b/src/components/__tests__/modal-safe-area.test.ts
@@ -2,12 +2,15 @@ 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.
+// A SafeAreaView inside a react-native Modal whose content starts off screen
+// measured no insets on iOS: the Modal is its own window, and the view was
+// measured before it slid in. 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), the drawers' account headers did too, and the bottom sheets'
+// last rows sat on the home indicator. Such modals use SafeAreaModal, which
+// gives the window its own SafeAreaProvider. Slid in means animationType
+// "slide", or "none" with the app's own slide animation (drawers, sheets).
const SRC = path.resolve(__dirname, '../..');
@@ -19,6 +22,15 @@ function sourceFiles(dir: string): string[] {
});
}
+/** Plain elements: the opening tag and what is between the tags. */
+function modals(source: string): { tag: string; body: string }[] {
+ return modalTags(source).map((tag) => {
+ const start = source.indexOf(tag) + tag.length;
+ const end = source.indexOf('', start);
+ return { tag, body: source.slice(start, end < 0 ? undefined : end) };
+ });
+}
+
/** Opening tags of plain elements, attributes included. */
function modalTags(source: string): string[] {
const tags: string[] = [];
@@ -40,18 +52,32 @@ function modalTags(source: string): string[] {
return tags;
}
-describe('slide-up modals with a SafeAreaView', () => {
- it('use SafeAreaModal so their header clears the status bar', () => {
+describe('modals that slide in with a SafeAreaView', () => {
+ it('use SafeAreaModal so their content clears the status bar and home indicator', () => {
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));
+ return modals(source).some(({ tag, body }) =>
+ /animationType="slide"/.test(tag)
+ || (/animationType="none"/.test(tag) && body.includes(' path.relative(SRC, file));
expect(offenders).toEqual([]);
});
+ // The mail drawer builds its panel (with the SafeAreaView) outside the
+ // modal's tags, so the check above can't see it there.
+ it('include the side drawers', () => {
+ const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file));
+ expect(drawers.length).toBeGreaterThanOrEqual(3);
+ for (const file of drawers) {
+ const source = fs.readFileSync(file, 'utf8');
+ expect(modalTags(source).filter((tag) => /animationType="none"/.test(tag)), path.relative(SRC, file)).toEqual([]);
+ expect(source).toContain(' {
const tags = modalTags(' close()} animationType="slide">\n');
expect(tags).toHaveLength(1);
diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx
index b79723aa..59ec99e6 100644
--- a/src/components/calendar/CalendarSidebarDrawer.tsx
+++ b/src/components/calendar/CalendarSidebarDrawer.tsx
@@ -3,7 +3,6 @@ import {
Animated,
Dimensions,
Easing,
- Modal,
Pressable,
ScrollView,
StyleSheet,
@@ -24,6 +23,7 @@ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { SafeAreaModal } from '../SafeAreaModal';
/** A calendar view listed at the top of the drawer. */
export interface DrawerViewOption {
@@ -157,7 +157,7 @@ export function CalendarSidebarDrawer({
};
return (
-
-
+
);
}
diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx
index ef5aaf8a..2b02d375 100644
--- a/src/components/calendar/EventDetailSheet.tsx
+++ b/src/components/calendar/EventDetailSheet.tsx
@@ -3,7 +3,6 @@ import {
Animated,
Dimensions,
Easing,
- Modal,
Platform,
Pressable,
ScrollView,
@@ -63,6 +62,7 @@ import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrenc
import type { TranslateFn } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { SafeAreaModal } from '../SafeAreaModal';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -248,7 +248,7 @@ export function EventDetailSheet({
};
return (
-
-
+
);
}
diff --git a/src/components/calendar/ICalImportSheet.tsx b/src/components/calendar/ICalImportSheet.tsx
index e26adbcc..9ae1b344 100644
--- a/src/components/calendar/ICalImportSheet.tsx
+++ b/src/components/calendar/ICalImportSheet.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
+ View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
@@ -19,6 +19,7 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
import type { Locale } from 'date-fns';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { SafeAreaModal } from '../SafeAreaModal';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -188,7 +189,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
};
return (
-
+
@@ -378,7 +379,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
)}
-
+
);
}
diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx
index 80d8a5a8..965638c7 100644
--- a/src/components/calendar/ICalSubscriptionSheet.tsx
+++ b/src/components/calendar/ICalSubscriptionSheet.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
+ View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, TextInput, ActivityIndicator, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
@@ -19,6 +19,7 @@ import {
import { jmapClient } from '../../api/jmap-client';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { SafeAreaModal } from '../SafeAreaModal';
// The webmail's refresh intervals and their labels.
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
@@ -135,7 +136,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
return (
-
+
@@ -313,7 +314,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
-
+
);
}
diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx
index b457c524..5f6e0726 100644
--- a/src/components/calendar/TasksSheet.tsx
+++ b/src/components/calendar/TasksSheet.tsx
@@ -4,7 +4,6 @@ import {
Animated,
Dimensions,
Easing,
- Modal,
Platform,
Pressable,
ScrollView,
@@ -45,6 +44,7 @@ import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { SafeAreaModal } from '../SafeAreaModal';
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -235,7 +235,7 @@ export function TasksSheet({
];
return (
-
+
@@ -480,7 +480,7 @@ export function TasksSheet({
}}
/>
)}
-
+
);
}
diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx
index 4a460d52..0a86f1f1 100644
--- a/src/components/contacts/ContactImportSheet.tsx
+++ b/src/components/contacts/ContactImportSheet.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
+ View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
@@ -15,6 +15,7 @@ import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { SafeAreaModal } from '../SafeAreaModal';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
@@ -164,7 +165,7 @@ export default function ContactImportSheet({
};
return (
-
+
@@ -304,7 +305,7 @@ export default function ContactImportSheet({
)}
-
+
);
}
diff --git a/src/components/contacts/ContactPickerSheet.tsx b/src/components/contacts/ContactPickerSheet.tsx
index 742b4e4a..99bf7401 100644
--- a/src/components/contacts/ContactPickerSheet.tsx
+++ b/src/components/contacts/ContactPickerSheet.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- View, Text, StyleSheet, Pressable, Modal, TextInput, FlatList,
+ View, Text, StyleSheet, Pressable, TextInput, FlatList,
Animated, Dimensions, Easing,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -18,6 +18,7 @@ import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { SafeAreaModal } from '../SafeAreaModal';
interface Props {
visible: boolean;
@@ -98,7 +99,7 @@ export default function ContactPickerSheet({
};
return (
-
+
@@ -179,7 +180,7 @@ export default function ContactPickerSheet({
/>
-
+
);
}
diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx
index ccc066af..299c0bf3 100644
--- a/src/components/contacts/ContactsSidebarDrawer.tsx
+++ b/src/components/contacts/ContactsSidebarDrawer.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
+ View, Text, StyleSheet, Pressable, ScrollView, Animated, Dimensions, Easing, TextInput, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
@@ -19,6 +19,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { SafeAreaModal } from '../SafeAreaModal';
type Nav = NativeStackNavigationProp;
@@ -162,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
);
return (
-
+
@@ -310,7 +311,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
-
+
);
}
diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx
index 1ba0f287..7400f292 100644
--- a/src/screens/ContactDetailScreen.tsx
+++ b/src/screens/ContactDetailScreen.tsx
@@ -1,8 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Image, Share,
- Animated, Dimensions, Easing, Modal,
-} from 'react-native';
+ Animated, Dimensions, Easing, } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -35,6 +34,7 @@ import { useColors } from '../theme/colors';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
+import { SafeAreaModal } from '../components/SafeAreaModal';
type Nav = NativeStackNavigationProp;
type Route = RouteProp;
@@ -814,7 +814,7 @@ function MoreActionsSheet({
}, [visible, slideY, overlayOpacity]);
return (
-
+
@@ -846,7 +846,7 @@ function MoreActionsSheet({
})}
-
+
);
}