diff --git a/docs/parity/07-filters-vacation-files.md b/docs/parity/07-filters-vacation-files.md index 9ff8f090..24eb989c 100644 --- a/docs/parity/07-filters-vacation-files.md +++ b/docs/parity/07-filters-vacation-files.md @@ -7,17 +7,17 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a ### Filters (Sieve) -- [ ] **RN generator corrupts WEB-authored attachment rules on any save** — `P1` — `bug` (missing feature with data-loss side effect) +- [x] **RN generator corrupts WEB-authored attachment rules on any save** — fixed in 1c6aa68 — `P1` — `bug` (missing feature with data-loss side effect) - What WEB does: `attachment` field with `has_any`/`has_type` comparators emits `header :mime :anychild ...` and adds `mime` to `require` (`lib/sieve/generator.ts:48-67`, `:132`; types `lib/jmap/sieve-types.ts:16-30`; changelog 1.7.3 "Extended filter rules — attachment field and multi-value conditions"). - What RN does: `src/lib/sieve/types.ts:21-26` has no `attachment` field. A single-value attachment rule from WEB passes RN's metadata validation (`src/lib/sieve/parser.ts:40-44` only requires strings) and is listed as an editable Bulwark rule. RN's generator has no attachment branch: `src/lib/sieve/generator.ts:36-62` falls through to `HEADER_MAP['attachment']` → `undefined`, emitting `header :contains "undefined" ""` and never adding `mime` to requires. Every RN save regenerates the whole script (`src/stores/filter-store.ts:102-118`: toggle, reorder, add, delete), so one toggle on the phone silently breaks the user's attachment rules server-side. WEB then re-reads that broken condition as a generic header rule. - Fix hint: port the WEB generator/parser/types diff (see next item) - `git diff` between `lib/sieve/generator.ts` and `src/lib/sieve/generator.ts` is ~60 lines. Until then, at minimum make `generateCondition` throw / keep `rawBlock` for unknown fields instead of emitting `"undefined"`. -- [ ] **Multi-value conditions (string[] values) unsupported → WEB scripts become opaque on RN** — `P1` — `missing` +- [x] **Multi-value conditions (string[] values) unsupported → WEB scripts become opaque on RN** — fixed in 1c6aa68 — `P1` — `missing` - What WEB does: `FilterCondition.value: string | string[]` (`lib/jmap/sieve-types.ts:40-49`); generator emits Sieve string-lists via `toValueList`/`formatStringArg` (`lib/sieve/generator.ts:15-32`, `57-90`); parser accepts list literals with `parseValueTail`/`classifyMatches` (`lib/sieve/parser.ts:344-400`, `455-484`) and validates array values (`parser.ts:37-44`); modal splits comma input on blur/save (`components/filters/filter-rule-modal.tsx:59-78`, `131-147`, `353-381`); summaries join with the locale's "or" (`components/settings/filter-settings.tsx:46-51`, `114-125`). - What RN does: `src/lib/sieve/types.ts:38` `value: string`; `src/lib/sieve/parser.ts:40-44` rejects array values so `parseScript` returns `OPAQUE` (`parser.ts:681`). Result: after a WEB user types "a, b" in one condition, RN shows the "edited outside the visual builder" banner with only raw editing and "Reset to visual builder" (which regenerates an empty script → all rules lost, `src/stores/filter-store.ts:188` + `saveFilters`). If a list value somehow reached the RN modal, `cond.value.trim()` at `src/components/filters/FilterRuleModal.tsx:159` would throw and `` (`:271`) would receive an array. - Fix hint: apply the WEB diff to `types.ts`, `parser.ts` (`isValidCondition`, `parseValueTail`, `classifyMatches`, body regex), `generator.ts` (`toValueList`, `formatStringArg`, size scalar), then add comma-split/join helpers to `FilterRuleModal`, array-aware `summarizeRule`, and locale keys `value_placeholder_multi`, `attachment_type_placeholder`, `condition_fields.attachment`, `comparators.has_any/has_type` (present in WEB `locales/en/common.json`, absent in RN `locales/en/common.json` settings.filters). -- [ ] **FilterRuleModal never re-initialises: editing rule B shows/saves rule A's fields** — `P1` — `rn-only-bug` +- [x] **FilterRuleModal never re-initialises: editing rule B shows/saves rule A's fields** — fixed in 1c6aa68 — `P1` — `rn-only-bug` - What WEB does: `FilterRuleModal` is conditionally mounted (`components/settings/filter-settings.tsx:697-707`), so `useState(rule?.name)` etc. run fresh per open. - What RN does: `src/components/settings/FilterSettings.tsx:359-365` always renders ``; the modal seeds `name/matchType/conditions/actions/stopProcessing` only in `useState` initialisers (`src/components/filters/FilterRuleModal.tsx:74-82`) and has no reset effect. Opening "Add Rule" after editing a rule shows the previous rule's content; opening rule B after rule A shows A's fields but saves them under B's `id` (`:170`), overwriting B. Compare `SieveEditorSheet.tsx:33-39`, which does re-seed on `visible`. - Fix hint: either mount the modal conditionally (`{showRuleModal && }`) or add a `useEffect([visible, rule])` that resets all local state like `SieveEditorSheet` does. @@ -37,7 +37,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a - What RN does: `src/api/sieve.ts:20-23` always uses the primary Sieve account; store has no account selection (`src/stores/filter-store.ts:32`). No `managedAccount` concept exists in RN (`grep -ri managedAccount src` → none). - Fix hint: only if the Accounts area adds shared-account management: thread an optional `accountId` through `api/sieve.ts` and the store's `fetchFilters/saveFilters`. -- [ ] **Sieve test files have mojibake in test names** — `P3` — `rn-only-bug` +- [x] **Sieve test files have mojibake in test names** — fixed in 1c6aa68 — `P3` — `rn-only-bug` - What WEB does: `lib/sieve/__tests__/*.test.ts` use "→" in `it(...)` names. - What RN does: `src/lib/sieve/__tests__/{parser,generator,external-rules}.test.ts` start with a UTF-8 BOM and contain "→" (double-encoded arrow) at e.g. `external-rules.test.ts:257`, `:343`, `generator.test.ts:369`, `parser.test.ts:104`. Cosmetic, but it shows the files were copied through a wrong encoding; re-copy from WEB when porting the 1.7.3 changes and add tests for list values / `:mime`. - Fix hint: rewrite the three files as UTF-8 without BOM from the WEB copies. diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index f9c2c92f..c5d3d085 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native'; import { Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock, - ChevronUp, ChevronDown, Palmtree, + ChevronUp, ChevronDown, Palmtree, ChevronRight, } from 'lucide-react-native'; import { SettingsSection, ToggleSwitch } from './settings-section'; import Button from '../Button'; @@ -17,13 +17,86 @@ import { useLocaleStore } from '../../stores/locale-store'; import { FilterRuleModal } from '../filters/FilterRuleModal'; import { SieveEditorSheet } from '../filters/SieveEditorSheet'; import type { FilterRule } from '../../lib/sieve/types'; -import { summarizeRule } from '../../lib/sieve/condition-value'; +import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value'; + +type Translate = (key: string, fallback?: string) => string; function isReadonlyRule(r: FilterRule): boolean { return r.origin === 'external' || r.origin === 'opaque'; } -export function FilterSettings() { +// Expanded "IF ... / THEN ..." view (webmail VisualRuleSummary, 1.4.6): every +// condition and action as a chip, with the match-type hint after the IF row. +function VisualRuleSummary({ rule, t, c }: { rule: FilterRule; t: Translate; c: ThemePalette }) { + const styles = useMemo(() => makeSummaryStyles(c), [c]); + const joiner = rule.matchType === 'all' ? t('settings.filters.and', 'and') : t('settings.filters.or', 'or'); + const matchLabel = rule.matchType === 'all' + ? t('settings.filters.match_all_conditions', 'all match') + : t('settings.filters.match_any_condition', 'any matches'); + return ( + + + {t('settings.filters.if', 'If')} + {rule.conditions.map((cond, i) => { + const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field); + const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator); + const value = formatConditionValue(cond, t); + return ( + + {i > 0 && {joiner}} + + + {field} + {` ${comparator}`} + {value ? {` ${value}`} : null} + + + + ); + })} + ({matchLabel}) + + + {t('settings.filters.then', 'Then')} + {rule.actions.map((a, i) => { + const action = t(`settings.filters.action_types.${a.type}`, a.type); + return ( + + {i > 0 && ›} + + + {action} + {a.value ? {` "${a.value}"`} : null} + + + + ); + })} + + + ); +} + +function makeSummaryStyles(c: ThemePalette) { + return StyleSheet.create({ + wrap: { gap: 4, marginTop: 6 }, + row: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 4 }, + keyword: { fontSize: 10, fontWeight: '600', letterSpacing: 0.8, textTransform: 'uppercase' }, + joiner: { fontSize: 10, fontStyle: 'italic', color: c.mutedForeground }, + chip: { paddingHorizontal: 6, paddingVertical: 1, borderRadius: radius.xs, backgroundColor: c.muted }, + chipText: { ...typography.caption, color: c.text }, + chipKey: { fontWeight: '500' }, + chipMuted: { color: c.mutedForeground }, + chipValue: { color: c.text }, + }); +} + +interface FilterSettingsProps { + /** Opens the Vacation Responder tab; makes the "active" banner actionable. */ + onOpenVacation?: () => void; +} + +export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -213,7 +286,12 @@ export function FilterSettings() { )} {!isOpaque && showVacationBanner && ( - + [styles.vacationBanner, pressed && onOpenVacation && { opacity: 0.8 }]} + > @@ -221,7 +299,13 @@ export function FilterSettings() { {t('settings.filters.vacation_active', 'Vacation Responder is active')} {t('settings.filters.vacation_active_description', 'Auto-reply is enabled for incoming messages')} - + {onOpenVacation && ( + + {t('settings.filters.vacation_configure', 'Configure')} + + + )} + )} {!isOpaque && rules.length === 0 && !showVacationBanner && ( @@ -249,9 +333,13 @@ export function FilterSettings() { {label} {hasStructured ? ( - - {summarizeRule(rule, t)} - + expandedView ? ( + + ) : ( + + {summarizeRule(rule, t)} + + ) ) : rule.rawBlock ? ( {rule.rawBlock.trim()} @@ -273,9 +361,13 @@ export function FilterSettings() { onPress={() => { setEditingRule(rule); setShowRuleModal(true); }} > {rule.name} - - {summarizeRule(rule, t)} - + {expandedView ? ( + + ) : ( + + {summarizeRule(rule, t)} + + )} @@ -399,6 +491,8 @@ function makeStyles(c: ThemePalette) { }, vacationTitle: { ...typography.bodyMedium, color: c.success }, vacationDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, + vacationConfigure: { flexDirection: 'row', alignItems: 'center', gap: 2 }, + vacationConfigureText: { ...typography.caption, color: c.success, fontWeight: '500' }, emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md }, emptyText: { ...typography.body, color: c.mutedForeground }, diff --git a/src/lib/capabilities.ts b/src/lib/capabilities.ts index 6c9b103c..9c322a68 100644 --- a/src/lib/capabilities.ts +++ b/src/lib/capabilities.ts @@ -33,3 +33,15 @@ export function useHasContacts(): boolean { export function useHasFiles(): boolean { return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true)); } + +// Settings tabs for Sieve filters and the vacation responder are gated on the +// same session capabilities api/sieve.ts and api/vacation.ts check, so the +// tab is disabled up front instead of opening onto a "not supported" screen. +export function useHasSieve(): boolean { + return useAuthStore((s) => (s.session ? CAPABILITIES.SIEVE in s.session.capabilities : true)); +} + +export function useHasVacation(): boolean { + return useAuthStore((s) => (s.session ? CAPABILITIES.VACATION in s.session.capabilities : true)); +} + diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index c37be7e4..8fdae6c7 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -37,7 +37,7 @@ import { ComposingSettings } from '../components/settings/ComposingSettings'; import { LayoutSettings } from '../components/settings/LayoutSettings'; import { DownloadsSettings } from '../components/settings/DownloadsSettings'; import { useLocaleStore } from '../stores/locale-store'; -import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities'; +import { useHasCalendar, useHasContacts, useHasFiles, useHasSieve, useHasVacation } from '../lib/capabilities'; import { supportsSideloadUpdates } from '../lib/platform-capabilities'; type Tab = @@ -170,13 +170,17 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin const hasCalendar = useHasCalendar(); const hasContacts = useHasContacts(); const hasFiles = useHasFiles(); + const hasSieve = useHasSieve(); + const hasVacation = useHasVacation(); const unavailableTabs = React.useMemo(() => { const set = new Set(); if (!hasCalendar) set.add('calendar'); if (!hasContacts) set.add('contacts'); if (!hasFiles) set.add('files'); + if (!hasSieve) set.add('filters'); + if (!hasVacation) set.add('vacation'); return set; - }, [hasCalendar, hasContacts, hasFiles]); + }, [hasCalendar, hasContacts, hasFiles, hasSieve, hasVacation]); const groupedTabs = React.useMemo(() => { void locale; // dependency: re-translate on locale change return groupTabs().map((g) => ({ @@ -225,7 +229,11 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin - {Component ? : null} + {selectedTab === 'filters' ? ( + setSelectedTab('vacation') : undefined} + /> + ) : Component ? : null} );