diff --git a/locales/rn/de.json b/locales/rn/de.json index 0632ec64..b55db009 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -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" } diff --git a/locales/rn/en.json b/locales/rn/en.json index 176c2bf2..2f6f3e80 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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\"" } diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 99fb47d7..0df26e58 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -6,6 +6,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { useLocaleStore } from '../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetBottomInset } from '../lib/sheet-bottom-inset'; interface DialogInput { value: string; @@ -53,11 +54,14 @@ export default function Dialog({ const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // The card centres in the space above the keyboard (a prompt's field and + // its confirm button stay visible) and above the navigation bar. + const bottomInset = useSheetBottomInset(visible); const confirmLabel = confirmText ?? t('common.confirm', 'Confirm'); const cancelLabel = cancelText ?? t('common.cancel', 'Cancel'); return ( - + {variant === 'destructive' && ( diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 7c08467f..8e6426f3 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { X, Check, Tag } from './icons'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -13,6 +12,7 @@ import { getEmailTagIds } from '../lib/thread-utils'; import type { Email } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; +import { useSheetBottomInset } from '../lib/sheet-bottom-inset'; import { NewTagRow } from './NewTagRow'; interface TagSheetProps { @@ -29,7 +29,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); - const insets = useSafeAreaInsets(); + // Above the taskbar, and above the keyboard while "New tag" is typed in. + const bottomInset = useSheetBottomInset(visible); const slideY = React.useRef(new Animated.Value(500)).current; // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); @@ -77,7 +78,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle @@ -86,7 +87,9 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle - {t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })} + {/* Not context_menu.items_selected: "{count} emails selected" has no + singular, and the sheet opens for one email too. */} + {t('context_menu.emails_selected', '{count, plural, one {# email selected} other {# emails selected}}', { count: selectedEmails.length })} diff --git a/src/components/__tests__/alert-buttons.test.ts b/src/components/__tests__/alert-buttons.test.ts new file mode 100644 index 00000000..8f2a81d2 --- /dev/null +++ b/src/components/__tests__/alert-buttons.test.ts @@ -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'); + }); +}); diff --git a/src/components/__tests__/example-placeholder-names.test.ts b/src/components/__tests__/example-placeholder-names.test.ts new file mode 100644 index 00000000..7002b379 --- /dev/null +++ b/src/components/__tests__/example-placeholder-names.test.ts @@ -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'); + expect(EXAMPLE_PLACEHOLDER.test(tag.tag)).toBe(true); + expect(NAMED.test(tag.tag)).toBe(false); + }); +}); diff --git a/src/components/__tests__/filter-folder-labels.test.ts b/src/components/__tests__/filter-folder-labels.test.ts new file mode 100644 index 00000000..db578637 --- /dev/null +++ b/src/components/__tests__/filter-folder-labels.test.ts @@ -0,0 +1,55 @@ +// The filter rule's "Move to folder" picker listed the server's folder names +// ("Sent Items", "Junk Mail", "Deleted Items") where the rest of the app +// shows the localized role names (localizeMailboxName). +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('expo-localization', () => ({ getLocales: () => [] })); + +import { translate } from '../../i18n'; +import { buildMailboxTargets } from '../../lib/sieve/rule-actions'; +import { folderOptionLabel, mailboxesByTargetId } from '../filters/folder-option-label'; +import type { Mailbox } from '../../api/types'; + +const mb = (id: string, name: string, role: string | null, parentId: string | null = null): Mailbox => ({ + id, name, role, parentId, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: {} as Mailbox['myRights'], isSubscribed: true, +} as unknown as Mailbox); + +const mailboxes = [ + mb('i', 'Inbox', 'inbox'), + mb('s', 'Sent Items', 'sent'), + mb('j', 'Junk Mail', 'junk'), + mb('d', 'Deleted Items', 'trash'), + mb('p', 'Projects', null), + mb('a', 'Alpha', null, 'p'), +]; + +function labels(locale: 'en' | 'de') { + const t = (key: string, fallback?: string) => translate(locale, key, fallback); + const byId = mailboxesByTargetId(mailboxes); + return Object.fromEntries(buildMailboxTargets(mailboxes).map((target) => [target.id, folderOptionLabel(target, byId.get(target.id), t)])); +} + +describe('filter rule folder picker labels', () => { + it('names system folders by role, like the sidebar', () => { + const en = labels('en'); + expect(en.s).toBe(translate('en', 'sidebar.mailboxes.sent')); + expect(en.j).toBe(translate('en', 'sidebar.mailboxes.spam')); + expect(en.d).toBe(translate('en', 'sidebar.mailboxes.trash')); + expect(en.s).not.toBe('Sent Items'); + expect(labels('de').d).toBe(translate('de', 'sidebar.mailboxes.trash')); + }); + + it('keeps user folders and their indent', () => { + const en = labels('en'); + expect(en.p).toBe('Projects'); + expect(en.a).toBe(' Alpha'); + }); + + it('is what the rule editor lists', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../filters/FilterRuleModal.tsx'), 'utf8'); + expect(source).toContain('label: folderOptionLabel(target, mailboxById.get(target.id), t)'); + }); +}); diff --git a/src/components/__tests__/placeholder-params.test.ts b/src/components/__tests__/placeholder-params.test.ts new file mode 100644 index 00000000..5d39563e --- /dev/null +++ b/src/components/__tests__/placeholder-params.test.ts @@ -0,0 +1,76 @@ +// A t() call whose catalog text has a {placeholder} but which passes no +// values shows the placeholder as it is: the New folder sheet labelled its +// parent picker with settings.folders.subfolder_of, the webmail's +// "Inside {name}" hint, and read "Inside {name}". The English fallback in the +// call ("Parent folder") hid it in review, since the catalog text wins. +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('expo-localization', () => ({ getLocales: () => [] })); + +import { getDictionary } from '../../i18n'; + +const SRC = path.resolve(__dirname, '../..'); + +// The settings, filters, files and sheet code this check covers. +const SCOPE = [ + 'components/settings', + 'components/filters', + 'components/files', + 'components/Dialog.tsx', + 'components/Input.tsx', + 'components/RadioGroup.tsx', + 'components/ToggleSwitch.tsx', + 'components/TagSheet.tsx', + 'components/NewTagRow.tsx', + 'components/MoveSheet.tsx', + 'components/IdentitySheet.tsx', + 'components/TemplateSheet.tsx', + 'screens/SettingsScreen.tsx', + 'screens/FilesScreen.tsx', +]; + +function files(rel: string): string[] { + const p = path.join(SRC, rel); + if (!fs.statSync(p).isDirectory()) return [p]; + return fs.readdirSync(p).flatMap((e) => (e === '__tests__' ? [] : files(path.join(rel, e)))) + .filter((f) => /\.tsx?$/.test(f)); +} + +function flatten(obj: Record, prefix = '', out = new Map()): Map { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object') flatten(v as Record, key, out); + else if (typeof v === 'string') out.set(key, v); + } + return out; +} + +// t('key', 'fallback') or t('key', "fallback") closed right after the fallback. +const CALL_WITHOUT_VALUES = /\btr?\(\s*'([^']+)'\s*,\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\s*,?\s*\)/g; +// {name} or {count, plural, ...}; not the {{name}} template tokens. +const PLACEHOLDER = /(? { + it('use catalog texts that have no placeholders', () => { + const en = flatten(getDictionary('en') as Record); + const offenders: string[] = []; + for (const file of SCOPE.flatMap(files)) { + const source = fs.readFileSync(file, 'utf8'); + for (const m of source.matchAll(CALL_WITHOUT_VALUES)) { + const text = en.get(m[1]); + if (text && PLACEHOLDER.test(text)) offenders.push(`${m[1]} = ${text} (${path.relative(SRC, file)})`); + } + } + expect(offenders).toEqual([]); + }); + + it('spots a call that leaves a placeholder unfilled', () => { + const calls = [..."t('settings.folders.subfolder_of', 'Parent folder')".matchAll(CALL_WITHOUT_VALUES)]; + expect(calls.map((m) => m[1])).toEqual(['settings.folders.subfolder_of']); + expect(PLACEHOLDER.test('Inside {name}')).toBe(true); + expect(PLACEHOLDER.test('Placeholders: {{recipient_name}}')).toBe(false); + expect([..."t('a.b', 'x', { name })".matchAll(CALL_WITHOUT_VALUES)]).toEqual([]); + }); +}); diff --git a/src/components/__tests__/plain-text-description.test.ts b/src/components/__tests__/plain-text-description.test.ts new file mode 100644 index 00000000..d4375091 --- /dev/null +++ b/src/components/__tests__/plain-text-description.test.ts @@ -0,0 +1,26 @@ +// "Plain text only" also makes the app show received mail as plain text +// (plain-text-reading.test.ts), but Settings > Composing described it with +// the webmail's text, which only talks about sending. +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('expo-localization', () => ({ getLocales: () => [] })); + +import { translate } from '../../i18n'; + +function description(rel: string): string { + const source = fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8'); + const m = /t\('(settings\.email_behavior\.plain_text_mode\.[a-z_]+)', (['"])(.*?)\2\)/.exec( + source.slice(source.indexOf("plain_text_mode.label")), + ); + if (!m) throw new Error(`no plain_text_mode description in ${rel}`); + return translate('en', m[1], m[3]); +} + +describe('the plain text only setting', () => { + it('mentions reading in Composing as well as in Reading', () => { + expect(description('settings/ComposingSettings.tsx')).toMatch(/\bread\b/i); + expect(description('settings/ReadingSettings.tsx')).toMatch(/\bread\b/i); + }); +}); diff --git a/src/components/__tests__/radio-options.test.ts b/src/components/__tests__/radio-options.test.ts new file mode 100644 index 00000000..e928627f --- /dev/null +++ b/src/components/__tests__/radio-options.test.ts @@ -0,0 +1,37 @@ +// The undo-send delay chips (and the other Composer choices), the Files sort +// sheet and the language list drew the picked option in another colour only: +// a screen reader heard every option alike and couldn't tell which was set. +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { radioOptionA11y, RADIO_GROUP_A11Y } from '../radio-a11y'; + +const SRC = path.resolve(__dirname, '../..'); +const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8').split('\r\n').join('\n'); + +describe('radioOptionA11y', () => { + it('gives the picked option the radio role and the selected and checked state', () => { + expect(radioOptionA11y(true)).toEqual({ accessibilityRole: 'radio', accessibilityState: { selected: true, checked: true } }); + expect(radioOptionA11y(false).accessibilityState).toEqual({ selected: false, checked: false }); + expect(RADIO_GROUP_A11Y.accessibilityRole).toBe('radiogroup'); + }); +}); + +describe('option groups', () => { + it('Composer chips (undo-send delay, autosave, signature) say which one is set', () => { + const source = read('components/settings/ComposingSettings.tsx'); + const segmented = source.slice(source.indexOf('const segmented'), source.indexOf('return (\n { + const source = read('screens/FilesScreen.tsx'); + expect(source).toContain('{...radioOptionA11y(sortKey === k)}'); + expect(source).toContain('{...radioOptionA11y(sortDir === d)}'); + }); + + it('the language list says which language is set', () => { + expect(read('components/settings/LanguageSettings.tsx')).toContain('{...radioOptionA11y(active)}'); + }); +}); diff --git a/src/components/__tests__/row-button-names.test.ts b/src/components/__tests__/row-button-names.test.ts new file mode 100644 index 00000000..6eaaaec8 --- /dev/null +++ b/src/components/__tests__/row-button-names.test.ts @@ -0,0 +1,31 @@ +// Buttons repeated on every row of a list read the same to a screen reader: +// TalkBack heard "Delete tag" on each tag in Settings > Tags and "Remove +// access" on each person a file is shared with, without saying whose. They +// carry the row's name now (webmail keyword-settings.tsx: "Delete {name}"). +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const SRC = path.resolve(__dirname, '../..'); +const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8').split('\r\n').join('\n'); + +describe('per-row buttons', () => { + it('name the tag they edit or delete', () => { + const source = read('components/settings/KeywordSettings.tsx'); + expect(source).toContain("t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })"); + expect(source).toContain("t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })"); + expect(source).not.toMatch(/accessibilityLabel=\{t\('settings\.keywords\.(delete|edit)',/); + }); + + it('name the person whose file access they remove', () => { + const source = read('components/files/ShareSheet.tsx'); + expect(source).toContain("t('files.share_remove_for', 'Remove access for {name}', { name: principalName(principalId) })"); + expect(source).not.toMatch(/accessibilityLabel=\{t\('files\.share_remove', 'Remove access'\)\}/); + }); + + it('name the delegate they change or remove', () => { + const source = read('components/settings/DelegateSettings.tsx'); + expect(source).toContain("accessibilityLabel={t('delegates.remove_for', 'Remove access for {name}', { name: display.name })}"); + expect(source).toContain("accessibilityLabel={t('delegates.dialog_title_change', 'Change access for {name}', { name: display.name })}"); + }); +}); diff --git a/src/components/__tests__/sheet-bottom-inset.test.ts b/src/components/__tests__/sheet-bottom-inset.test.ts new file mode 100644 index 00000000..112c920e --- /dev/null +++ b/src/components/__tests__/sheet-bottom-inset.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// With edge-to-edge on, every RN Modal on Android reaches under the +// navigation bar (a tablet's taskbar, a phone's gesture bar). Bottom sheets +// that ended at the screen edge without padding for it put their last row +// under the taskbar on a Pixel Tablet: New folder's Save and Cancel, the +// Files item actions' Delete and Delete permanently. A tap there opened an +// app from the taskbar instead. Every sheet surface inside a plain Modal pads +// its bottom by the inset (useSheetBottomInset, or insets.bottom). + +const SRC = path.resolve(__dirname, '../..'); + +// Sheets in files other agents own; their fix is reported, not made here. +const OWNED_ELSEWHERE = new Set([ + 'components/SidebarDrawer.tsx', + 'components/calendar/CalendarEditSheet.tsx', + 'components/sharing/ShareCollectionSheet.tsx', +]); + +function sourceFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = path.join(dir, e.name); + if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p); + return e.name.endsWith('.tsx') ? [p] : []; + }); +} + +/** What is between each plain and its . */ +function modalBodies(source: string): string[] { + const out: string[] = []; + const re = /]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source))) { + const end = source.indexOf('', m.index); + out.push(source.slice(m.index, end < 0 ? undefined : end)); + } + return out; +} + +/** A body that renders a bottom sheet surface (styles.sheet / styles.modalSheet). */ +function rendersSheet(body: string): boolean { + return /styles\.(sheet|modalSheet)\b/.test(body); +} + +function padsBottom(body: string): boolean { + return /bottomInset|insets\.bottom/.test(body); +} + +describe('bottom sheets in a Modal', () => { + it('pad their content above the navigation bar or taskbar', () => { + const offenders = sourceFiles(SRC).flatMap((file) => { + const rel = path.relative(SRC, file).split(path.sep).join('/'); + if (OWNED_ELSEWHERE.has(rel)) return []; + const source = fs.readFileSync(file, 'utf8'); + return modalBodies(source) + .filter((body) => rendersSheet(body) && !padsBottom(body)) + .map(() => rel); + }); + expect(offenders).toEqual([]); + }); + + it('covers the sheets the tablet walkthrough found', () => { + for (const rel of ['components/settings/FolderSettings.tsx', 'screens/FilesScreen.tsx', 'components/files/ShareSheet.tsx']) { + const bodies = modalBodies(fs.readFileSync(path.join(SRC, rel), 'utf8')).filter(rendersSheet); + expect(bodies.length, rel).toBeGreaterThan(0); + } + }); + + it('finds the sheet in a Modal body', () => { + const src = '\n\n'; + const bodies = modalBodies(src); + expect(bodies).toHaveLength(1); + expect(rendersSheet(bodies[0])).toBe(true); + expect(padsBottom(bodies[0])).toBe(true); + expect(padsBottom('')).toBe(false); + }); +}); diff --git a/src/components/__tests__/tag-sheet-title.test.ts b/src/components/__tests__/tag-sheet-title.test.ts new file mode 100644 index 00000000..7d3ed63b --- /dev/null +++ b/src/components/__tests__/tag-sheet-title.test.ts @@ -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'); + }); +}); diff --git a/src/components/files/ShareSheet.tsx b/src/components/files/ShareSheet.tsx index 9eafa3dc..3fee704d 100644 --- a/src/components/files/ShareSheet.tsx +++ b/src/components/files/ShareSheet.tsx @@ -22,6 +22,7 @@ import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { useSheetInsets } from '../../lib/use-layout'; +import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; type RolePreset = 'read' | 'readWrite' | 'manager'; @@ -71,6 +72,7 @@ interface ShareSheetProps { export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) { // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); + const bottomInset = useSheetBottomInset(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -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 ( - {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} + {principalName(principalId)} {p?.email || unresolved ? ( @@ -189,7 +197,7 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps - + @@ -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) })} > diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 7968b597..ebc61f54 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -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({ @@ -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} /> @@ -411,6 +422,7 @@ export function FilterRuleModal({ } keyboardType={condition.field === 'size' ? 'numeric' : 'default'} autoCapitalize="none" + accessibilityLabel={t('settings.filters.condition_value', 'Condition value')} /> )} diff --git a/src/components/filters/SieveEditorSheet.tsx b/src/components/filters/SieveEditorSheet.tsx index 97337e49..db97d1c0 100644 --- a/src/components/filters/SieveEditorSheet.tsx +++ b/src/components/filters/SieveEditorSheet.tsx @@ -11,6 +11,7 @@ import Button from '../Button'; import { useLocaleStore } from '../../stores/locale-store'; import { SafeAreaModal } from '../SafeAreaModal'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset'; import { seedsOnRender } from '../../lib/sieve/editor-seed'; interface SieveEditorSheetProps { @@ -25,6 +26,8 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // The Android dialog doesn't shrink for the keyboard; the body ends above it. + const keyboardPadding = useModalKeyboardPadding(visible); const [script, setScript] = useState(content); const [isValidating, setIsValidating] = useState(false); @@ -85,7 +88,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate diff --git a/src/components/filters/folder-option-label.ts b/src/components/filters/folder-option-label.ts new file mode 100644 index 00000000..fb3f3c1a --- /dev/null +++ b/src/components/filters/folder-option-label.ts @@ -0,0 +1,27 @@ +import { localizeMailboxName } from '../../lib/mailbox-label'; +import type { MailboxTarget } from '../../lib/sieve/rule-actions'; +import type { Mailbox } from '../../api/types'; + +type Translate = (key: string, fallback?: string) => string; + +/** + * A filter rule's folder picker row: the folder's indent and its name as the + * rest of the app shows it. The targets carry the server name, so system + * folders read "Sent Items", "Junk Mail" or "Deleted Items" here and + * "Sent", "Spam" or "Trash" everywhere else. The rule itself keeps the + * server path (fileinto needs it); only the label changes. + */ +export function folderOptionLabel( + target: MailboxTarget, + mailbox: Pick | undefined, + t: Translate, +): string { + if (!mailbox?.role) return target.label; + const indent = /^ */.exec(target.label)?.[0] ?? ''; + return indent + localizeMailboxName(mailbox.role, mailbox.name, t); +} + +/** Mailboxes by the id the targets use (the server id of a shared folder). */ +export function mailboxesByTargetId(mailboxes: Mailbox[]): Map { + return new Map(mailboxes.map((m) => [m.originalId ?? m.id, m])); +} diff --git a/src/components/radio-a11y.ts b/src/components/radio-a11y.ts new file mode 100644 index 00000000..06a6b0b1 --- /dev/null +++ b/src/components/radio-a11y.ts @@ -0,0 +1,17 @@ +// One choice of a set of options (the undo-send delay, the Files sort order, +// the app language). Drawn as a styled Pressable, an option told a screen +// reader nothing about which one was picked: TalkBack read "5 seconds" for +// every chip alike. A radio role with the selected and checked state reads +// "selected" (Android) or "checked" (iOS) on the current one. + +export interface RadioOptionA11y { + accessibilityRole: 'radio'; + accessibilityState: { selected: boolean; checked: boolean }; +} + +export function radioOptionA11y(selected: boolean): RadioOptionA11y { + return { accessibilityRole: 'radio', accessibilityState: { selected, checked: selected } }; +} + +/** For the View around the options. */ +export const RADIO_GROUP_A11Y = { accessibilityRole: 'radiogroup' } as const; diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index 50d49c30..33471199 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -8,6 +8,7 @@ import { useColors } from '../../theme/colors'; import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing'; +import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y'; export function ComposingSettings() { const c = useColors(); @@ -82,12 +83,13 @@ export function ComposingSettings() { current: T, onPick: (value: T) => void, ) => ( - + {options.map((opt) => { const active = current === opt.value; return ( onPick(opt.value)} style={[styles.segment, active && styles.segmentActive]} > @@ -131,7 +133,10 @@ export function ComposingSettings() { updateSetting('plainTextMode', v)} /> diff --git a/src/components/settings/ContentSendersSettings.tsx b/src/components/settings/ContentSendersSettings.tsx index f8a14461..e33824c4 100644 --- a/src/components/settings/ContentSendersSettings.tsx +++ b/src/components/settings/ContentSendersSettings.tsx @@ -14,6 +14,7 @@ import { isTrustedSendersSyncOn } from '../../lib/trusted-senders'; import { removeTrustedSenderWithUndo } from '../../lib/settings-undo'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; import { ToastHost } from '../ToastHost'; interface TrustedRow { @@ -48,6 +49,8 @@ export function ContentSendersSettings() { const removeFromTrustedSendersBook = useContactsStore((s) => s.removeFromTrustedSendersBook); const [modalOpen, setModalOpen] = useState(false); + // The card centres above the keyboard while an address is typed in. + const bottomInset = useSheetBottomInset(modalOpen); const [newSender, setNewSender] = useState(''); const [busy, setBusy] = useState(null); @@ -197,7 +200,7 @@ export function ContentSendersSettings() { animationType="fade" onRequestClose={() => setModalOpen(false)} > - setModalOpen(false)}> + setModalOpen(false)}> {}}> {t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')} diff --git a/src/components/settings/DelegateDialog.tsx b/src/components/settings/DelegateDialog.tsx index 28bd89ff..76cf9553 100644 --- a/src/components/settings/DelegateDialog.tsx +++ b/src/components/settings/DelegateDialog.tsx @@ -19,6 +19,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset'; import { findPrincipalByEmail } from '../../api/delegation'; import type { Principal } from '../../api/types'; import type { PrincipalListing } from '../../api/files'; @@ -74,6 +75,8 @@ export function DelegateDialog({ const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // The Android dialog doesn't shrink for the keyboard; the body ends above it. + const keyboardPadding = useModalKeyboardPadding(); const changing = !!initial; const [principal, setPrincipal] = useState(initial?.principal ?? null); @@ -154,7 +157,7 @@ export function DelegateDialog({ return ( - + {changing && principal diff --git a/src/components/settings/DelegateSettings.tsx b/src/components/settings/DelegateSettings.tsx index c349b279..e13a88d0 100644 --- a/src/components/settings/DelegateSettings.tsx +++ b/src/components/settings/DelegateSettings.tsx @@ -241,10 +241,24 @@ export function DelegateSettings() { - - diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx index 628b85b6..f4878192 100644 --- a/src/components/settings/FolderSettings.tsx +++ b/src/components/settings/FolderSettings.tsx @@ -27,6 +27,7 @@ import { jmapClient } from '../../api/jmap-client'; import type { Mailbox } from '../../api/types'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { useSheetInsets } from '../../lib/use-layout'; +import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; import Badge from '../Badge'; const ROLE_ICON: Record = { @@ -76,6 +77,9 @@ export function FolderSettings() { const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const [editor, setEditor] = useState(null); + // Save and Cancel above the taskbar, and above the keyboard while the + // name is typed in. + const bottomInset = useSheetBottomInset(!!editor); const [draftName, setDraftName] = useState(''); const [draftParent, setDraftParent] = useState(NO_PARENT); const [draftAccount, setDraftAccount] = useState(OWN_ACCOUNT); @@ -300,7 +304,7 @@ export function FolderSettings() { - + {editor?.kind === 'edit' @@ -348,7 +352,8 @@ export function FolderSettings() { {editor?.kind === 'edit' ? t('settings.folders.move_under', 'Move under') - : t('settings.folders.subfolder_of', 'Parent folder')} + // Not subfolder_of: that is the webmail's "Inside {name}" hint. + : t('settings.folders.parent_folder', 'Parent folder')}