Merge branch 'cu-sheets' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 14:04:53 +01:00
38 changed files with 1187 additions and 67 deletions
+10
View File
@@ -277,11 +277,15 @@
},
"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"
}
},
"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",
@@ -537,6 +541,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",
@@ -956,6 +961,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",
@@ -1264,6 +1270,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"
}
+10
View File
@@ -320,6 +320,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"
@@ -328,6 +329,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",
@@ -733,6 +737,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",
@@ -1240,6 +1245,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",
@@ -1628,6 +1634,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\""
}
+5 -1
View File
@@ -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 (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]}>
<View style={styles.dialog}>
<View style={styles.content}>
{variant === 'destructive' && (
+7 -4
View File
@@ -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
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
{ paddingBottom: Math.max(bottomInset, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -86,7 +87,9 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
</View>
<View style={styles.header}>
<Text style={styles.title}>
{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 })}
</Text>
<Pressable onPress={onClose} hitSlop={10} style={styles.close}
accessibilityRole="button"
+4 -1
View File
@@ -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)
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
{ paddingBottom: Math.max(bottomInset, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -0,0 +1,68 @@
// An Android alert shows at most three buttons and drops the rest. The Files
// tab's recent-file menu was an Alert with Open, Show in folder, Remove from
// recent and Cancel: Cancel fell off, and since an Android alert also ignores
// Back and outside taps, the menu could only be left by picking an action.
// Menus with more than three choices are sheets.
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const SRC = path.resolve(__dirname, '../..');
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 /\.tsx?$/.test(e.name) ? [p] : [];
});
}
/** Each Alert.alert(...) call with its arguments. */
function alertCalls(source: string): { call: string; line: number }[] {
const out: { call: string; line: number }[] = [];
let at = source.indexOf('Alert.alert(');
while (at >= 0) {
let depth = 0;
let i = at + 'Alert.alert'.length;
for (; i < source.length; i++) {
if (source[i] === '(') depth++;
else if (source[i] === ')' && --depth === 0) break;
}
out.push({ call: source.slice(at, i + 1), line: source.slice(0, at).split('\n').length });
at = source.indexOf('Alert.alert(', i);
}
return out;
}
function buttonCount(call: string): number {
return (call.match(/\btext:/g) ?? []).length;
}
describe('alerts', () => {
it('have at most three buttons, all of which Android shows', () => {
const offenders = sourceFiles(SRC).flatMap((file) =>
alertCalls(fs.readFileSync(file, 'utf8'))
.filter(({ call }) => buttonCount(call) > 3)
.map(({ line }) => `${path.relative(SRC, file)}:${line}`));
expect(offenders).toEqual([]);
});
it('counts the buttons of a call', () => {
const [call] = alertCalls("Alert.alert(name, undefined, [{ text: t('a', 'A'), onPress: () => f(1) }, { text: 'B' }]);");
expect(buttonCount(call.call)).toBe(2);
});
});
describe('the Files recent and starred menus', () => {
const source = fs.readFileSync(path.join(SRC, 'screens/FilesScreen.tsx'), 'utf8');
it('open a sheet with a Cancel row that Back and an outside tap close', () => {
const body = source.slice(source.indexOf('function PlaceActionsSheet'), source.indexOf('interface FolderPickerSheetProps'));
expect(body).toContain('onRequestClose={onClose}');
expect(body).toContain('onPress={onClose}');
expect(body).toContain("t('files.cancel', 'Cancel')");
const recent = source.slice(source.indexOf('const recentActions'), source.indexOf('const starredActions'));
expect(recent).toContain('setPlaceMenu(');
expect(recent).not.toContain('Alert.alert');
});
});
@@ -0,0 +1,57 @@
// A text field with no label of its own is named by its placeholder. When
// the placeholder is an example, TalkBack read the filter rule's name field
// as "e.g., Sort newsletters" (and the size condition as "e.g., 1000000").
// Fields with an example placeholder get their label as the accessible name,
// as in the webmail's filter-rule-modal.tsx.
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const SRC = path.resolve(__dirname, '../..');
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] : [];
});
}
function openingTags(source: string, name: string): { tag: string; line: number }[] {
const out: { tag: string; line: number }[] = [];
const re = new RegExp(`<${name}\\b`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
let depth = 0;
let i = m.index;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
out.push({ tag: source.slice(m.index, i + 1), line: source.slice(0, m.index).split('\n').length });
}
return out;
}
const EXAMPLE_PLACEHOLDER = /placeholder=\{[^}]*'e\.g\.?,?\s/;
const NAMED = /accessibilityLabel=|\blabel=/;
describe('text fields with an example placeholder', () => {
it('are named by a label', () => {
const offenders = sourceFiles(SRC).flatMap((file) => {
const source = fs.readFileSync(file, 'utf8');
return ['Input', 'TextInput'].flatMap((name) => openingTags(source, name))
.filter(({ tag }) => EXAMPLE_PLACEHOLDER.test(tag) && !NAMED.test(tag))
.map(({ line }) => `${path.relative(SRC, file)}:${line}`);
});
expect(offenders).toEqual([]);
});
it('spots an unnamed one', () => {
const [tag] = openingTags("<Input value={name} placeholder={t('x', 'e.g., Sort newsletters')} />", 'Input');
expect(EXAMPLE_PLACEHOLDER.test(tag.tag)).toBe(true);
expect(NAMED.test(tag.tag)).toBe(false);
});
});
@@ -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)');
});
});
@@ -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<string, unknown>, prefix = '', out = new Map<string, string>()): Map<string, string> {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object') flatten(v as Record<string, unknown>, 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 = /(?<!\{)\{\s*\w+\s*[,}]/;
describe('t() calls without values', () => {
it('use catalog texts that have no placeholders', () => {
const en = flatten(getDictionary('en') as Record<string, unknown>);
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([]);
});
});
@@ -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);
});
});
@@ -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 <SettingsSection'));
expect(segmented).toContain('{...RADIO_GROUP_A11Y}');
expect(segmented).toContain('{...radioOptionA11y(active)}');
});
it('the Files sort sheet says which order is set', () => {
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)}');
});
});
@@ -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 })}");
});
});
@@ -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 <Modal ...> and its </Modal>. */
function modalBodies(source: string): string[] {
const out: string[] = [];
const re = /<Modal[\s>]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(source))) {
const end = source.indexOf('</Modal>', 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 = '<Modal visible transparent>\n<View style={[styles.sheet, { paddingBottom: bottomInset }]} />\n</Modal>';
const bodies = modalBodies(src);
expect(bodies).toHaveLength(1);
expect(rendersSheet(bodies[0])).toBe(true);
expect(padsBottom(bodies[0])).toBe(true);
expect(padsBottom('<Modal><View style={styles.sheet} /></Modal>')).toBe(false);
});
});
@@ -0,0 +1,24 @@
// The tag picker's header read "1 emails selected": it used the webmail's
// context_menu.items_selected ("{count} emails selected"), which has no
// singular since the webmail shows it for several emails only.
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';
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');
});
});
+13 -4
View File
@@ -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);
@@ -165,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 (
<View style={styles.principalInfo}>
<Text style={styles.principalName} numberOfLines={1}>
{p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)}
{principalName(principalId)}
</Text>
{p?.email || unresolved ? (
<Text style={styles.principalEmail} numberOfLines={1}>
@@ -189,7 +197,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
@@ -255,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) })}
>
<Trash2 size={18} color={c.error} />
</Pressable>
+14 -2
View File
@@ -45,6 +45,8 @@ 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'];
@@ -91,6 +93,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;
@@ -128,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);
@@ -213,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 });
@@ -315,7 +323,7 @@ export function FilterRuleModal({
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={{ flex: 1, paddingBottom: keyboardPadding }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
@@ -326,6 +334,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}
/>
</View>
@@ -411,6 +422,7 @@ export function FilterRuleModal({
}
keyboardType={condition.field === 'size' ? 'numeric' : 'default'}
autoCapitalize="none"
accessibilityLabel={t('settings.filters.condition_value', 'Condition value')}
/>
)}
</View>
+4 -1
View File
@@ -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
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={{ flex: 1, paddingBottom: keyboardPadding }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.body}>
@@ -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<Mailbox, 'role' | 'name'> | 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<string, Mailbox> {
return new Map(mailboxes.map((m) => [m.originalId ?? m.id, m]));
}
+17
View File
@@ -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;
@@ -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,
) => (
<View style={styles.segmentRow}>
<View style={styles.segmentRow} {...RADIO_GROUP_A11Y}>
{options.map((opt) => {
const active = current === opt.value;
return (
<Pressable
key={String(opt.value)}
{...radioOptionA11y(active)}
onPress={() => onPick(opt.value)}
style={[styles.segment, active && styles.segmentActive]}
>
@@ -131,7 +133,10 @@ export function ComposingSettings() {
<SettingItem
label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")}
description={t('settings.email_behavior.plain_text_mode.description', "Disable the rich text editor and send all emails as plain text only, including replies and forwards")}
// The app also shows received mail as plain text with this on, which
// the webmail's description (sending only) leaves out; same text as
// under Reading.
description={t('settings.email_behavior.plain_text_mode.description_mobile', 'Compose and read in plain text only.')}
>
<ToggleSwitch checked={plainTextMode} onChange={(v) => updateSetting('plainTextMode', v)} />
</SettingItem>
@@ -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<string | null>(null);
@@ -197,7 +200,7 @@ export function ContentSendersSettings() {
animationType="fade"
onRequestClose={() => setModalOpen(false)}
>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
<Pressable accessible={false} style={[styles.modalBackdrop, { paddingBottom: bottomInset }]} onPress={() => setModalOpen(false)}>
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
+4 -1
View File
@@ -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<Principal | null>(initial?.principal ?? null);
@@ -154,7 +157,7 @@ export function DelegateDialog({
return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible animationType="slide" onRequestClose={close}>
<SafeAreaView style={styles.screen} edges={['top', 'bottom', 'left', 'right']}>
<KeyboardAvoidingView style={styles.screen} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<KeyboardAvoidingView style={[styles.screen, { paddingBottom: keyboardPadding }]} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">
{changing && principal
+16 -2
View File
@@ -241,10 +241,24 @@ export function DelegateSettings() {
</View>
</View>
<View style={styles.rowButtons}>
<Button variant="outline" size="sm" onPress={() => setDialog({ mode: 'change', summary })} disabled={busy || incomplete}>
{/* Named with the person: every row has the same two buttons. */}
<Button
variant="outline"
size="sm"
onPress={() => setDialog({ mode: 'change', summary })}
disabled={busy || incomplete}
accessibilityLabel={t('delegates.dialog_title_change', 'Change access for {name}', { name: display.name })}
>
{t('delegates.change', 'Change')}
</Button>
<Button variant="outline" size="sm" onPress={() => confirmRemove(summary)} disabled={incomplete} loading={busy}>
<Button
variant="outline"
size="sm"
onPress={() => confirmRemove(summary)}
disabled={incomplete}
loading={busy}
accessibilityLabel={t('delegates.remove_for', 'Remove access for {name}', { name: display.name })}
>
{t('delegates.remove', 'Remove access')}
</Button>
</View>
+7 -2
View File
@@ -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<string, any> = {
@@ -76,6 +77,9 @@ export function FolderSettings() {
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const [editor, setEditor] = useState<Editor | null>(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<string>(NO_PARENT);
const [draftAccount, setDraftAccount] = useState<string>(OWN_ACCOUNT);
@@ -300,7 +304,7 @@ export function FolderSettings() {
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editor?.kind === 'edit'
@@ -348,7 +352,8 @@ export function FolderSettings() {
<Text style={styles.fieldLabel}>
{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')}
</Text>
<Select
value={draftParent}
+4 -1
View File
@@ -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';
@@ -111,6 +112,8 @@ export function IdentitySettings() {
const preferredId = accountId ? preferredIdentityIds[accountId] : undefined;
const [editing, setEditing] = useState<DraftIdentity | null>(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<string | null>(null);
@@ -330,7 +333,7 @@ export function IdentitySettings() {
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={requestClose}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? t('settings.identities.edit', "Edit identity") : t('settings.identities.create', "New identity")}
+2 -2
View File
@@ -120,10 +120,10 @@ export function KeywordSettings() {
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
@@ -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 (
<Pressable
key={code}
{...radioOptionA11y(active)}
onPress={() => setOverride(code === 'system' ? null : (code as LocaleCode))}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
+5 -2
View File
@@ -19,6 +19,7 @@ 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.
@@ -39,6 +40,8 @@ export function TemplateSettings() {
const [editing, setEditing] = useState<EmailTemplate | null>(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('');
@@ -213,7 +216,7 @@ export function TemplateSettings() {
{/* Edit/Create modal */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? tr('settings.templates.edit', 'Edit Template') : tr('settings.templates.add', 'New Template')}
@@ -287,7 +290,7 @@ export function TemplateSettings() {
{/* Import modal */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={[styles.modalOverlay, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<View style={styles.modalSheet}>
<View style={[styles.modalSheet, { paddingBottom: bottomInset }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{tr('settings.templates.import', 'Import')}</Text>
<Pressable onPress={() => setImportVisible(false)} hitSlop={8} accessibilityRole="button" accessibilityLabel={tr('common.close', 'Close')}>
+31 -10
View File
@@ -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')}
>
<View style={styles.statusRow}>
<View style={[styles.pill, enabled ? styles.pillActive : styles.pillInactive]}>
<Text style={[styles.pillText, enabled ? styles.pillTextActive : styles.pillTextInactive]}>
{enabled
<View style={[styles.pill, status === 'active' ? styles.pillActive : status === 'scheduled' ? styles.pillScheduled : styles.pillInactive]}>
<Text style={[styles.pillText, status === 'active' ? styles.pillTextActive : status === 'scheduled' ? styles.pillTextScheduled : styles.pillTextInactive]}>
{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')}
</Text>
</View>
<ToggleSwitch checked={enabled} onChange={setEnabled} />
@@ -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 && <Text style={styles.fieldError}>{subjectTooLong}</Text>}
</View>
@@ -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() {
)}
<View style={styles.saveRow}>
<Button onPress={() => void handleSave()} disabled={!canSave} loading={store.isSaving}>
{store.isSaving
? t('settings.vacation.saving', 'Saving...')
: t('settings.vacation.save', 'Save Changes')}
{/* Named, so Android doesn't keep announcing the spinner's "busy"
once the save is done: React Native leaves a view's old content
description when the new one would be empty. */}
<Button
onPress={() => void handleSave()}
disabled={!canSave}
loading={store.isSaving}
accessibilityLabel={saveLabel}
>
{saveLabel}
</Button>
</View>
</View>
@@ -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,
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 800, height: 1280 }) }));
vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) }));
import { FILES_LIST_MIN, PREVIEW_HEADER_CHROME, PREVIEW_TITLE_MIN, filesListWidth } from '../files-split';
// In the tablet split view the docked file preview had a fixed 45 % of the
// row. On a Pixel Tablet in portrait (about 720 dp next to the tab rail)
// that left the header's file name 2 px between its six buttons.
const titleRoom = (contentWidth: number) => contentWidth - filesListWidth(contentWidth) - PREVIEW_HEADER_CHROME;
describe('filesListWidth', () => {
it('leaves the file name room in the preview header on a tablet in portrait', () => {
expect(titleRoom(720)).toBeGreaterThanOrEqual(PREVIEW_TITLE_MIN);
expect(filesListWidth(720)).toBeGreaterThanOrEqual(FILES_LIST_MIN);
});
it('gives the list its usual pane width on a wide window', () => {
expect(filesListWidth(1200)).toBe(408);
expect(titleRoom(1200)).toBeGreaterThan(400);
});
it('keeps the list usable on a narrow split', () => {
expect(filesListWidth(600)).toBe(FILES_LIST_MIN);
});
});
describe('the Files split view', () => {
it('sizes the list column with filesListWidth and lets the preview take the rest', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/FilesScreen.tsx'), 'utf8');
expect(source).toContain('width: filesListWidth(contentWidth)');
expect(source).not.toMatch(/previewPane: \{[^}]*width: '45%'/);
});
});
@@ -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);
});
});
@@ -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<string, Listener>();
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: <T,>(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);
}
});
});
+53
View File
@@ -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('<Button\n onPress={() => void handleSave()}'));
expect(save.slice(0, save.indexOf('</Button>'))).toContain('accessibilityLabel={saveLabel}');
});
});
+24
View File
@@ -0,0 +1,24 @@
import { listPaneWidth } from './use-layout';
/**
* Fixed chrome of the docked file preview's header: 16 dp padding on each
* side and six 40 dp buttons (close, previous, next, open, download, share)
* with 8 dp gaps, the title's gap included.
*/
export const PREVIEW_HEADER_CHROME = 2 * 16 + 6 * 40 + 6 * 8;
/** Room the file name keeps in the docked preview's header. */
export const PREVIEW_TITLE_MIN = 120;
/** The file list never gets narrower than this next to a preview. */
export const FILES_LIST_MIN = 280;
/**
* Width of the file list next to the docked preview. The preview had a fixed
* 45 %: on a tablet in portrait (about 720 dp next to the tab rail) that was
* 324 dp, and the six header buttons left the file name 2 px. The list takes
* the usual list-pane width (listPaneWidth, as in Mail and Contacts) and
* gives way, down to FILES_LIST_MIN, until the name has PREVIEW_TITLE_MIN.
*/
export function filesListWidth(contentWidth: number): number {
const previewMin = PREVIEW_HEADER_CHROME + PREVIEW_TITLE_MIN;
return Math.max(FILES_LIST_MIN, Math.min(listPaneWidth(contentWidth), contentWidth - previewMin));
}
+7
View File
@@ -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);
}
}
+110
View File
@@ -0,0 +1,110 @@
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<KeyboardMetrics, 'screenY' | 'height'> | 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
* 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.
* 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(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);
}
+18
View File
@@ -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';
}
+118 -31
View File
@@ -22,6 +22,7 @@ import {
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download, Search,
ArrowUpDown, ArrowUp, ArrowDown, FolderInput, Copy, Check, Eye, ExternalLink, FileSymlink, RotateCcw, Star, Clock,
FolderOpen, type AppIcon,
} from '../components/icons';
import { useFocusEffect } from '@react-navigation/native';
import { File, Paths } from 'expo-file-system';
@@ -69,6 +70,9 @@ 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 { 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';
@@ -254,6 +258,10 @@ export default function FilesScreen() {
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
const [renameValue, setRenameValue] = useState('');
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
// The menu of a starred folder or a recent file.
const [placeMenu, setPlaceMenu] = useState<PlaceMenu | null>(null);
// Width inside the side insets, for the list next to a docked preview.
const [contentWidth, setContentWidth] = useState(0);
const [shareTarget, setShareTarget] = useState<FileRow | null>(null);
const [moveTarget, setMoveTarget] = useState<FileRow | null>(null);
const [previewTarget, setPreviewTarget] = useState<FileRow | null>(null);
@@ -1323,29 +1331,41 @@ export default function FilesScreen() {
openFolderStack(place.stack);
if (!reveal && !isSymlink(place.node)) openFile({ ...place.node, displayName: place.node.name });
};
// A sheet, not an Alert: an Android alert shows at most three buttons, so
// the recent file's Cancel was dropped, and it ignores Back and outside
// taps. Same items as the webmail's sidebar menu (files-sidebar.tsx).
const recentActions = (id: string, name: string) => {
Alert.alert(name, undefined, [
{ text: t('files.open', 'Open'), onPress: () => openRecent(id) },
{ text: t('files.show_in_folder', 'Show in folder'), onPress: () => openRecent(id, true) },
{
text: t('files.remove_from_recent', 'Remove from recent'),
onPress: () => {
if (activeAccountId) useFilesPlacesStore.getState().removeRecentFiles(activeAccountId, [id]);
setPlaceMenu({
title: name,
actions: [
{ key: 'open', label: t('files.open', 'Open'), icon: Eye, onPress: () => openRecent(id) },
{ key: 'reveal', label: t('files.show_in_folder', 'Show in folder'), icon: FolderOpen, onPress: () => openRecent(id, true) },
{
key: 'remove',
label: t('files.remove_from_recent', 'Remove from recent'),
icon: X,
onPress: () => {
if (activeAccountId) useFilesPlacesStore.getState().removeRecentFiles(activeAccountId, [id]);
},
},
},
{ text: t('files.cancel', 'Cancel'), style: 'cancel' },
]);
],
});
};
const starredActions = (folderPath: string, name: string) => {
Alert.alert(name, undefined, [
{
text: t('files.unstar', 'Remove star'),
onPress: () => {
if (activeAccountId) useFilesPlacesStore.getState().toggleFavorite(activeAccountId, folderPath);
const starredActions = (folderPath: string, name: string, stack: FolderStep[]) => {
setPlaceMenu({
title: name,
actions: [
{ key: 'open', label: t('files.open', 'Open'), icon: FolderOpen, onPress: () => openFolderStack(stack) },
{
key: 'unstar',
label: t('files.unstar', 'Remove star'),
icon: Star,
onPress: () => {
if (activeAccountId) useFilesPlacesStore.getState().toggleFavorite(activeAccountId, folderPath);
},
},
},
{ text: t('files.cancel', 'Cancel'), style: 'cancel' },
]);
],
});
};
const placesHeader = showPlaces && (starredFolders.length > 0 || recentFiles.length > 0) ? (
<View style={styles.places}>
@@ -1358,7 +1378,7 @@ export default function FilesScreen() {
<Pressable
key={folderPath}
onPress={() => openFolderStack(stack)}
onLongPress={() => starredActions(folderPath, name)}
onLongPress={() => starredActions(folderPath, name, stack)}
delayLongPress={300}
style={({ pressed }) => [styles.fileRow, pressed && { backgroundColor: c.surfaceHover }]}
accessibilityRole="button"
@@ -1366,7 +1386,7 @@ export default function FilesScreen() {
<Star size={20} color={c.starred} fill={c.starred} />
<Text style={[styles.fileName, styles.placeName]} numberOfLines={1}>{name}</Text>
<Pressable
onPress={() => starredActions(folderPath, name)}
onPress={() => starredActions(folderPath, name, stack)}
hitSlop={8}
style={styles.rowMore}
accessibilityRole="button"
@@ -1547,8 +1567,11 @@ export default function FilesScreen() {
// On a wide window an open file shows beside the list instead of covering it.
const previewDocked = isSplit && previewTarget != null;
return (
<View style={[styles.container, previewDocked && styles.splitRow, { paddingLeft: insets.left, paddingRight: insets.right }]}>
<View style={styles.listColumn}>
<View
style={[styles.container, previewDocked && styles.splitRow, { paddingLeft: insets.left, paddingRight: insets.right }]}
onLayout={(e) => setContentWidth(e.nativeEvent.layout.width - insets.left - insets.right)}
>
<View style={[styles.listColumn, previewDocked && { flex: 0, width: filesListWidth(contentWidth) }]}>
{renderHeader()}
{body}
</View>
@@ -1655,6 +1678,8 @@ export default function FilesScreen() {
onPick={(destinationId) => moveTarget && void performMove(moveTarget, destinationId)}
/>
<PlaceActionsSheet menu={placeMenu} t={t} onClose={() => setPlaceMenu(null)} />
<SortSheet
visible={sortSheetOpen}
sortKey={sortKey}
@@ -1735,6 +1760,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 (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
@@ -1744,7 +1771,7 @@ function PromptModal(props: PromptModalProps) {
onRequestClose={props.onCancel}
>
<TouchableWithoutFeedback accessible={false} onPress={props.onCancel}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.dialog}>
<Text style={styles.title}>{props.title}</Text>
@@ -1807,6 +1834,7 @@ function ActionsSheet({
}: ActionsSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const bottomInset = useSheetBottomInset(!!target);
const formatModified = useFormatModified();
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
@@ -1828,7 +1856,7 @@ function ActionsSheet({
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
</Text>
@@ -1871,7 +1899,7 @@ function ActionsSheet({
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{target.displayName}
</Text>
@@ -1946,6 +1974,62 @@ function ActionsSheet({
);
}
interface PlaceAction {
key: string;
label: string;
icon: AppIcon;
onPress: () => void;
}
interface PlaceMenu {
title: string;
actions: PlaceAction[];
}
/**
* The actions of a starred folder or a recent file, with a Cancel row. Back
* and a tap outside close it too.
*/
function PlaceActionsSheet({ menu, t, onClose }: { menu: PlaceMenu | null; t: Translate; onClose: () => void }) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const bottomInset = useSheetBottomInset(!!menu);
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!menu) return null;
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>{menu.title}</Text>
{menu.actions.map((a) => {
const Icon = a.icon;
return (
<Pressable
key={a.key}
style={styles.action}
accessibilityRole="button"
onPress={() => { onClose(); a.onPress(); }}
>
<Icon size={18} color={c.text} />
<Text style={styles.actionLabel}>{a.label}</Text>
</Pressable>
);
})}
<View style={styles.sheetDivider} />
<Pressable style={styles.action} accessibilityRole="button" onPress={onClose}>
<Text style={styles.actionLabel}>{t('files.cancel', 'Cancel')}</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
interface FolderPickerSheetProps {
target: FileRow | null;
allNodes: FileNode[];
@@ -1961,6 +2045,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(!!target);
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
const options = useMemo(() => {
@@ -2003,7 +2088,7 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{t('files.move_to', 'Move to…')} · {target.displayName}
</Text>
@@ -2051,6 +2136,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(visible);
const c = useColors();
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
if (!visible) return null;
@@ -2060,10 +2146,10 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback accessible={false}>
<View style={styles.sheet}>
<View style={[styles.sheet, { paddingBottom: spacing.xxl + bottomInset }]}>
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
{keys.map((k) => (
<Pressable key={k} style={styles.action} onPress={() => { onChangeKey(k); onClose(); }}>
<Pressable key={k} style={styles.action} {...radioOptionA11y(sortKey === k)} onPress={() => { onChangeKey(k); onClose(); }}>
<Text style={[styles.actionLabel, sortKey === k && { color: c.primary, fontWeight: '600' }]}>
{t(`files.${k}`, k)}
</Text>
@@ -2072,7 +2158,7 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
))}
<View style={styles.sheetDivider} />
{(['asc', 'desc'] as FilesSortDir[]).map((d) => (
<Pressable key={d} style={styles.action} onPress={() => { onChangeDir(d); onClose(); }}>
<Pressable key={d} style={styles.action} {...radioOptionA11y(sortDir === d)} onPress={() => { onChangeDir(d); onClose(); }}>
{d === 'asc' ? <ArrowUp size={18} color={c.text} /> : <ArrowDown size={18} color={c.text} />}
<Text style={[styles.actionLabel, sortDir === d && { color: c.primary, fontWeight: '600' }]}>
{d === 'asc'
@@ -2098,7 +2184,8 @@ function makeStyles(c: ThemePalette) {
},
splitRow: { flexDirection: 'row' },
listColumn: { flex: 1 },
previewPane: { width: '45%', borderLeftWidth: 1, borderLeftColor: c.border },
// The rest of the row; the list column gets filesListWidth.
previewPane: { flex: 1, borderLeftWidth: 1, borderLeftColor: c.border },
header: {
paddingBottom: spacing.sm,
borderBottomWidth: 1,