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.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')} ", 'Input'); + expect(EXAMPLE_PLACEHOLDER.test(tag.tag)).toBe(true); + expect(NAMED.test(tag.tag)).toBe(false); + }); +}); diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 0bfb53a2..84cdfadb 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -329,6 +329,9 @@ export function FilterRuleModal({ value={name} onChangeText={setName} placeholder={t('settings.filters.rule_name_placeholder', 'e.g., Sort newsletters')} + // Named by its label, not the example in the placeholder + // (webmail filter-rule-modal.tsx). + accessibilityLabel={t('settings.filters.rule_name', 'Rule Name')} maxLength={200} /> @@ -414,6 +417,7 @@ export function FilterRuleModal({ } keyboardType={condition.field === 'size' ? 'numeric' : 'default'} autoCapitalize="none" + accessibilityLabel={t('settings.filters.condition_value', 'Condition value')} /> )} From 9781b304787464762e25cd5f0737e1d58c3c9025 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:38:46 +0100 Subject: [PATCH 07/13] fix: show a scheduled out of office as scheduled, mark its placeholders and stop Save reading as busy --- locales/rn/de.json | 4 ++ locales/rn/en.json | 4 ++ src/components/settings/VacationSettings.tsx | 41 +++++++++++---- src/lib/__tests__/vacation-status.test.ts | 53 ++++++++++++++++++++ src/lib/vacation-status.ts | 18 +++++++ 5 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 src/lib/__tests__/vacation-status.test.ts create mode 100644 src/lib/vacation-status.ts diff --git a/locales/rn/de.json b/locales/rn/de.json index f810eca3..234ed8ae 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -1261,6 +1261,10 @@ "mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet." }, "vacation": { + "status": { + "ended": "Beendet", + "scheduled": "Geplant" + }, "warnings": { "date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben" } diff --git a/locales/rn/en.json b/locales/rn/en.json index 8b53ba9e..730e7571 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1626,6 +1626,10 @@ "description": "The message that will be sent as a reply", "title": "Auto-Reply Message" }, + "status": { + "ended": "Ended", + "scheduled": "Scheduled" + }, "warnings": { "date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\"" } diff --git a/src/components/settings/VacationSettings.tsx b/src/components/settings/VacationSettings.tsx index 09caed0c..1e96a348 100644 --- a/src/components/settings/VacationSettings.tsx +++ b/src/components/settings/VacationSettings.tsx @@ -29,6 +29,7 @@ import { utcIsoToLocalInput, } from '../../lib/vacation-dates'; import { STALWART_VACATION_LIMITS, vacationOversize } from '../../lib/vacation-limits'; +import { vacationStatus } from '../../lib/vacation-status'; import { jmapClient } from '../../api/jmap-client'; import { getSieveCapabilities } from '../../api/sieve'; @@ -139,6 +140,8 @@ export function VacationSettings() { const toParsed = parseLocalInput(toDate); const formatError = !isValidLocalInput(fromDate) || !isValidLocalInput(toDate); const endBeforeStart = !!(fromParsed && toParsed && toParsed <= fromParsed); + // "Scheduled" while the start date is ahead, not "Active". + const status = vacationStatus(enabled, fromParsed, toParsed); const htmlText = htmlEnabled ? htmlToPlainText(htmlBody).trim() : ''; const hasHtmlContent = htmlText.length > 0; const emptyBody = enabled && !body.trim() && !hasHtmlContent; @@ -235,6 +238,9 @@ export function VacationSettings() { forwardChanged, forwardSettings, audienceChanged, audienceSettings, ]); + const saveLabel = store.isSaving + ? t('settings.vacation.saving', 'Saving...') + : t('settings.vacation.save', 'Save Changes'); const title = t('settings.vacation.title', 'Vacation Responder'); const description = t('settings.vacation.description', "Automatically reply to incoming emails while you're away"); @@ -284,11 +290,15 @@ export function VacationSettings() { description={t('settings.vacation.status.description', 'Send an automatic reply to people who email you')} > - - - {enabled + + + {status === 'active' ? t('settings.vacation.status.active', 'Active') - : t('settings.vacation.status.inactive', 'Inactive')} + : status === 'scheduled' + ? t('settings.vacation.status.scheduled', 'Scheduled') + : status === 'ended' + ? t('settings.vacation.status.ended', 'Ended') + : t('settings.vacation.status.inactive', 'Inactive')} @@ -373,7 +383,7 @@ export function VacationSettings() { placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')} placeholderTextColor={c.mutedForeground} accessibilityLabel={t('settings.vacation.message.subject_label', 'Subject')} - style={[styles.subjectInput, oversize.subject && styles.inputInvalid]} + style={[styles.subjectInput, !subject && styles.placeholderShown, oversize.subject && styles.inputInvalid]} /> {subjectTooLong && {subjectTooLong}} @@ -386,7 +396,7 @@ export function VacationSettings() { onChangeText={setBody} placeholder={t('settings.vacation.message.body_placeholder', 'Thank you for your email. I am currently out of the office and will respond when I return.')} placeholderTextColor={c.mutedForeground} - style={[styles.bodyInput, oversize.body && styles.inputInvalid]} + style={[styles.bodyInput, !body && styles.placeholderShown, oversize.body && styles.inputInvalid]} multiline numberOfLines={6} textAlignVertical="top" @@ -528,10 +538,16 @@ export function VacationSettings() { )} - @@ -597,6 +613,11 @@ function makeStyles(c: ThemePalette) { pillText: { fontSize: 12 }, pillTextActive: { color: c.success }, pillTextInactive: { color: c.mutedForeground }, + pillScheduled: { backgroundColor: c.primaryBg }, + pillTextScheduled: { color: c.primary }, + // An empty field's placeholder in italics: the sample reply read as a + // message already typed, next to the warning that the body is empty. + placeholderShown: { fontStyle: 'italic' }, dateInput: { minWidth: 180, paddingHorizontal: spacing.md, diff --git a/src/lib/__tests__/vacation-status.test.ts b/src/lib/__tests__/vacation-status.test.ts new file mode 100644 index 00000000..f736114c --- /dev/null +++ b/src/lib/__tests__/vacation-status.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { vacationStatus } from '../vacation-status'; + +const NOW = new Date(2026, 10, 1, 12, 0); +const day = (offset: number) => new Date(2026, 10, 1 + offset, 12, 0); + +describe('vacationStatus', () => { + it('is scheduled, not active, while the start date is ahead', () => { + expect(vacationStatus(true, day(3), day(10), NOW)).toBe('scheduled'); + expect(vacationStatus(true, day(3), null, NOW)).toBe('scheduled'); + }); + + it('is active inside the range or without dates', () => { + expect(vacationStatus(true, day(-1), day(2), NOW)).toBe('active'); + expect(vacationStatus(true, null, null, NOW)).toBe('active'); + expect(vacationStatus(true, null, day(1), NOW)).toBe('active'); + }); + + it('has ended after the end date', () => { + expect(vacationStatus(true, day(-5), day(-1), NOW)).toBe('ended'); + }); + + it('is inactive when switched off, whatever the dates', () => { + expect(vacationStatus(false, day(3), day(10), NOW)).toBe('inactive'); + }); +}); + +// Out of office in the phone walkthrough: the pill said "Active" for a range +// that starts next week; the sample reply in the empty body looked typed in, +// next to the warning that the body is empty; and after a save TalkBack read +// the disabled Save as "busy". +describe('the out-of-office pane', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../../components/settings/VacationSettings.tsx'), 'utf8') + .split('\r\n').join('\n'); + + it('shows the status from the dates', () => { + expect(source).toContain('vacationStatus(enabled, fromParsed, toParsed)'); + expect(source).toContain("t('settings.vacation.status.scheduled', 'Scheduled')"); + }); + + it('sets an empty subject or body placeholder apart from typed text', () => { + expect(source).toContain('!body && styles.placeholderShown'); + expect(source).toContain('!subject && styles.placeholderShown'); + expect(source).toMatch(/placeholderShown: \{ fontStyle: 'italic' \}/); + }); + + it('names the Save button, so its old "busy" description is replaced', () => { + const save = source.slice(source.indexOf(' void handleSave()}')); + expect(save.slice(0, save.indexOf(''))).toContain('accessibilityLabel={saveLabel}'); + }); +}); diff --git a/src/lib/vacation-status.ts b/src/lib/vacation-status.ts new file mode 100644 index 00000000..557720a5 --- /dev/null +++ b/src/lib/vacation-status.ts @@ -0,0 +1,18 @@ +/** + * What the out-of-office status pill says. Switched on with a start date + * still ahead, the server sends no replies yet: the pill said "Active" all + * the same. It says "Scheduled" until the start and "Ended" after the end. + */ +export type VacationStatus = 'inactive' | 'scheduled' | 'active' | 'ended'; + +export function vacationStatus( + enabled: boolean, + from: Date | null, + until: Date | null, + now: Date = new Date(), +): VacationStatus { + if (!enabled) return 'inactive'; + if (from && from.getTime() > now.getTime()) return 'scheduled'; + if (until && until.getTime() <= now.getTime()) return 'ended'; + return 'active'; +} From e7fd665bb4464a2b285fc11160215e033a6e06b0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:41:43 +0100 Subject: [PATCH 08/13] fix: tell screen readers which undo-send delay, sort order and language is picked --- .../__tests__/radio-options.test.ts | 37 +++++++++++++++++++ src/components/radio-a11y.ts | 17 +++++++++ src/components/settings/ComposingSettings.tsx | 4 +- src/components/settings/LanguageSettings.tsx | 2 + src/screens/FilesScreen.tsx | 5 ++- 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/components/__tests__/radio-options.test.ts create mode 100644 src/components/radio-a11y.ts diff --git a/src/components/__tests__/radio-options.test.ts b/src/components/__tests__/radio-options.test.ts new file mode 100644 index 00000000..e928627f --- /dev/null +++ b/src/components/__tests__/radio-options.test.ts @@ -0,0 +1,37 @@ +// The undo-send delay chips (and the other Composer choices), the Files sort +// sheet and the language list drew the picked option in another colour only: +// a screen reader heard every option alike and couldn't tell which was set. +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { radioOptionA11y, RADIO_GROUP_A11Y } from '../radio-a11y'; + +const SRC = path.resolve(__dirname, '../..'); +const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8').split('\r\n').join('\n'); + +describe('radioOptionA11y', () => { + it('gives the picked option the radio role and the selected and checked state', () => { + expect(radioOptionA11y(true)).toEqual({ accessibilityRole: 'radio', accessibilityState: { selected: true, checked: true } }); + expect(radioOptionA11y(false).accessibilityState).toEqual({ selected: false, checked: false }); + expect(RADIO_GROUP_A11Y.accessibilityRole).toBe('radiogroup'); + }); +}); + +describe('option groups', () => { + it('Composer chips (undo-send delay, autosave, signature) say which one is set', () => { + const source = read('components/settings/ComposingSettings.tsx'); + const segmented = source.slice(source.indexOf('const segmented'), source.indexOf('return (\n { + const source = read('screens/FilesScreen.tsx'); + expect(source).toContain('{...radioOptionA11y(sortKey === k)}'); + expect(source).toContain('{...radioOptionA11y(sortDir === d)}'); + }); + + it('the language list says which language is set', () => { + expect(read('components/settings/LanguageSettings.tsx')).toContain('{...radioOptionA11y(active)}'); + }); +}); diff --git a/src/components/radio-a11y.ts b/src/components/radio-a11y.ts new file mode 100644 index 00000000..06a6b0b1 --- /dev/null +++ b/src/components/radio-a11y.ts @@ -0,0 +1,17 @@ +// One choice of a set of options (the undo-send delay, the Files sort order, +// the app language). Drawn as a styled Pressable, an option told a screen +// reader nothing about which one was picked: TalkBack read "5 seconds" for +// every chip alike. A radio role with the selected and checked state reads +// "selected" (Android) or "checked" (iOS) on the current one. + +export interface RadioOptionA11y { + accessibilityRole: 'radio'; + accessibilityState: { selected: boolean; checked: boolean }; +} + +export function radioOptionA11y(selected: boolean): RadioOptionA11y { + return { accessibilityRole: 'radio', accessibilityState: { selected, checked: selected } }; +} + +/** For the View around the options. */ +export const RADIO_GROUP_A11Y = { accessibilityRole: 'radiogroup' } as const; diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index 50d49c30..abd83843 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -8,6 +8,7 @@ import { useColors } from '../../theme/colors'; import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing'; +import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y'; export function ComposingSettings() { const c = useColors(); @@ -82,12 +83,13 @@ export function ComposingSettings() { current: T, onPick: (value: T) => void, ) => ( - + {options.map((opt) => { const active = current === opt.value; return ( onPick(opt.value)} style={[styles.segment, active && styles.segmentActive]} > diff --git a/src/components/settings/LanguageSettings.tsx b/src/components/settings/LanguageSettings.tsx index 320da97d..0a43cf9f 100644 --- a/src/components/settings/LanguageSettings.tsx +++ b/src/components/settings/LanguageSettings.tsx @@ -8,6 +8,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore, type DateFormat, type TimeFormat } from '../../stores/settings-store'; import { formatListDate } from '../../lib/date-format'; import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n'; +import { radioOptionA11y } from '../radio-a11y'; export function LanguageSettings() { const c = useColors(); @@ -43,6 +44,7 @@ export function LanguageSettings() { return ( setOverride(code === 'system' ? null : (code as LocaleCode))} style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} > diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index d9c040d0..8aafeb73 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -72,6 +72,7 @@ import { formatListDate } from '../lib/date-format'; import { useLayout, useSheetInsets } from '../lib/use-layout'; import { useSheetBottomInset } from '../lib/sheet-bottom-inset'; import { filesListWidth } from '../lib/files-split'; +import { radioOptionA11y } from '../components/radio-a11y'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { filesBackStep } from '../lib/files-back'; import { driveChange, type DriveScope } from '../lib/files-drive'; @@ -2148,7 +2149,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang {t('files.settings_default_sort', 'Sort')} {keys.map((k) => ( - { onChangeKey(k); onClose(); }}> + { onChangeKey(k); onClose(); }}> {t(`files.${k}`, k)} @@ -2157,7 +2158,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang ))} {(['asc', 'desc'] as FilesSortDir[]).map((d) => ( - { onChangeDir(d); onClose(); }}> + { onChangeDir(d); onClose(); }}> {d === 'asc' ? : } {d === 'asc' From d6b9b342e98cc5a36fbd181394416053221b6dbe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:44:54 +0100 Subject: [PATCH 09/13] fix: name the tag or person on each row's delete and remove-access button --- locales/rn/de.json | 4 +++ locales/rn/en.json | 4 +++ .../__tests__/row-button-names.test.ts | 31 +++++++++++++++++++ src/components/files/ShareSheet.tsx | 13 ++++++-- src/components/settings/DelegateSettings.tsx | 18 +++++++++-- src/components/settings/KeywordSettings.tsx | 4 +-- 6 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 src/components/__tests__/row-button-names.test.ts diff --git a/locales/rn/de.json b/locales/rn/de.json index 234ed8ae..3046e3c4 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -278,6 +278,9 @@ "login_changed": "Sie haben das Konto gewechselt, daher wurde die Regel nicht gespeichert" } }, + "delegates": { + "remove_for": "Zugriff für {name} entfernen" + }, "device_sync": { "auth_problem_text": "Der Server akzeptiert die Anmeldung von {account} nicht mehr, daher werden die Kontakte und Kalender dieses Kontos auf diesem Gerät nicht mehr synchronisiert. Melden Sie sich erneut an, um fortzufahren. Auf diesem Gerät vorgenommene Änderungen bleiben erhalten.", "auth_problem_title": "Erneut anmelden, um weiter zu synchronisieren", @@ -533,6 +536,7 @@ "share_no_users": "Keine anderen Benutzer gefunden", "share_remove": "Zugriff entfernen", "share_remove_confirm": "Zugriff entfernen?", + "share_remove_for": "Zugriff für {name} entfernen", "share_role_custom": "Benutzerdefiniert", "share_search_placeholder": "Nach Name oder E-Mail suchen", "share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar", diff --git a/locales/rn/en.json b/locales/rn/en.json index 730e7571..b9ff4a2b 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -325,6 +325,9 @@ "deep_link": { "open_failed": "Couldn't open this" }, + "delegates": { + "remove_for": "Remove access for {name}" + }, "device_sync": { "auth_problem_text": "The server no longer accepts the sign-in of {account}, so its contacts and calendars on this device stopped syncing. Sign in again to resume. Changes made on this device are kept.", "auth_problem_title": "Sign in again to keep syncing", @@ -730,6 +733,7 @@ "share_no_users": "No other users found", "share_remove": "Remove access", "share_remove_confirm": "Remove access?", + "share_remove_for": "Remove access for {name}", "share_role_custom": "Custom", "share_search_placeholder": "Search by name or email", "share_unavailable": "Sharing is not available on this device", diff --git a/src/components/__tests__/row-button-names.test.ts b/src/components/__tests__/row-button-names.test.ts new file mode 100644 index 00000000..6eaaaec8 --- /dev/null +++ b/src/components/__tests__/row-button-names.test.ts @@ -0,0 +1,31 @@ +// Buttons repeated on every row of a list read the same to a screen reader: +// TalkBack heard "Delete tag" on each tag in Settings > Tags and "Remove +// access" on each person a file is shared with, without saying whose. They +// carry the row's name now (webmail keyword-settings.tsx: "Delete {name}"). +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const SRC = path.resolve(__dirname, '../..'); +const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8').split('\r\n').join('\n'); + +describe('per-row buttons', () => { + it('name the tag they edit or delete', () => { + const source = read('components/settings/KeywordSettings.tsx'); + expect(source).toContain("t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })"); + expect(source).toContain("t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })"); + expect(source).not.toMatch(/accessibilityLabel=\{t\('settings\.keywords\.(delete|edit)',/); + }); + + it('name the person whose file access they remove', () => { + const source = read('components/files/ShareSheet.tsx'); + expect(source).toContain("t('files.share_remove_for', 'Remove access for {name}', { name: principalName(principalId) })"); + expect(source).not.toMatch(/accessibilityLabel=\{t\('files\.share_remove', 'Remove access'\)\}/); + }); + + it('name the delegate they change or remove', () => { + const source = read('components/settings/DelegateSettings.tsx'); + expect(source).toContain("accessibilityLabel={t('delegates.remove_for', 'Remove access for {name}', { name: display.name })}"); + expect(source).toContain("accessibilityLabel={t('delegates.dialog_title_change', 'Change access for {name}', { name: display.name })}"); + }); +}); diff --git a/src/components/files/ShareSheet.tsx b/src/components/files/ShareSheet.tsx index 7e06f5a3..3fee704d 100644 --- a/src/components/files/ShareSheet.tsx +++ b/src/components/files/ShareSheet.tsx @@ -167,15 +167,21 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") : null; - const renderPrincipalLabel = (principalId: string) => { + const principalName = (principalId: string) => { const p = principalsById.get(principalId); // Stalwart 1.0 with directory queries off hides every principal the user // has no relationship with, so a sharee may have no name. const unresolved = !p && !loadingPrincipals; + return p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId); + }; + + const renderPrincipalLabel = (principalId: string) => { + const p = principalsById.get(principalId); + const unresolved = !p && !loadingPrincipals; return ( - {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} + {principalName(principalId)} {p?.email || unresolved ? ( @@ -257,7 +263,8 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps )} hitSlop={8} accessibilityRole="button" - accessibilityLabel={t('files.share_remove', 'Remove access')} + // One per person: the name tells the buttons apart. + accessibilityLabel={t('files.share_remove_for', 'Remove access for {name}', { name: principalName(principalId) })} > diff --git a/src/components/settings/DelegateSettings.tsx b/src/components/settings/DelegateSettings.tsx index c349b279..e13a88d0 100644 --- a/src/components/settings/DelegateSettings.tsx +++ b/src/components/settings/DelegateSettings.tsx @@ -241,10 +241,24 @@ export function DelegateSettings() { - - diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index 83d867fb..c3f6ca23 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -120,10 +120,10 @@ export function KeywordSettings() { {kw.label} $label:{kw.id} - setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}> + setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}> - deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}> + deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}> From 9ce89e2672bcaab41d9bc99d07a188d27ad658b4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:47:05 +0100 Subject: [PATCH 10/13] fix: say "1 email selected" in the tag picker header --- locales/rn/de.json | 1 + locales/rn/en.json | 1 + src/components/TagSheet.tsx | 4 +++- .../__tests__/tag-sheet-title.test.ts | 24 +++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/components/__tests__/tag-sheet-title.test.ts diff --git a/locales/rn/de.json b/locales/rn/de.json index 3046e3c4..d330d520 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -273,6 +273,7 @@ }, "context_menu": { "color_tag": "Label", + "emails_selected": "{count, plural, one {# E-Mail ausgewählt} other {# E-Mails ausgewählt}}", "remove_color": "Label entfernen", "rules": { "login_changed": "Sie haben das Konto gewechselt, daher wurde die Regel nicht gespeichert" diff --git a/locales/rn/en.json b/locales/rn/en.json index b9ff4a2b..c853df06 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -317,6 +317,7 @@ }, "context_menu": { "color_tag": "Tag", + "emails_selected": "{count, plural, one {# email selected} other {# emails selected}}", "remove_color": "Remove tag", "rules": { "login_changed": "You switched accounts, so the rule was not saved" diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index ebd3ad7b..8e6426f3 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -87,7 +87,9 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle - {t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })} + {/* Not context_menu.items_selected: "{count} emails selected" has no + singular, and the sheet opens for one email too. */} + {t('context_menu.emails_selected', '{count, plural, one {# email selected} other {# emails selected}}', { count: selectedEmails.length })} ({ getLocales: () => [] })); + +import { translate } from '../../i18n'; + +const source = fs.readFileSync(path.resolve(__dirname, '../TagSheet.tsx'), 'utf8'); +// The key and, when it is a literal, the English fallback. +const call = /t\('(context_menu\.[a-z_]+)', (?:'([^']*)'|`[^`]*`), \{ count: selectedEmails\.length \}\)/.exec(source); + +describe('the tag picker header', () => { + it('counts the selection with a plural', () => { + expect(call).not.toBeNull(); + const [, key, fallback] = call!; + expect(translate('en', key, fallback, { count: 1 })).toBe('1 email selected'); + expect(translate('en', key, fallback, { count: 3 })).toBe('3 emails selected'); + expect(translate('de', key, fallback, { count: 1 })).toBe('1 E-Mail ausgewählt'); + }); +}); From b17d4d40699ce1de23cf442fe497c8352eff1c90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:49:05 +0100 Subject: [PATCH 11/13] fix: say in Composing that "Plain text only" also shows received mail as plain text --- .../__tests__/plain-text-description.test.ts | 26 +++++++++++++++++++ src/components/settings/ComposingSettings.tsx | 5 +++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/components/__tests__/plain-text-description.test.ts diff --git a/src/components/__tests__/plain-text-description.test.ts b/src/components/__tests__/plain-text-description.test.ts new file mode 100644 index 00000000..d4375091 --- /dev/null +++ b/src/components/__tests__/plain-text-description.test.ts @@ -0,0 +1,26 @@ +// "Plain text only" also makes the app show received mail as plain text +// (plain-text-reading.test.ts), but Settings > Composing described it with +// the webmail's text, which only talks about sending. +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('expo-localization', () => ({ getLocales: () => [] })); + +import { translate } from '../../i18n'; + +function description(rel: string): string { + const source = fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8'); + const m = /t\('(settings\.email_behavior\.plain_text_mode\.[a-z_]+)', (['"])(.*?)\2\)/.exec( + source.slice(source.indexOf("plain_text_mode.label")), + ); + if (!m) throw new Error(`no plain_text_mode description in ${rel}`); + return translate('en', m[1], m[3]); +} + +describe('the plain text only setting', () => { + it('mentions reading in Composing as well as in Reading', () => { + expect(description('settings/ComposingSettings.tsx')).toMatch(/\bread\b/i); + expect(description('settings/ReadingSettings.tsx')).toMatch(/\bread\b/i); + }); +}); diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index abd83843..33471199 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -133,7 +133,10 @@ export function ComposingSettings() { updateSetting('plainTextMode', v)} /> From 7eb72894720c180fa9b01e28a5c5d95fee1fc710 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:52:07 +0100 Subject: [PATCH 12/13] fix: stop a settings search for "recover" from offering Reading --- src/lib/__tests__/settings-search-recovery.test.ts | 10 ++++++++++ src/lib/settings-search.ts | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/src/lib/__tests__/settings-search-recovery.test.ts b/src/lib/__tests__/settings-search-recovery.test.ts index 852677e5..d1d4e6e8 100644 --- a/src/lib/__tests__/settings-search-recovery.test.ts +++ b/src/lib/__tests__/settings-search-recovery.test.ts @@ -22,4 +22,14 @@ describe('settings search for Delegates and Recover deleted items', () => { expect(finds('recovery', 'Recover deleted items', 'emptied')).toBe(true); expect(finds('recovery', 'Recover deleted items', 'colleague')).toBe(false); }); + + // The delete action's warning ("... cannot be recovered") put Reading in + // the results, and it has nothing about recovering. + it('finds "recover" in Recover deleted items only, not in Reading', () => { + const q = normalizeSettingsQuery('recover'); + expect(tabMatchesQuery(index, 'recovery', 'Recover deleted items', q)).toBe(true); + expect(tabMatchesQuery(index, 'reading', 'Reading', q)).toBe(false); + // Reading is still found by the delete action itself. + expect(tabMatchesQuery(index, 'reading', 'Reading', normalizeSettingsQuery('delete action'))).toBe(true); + }); }); diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index ccf96778..06948ffc 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -323,6 +323,12 @@ export function getByPath(obj: unknown, path: string): unknown { return cur; } +// Leaves that say what a setting does once picked, not what it is: the +// delete action's warning ("... cannot be recovered") made a search for +// "recover" offer Reading, which has nothing about recovering. The webmail +// indexes labels only. +const UNSEARCHED_LEAVES = new Set(['warning']); + // Every string leaf key below `path`. export function collectStringKeys(node: unknown, path: string, sink: string[]): void { if (typeof node === 'string') { @@ -331,6 +337,7 @@ export function collectStringKeys(node: unknown, path: string, sink: string[]): } if (!node || typeof node !== 'object' || Array.isArray(node)) return; for (const [key, value] of Object.entries(node as Dictionary)) { + if (typeof value === 'string' && UNSEARCHED_LEAVES.has(key)) continue; collectStringKeys(value, `${path}.${key}`, sink); } } From 56c8b98bb6c2bce70c423e53cb8220137f3c5664 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:54:39 +0100 Subject: [PATCH 13/13] fix: show localized system folder names in the filter rule folder picker --- .../__tests__/filter-folder-labels.test.ts | 55 +++++++++++++++++++ src/components/filters/FilterRuleModal.tsx | 7 ++- src/components/filters/folder-option-label.ts | 27 +++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/components/__tests__/filter-folder-labels.test.ts create mode 100644 src/components/filters/folder-option-label.ts diff --git a/src/components/__tests__/filter-folder-labels.test.ts b/src/components/__tests__/filter-folder-labels.test.ts new file mode 100644 index 00000000..db578637 --- /dev/null +++ b/src/components/__tests__/filter-folder-labels.test.ts @@ -0,0 +1,55 @@ +// The filter rule's "Move to folder" picker listed the server's folder names +// ("Sent Items", "Junk Mail", "Deleted Items") where the rest of the app +// shows the localized role names (localizeMailboxName). +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('expo-localization', () => ({ getLocales: () => [] })); + +import { translate } from '../../i18n'; +import { buildMailboxTargets } from '../../lib/sieve/rule-actions'; +import { folderOptionLabel, mailboxesByTargetId } from '../filters/folder-option-label'; +import type { Mailbox } from '../../api/types'; + +const mb = (id: string, name: string, role: string | null, parentId: string | null = null): Mailbox => ({ + id, name, role, parentId, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: {} as Mailbox['myRights'], isSubscribed: true, +} as unknown as Mailbox); + +const mailboxes = [ + mb('i', 'Inbox', 'inbox'), + mb('s', 'Sent Items', 'sent'), + mb('j', 'Junk Mail', 'junk'), + mb('d', 'Deleted Items', 'trash'), + mb('p', 'Projects', null), + mb('a', 'Alpha', null, 'p'), +]; + +function labels(locale: 'en' | 'de') { + const t = (key: string, fallback?: string) => translate(locale, key, fallback); + const byId = mailboxesByTargetId(mailboxes); + return Object.fromEntries(buildMailboxTargets(mailboxes).map((target) => [target.id, folderOptionLabel(target, byId.get(target.id), t)])); +} + +describe('filter rule folder picker labels', () => { + it('names system folders by role, like the sidebar', () => { + const en = labels('en'); + expect(en.s).toBe(translate('en', 'sidebar.mailboxes.sent')); + expect(en.j).toBe(translate('en', 'sidebar.mailboxes.spam')); + expect(en.d).toBe(translate('en', 'sidebar.mailboxes.trash')); + expect(en.s).not.toBe('Sent Items'); + expect(labels('de').d).toBe(translate('de', 'sidebar.mailboxes.trash')); + }); + + it('keeps user folders and their indent', () => { + const en = labels('en'); + expect(en.p).toBe('Projects'); + expect(en.a).toBe(' Alpha'); + }); + + it('is what the rule editor lists', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../filters/FilterRuleModal.tsx'), 'utf8'); + expect(source).toContain('label: folderOptionLabel(target, mailboxById.get(target.id), t)'); + }); +}); diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 84cdfadb..ebc61f54 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 { folderOptionLabel, mailboxesByTargetId } from './folder-option-label'; import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset'; const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all']; @@ -131,6 +132,7 @@ export function FilterRuleModal({ const periodProblem = periodOn ? periodError(activeFrom, activeUntil) : null; const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]); + const mailboxById = useMemo(() => mailboxesByTargetId(mailboxes), [mailboxes]); // A message can collect only so many forwards on the server (#1155, #1208). const listedRules = useFilterStore((s) => s.rules); @@ -216,7 +218,10 @@ export function FilterRuleModal({ // listed (deleted, or its account's folders not loaded) stays selectable // under its path, so saving doesn't silently retarget the rule. const folderOptions = (action: FilterAction) => { - const options = mailboxTargets.map((target) => ({ value: target.id, label: target.label })); + const options = mailboxTargets.map((target) => ({ + value: target.id, + label: folderOptionLabel(target, mailboxById.get(target.id), t), + })); const selected = mailboxIdFor(action, mailboxTargets); if (!options.some((o) => o.value === selected) && (selected || action.value)) { options.unshift({ value: selected, label: action.value || selected }); diff --git a/src/components/filters/folder-option-label.ts b/src/components/filters/folder-option-label.ts new file mode 100644 index 00000000..fb3f3c1a --- /dev/null +++ b/src/components/filters/folder-option-label.ts @@ -0,0 +1,27 @@ +import { localizeMailboxName } from '../../lib/mailbox-label'; +import type { MailboxTarget } from '../../lib/sieve/rule-actions'; +import type { Mailbox } from '../../api/types'; + +type Translate = (key: string, fallback?: string) => string; + +/** + * A filter rule's folder picker row: the folder's indent and its name as the + * rest of the app shows it. The targets carry the server name, so system + * folders read "Sent Items", "Junk Mail" or "Deleted Items" here and + * "Sent", "Spam" or "Trash" everywhere else. The rule itself keeps the + * server path (fileinto needs it); only the label changes. + */ +export function folderOptionLabel( + target: MailboxTarget, + mailbox: Pick | undefined, + t: Translate, +): string { + if (!mailbox?.role) return target.label; + const indent = /^ */.exec(target.label)?.[0] ?? ''; + return indent + localizeMailboxName(mailbox.role, mailbox.name, t); +} + +/** Mailboxes by the id the targets use (the server id of a shared folder). */ +export function mailboxesByTargetId(mailboxes: Mailbox[]): Map { + return new Map(mailboxes.map((m) => [m.originalId ?? m.id, m])); +}