From 9d63a66d2a4350a64dd6747a788fa805a28596fa Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 15:58:57 -0700 Subject: [PATCH 1/2] Spam filter strength as a level: Relaxed, Balanced or Strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spam filter › General opens with how hard the filter is, instead of three score thresholds whose meaning isn't on the page: - Relaxed: Junk from a score of 8; nothing refused. - Balanced (the server's stock setting): Junk from 5; nothing refused. - Strict: Junk from 4, and refused at delivery from 12, so the sender of a misjudged message gets a bounce. Under the levels, what happens by score in words. No level discards: discarded mail is deleted and nobody is told. A discard threshold set by hand is flagged, and picking a level turns it off. Beside it: the filter's on/off, trusting replies and address-book senders, and Pyzor (noting that a fingerprint of each message goes to public.pyzor.org), with links to Local AI, blocklists, scores and trusted domains. Thresholds set by hand show as such. The form below reloads after a save. --- src/components/layout/MainContent.tsx | 5 + src/features/spamLevel/SpamLevels.tsx | 274 ++++++++++++++++++++ src/features/spamLevel/SpamSettingsPage.tsx | 20 ++ src/features/spamLevel/levels.test.ts | 31 +++ src/features/spamLevel/levels.ts | 57 ++++ 5 files changed, 387 insertions(+) create mode 100644 src/features/spamLevel/SpamLevels.tsx create mode 100644 src/features/spamLevel/SpamSettingsPage.tsx create mode 100644 src/features/spamLevel/levels.test.ts create mode 100644 src/features/spamLevel/levels.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index ba1a3e7..02243f2 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; +import { SpamSettingsPage } from '@/features/spamLevel/SpamSettingsPage'; import { WebhooksPage } from '@/features/events/WebhooksPage'; import { ListenersPage } from '@/features/ports/ListenersPage'; import { MetricsPage } from '@/features/metricsConnect/MetricsPage'; @@ -276,6 +277,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (resolved.objectName === 'x:Metrics') { return ; } + // inbuxa: Spam filter › General opens with how hard the filter is (settings-reorg). + if (resolved.objectName === 'x:SpamSettings') { + return ; + } if (resolved.objectName === 'x:Security') { return (
diff --git a/src/features/spamLevel/SpamLevels.tsx b/src/features/spamLevel/SpamLevels.tsx new file mode 100644 index 0000000..e03462f --- /dev/null +++ b/src/features/spamLevel/SpamLevels.tsx @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: at the top of Spam filter › General, how hard the filter is as + * one choice, and the few switches that matter (settings-reorg, first + * wave). The form below stays for anything finer. + */ + +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ShieldAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import { useAccountStore } from '@/stores/accountStore'; +import { useSchemaStore } from '@/stores/schemaStore'; +import type { JmapSetResponse } from '@/types/jmap'; +import { toast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { LEVEL_THRESHOLDS, LEVELS, levelOf, outcomes, type Level, type Thresholds } from './levels'; + +interface State { + thresholds: Thresholds; + enable: boolean; + trustContacts: boolean; + trustReplies: boolean; + pyzor: boolean | null; +} + +async function load(): Promise { + const res = await jmapRequest([ + [ + 'x:SpamSettings/get', + { + accountId: getAccountId('x:SpamSettings'), + ids: ['singleton'], + properties: ['scoreSpam', 'scoreReject', 'scoreDiscard', 'enable', 'trustContacts', 'trustReplies'], + }, + 's', + ], + ['x:SpamPyzor/get', { accountId: getAccountId('x:SpamPyzor'), ids: ['singleton'], properties: ['enable'] }, 'p'], + ]); + const one = (id: string) => + ((res.find(([name, , i]) => i === id && name !== 'error')?.[1] as { list?: Record[] } | undefined) + ?.list ?? [])[0]; + const s = one('s') ?? {}; + const p = one('p'); + return { + thresholds: { + scoreSpam: Number(s.scoreSpam ?? 5), + scoreReject: Number(s.scoreReject ?? 0), + scoreDiscard: Number(s.scoreDiscard ?? 0), + }, + enable: s.enable !== false, + trustContacts: s.trustContacts !== false, + trustReplies: s.trustReplies !== false, + pyzor: p ? p.enable !== false : null, + }; +} + +export function SpamLevels({ onApplied }: { onApplied?: () => void }) { + const { t } = useTranslation(); + const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysSpamSettings', 'Update')); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const [saved, setSaved] = useState(null); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let live = true; + load() + .then((s) => { + if (!live) return; + setSaved(s); + setDraft(s); + }) + .catch(() => undefined); + return () => { + live = false; + }; + }, []); + + if (!saved || !draft) return null; + + const current = levelOf(saved.thresholds); + const picked = levelOf(draft.thresholds); + const changed = + JSON.stringify(draft.thresholds) !== JSON.stringify(saved.thresholds) || + draft.enable !== saved.enable || + draft.trustContacts !== saved.trustContacts || + draft.trustReplies !== saved.trustReplies || + draft.pyzor !== saved.pyzor; + + const label: Record = { + relaxed: t('spamLevels.relaxed', 'Relaxed'), + balanced: t('spamLevels.balanced', 'Balanced'), + strict: t('spamLevels.strict', 'Strict'), + }; + const hint: Record = { + relaxed: t( + 'spamLevels.relaxedHint', + 'Only clear spam goes to Junk. Some spam reaches the inbox; almost nothing real is misfiled.', + ), + balanced: t('spamLevels.balancedHint', 'The server’s stock setting. Likely spam goes to Junk; nothing is refused.'), + strict: t( + 'spamLevels.strictHint', + 'More goes to Junk, and blatant spam is refused outright. The sender of a misjudged message gets a bounce.', + ), + }; + const link = (view: string, text: string) => ( + + {text} + + ); + + const save = async () => { + setBusy(true); + try { + const calls: [string, Record, string][] = []; + const patch: Record = {}; + if (JSON.stringify(draft.thresholds) !== JSON.stringify(saved.thresholds)) Object.assign(patch, draft.thresholds); + if (draft.enable !== saved.enable) patch.enable = draft.enable; + if (draft.trustContacts !== saved.trustContacts) patch.trustContacts = draft.trustContacts; + if (draft.trustReplies !== saved.trustReplies) patch.trustReplies = draft.trustReplies; + if (Object.keys(patch).length > 0) { + calls.push([ + 'x:SpamSettings/set', + { accountId: getAccountId('x:SpamSettings'), update: { singleton: patch } }, + 's', + ]); + } + if (draft.pyzor !== saved.pyzor && draft.pyzor !== null) { + calls.push([ + 'x:SpamPyzor/set', + { accountId: getAccountId('x:SpamPyzor'), update: { singleton: { enable: draft.pyzor } } }, + 'p', + ]); + } + const responses = await jmapRequest(calls); + const failed = responses + .map(([, body]) => (body as unknown as JmapSetResponse).notUpdated?.singleton) + .filter(Boolean) + .map((e) => e!.description ?? e!.type); + if (failed.length > 0) throw new Error(failed.join('; ')); + const fresh = await load(); + setSaved(fresh); + setDraft(fresh); + onApplied?.(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('spamLevels.failed', 'Not saved'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + const toggle = (key: 'enable' | 'trustContacts' | 'trustReplies' | 'pyzor', text: string, sub?: string) => ( + + ); + + return ( +
+
+ +

{t('spamLevels.title', 'How hard the spam filter is')}

+ {current === null && ( + + {t('spamLevels.byHand', 'Thresholds set by hand below')} + + )} + {changed && ( + + )} +
+ + {toggle('enable', t('spamLevels.enable', 'Filter incoming mail for spam'))} + + {draft.enable && ( + <> +
+ {LEVELS.map((l) => ( + + ))} +
+
    + {outcomes(draft.thresholds).map((line) => ( +
  • {line}
  • + ))} +
+ {draft.thresholds.scoreDiscard > 0 && ( +

+ {t( + 'spamLevels.discardWarning', + 'A discard threshold is set by hand. Picking a level turns it off: deleted mail can’t be found again, and nobody is told.', + )} +

+ )} + +
+ {toggle( + 'trustReplies', + t('spamLevels.trustReplies', 'Never treat replies to people’s own messages as spam'), + )} + {toggle( + 'trustContacts', + t('spamLevels.trustContacts', 'Never treat mail from people in their address book as spam'), + )} + {draft.pyzor !== null && + toggle( + 'pyzor', + t('spamLevels.pyzor', 'Check messages against Pyzor’s shared spam list'), + t( + 'spamLevels.pyzorHint', + 'Sends a fingerprint of each message, not the message itself, to public.pyzor.org.', + ), + )} +
+ +

+ {t('spamLevels.more', 'More:')} {link('CustomComponent/LocalAi', t('spamLevels.localAi', 'Local AI'))} + {' · '} + {link('x:SpamDnsblServer', t('spamLevels.blocklists', 'Blocklists'))} + {' · '} + {link('x:SpamTag', t('spamLevels.scores', 'Scores'))} + {' · '} + {link('x:MemoryLookupKey/SpamTrustedDomain', t('spamLevels.trusted', 'Trusted domains'))} +

+ + )} +
+ ); +} diff --git a/src/features/spamLevel/SpamSettingsPage.tsx b/src/features/spamLevel/SpamSettingsPage.tsx new file mode 100644 index 0000000..321d9cf --- /dev/null +++ b/src/features/spamLevel/SpamSettingsPage.tsx @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useState } from 'react'; +import { DynamicForm } from '@/components/forms/DynamicForm'; +import { SpamLevels } from './SpamLevels'; + +/** Spam filter › General: the level and switches, then the full form, reloaded after a save. */ +export function SpamSettingsPage({ viewName }: { viewName: string }) { + const [version, setVersion] = useState(0); + return ( +
+ setVersion((v) => v + 1)} /> + +
+ ); +} diff --git a/src/features/spamLevel/levels.test.ts b/src/features/spamLevel/levels.test.ts new file mode 100644 index 0000000..3d3573e --- /dev/null +++ b/src/features/spamLevel/levels.test.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { levelOf, LEVEL_THRESHOLDS, outcomes } from './levels'; + +describe('spam levels', () => { + it('Balanced is the stock setting', () => { + expect(levelOf({ scoreSpam: 5, scoreReject: 0, scoreDiscard: 0 })).toBe('balanced'); + }); + + it('recognise each level, and hand-set values as none', () => { + for (const l of ['relaxed', 'balanced', 'strict'] as const) expect(levelOf(LEVEL_THRESHOLDS[l])).toBe(l); + expect(levelOf({ scoreSpam: 6, scoreReject: 0, scoreDiscard: 0 })).toBeNull(); + expect(levelOf({ scoreSpam: 5, scoreReject: 0, scoreDiscard: 20 })).toBeNull(); + }); + + it('never discard, and say what happens in words', () => { + for (const l of ['relaxed', 'balanced', 'strict'] as const) expect(LEVEL_THRESHOLDS[l].scoreDiscard).toBe(0); + expect(outcomes(LEVEL_THRESHOLDS.strict)).toEqual([ + 'Scoring 4 or more: filed in Junk.', + '12 or more: refused, and the sender gets a bounce.', + ]); + expect(outcomes(LEVEL_THRESHOLDS.balanced)[1]).toBe( + 'Nothing is refused or deleted: people can always look in Junk.', + ); + }); +}); diff --git a/src/features/spamLevel/levels.ts b/src/features/spamLevel/levels.ts new file mode 100644 index 0000000..6bb8b9c --- /dev/null +++ b/src/features/spamLevel/levels.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: how hard the spam filter is, as three levels (settings-reorg, + * first wave). What the thresholds do (spam-filter/src/analysis/score.rs): + * + * - scoreSpam: a message scoring at or above it goes to Junk; + * - scoreReject: at or above it, the message is refused during delivery, so + * the sender gets a bounce and a mistake is visible to them; + * - scoreDiscard: at or above it, the message is accepted and silently + * deleted. Nobody learns of a mistake, so no level uses it. + * + * A reject or discard threshold of 0 is off. Balanced is the server's stock + * setting. + */ + +export type Level = 'relaxed' | 'balanced' | 'strict'; +export const LEVELS: Level[] = ['relaxed', 'balanced', 'strict']; + +export interface Thresholds { + scoreSpam: number; + scoreReject: number; + scoreDiscard: number; +} + +export const LEVEL_THRESHOLDS: Record = { + relaxed: { scoreSpam: 8, scoreReject: 0, scoreDiscard: 0 }, + balanced: { scoreSpam: 5, scoreReject: 0, scoreDiscard: 0 }, + strict: { scoreSpam: 4, scoreReject: 12, scoreDiscard: 0 }, +}; + +export function levelOf(t: Partial): Level | null { + return ( + LEVELS.find((l) => { + const want = LEVEL_THRESHOLDS[l]; + return ( + Number(t.scoreSpam) === want.scoreSpam && + Number(t.scoreReject ?? 0) === want.scoreReject && + Number(t.scoreDiscard ?? 0) === want.scoreDiscard + ); + }) ?? null + ); +} + +/** What happens to a message by score, in words: "5 and up: Junk. 12 and up: refused." */ +export function outcomes(t: Thresholds): string[] { + const lines = [`Scoring ${t.scoreSpam} or more: filed in Junk.`]; + if (t.scoreReject > 0) lines.push(`${t.scoreReject} or more: refused, and the sender gets a bounce.`); + if (t.scoreDiscard > 0) lines.push(`${t.scoreDiscard} or more: accepted and deleted without telling anyone.`); + if (t.scoreReject <= 0 && t.scoreDiscard <= 0) + lines.push('Nothing is refused or deleted: people can always look in Junk.'); + return lines; +} -- 2.54.0 From df2d8209e193e814baf8148aae61be41aa1a0872 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 16:01:09 -0700 Subject: [PATCH 2/2] Page heading first, then the summary card, on every page that has one The level and sentence cards on Spam filter, Sender checks, Retention, Reports and Metrics sat above the page's own heading, and on the last three the heading was inside the fold, so those pages had no title at the top. The form now takes an intro, drawn right under its heading, and can fold its fields and Save under a label. All five pages use it: heading, card, then the settings (folded where the card does the everyday work). --- src/components/forms/DynamicForm.tsx | 275 ++++++++++-------- src/features/metricsConnect/MetricsPage.tsx | 23 +- src/features/reports/ReportsPage.tsx | 27 +- src/features/retention/RetentionPage.tsx | 27 +- .../senderChecks/SenderChecksPage.tsx | 12 +- src/features/spamLevel/SpamSettingsPage.tsx | 12 +- 6 files changed, 187 insertions(+), 189 deletions(-) diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index a27233f..5e453fa 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -13,7 +13,7 @@ import { HelpPanel } from '@/help/HelpPanel'; import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras'; import { FailedRecipients } from '@/features/ai/explain/FailedRecipients'; import { iconForView } from '@/lib/viewIcon'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'; import { flushSync } from 'react-dom'; import { useNavigate, useBlocker } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -40,7 +40,8 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ArrowLeft, Save, Trash2, Loader2 } from 'lucide-react'; +import { ArrowLeft, ChevronDown, Save, Trash2, Loader2 } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { AccountLockButton } from '@/features/lock/AccountLockButton'; import { useSchemaStore } from '@/stores/schemaStore'; @@ -73,9 +74,13 @@ import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap interface DynamicFormProps { viewName: string; objectId: string | null; + /** inbuxa: a summary drawn right under the page heading, above the fields (settings-reorg). */ + intro?: ReactNode; + /** inbuxa: fold the fields and Save under this label, for pages whose intro does the everyday work. */ + foldSections?: string; } -export function DynamicForm({ viewName, objectId }: DynamicFormProps) { +export function DynamicForm({ viewName, objectId, intro, foldSections }: DynamicFormProps) { const { t } = useTranslation(); const navigate = useNavigate(); const schema = useSchemaStore((s) => s.schema); @@ -790,6 +795,8 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { actions={} /> + {intro} + {generalError && (

{generalError}

@@ -816,136 +823,138 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { )} - {sectionsToRender.map((section, sectionIdx) => ( - - {section.title && ( - - {section.title} - - )} - -
- {resolved.obj.objectName === 'x:Domain' && - objectId && - !readOnly && - section.fields.some((sf) => sf.formField.name === 'dnsManagement') && ( - - )} - {section.fields.map((sf) => { - const { formField, field, visible, enterpriseDisabled } = sf; - if (!visible) return null; + + {sectionsToRender.map((section, sectionIdx) => ( + + {section.title && ( + + {section.title} + + )} + +
+ {resolved.obj.objectName === 'x:Domain' && + objectId && + !readOnly && + section.fields.some((sf) => sf.formField.name === 'dnsManagement') && ( + + )} + {section.fields.map((sf) => { + const { formField, field, visible, enterpriseDisabled } = sf; + if (!visible) return null; - if (formField.name === '@type' && sch.type === 'multiple') { - return ( -
- - -
+ if (formField.name === '@type' && sch.type === 'multiple') { + return ( +
+ + +
+ ); + } + + const fieldValue = formData[formField.name]; + const fieldError = fieldErrors[formField.name]; + const fieldReadOnly = + readOnly || + (field.update === 'immutable' && !isCreate) || + field.update === 'serverSet' || + enterpriseDisabled; + + const widget = ( + handleFieldChange(formField.name, v)} + readOnly={fieldReadOnly} + error={fieldError} + schema={schema} + sieveScriptName={ + isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined + } + helpScope={helpScope} + explainTarget={ + isCreate + ? undefined + : { object: resolved.obj.objectName, id: isSingleton ? 'singleton' : String(objectId) } + } + /> ); - } - const fieldValue = formData[formField.name]; - const fieldError = fieldErrors[formField.name]; - const fieldReadOnly = - readOnly || - (field.update === 'immutable' && !isCreate) || - field.update === 'serverSet' || - enterpriseDisabled; + if (enterpriseDisabled) { + return ( + + + +
{widget}
+
+ +

{t('enterprise.featureDisabled', "This feature isn't available on this server.")}

+
+
+
+ ); + } - const widget = ( - handleFieldChange(formField.name, v)} - readOnly={fieldReadOnly} - error={fieldError} - schema={schema} - sieveScriptName={ - isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined - } - helpScope={helpScope} - explainTarget={ - isCreate - ? undefined - : { object: resolved.obj.objectName, id: isSingleton ? 'singleton' : String(objectId) } - } - /> - ); + return widget; + })} +
+
+
+ ))} - if (enterpriseDisabled) { - return ( - - - -
{widget}
-
- -

{t('enterprise.featureDisabled', "This feature isn't available on this server.")}

-
-
-
- ); - } - - return widget; - })} -
-
-
- ))} - -
-
- {canDelete && ( - + )} + {/* inbuxa: lock or unlock a person beside Delete (AL-1) */} + {viewName === 'x:Account/User' && objectId && } +
+
+ {isDirty && ( + {t('form.unsavedChangesLabel', 'Unsaved changes')} + )} + - )} - {/* inbuxa: lock or unlock a person beside Delete (AL-1) */} - {viewName === 'x:Account/User' && objectId && } + {!readOnly && ( + + )} +
-
- {isDirty && ( - {t('form.unsavedChangesLabel', 'Unsaved changes')} - )} - - {!readOnly && ( - - )} -
-
+ {children}; + return ( + + + + {label} + + {children} + + ); +} diff --git a/src/features/metricsConnect/MetricsPage.tsx b/src/features/metricsConnect/MetricsPage.tsx index 1b56aba..d793134 100644 --- a/src/features/metricsConnect/MetricsPage.tsx +++ b/src/features/metricsConnect/MetricsPage.tsx @@ -6,27 +6,20 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { MetricsConnect } from './MetricsConnect'; -/** A metrics page: where the numbers go, then this page's own settings folded underneath. */ +/** A metrics page: the page heading, where the numbers go, then this page's own settings folded underneath. */ export function MetricsPage({ viewName }: { viewName: string }) { const { t } = useTranslation(); const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - - - - {t('metricsConnect.more', 'Which metrics, and every setting on this page')} - - - - - -
+ setVersion((v) => v + 1)} />} + foldSections={t('metricsConnect.more', 'Which metrics, and every setting on this page')} + /> ); } diff --git a/src/features/reports/ReportsPage.tsx b/src/features/reports/ReportsPage.tsx index 29a17dd..8e3dc83 100644 --- a/src/features/reports/ReportsPage.tsx +++ b/src/features/reports/ReportsPage.tsx @@ -6,31 +6,20 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { ReportsSheet } from './ReportsSheet'; -/** - * A report page: what goes out and what comes in, in a few sentences, then - * this page's own settings (names, subjects, signing) folded underneath and - * reloaded after a save. - */ +/** A report page: the page heading, what goes out and comes in, then this page's own settings folded underneath and reloaded after a save. */ export function ReportsPage({ viewName }: { viewName: string }) { const { t } = useTranslation(); const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - - - - {t('reports.more', 'Names, subjects and every setting on this page')} - - - - - -
+ setVersion((v) => v + 1)} />} + foldSections={t('reports.more', 'Names, subjects and every setting on this page')} + /> ); } diff --git a/src/features/retention/RetentionPage.tsx b/src/features/retention/RetentionPage.tsx index 9d120ce..9e7a92a 100644 --- a/src/features/retention/RetentionPage.tsx +++ b/src/features/retention/RetentionPage.tsx @@ -6,31 +6,20 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { RetentionSheet } from './RetentionSheet'; -/** - * A retention page: every "how long" as sentences, then this page's own - * settings (schedules and the like) folded underneath, reloaded after the - * sentences are saved so the two never disagree. - */ +/** A retention page: the page heading, every "how long" as sentences, then this page's own settings folded underneath and reloaded after a save. */ export function RetentionPage({ viewName }: { viewName: string }) { const { t } = useTranslation(); const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - - - - {t('retention.more', 'Schedules and every setting on this page')} - - - - - -
+ setVersion((v) => v + 1)} />} + foldSections={t('retention.more', 'Schedules and every setting on this page')} + /> ); } diff --git a/src/features/senderChecks/SenderChecksPage.tsx b/src/features/senderChecks/SenderChecksPage.tsx index 8413e68..050077c 100644 --- a/src/features/senderChecks/SenderChecksPage.tsx +++ b/src/features/senderChecks/SenderChecksPage.tsx @@ -8,13 +8,15 @@ import { useState } from 'react'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { SenderChecksLevels } from './SenderChecksLevels'; -/** Sender checks: the level choice, then the full form, reloaded when a level is applied. */ +/** Sender checks: the page heading, the level choice, then the full form, reloaded when a level is applied. */ export function SenderChecksPage({ viewName }: { viewName: string }) { const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - -
+ setVersion((v) => v + 1)} />} + /> ); } diff --git a/src/features/spamLevel/SpamSettingsPage.tsx b/src/features/spamLevel/SpamSettingsPage.tsx index 321d9cf..7900980 100644 --- a/src/features/spamLevel/SpamSettingsPage.tsx +++ b/src/features/spamLevel/SpamSettingsPage.tsx @@ -8,13 +8,15 @@ import { useState } from 'react'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { SpamLevels } from './SpamLevels'; -/** Spam filter › General: the level and switches, then the full form, reloaded after a save. */ +/** Spam filter › General: the page heading, how hard the filter is, then the full form, reloaded after a save. */ export function SpamSettingsPage({ viewName }: { viewName: string }) { const [version, setVersion] = useState(0); return ( -
- setVersion((v) => v + 1)} /> - -
+ setVersion((v) => v + 1)} />} + /> ); } -- 2.54.0