From 426f740359a987b2266761aadfdd3e5eb7f00b9e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 13:15:21 +0100
Subject: [PATCH 01/13] fix: keep bottom sheet buttons above the tablet taskbar
---
.../__tests__/sheet-bottom-inset.test.ts | 79 +++++++++++++++++++
src/components/files/ShareSheet.tsx | 4 +-
src/components/settings/FolderSettings.tsx | 4 +-
src/components/settings/IdentitySettings.tsx | 4 +-
src/components/settings/TemplateSettings.tsx | 6 +-
src/lib/sheet-bottom-inset.ts | 20 +++++
src/screens/FilesScreen.tsx | 12 ++-
7 files changed, 120 insertions(+), 9 deletions(-)
create mode 100644 src/components/__tests__/sheet-bottom-inset.test.ts
create mode 100644 src/lib/sheet-bottom-inset.ts
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(); }}>
From 980243469cd0decfc27905e00a71b5e1d049be8e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 13:19:56 +0100
Subject: [PATCH 02/13] fix: keep sheet and dialog fields and buttons above the
keyboard
---
src/components/Dialog.tsx | 6 +-
src/components/TagSheet.tsx | 7 +-
src/components/TemplateSheet.tsx | 5 +-
src/components/filters/FilterRuleModal.tsx | 5 +-
src/components/filters/SieveEditorSheet.tsx | 5 +-
.../settings/ContentSendersSettings.tsx | 5 +-
src/components/settings/DelegateDialog.tsx | 5 +-
src/components/settings/FolderSettings.tsx | 4 +-
src/components/settings/IdentitySettings.tsx | 3 +-
src/components/settings/TemplateSettings.tsx | 3 +-
src/lib/__tests__/sheet-bottom-inset.test.ts | 164 ++++++++++++++++++
src/lib/sheet-bottom-inset.ts | 94 +++++++++-
src/screens/FilesScreen.tsx | 10 +-
13 files changed, 298 insertions(+), 18 deletions(-)
create mode 100644 src/lib/__tests__/sheet-bottom-inset.test.ts
diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx
index 99fb47d7..0df26e58 100644
--- a/src/components/Dialog.tsx
+++ b/src/components/Dialog.tsx
@@ -6,6 +6,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
+import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
interface DialogInput {
value: string;
@@ -53,11 +54,14 @@ export default function Dialog({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
+ // The card centres in the space above the keyboard (a prompt's field and
+ // its confirm button stay visible) and above the navigation bar.
+ const bottomInset = useSheetBottomInset(visible);
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
return (
-
+
{variant === 'destructive' && (
diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx
index 7c08467f..ebd3ad7b 100644
--- a/src/components/TagSheet.tsx
+++ b/src/components/TagSheet.tsx
@@ -2,7 +2,6 @@ import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Tag } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -13,6 +12,7 @@ import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
+import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
import { NewTagRow } from './NewTagRow';
interface TagSheetProps {
@@ -29,7 +29,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
- const insets = useSafeAreaInsets();
+ // Above the taskbar, and above the keyboard while "New tag" is typed in.
+ const bottomInset = useSheetBottomInset(visible);
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
@@ -77,7 +78,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
diff --git a/src/components/TemplateSheet.tsx b/src/components/TemplateSheet.tsx
index 2faef8e6..bcde0be3 100644
--- a/src/components/TemplateSheet.tsx
+++ b/src/components/TemplateSheet.tsx
@@ -12,6 +12,7 @@ import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store
import { filterTemplates } from '../lib/template-utils';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
+import { useSheetBottomInset } from '../lib/sheet-bottom-inset';
import { TemplateSettings } from './settings/TemplateSettings';
interface TemplateSheetProps {
@@ -41,6 +42,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
const [query, setQuery] = React.useState('');
// The template manager, shown in place of the picker.
const [managing, setManaging] = React.useState(false);
+ // Above the keyboard while the search field is typed in.
+ const bottomInset = useSheetBottomInset(visible && !managing);
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
@@ -130,7 +133,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx
index 7968b597..0bfb53a2 100644
--- a/src/components/filters/FilterRuleModal.tsx
+++ b/src/components/filters/FilterRuleModal.tsx
@@ -45,6 +45,7 @@ import { SafeAreaModal } from '../SafeAreaModal';
import { useFilterStore } from '../../stores/filter-store';
import { forwardsOfEditedRule, ruleForwardLimit } from '../../lib/sieve/forward-limit';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all'];
@@ -91,6 +92,8 @@ export function FilterRuleModal({
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
+ // The Android dialog doesn't shrink for the keyboard; the body ends above it.
+ const keyboardPadding = useModalKeyboardPadding(visible);
const keywords = useKeywordsStore((s) => s.keywords);
const isEdit = !!editedRule;
const rule = editedRule ?? initialRule;
@@ -315,7 +318,7 @@ export function FilterRuleModal({
diff --git a/src/components/filters/SieveEditorSheet.tsx b/src/components/filters/SieveEditorSheet.tsx
index 97337e49..db97d1c0 100644
--- a/src/components/filters/SieveEditorSheet.tsx
+++ b/src/components/filters/SieveEditorSheet.tsx
@@ -11,6 +11,7 @@ import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset';
import { seedsOnRender } from '../../lib/sieve/editor-seed';
interface SieveEditorSheetProps {
@@ -25,6 +26,8 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
+ // The Android dialog doesn't shrink for the keyboard; the body ends above it.
+ const keyboardPadding = useModalKeyboardPadding(visible);
const [script, setScript] = useState(content);
const [isValidating, setIsValidating] = useState(false);
@@ -85,7 +88,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx
index f8a14461..e33824c4 100644
--- a/src/components/settings/ContentSendersSettings.tsx
+++ b/src/components/settings/ContentSendersSettings.tsx
@@ -14,6 +14,7 @@ import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { removeTrustedSenderWithUndo } from '../../lib/settings-undo';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import { ToastHost } from '../ToastHost';
interface TrustedRow {
@@ -48,6 +49,8 @@ export function ContentSendersSettings() {
const removeFromTrustedSendersBook = useContactsStore((s) => s.removeFromTrustedSendersBook);
const [modalOpen, setModalOpen] = useState(false);
+ // The card centres above the keyboard while an address is typed in.
+ const bottomInset = useSheetBottomInset(modalOpen);
const [newSender, setNewSender] = useState('');
const [busy, setBusy] = useState(null);
@@ -197,7 +200,7 @@ export function ContentSendersSettings() {
animationType="fade"
onRequestClose={() => setModalOpen(false)}
>
- setModalOpen(false)}>
+ setModalOpen(false)}>
{}}>
{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}
diff --git a/src/components/settings/DelegateDialog.tsx b/src/components/settings/DelegateDialog.tsx
index 28bd89ff..76cf9553 100644
--- a/src/components/settings/DelegateDialog.tsx
+++ b/src/components/settings/DelegateDialog.tsx
@@ -19,6 +19,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset';
import { findPrincipalByEmail } from '../../api/delegation';
import type { Principal } from '../../api/types';
import type { PrincipalListing } from '../../api/files';
@@ -74,6 +75,8 @@ export function DelegateDialog({
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
+ // The Android dialog doesn't shrink for the keyboard; the body ends above it.
+ const keyboardPadding = useModalKeyboardPadding();
const changing = !!initial;
const [principal, setPrincipal] = useState(initial?.principal ?? null);
@@ -154,7 +157,7 @@ export function DelegateDialog({
return (
-
+
{changing && principal
diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx
index e51722ec..2268cb5e 100644
--- a/src/components/settings/FolderSettings.tsx
+++ b/src/components/settings/FolderSettings.tsx
@@ -69,7 +69,6 @@ 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);
@@ -78,6 +77,9 @@ export function FolderSettings() {
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const [editor, setEditor] = useState(null);
+ // Save and Cancel above the taskbar, and above the keyboard while the
+ // name is typed in.
+ const bottomInset = useSheetBottomInset(!!editor);
const [draftName, setDraftName] = useState('');
const [draftParent, setDraftParent] = useState(NO_PARENT);
const [draftAccount, setDraftAccount] = useState(OWN_ACCOUNT);
diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx
index 96c6bb6b..7daaa775 100644
--- a/src/components/settings/IdentitySettings.tsx
+++ b/src/components/settings/IdentitySettings.tsx
@@ -99,7 +99,6 @@ 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);
@@ -113,6 +112,8 @@ export function IdentitySettings() {
const preferredId = accountId ? preferredIdentityIds[accountId] : undefined;
const [editing, setEditing] = useState(null);
+ // Save above the taskbar, and above the keyboard while a field is typed in.
+ const bottomInset = useSheetBottomInset(!!editing);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState(null);
diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx
index a5c249d5..e94008fe 100644
--- a/src/components/settings/TemplateSettings.tsx
+++ b/src/components/settings/TemplateSettings.tsx
@@ -24,7 +24,6 @@ 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);
@@ -41,6 +40,8 @@ export function TemplateSettings() {
const [editing, setEditing] = useState(null);
const [importVisible, setImportVisible] = useState(false);
+ // Save above the taskbar, and above the keyboard while a field is typed in.
+ const bottomInset = useSheetBottomInset(!!editing || importVisible);
const [importText, setImportText] = useState('');
const [draftName, setDraftName] = useState('');
const [draftSubject, setDraftSubject] = useState('');
diff --git a/src/lib/__tests__/sheet-bottom-inset.test.ts b/src/lib/__tests__/sheet-bottom-inset.test.ts
new file mode 100644
index 00000000..7c10a467
--- /dev/null
+++ b/src/lib/__tests__/sheet-bottom-inset.test.ts
@@ -0,0 +1,164 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+// A Modal on Android is an edge-to-edge window the keyboard doesn't resize
+// (and an iOS Modal never resizes), so the keyboard covered sheet inputs and
+// their buttons: the New folder name and Save, the tag picker's "New tag"
+// field, the Files new-folder prompt. useSheetBottomInset pads a sheet by the
+// keyboard's overlap while it is open; full-screen modals pad their body by
+// the part above the safe-area inset (useModalKeyboardPadding).
+
+type Listener = (e: unknown) => void;
+const keyboardListeners = new Map();
+let keyboardVisible = false;
+let keyboardMetrics: { screenY: number; height: number } | undefined;
+let os = 'android';
+let safeBottom = 0;
+
+vi.mock('react-native', () => ({
+ Dimensions: {
+ get: () => ({ width: 800, height: 1280 }),
+ addEventListener: () => ({ remove() {} }),
+ },
+ Keyboard: {
+ addListener: (name: string, fn: Listener) => {
+ keyboardListeners.set(name, fn);
+ return { remove() { keyboardListeners.delete(name); } };
+ },
+ isVisible: () => keyboardVisible,
+ metrics: () => keyboardMetrics,
+ dismiss: vi.fn(),
+ },
+ get Platform() {
+ return { OS: os };
+ },
+}));
+vi.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 0, left: 0, right: 0, bottom: safeBottom }),
+}));
+
+// Just enough React to run the hook: one state slot that survives re-runs.
+let state = 0;
+vi.mock('react', () => ({
+ default: {
+ useState: (initial: number) => [state ?? initial, (v: number) => { state = v; }],
+ useRef: (v: T) => ({ current: v }),
+ useEffect: (fn: () => void) => { fn(); },
+ },
+}));
+
+import {
+ modalKeyboardPadding, sheetBottomInset, useModalKeyboardPadding, useSheetBottomInset,
+} from '../sheet-bottom-inset';
+
+beforeEach(() => {
+ keyboardListeners.clear();
+ keyboardVisible = false;
+ keyboardMetrics = undefined;
+ os = 'android';
+ safeBottom = 0;
+ state = 0;
+});
+
+describe('sheetBottomInset', () => {
+ it('keeps the navigation bar or taskbar clear without a keyboard', () => {
+ expect(sheetBottomInset(60, 0)).toBe(60);
+ });
+
+ it('keeps the keyboard clear while it is open', () => {
+ expect(sheetBottomInset(24, 324)).toBe(324);
+ });
+});
+
+describe('modalKeyboardPadding', () => {
+ it('adds the part of the keyboard above the safe-area inset on Android', () => {
+ expect(modalKeyboardPadding(324, 24, 'android')).toBe(300);
+ expect(modalKeyboardPadding(0, 24, 'android')).toBe(0);
+ });
+
+ it('leaves iOS to its KeyboardAvoidingView', () => {
+ expect(modalKeyboardPadding(324, 34, 'ios')).toBe(0);
+ });
+});
+
+describe('useSheetBottomInset', () => {
+ it('pads by the taskbar, then by the keyboard while it is open', () => {
+ safeBottom = 60;
+ expect(useSheetBottomInset(true)).toBe(60);
+ // Tablet: 1280 tall, keyboard top at 880.
+ keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 880, height: 340 } });
+ expect(useSheetBottomInset(true)).toBe(400);
+ keyboardListeners.get('keyboardDidHide')!({});
+ expect(useSheetBottomInset(true)).toBe(60);
+ });
+
+ it('uses a keyboard that is already open when the sheet opens', () => {
+ keyboardVisible = true;
+ keyboardMetrics = { screenY: 980, height: 300 };
+ useSheetBottomInset(true); // the effect reads the open keyboard
+ expect(useSheetBottomInset(true)).toBe(300);
+ });
+
+ it('follows the iOS frame events', () => {
+ os = 'ios';
+ safeBottom = 34;
+ useSheetBottomInset(true);
+ keyboardListeners.get('keyboardWillChangeFrame')!({ endCoordinates: { screenY: 944, height: 336 } });
+ expect(useSheetBottomInset(true)).toBe(336);
+ });
+
+ it("doesn't listen while the sheet is closed", () => {
+ safeBottom = 24;
+ expect(useSheetBottomInset(false)).toBe(24);
+ expect(keyboardListeners.size).toBe(0);
+ });
+});
+
+describe('useModalKeyboardPadding', () => {
+ it('ends a full-screen modal body above the keyboard', () => {
+ safeBottom = 24;
+ expect(useModalKeyboardPadding(true)).toBe(0);
+ keyboardListeners.get('keyboardDidShow')!({ endCoordinates: { screenY: 956, height: 300 } });
+ expect(useModalKeyboardPadding(true)).toBe(300);
+ });
+});
+
+// The sheets and dialogs with a text field in the files this fix covers.
+const SRC = path.resolve(__dirname, '../..');
+const WITH_INPUTS = [
+ 'components/Dialog.tsx',
+ 'components/TagSheet.tsx',
+ 'components/TemplateSheet.tsx',
+ 'components/files/ShareSheet.tsx',
+ 'components/filters/FilterRuleModal.tsx',
+ 'components/filters/SieveEditorSheet.tsx',
+ 'components/settings/ContentSendersSettings.tsx',
+ 'components/settings/DelegateDialog.tsx',
+ 'components/settings/FolderSettings.tsx',
+ 'components/settings/IdentitySettings.tsx',
+ 'components/settings/TemplateSettings.tsx',
+ 'screens/FilesScreen.tsx',
+];
+
+function modalBodies(source: string): string[] {
+ const out: string[] = [];
+ const re = /<(SafeAreaModal|Modal)[\s>]/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(source))) {
+ const end = source.indexOf(`${m[1]}>`, m.index);
+ out.push(source.slice(m.index, end < 0 ? undefined : end));
+ }
+ return out;
+}
+
+describe('modals with a text field', () => {
+ it('make room for the keyboard', () => {
+ for (const rel of WITH_INPUTS) {
+ const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8'))
+ .filter((b) => /<(TextInput|Input|NewTagRow)\b/.test(b));
+ expect(bodies.length, rel).toBeGreaterThan(0);
+ for (const body of bodies) expect(/bottomInset|keyboardPadding/.test(body), rel).toBe(true);
+ }
+ });
+});
diff --git a/src/lib/sheet-bottom-inset.ts b/src/lib/sheet-bottom-inset.ts
index 0c9eab9c..8ff5cf35 100644
--- a/src/lib/sheet-bottom-inset.ts
+++ b/src/lib/sheet-bottom-inset.ts
@@ -1,4 +1,77 @@
+import React from 'react';
+import { Dimensions, Keyboard, Platform, type KeyboardMetrics } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { keyboardObstruction } from './use-keyboard-obstruction';
+
+/**
+ * Bottom padding for a sheet whose surface reaches the screen bottom: clear
+ * of the navigation bar, and of the keyboard while it is open (the keyboard's
+ * overlap is measured from the screen bottom, so it already covers the bar).
+ */
+export function sheetBottomInset(safeBottom: number, keyboard: number): number {
+ return Math.max(0, safeBottom, keyboard);
+}
+
+/**
+ * Extra bottom padding for a full-screen modal whose SafeAreaView already
+ * keeps `safeBottom` clear: the part of the keyboard above that. Android only;
+ * on iOS those modals use a KeyboardAvoidingView.
+ */
+export function modalKeyboardPadding(keyboard: number, safeBottom: number, os: string): number {
+ if (os !== 'android') return 0;
+ return Math.max(0, Math.round(keyboard - safeBottom));
+}
+
+/**
+ * How much of the screen bottom the keyboard covers while `enabled`. A Modal
+ * on Android is an edge-to-edge window the keyboard does not resize, and an
+ * iOS Modal never resizes, so a sheet has to make room itself. Tracked only
+ * while the sheet is open, so a hidden sheet doesn't re-render on every
+ * keyboard change. A rotation leaves a stale frame (Android sends no new
+ * keyboard event), so it closes the keyboard like use-keyboard-obstruction.
+ */
+function useKeyboardOverlap(enabled: boolean, safeBottom: number): number {
+ const [overlap, setOverlap] = React.useState(0);
+ const insetRef = React.useRef(safeBottom);
+ insetRef.current = safeBottom;
+ React.useEffect(() => {
+ if (!enabled) {
+ setOverlap(0);
+ return undefined;
+ }
+ const apply = (frame: Pick | undefined | null) => {
+ if (!frame) {
+ setOverlap(0);
+ return;
+ }
+ setOverlap(keyboardObstruction(Dimensions.get('screen').height, frame.screenY, frame.height, insetRef.current));
+ };
+ // The keyboard may already be up when the sheet opens.
+ if (Keyboard.isVisible()) apply(Keyboard.metrics());
+ const subs =
+ Platform.OS === 'ios'
+ ? [
+ Keyboard.addListener('keyboardWillChangeFrame', (e) => apply(e.endCoordinates)),
+ Keyboard.addListener('keyboardWillHide', () => setOverlap(0)),
+ ]
+ : [
+ Keyboard.addListener('keyboardDidShow', (e) => apply(e.endCoordinates)),
+ Keyboard.addListener('keyboardDidHide', () => setOverlap(0)),
+ ];
+ let lastWindow = Dimensions.get('window');
+ const dims = Dimensions.addEventListener('change', ({ window }) => {
+ if (window.width === lastWindow.width && window.height === lastWindow.height) return;
+ lastWindow = window;
+ setOverlap(0);
+ if (Keyboard.isVisible()) Keyboard.dismiss();
+ });
+ return () => {
+ for (const s of subs) s.remove();
+ dims.remove();
+ };
+ }, [enabled]);
+ return enabled ? overlap : 0;
+}
/**
* How far the content of a bottom sheet or dialog has to stay above the
@@ -11,10 +84,27 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
* 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.
+ * The same window doesn't shrink for the keyboard, so while the keyboard is
+ * open this is its overlap instead: a sheet's text field and its Save button
+ * stay above it, and a centred dialog padded by it moves up.
*
* 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.
+ * `enabled` is the sheet's visibility, for sheets that stay mounted.
*/
-export function useSheetBottomInset(): number {
- return useSafeAreaInsets().bottom;
+export function useSheetBottomInset(enabled = true): number {
+ const safeBottom = useSafeAreaInsets().bottom;
+ const keyboard = useKeyboardOverlap(enabled, safeBottom);
+ return sheetBottomInset(safeBottom, keyboard);
+}
+
+/**
+ * `modalKeyboardPadding` for a full-screen modal (a SafeAreaView with the
+ * bottom edge inside a SafeAreaModal): pad the scrolling body by this so the
+ * focused field and the last row stay above the keyboard on Android.
+ */
+export function useModalKeyboardPadding(enabled = true): number {
+ const safeBottom = useSafeAreaInsets().bottom;
+ const keyboard = useKeyboardOverlap(enabled, safeBottom);
+ return modalKeyboardPadding(keyboard, safeBottom, Platform.OS);
}
diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx
index 32d5c400..92aa7fbd 100644
--- a/src/screens/FilesScreen.tsx
+++ b/src/screens/FilesScreen.tsx
@@ -1736,6 +1736,8 @@ interface PromptModalProps {
function PromptModal(props: PromptModalProps) {
const c = useColors();
+ // Centred above the keyboard, so the name field and its button stay visible.
+ const bottomInset = useSheetBottomInset(props.visible);
const styles = React.useMemo(() => makePromptStyles(c), [c]);
return (
-
+
{props.title}
@@ -1808,7 +1810,7 @@ function ActionsSheet({
}: ActionsSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
- const bottomInset = useSheetBottomInset();
+ const bottomInset = useSheetBottomInset(!!target);
const formatModified = useFormatModified();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
@@ -1963,7 +1965,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 bottomInset = useSheetBottomInset(!!target);
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
const options = useMemo(() => {
@@ -2054,7 +2056,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 bottomInset = useSheetBottomInset(visible);
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!visible) return null;
From e4eda0a840d362cbeeeacd99ff6b0c333c0d9692 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 13:22:56 +0100
Subject: [PATCH 03/13] fix: label the new folder parent picker "Parent folder"
instead of "Inside {name}"
---
locales/rn/de.json | 1 +
locales/rn/en.json | 1 +
.../__tests__/placeholder-params.test.ts | 76 +++++++++++++++++++
src/components/settings/FolderSettings.tsx | 3 +-
4 files changed, 80 insertions(+), 1 deletion(-)
create mode 100644 src/components/__tests__/placeholder-params.test.ts
diff --git a/locales/rn/de.json b/locales/rn/de.json
index 7c8e1686..f810eca3 100644
--- a/locales/rn/de.json
+++ b/locales/rn/de.json
@@ -952,6 +952,7 @@
"not_empty_message": "\"{name}\" enthält {count} E-Mails. Trotzdem löschen?",
"not_empty_title": "Ordner nicht leer",
"own_account": "Meine Ordner",
+ "parent_folder": "Übergeordneter Ordner",
"role": "Ordnerrolle",
"role_hint": "Weisen Sie diesem Ordner eine Sonderrolle zu (Archiv, Spam, …). Jede Rolle kann nur einem Ordner zugewiesen werden.",
"save_failed": "Speichern fehlgeschlagen",
diff --git a/locales/rn/en.json b/locales/rn/en.json
index fb90bce8..8b53ba9e 100644
--- a/locales/rn/en.json
+++ b/locales/rn/en.json
@@ -1237,6 +1237,7 @@
"not_empty_message": "\"{name}\" contains {count} emails. Delete anyway?",
"not_empty_title": "Folder not empty",
"own_account": "My folders",
+ "parent_folder": "Parent folder",
"role": "Special use",
"role_hint": "Assign a special-use role (Archive, Junk, …) to this folder. Each role can be held by one folder.",
"save_failed": "Save failed",
diff --git a/src/components/__tests__/placeholder-params.test.ts b/src/components/__tests__/placeholder-params.test.ts
new file mode 100644
index 00000000..5d39563e
--- /dev/null
+++ b/src/components/__tests__/placeholder-params.test.ts
@@ -0,0 +1,76 @@
+// A t() call whose catalog text has a {placeholder} but which passes no
+// values shows the placeholder as it is: the New folder sheet labelled its
+// parent picker with settings.folders.subfolder_of, the webmail's
+// "Inside {name}" hint, and read "Inside {name}". The English fallback in the
+// call ("Parent folder") hid it in review, since the catalog text wins.
+import { describe, expect, it, vi } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+vi.mock('expo-localization', () => ({ getLocales: () => [] }));
+
+import { getDictionary } from '../../i18n';
+
+const SRC = path.resolve(__dirname, '../..');
+
+// The settings, filters, files and sheet code this check covers.
+const SCOPE = [
+ 'components/settings',
+ 'components/filters',
+ 'components/files',
+ 'components/Dialog.tsx',
+ 'components/Input.tsx',
+ 'components/RadioGroup.tsx',
+ 'components/ToggleSwitch.tsx',
+ 'components/TagSheet.tsx',
+ 'components/NewTagRow.tsx',
+ 'components/MoveSheet.tsx',
+ 'components/IdentitySheet.tsx',
+ 'components/TemplateSheet.tsx',
+ 'screens/SettingsScreen.tsx',
+ 'screens/FilesScreen.tsx',
+];
+
+function files(rel: string): string[] {
+ const p = path.join(SRC, rel);
+ if (!fs.statSync(p).isDirectory()) return [p];
+ return fs.readdirSync(p).flatMap((e) => (e === '__tests__' ? [] : files(path.join(rel, e))))
+ .filter((f) => /\.tsx?$/.test(f));
+}
+
+function flatten(obj: Record, prefix = '', out = new Map()): Map {
+ for (const [k, v] of Object.entries(obj)) {
+ const key = prefix ? `${prefix}.${k}` : k;
+ if (v && typeof v === 'object') flatten(v as Record, key, out);
+ else if (typeof v === 'string') out.set(key, v);
+ }
+ return out;
+}
+
+// t('key', 'fallback') or t('key', "fallback") closed right after the fallback.
+const CALL_WITHOUT_VALUES = /\btr?\(\s*'([^']+)'\s*,\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\s*,?\s*\)/g;
+// {name} or {count, plural, ...}; not the {{name}} template tokens.
+const PLACEHOLDER = /(? {
+ it('use catalog texts that have no placeholders', () => {
+ const en = flatten(getDictionary('en') as Record);
+ const offenders: string[] = [];
+ for (const file of SCOPE.flatMap(files)) {
+ const source = fs.readFileSync(file, 'utf8');
+ for (const m of source.matchAll(CALL_WITHOUT_VALUES)) {
+ const text = en.get(m[1]);
+ if (text && PLACEHOLDER.test(text)) offenders.push(`${m[1]} = ${text} (${path.relative(SRC, file)})`);
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it('spots a call that leaves a placeholder unfilled', () => {
+ const calls = [..."t('settings.folders.subfolder_of', 'Parent folder')".matchAll(CALL_WITHOUT_VALUES)];
+ expect(calls.map((m) => m[1])).toEqual(['settings.folders.subfolder_of']);
+ expect(PLACEHOLDER.test('Inside {name}')).toBe(true);
+ expect(PLACEHOLDER.test('Placeholders: {{recipient_name}}')).toBe(false);
+ expect([..."t('a.b', 'x', { name })".matchAll(CALL_WITHOUT_VALUES)]).toEqual([]);
+ });
+});
diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx
index 2268cb5e..f4878192 100644
--- a/src/components/settings/FolderSettings.tsx
+++ b/src/components/settings/FolderSettings.tsx
@@ -352,7 +352,8 @@ export function FolderSettings() {
{editor?.kind === 'edit'
? t('settings.folders.move_under', 'Move under')
- : t('settings.folders.subfolder_of', 'Parent folder')}
+ // Not subfolder_of: that is the webmail's "Inside {name}" hint.
+ : t('settings.folders.parent_folder', 'Parent folder')}