diff --git a/src/components/__tests__/sheet-bottom-inset.test.ts b/src/components/__tests__/sheet-bottom-inset.test.ts
new file mode 100644
index 00000000..112c920e
--- /dev/null
+++ b/src/components/__tests__/sheet-bottom-inset.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+// With edge-to-edge on, every RN Modal on Android reaches under the
+// navigation bar (a tablet's taskbar, a phone's gesture bar). Bottom sheets
+// that ended at the screen edge without padding for it put their last row
+// under the taskbar on a Pixel Tablet: New folder's Save and Cancel, the
+// Files item actions' Delete and Delete permanently. A tap there opened an
+// app from the taskbar instead. Every sheet surface inside a plain Modal pads
+// its bottom by the inset (useSheetBottomInset, or insets.bottom).
+
+const SRC = path.resolve(__dirname, '../..');
+
+// Sheets in files other agents own; their fix is reported, not made here.
+const OWNED_ELSEWHERE = new Set([
+ 'components/SidebarDrawer.tsx',
+ 'components/calendar/CalendarEditSheet.tsx',
+ 'components/sharing/ShareCollectionSheet.tsx',
+]);
+
+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] : [];
+ });
+}
+
+/** What is between each plain and its . */
+function modalBodies(source: string): string[] {
+ const out: string[] = [];
+ const re = /]/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(source))) {
+ const end = source.indexOf('', m.index);
+ out.push(source.slice(m.index, end < 0 ? undefined : end));
+ }
+ return out;
+}
+
+/** A body that renders a bottom sheet surface (styles.sheet / styles.modalSheet). */
+function rendersSheet(body: string): boolean {
+ return /styles\.(sheet|modalSheet)\b/.test(body);
+}
+
+function padsBottom(body: string): boolean {
+ return /bottomInset|insets\.bottom/.test(body);
+}
+
+describe('bottom sheets in a Modal', () => {
+ it('pad their content above the navigation bar or taskbar', () => {
+ const offenders = sourceFiles(SRC).flatMap((file) => {
+ const rel = path.relative(SRC, file).split(path.sep).join('/');
+ if (OWNED_ELSEWHERE.has(rel)) return [];
+ const source = fs.readFileSync(file, 'utf8');
+ return modalBodies(source)
+ .filter((body) => rendersSheet(body) && !padsBottom(body))
+ .map(() => rel);
+ });
+ expect(offenders).toEqual([]);
+ });
+
+ it('covers the sheets the tablet walkthrough found', () => {
+ for (const rel of ['components/settings/FolderSettings.tsx', 'screens/FilesScreen.tsx', 'components/files/ShareSheet.tsx']) {
+ const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8')).filter(rendersSheet);
+ expect(bodies.length, rel).toBeGreaterThan(0);
+ }
+ });
+
+ it('finds the sheet in a Modal body', () => {
+ const src = '\n\n';
+ const bodies = modalBodies(src);
+ expect(bodies).toHaveLength(1);
+ expect(rendersSheet(bodies[0])).toBe(true);
+ expect(padsBottom(bodies[0])).toBe(true);
+ expect(padsBottom('')).toBe(false);
+ });
+});
diff --git a/src/components/files/ShareSheet.tsx b/src/components/files/ShareSheet.tsx
index 9eafa3dc..7e06f5a3 100644
--- a/src/components/files/ShareSheet.tsx
+++ b/src/components/files/ShareSheet.tsx
@@ -22,6 +22,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 { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
type RolePreset = 'read' | 'readWrite' | 'manager';
@@ -71,6 +72,7 @@ interface ShareSheetProps {
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
+ const bottomInset = useSheetBottomInset();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -189,7 +191,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
-
+
diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx
index 628b85b6..e51722ec 100644
--- a/src/components/settings/FolderSettings.tsx
+++ b/src/components/settings/FolderSettings.tsx
@@ -27,6 +27,7 @@ import { jmapClient } from '../../api/jmap-client';
import type { Mailbox } from '../../api/types';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import Badge from '../Badge';
const ROLE_ICON: Record = {
@@ -68,6 +69,7 @@ function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => strin
export function FolderSettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
+ const bottomInset = useSheetBottomInset();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -300,7 +302,7 @@ export function FolderSettings() {
-
+
{editor?.kind === 'edit'
diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx
index 480e8217..96c6bb6b 100644
--- a/src/components/settings/IdentitySettings.tsx
+++ b/src/components/settings/IdentitySettings.tsx
@@ -29,6 +29,7 @@ import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import Badge from '../Badge';
import { identityDraftChanged } from './identity-draft';
@@ -98,6 +99,7 @@ function parseAddressList(value: string): { list: EmailAddress[]; invalid: strin
export function IdentitySettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
+ const bottomInset = useSheetBottomInset();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const identities = useSettingsStore((s) => s.identities);
@@ -330,7 +332,7 @@ export function IdentitySettings() {
-
+
{editing?.id ? t('settings.identities.edit', "Edit identity") : t('settings.identities.create', "New identity")}
diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx
index c9285fad..a5c249d5 100644
--- a/src/components/settings/TemplateSettings.tsx
+++ b/src/components/settings/TemplateSettings.tsx
@@ -19,10 +19,12 @@ import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-st
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
+import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
export function TemplateSettings() {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
+ const bottomInset = useSheetBottomInset();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const tr = useLocaleStore((s) => s.t);
@@ -213,7 +215,7 @@ export function TemplateSettings() {
{/* Edit/Create modal */}
-
+
{editing?.id ? tr('settings.templates.edit', 'Edit Template') : tr('settings.templates.add', 'New Template')}
@@ -287,7 +289,7 @@ export function TemplateSettings() {
{/* Import modal */}
setImportVisible(false)}>
-
+
{tr('settings.templates.import', 'Import')}
setImportVisible(false)} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}>
diff --git a/src/lib/sheet-bottom-inset.ts b/src/lib/sheet-bottom-inset.ts
new file mode 100644
index 00000000..0c9eab9c
--- /dev/null
+++ b/src/lib/sheet-bottom-inset.ts
@@ -0,0 +1,20 @@
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+/**
+ * How far the content of a bottom sheet or dialog has to stay above the
+ * screen bottom.
+ *
+ * Every RN Modal on Android is edge to edge here: with edge-to-edge on (Expo
+ * SDK 54) the Modal forces statusBarTranslucent and navigationBarTranslucent,
+ * so its window reaches under the navigation bar, a phone's gesture bar or a
+ * tablet's taskbar. A sheet that ends at `bottom: 0` (or a backdrop that
+ * aligns it with `justifyContent: 'flex-end'`) puts its last row under that
+ * bar, where a tap goes to the taskbar instead (an app icon opened). The sheet
+ * keeps its surface down to the screen edge and pads its content by this.
+ *
+ * Inside a plain Modal the insets come from the app's provider (React context
+ * reaches into the Modal); inside a SafeAreaModal from the Modal's own.
+ */
+export function useSheetBottomInset(): number {
+ return useSafeAreaInsets().bottom;
+}
diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx
index a97e9434..32d5c400 100644
--- a/src/screens/FilesScreen.tsx
+++ b/src/screens/FilesScreen.tsx
@@ -69,6 +69,7 @@ import { filesGridLayout } from '../lib/files-grid';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { formatListDate } from '../lib/date-format';
import { useLayout, useSheetInsets } from '../lib/use-layout';
+import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { filesBackStep } from '../lib/files-back';
import { driveChange, type DriveScope } from '../lib/files-drive';
@@ -1807,6 +1808,7 @@ function ActionsSheet({
}: ActionsSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
+ const bottomInset = useSheetBottomInset();
const formatModified = useFormatModified();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
@@ -1828,7 +1830,7 @@ function ActionsSheet({
-
+
{target.displayName}
@@ -1871,7 +1873,7 @@ function ActionsSheet({
-
+
{target.displayName}
@@ -1961,6 +1963,7 @@ interface FolderPickerSheetProps {
function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }: FolderPickerSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
+ const bottomInset = useSheetBottomInset();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
const options = useMemo(() => {
@@ -2003,7 +2006,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
-
+
{t('files.move_to', 'Move to…')} · {target.displayName}
@@ -2051,6 +2054,7 @@ interface SortSheetProps {
function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChangeDir }: SortSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
+ const bottomInset = useSheetBottomInset();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!visible) return null;
@@ -2060,7 +2064,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
-
+
{t('files.settings_default_sort', 'Sort')}
{keys.map((k) => (
{ onChangeKey(k); onClose(); }}>