mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: keep the drawers' account header below the status bar and sheet rows above the home indicator on iPhone
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -1268,7 +1272,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
onClose={() => setPrompt(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Modal> 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('</Modal>', start);
|
||||
return { tag, body: source.slice(start, end < 0 ? undefined : end) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Opening tags of plain <Modal> 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('<SafeAreaView')));
|
||||
})
|
||||
.map((file) => 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('<SafeAreaModal');
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -271,7 +271,7 @@ export function CalendarSidebarDrawer({
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
@@ -464,7 +464,7 @@ export function EventDetailSheet({
|
||||
{/* The screen's own toast host sits under this Modal; this one shows
|
||||
the copy confirmation above the sheet. */}
|
||||
<ToastHost />
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -378,7 +379,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -313,7 +314,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
<View style={{ height: insets.bottom }} />
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -480,7 +480,7 @@ export function TasksSheet({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -304,7 +305,7 @@ export default function ContactImportSheet({
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -179,7 +180,7 @@ export default function ContactPickerSheet({
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RootStackParamList>;
|
||||
|
||||
@@ -162,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -310,7 +311,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RootStackParamList, 'ContactDetail'>;
|
||||
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
||||
@@ -814,7 +814,7 @@ function MoreActionsSheet({
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
@@ -846,7 +846,7 @@ function MoreActionsSheet({
|
||||
})}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user