From 470e7fc3ba2d97112462cc49dd20fce7cc5cb5bb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:51:48 +0200 Subject: [PATCH] fix: keep the drawers' account header below the status bar and sheet rows above the home indicator on iPhone --- src/components/SafeAreaModal.tsx | 11 ++--- src/components/SidebarDrawer.tsx | 8 +++- .../__tests__/modal-safe-area.test.ts | 44 +++++++++++++++---- .../calendar/CalendarSidebarDrawer.tsx | 6 +-- src/components/calendar/EventDetailSheet.tsx | 6 +-- src/components/calendar/ICalImportSheet.tsx | 7 +-- .../calendar/ICalSubscriptionSheet.tsx | 7 +-- src/components/calendar/TasksSheet.tsx | 6 +-- .../contacts/ContactImportSheet.tsx | 7 +-- .../contacts/ContactPickerSheet.tsx | 7 +-- .../contacts/ContactsSidebarDrawer.tsx | 7 +-- src/screens/ContactDetailScreen.tsx | 8 ++-- 12 files changed, 80 insertions(+), 44 deletions(-) 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({ })} - + ); }