diff --git a/src/components/__tests__/filter-folder-labels.test.ts b/src/components/__tests__/filter-folder-labels.test.ts new file mode 100644 index 00000000..db578637 --- /dev/null +++ b/src/components/__tests__/filter-folder-labels.test.ts @@ -0,0 +1,55 @@ +// The filter rule's "Move to folder" picker listed the server's folder names +// ("Sent Items", "Junk Mail", "Deleted Items") where the rest of the app +// shows the localized role names (localizeMailboxName). +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('expo-localization', () => ({ getLocales: () => [] })); + +import { translate } from '../../i18n'; +import { buildMailboxTargets } from '../../lib/sieve/rule-actions'; +import { folderOptionLabel, mailboxesByTargetId } from '../filters/folder-option-label'; +import type { Mailbox } from '../../api/types'; + +const mb = (id: string, name: string, role: string | null, parentId: string | null = null): Mailbox => ({ + id, name, role, parentId, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: {} as Mailbox['myRights'], isSubscribed: true, +} as unknown as Mailbox); + +const mailboxes = [ + mb('i', 'Inbox', 'inbox'), + mb('s', 'Sent Items', 'sent'), + mb('j', 'Junk Mail', 'junk'), + mb('d', 'Deleted Items', 'trash'), + mb('p', 'Projects', null), + mb('a', 'Alpha', null, 'p'), +]; + +function labels(locale: 'en' | 'de') { + const t = (key: string, fallback?: string) => translate(locale, key, fallback); + const byId = mailboxesByTargetId(mailboxes); + return Object.fromEntries(buildMailboxTargets(mailboxes).map((target) => [target.id, folderOptionLabel(target, byId.get(target.id), t)])); +} + +describe('filter rule folder picker labels', () => { + it('names system folders by role, like the sidebar', () => { + const en = labels('en'); + expect(en.s).toBe(translate('en', 'sidebar.mailboxes.sent')); + expect(en.j).toBe(translate('en', 'sidebar.mailboxes.spam')); + expect(en.d).toBe(translate('en', 'sidebar.mailboxes.trash')); + expect(en.s).not.toBe('Sent Items'); + expect(labels('de').d).toBe(translate('de', 'sidebar.mailboxes.trash')); + }); + + it('keeps user folders and their indent', () => { + const en = labels('en'); + expect(en.p).toBe('Projects'); + expect(en.a).toBe(' Alpha'); + }); + + it('is what the rule editor lists', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../filters/FilterRuleModal.tsx'), 'utf8'); + expect(source).toContain('label: folderOptionLabel(target, mailboxById.get(target.id), t)'); + }); +}); diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 84cdfadb..ebc61f54 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -45,6 +45,7 @@ import { SafeAreaModal } from '../SafeAreaModal'; import { useFilterStore } from '../../stores/filter-store'; import { forwardsOfEditedRule, ruleForwardLimit } from '../../lib/sieve/forward-limit'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { folderOptionLabel, mailboxesByTargetId } from './folder-option-label'; import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset'; const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all']; @@ -131,6 +132,7 @@ export function FilterRuleModal({ const periodProblem = periodOn ? periodError(activeFrom, activeUntil) : null; const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]); + const mailboxById = useMemo(() => mailboxesByTargetId(mailboxes), [mailboxes]); // A message can collect only so many forwards on the server (#1155, #1208). const listedRules = useFilterStore((s) => s.rules); @@ -216,7 +218,10 @@ export function FilterRuleModal({ // listed (deleted, or its account's folders not loaded) stays selectable // under its path, so saving doesn't silently retarget the rule. const folderOptions = (action: FilterAction) => { - const options = mailboxTargets.map((target) => ({ value: target.id, label: target.label })); + const options = mailboxTargets.map((target) => ({ + value: target.id, + label: folderOptionLabel(target, mailboxById.get(target.id), t), + })); const selected = mailboxIdFor(action, mailboxTargets); if (!options.some((o) => o.value === selected) && (selected || action.value)) { options.unshift({ value: selected, label: action.value || selected }); diff --git a/src/components/filters/folder-option-label.ts b/src/components/filters/folder-option-label.ts new file mode 100644 index 00000000..fb3f3c1a --- /dev/null +++ b/src/components/filters/folder-option-label.ts @@ -0,0 +1,27 @@ +import { localizeMailboxName } from '../../lib/mailbox-label'; +import type { MailboxTarget } from '../../lib/sieve/rule-actions'; +import type { Mailbox } from '../../api/types'; + +type Translate = (key: string, fallback?: string) => string; + +/** + * A filter rule's folder picker row: the folder's indent and its name as the + * rest of the app shows it. The targets carry the server name, so system + * folders read "Sent Items", "Junk Mail" or "Deleted Items" here and + * "Sent", "Spam" or "Trash" everywhere else. The rule itself keeps the + * server path (fileinto needs it); only the label changes. + */ +export function folderOptionLabel( + target: MailboxTarget, + mailbox: Pick | undefined, + t: Translate, +): string { + if (!mailbox?.role) return target.label; + const indent = /^ */.exec(target.label)?.[0] ?? ''; + return indent + localizeMailboxName(mailbox.role, mailbox.name, t); +} + +/** Mailboxes by the id the targets use (the server id of a shared folder). */ +export function mailboxesByTargetId(mailboxes: Mailbox[]): Map { + return new Map(mailboxes.map((m) => [m.originalId ?? m.id, m])); +}