mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Merge branch 'cu-sheets' into catch-up-2026-11
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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\""
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user