Files

812 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { MoveButtons } from "@/components/ui/move-buttons";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { SettingsSection, ToggleSwitch } from "./settings-section";
import { Button } from "@/components/ui/button";
import { TermInfo } from "@/components/ui/term";
import { IconButton } from "@/components/ui/icon-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { FilterRuleModal } from "@/components/filters/filter-rule-modal";
import { SieveEditorModal } from "@/components/filters/sieve-editor-modal";
import { useFilterStore } from "@/stores/filter-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import type { FilterRule } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { forwardsAround, inRunOrder, worstCaseForwards } from "@/lib/filters/forward-limit";
import { isPeriodBoundary, periodStatus, supportsPeriods } from "@/lib/sieve/period";
import { formatDateTime } from "@/lib/utils";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import {
Plus,
GripVertical,
Trash2,
Code,
AlertTriangle,
Loader2,
Filter,
RotateCcw,
PalmtreeIcon,
Lock,
} from "@/components/icons";
function isReadonlyRule(r: FilterRule): boolean {
return r.origin === "external" || r.origin === "opaque";
}
/**
* The rule's period ("5 Oct 2026, 08:00 – …") and, for a rule that is on,
* where now falls in it; null without a period.
*/
function usePeriodLabel(rule: FilterRule): { range: string; status: string | null } | null {
const t = useTranslations("settings.filters");
const timeFormat = useSettingsStore((s) => s.timeFormat);
const status = periodStatus(rule);
if (!status) return null;
const format = (boundary: string | undefined) => isPeriodBoundary(boundary)
? formatDateTime(boundary, timeFormat, { day: "numeric", month: "short", year: "numeric" })
: "…";
return {
range: `${format(rule.activeFrom)} – ${format(rule.activeUntil)}`,
// A rule that is off does nothing in any period; "active" next to it
// would say otherwise.
status: rule.enabled ? t(`period_status_${status}`) : null,
};
}
export function RuleSummary({ rule }: { rule: FilterRule }) {
const t = useTranslations("settings.filters");
const period = usePeriodLabel(rule);
const conditions = rule.conditions.slice(0, 2).map((c) => {
const field = t(`condition_fields.${c.field}`);
// "All messages" says it all: no comparator, no value.
if (c.field === "all") return field;
const comparator = t(`comparators.${c.comparator}`);
// has_any is a no-value test ("attachment is present"); appending
// `""` would look broken in the summary line.
if (c.field === "attachment" && c.comparator === "has_any") {
return `${field} ${comparator}`;
}
// Multi-value conditions render as "a" / "b" / "c" with the locale's
// OR-glue between items so the line still reads as natural language.
const valueStr = Array.isArray(c.value)
? c.value.map((v) => `"${v}"`).join(` ${t("or")} `)
: `"${c.value}"`;
return `${field} ${comparator} ${valueStr}`;
});
const joiner = rule.matchType === "all" ? t("and") : t("or");
const extra = rule.conditions.length > 2
? ` (+${rule.conditions.length - 2})`
: "";
const actions = rule.actions.slice(0, 2).map((a) => {
const action = t(`action_types.${a.type}`);
return a.value ? `${action} "${a.value}"` : action;
});
return (
<div className="text-xs text-muted-foreground break-words">
<span className="inline">
{conditions.map((cond, i) => (
<span key={i}>
{i > 0 && <span className="italic opacity-70"> {joiner} </span>}
{cond}
</span>
))}
{extra}
</span>
<span className="mx-1 opacity-50">→</span>
<span className="inline">
{actions.map((act, i) => (
<span key={i}>
{i > 0 && ", "}
{act}
</span>
))}
</span>
{period && <span className="ms-1">· {period.range}{period.status && ` (${period.status})`}</span>}
</div>
);
}
export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
const t = useTranslations("settings.filters");
const period = usePeriodLabel(rule);
const joiner = rule.matchType === "all" ? t("and") : t("or");
const matchLabel = rule.matchType === "all" ? t("match_all_conditions") : t("match_any_condition");
return (
<div className="mt-1.5 space-y-1 text-xs">
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-[0.6875rem] font-semibold text-blue-500 dark:text-blue-400">
{t("if")}
</span>
{rule.conditions.map((c, i) => {
const field = t(`condition_fields.${c.field}`);
// "All messages" says it all: no comparator, no value.
const everyMessage = c.field === "all";
const comparator = everyMessage ? null : t(`comparators.${c.comparator}`);
return (
<span key={i} className="contents">
{i > 0 && (
<span className="text-[0.625rem] text-muted-foreground italic">{joiner}</span>
)}
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="shrink-0 font-medium text-blue-600 dark:text-blue-400">{field}</span>
{!everyMessage && <span className="shrink-0 text-muted-foreground">{comparator}</span>}
{!everyMessage && !(c.field === "attachment" && c.comparator === "has_any") && (
// The values of one condition stay in its chip, where "or"
// cannot be mistaken for the joiner between conditions. They
// wrap between each other under the first value, the "or"
// leading the next line; a single value too long for any
// line breaks inside itself instead of leaving the card.
<span className="flex min-w-0 flex-wrap items-baseline gap-x-1 text-foreground">
{(Array.isArray(c.value) ? c.value : [c.value]).map((v, k) => (
<span key={k} className="min-w-0 wrap-anywhere">
{k > 0 && (
<span className="text-muted-foreground italic">{t("or")} </span>
)}
“{v}”
</span>
))}
</span>
)}
</span>
</span>
);
})}
<span className="text-[0.625rem] text-muted-foreground italic">({matchLabel})</span>
</div>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-[0.6875rem] font-semibold text-emerald-500 dark:text-emerald-400">
{t("then")}
</span>
{rule.actions.map((a, i) => {
const action = t(`action_types.${a.type}`);
return (
<span key={i} className="contents">
{i > 0 && (
<span className="text-muted-foreground">›</span>
)}
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="shrink-0 font-medium text-emerald-600 dark:text-emerald-400">{action}</span>
{a.value && <span className="min-w-0 wrap-anywhere text-muted-foreground">“{a.value}”</span>}
</span>
</span>
);
})}
</div>
{period && (
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-[0.6875rem] font-semibold text-violet-500 dark:text-violet-400">
{t("period_title")}
</span>
<span className="min-w-0 max-w-full px-1.5 py-px rounded-sm bg-muted/60 text-foreground wrap-anywhere">
{period.range}
</span>
{period.status && (
<span className="text-[0.625rem] text-muted-foreground italic">({period.status})</span>
)}
</div>
)}
</div>
);
}
export function FilterSettings() {
const t = useTranslations("settings.filters");
const tNotifications = useTranslations("notifications");
const { client } = useAuthStore();
const storeMailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const {
rules,
isLoading,
isSaving,
error,
isSupported,
isOpaque,
rawScript,
vacationSettings,
includeVacation,
vacationForward,
sieveCapabilities,
selectAccount,
saveFilters,
addRule,
updateRule,
deleteRule,
reorderRules,
toggleRule,
setRawScript,
resetToVisualBuilder,
validateScript,
} = useFilterStore();
// Scoped to a shared/group account when the settings panel is managing one.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const isPrimaryAccount = !managedAccountId;
// Folders offered as "move to" targets in the rule editor must belong to the
// account whose filters we're editing. For a shared account, fetch that
// account's mailboxes (the email store only holds the active account's). They
// are this account's own folders within its Sieve context, so present them as
// non-shared — otherwise the rule modal filters them out (it drops isShared
// mailboxes, which are normally other accounts' folders merged into the view).
const [scopedMailboxes, setScopedMailboxes] = useState<Mailbox[]>([]);
useEffect(() => {
if (!client || !managedAccountId) {
setScopedMailboxes([]);
return;
}
let cancelled = false;
void client
.getMailboxes(managedAccountId)
.then((mbs) => {
if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false })));
})
.catch(() => {
if (!cancelled) setScopedMailboxes([]);
});
return () => { cancelled = true; };
}, [client, managedAccountId]);
// The "move to" folder list for the primary account comes from the email
// store, which is normally populated when the mail view mounts. When the app
// is opened or refreshed directly on Settings (the mail view never mounted),
// that store is empty, leaving the rule editor's folder dropdown blank. Fetch
// mailboxes on demand here so Filters never depends on having visited Inbox
// first. fetchMailboxes guards against transient empty results and selecting
// an inbox, so it's safe to call independently; a ref keeps it to one attempt
// per client.
const primaryFetchClientRef = useRef<typeof client | null>(null);
useEffect(() => {
if (managedAccountId || !client || storeMailboxes.length > 0) return;
if (primaryFetchClientRef.current === client) return;
primaryFetchClientRef.current = client;
void fetchMailboxes(client);
}, [client, managedAccountId, storeMailboxes.length, fetchMailboxes]);
const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
// Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the
// "vacation active" banner only applies when editing the personal account.
const vacationEnabled =
isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled || includeVacation);
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
const [showSieveEditor, setShowSieveEditor] = useState(false);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
// A message can collect only so many forwards on this server. The rules run
// in the order listed, behind the out of office forwarding, and one that
// stops ends the script for its messages, so the order counts (see
// lib/filters/forward-limit.ts).
const redirectLimit = sieveCapabilities?.maxNumberRedirects;
const runOrder = inRunOrder(rules, vacationForward);
const tooManyForwards =
typeof redirectLimit === "number" && redirectLimit > 0 && worstCaseForwards(runOrder) > redirectLimit;
// A limit of 0: the server runs no redirect, so the rules' forwards do nothing.
const forwardsIgnored = redirectLimit === 0 && worstCaseForwards(rules) > 0;
// The edited rule where it is; a new one goes below Bulwark's own rules.
const editedIndex = editingRule ? rules.findIndex((r) => r.id === editingRule.id) : -1;
const forwardsOfEdited = forwardsAround(
runOrder,
runOrder.length - rules.length +
(editedIndex >= 0 ? editedIndex : rules.filter((r) => !isReadonlyRule(r)).length),
editedIndex >= 0,
);
useEffect(() => {
if (client && isSupported) {
// Always pass a concrete account id (managed shared account, or the
// primary Sieve account) so switching never falls back to a stale
// previously-selected account.
void selectAccount(client, managedAccountId ?? client.getSieveAccountId());
}
}, [client, isSupported, managedAccountId, selectAccount]);
const handleToggle = useCallback(
async (ruleId: string) => {
toggleRule(ruleId);
if (client) {
try {
await saveFilters(client);
} catch {
toggleRule(ruleId);
toast.error(tNotifications("filters_save_failed"));
}
}
},
[client, toggleRule, saveFilters, tNotifications]
);
// A rule can be put back as it was, so it goes at once with an Undo (R3).
const handleDelete = useCallback(
async (ruleId: string) => {
const before = useFilterStore.getState().rules;
const deletedRule = before.find((r) => r.id === ruleId);
if (!deletedRule) return;
deleteRule(ruleId);
if (!client) return;
const putBack = () => useFilterStore.setState({ rules: before });
try {
await saveFilters(client);
} catch {
putBack();
toast.error(tNotifications("filters_save_failed"));
return;
}
toast.success(tNotifications("filters_deleted"), {
action: {
label: tNotifications("rule_undo"),
onClick: () => {
if (useFilterStore.getState().rules.some((r) => r.id === ruleId)) return;
const current = useFilterStore.getState().rules;
putBack();
saveFilters(client).catch(() => {
useFilterStore.setState({ rules: current });
toast.error(tNotifications("filters_save_failed"));
});
},
},
});
},
[client, deleteRule, saveFilters, tNotifications]
);
const handleSaveRule = useCallback(
async (rule: FilterRule) => {
const previousRules = [...rules];
if (editingRule) {
updateRule(rule.id, rule);
} else {
addRule(rule);
}
setShowRuleModal(false);
setEditingRule(undefined);
if (client) {
try {
await saveFilters(client);
} catch {
useFilterStore.setState({ rules: previousRules });
toast.error(tNotifications("filters_save_failed"));
}
}
},
[editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications]
);
const handleSaveSieve = useCallback(
async (content: string) => {
setRawScript(content);
useFilterStore.setState({ isOpaque: true, rules: [] });
if (client) {
try {
await saveFilters(client);
toast.success(tNotifications("filters_saved"));
setShowSieveEditor(false);
} catch {
toast.error(tNotifications("filters_save_failed"));
}
}
},
[client, setRawScript, saveFilters, tNotifications]
);
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
// The script typed in the raw editor is thrown away, so it asks first (R3).
const handleResetToVisual = useCallback(async () => {
const ok = await confirm({
title: t("reset_title"),
message: t("reset_warning"),
confirmText: t("confirm_reset"),
variant: "destructive",
});
if (!ok) return;
resetToVisualBuilder();
if (client) {
try {
await saveFilters(client);
toast.success(tNotifications("filters_saved"));
} catch {
toast.error(tNotifications("filters_save_failed"));
}
}
}, [confirm, t, resetToVisualBuilder, client, saveFilters, tNotifications]);
const handleValidate = useCallback(
async (content: string) => {
if (!client) return { isValid: false, errors: ["No client"] };
return validateScript(client, content);
},
[client, validateScript]
);
const handleDragStart = useCallback(
(e: React.DragEvent, index: number) => {
draggedIndexRef.current = index;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
},
[]
);
const handleDragOver = useCallback(
(e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIndex(index);
},
[]
);
// Dragging and the Move up / Move down buttons both end here.
const moveRule = useCallback(
async (fromIndex: number, dropIndex: number) => {
if (fromIndex === dropIndex || dropIndex < 0 || dropIndex >= rules.length) return;
const previousOrder = rules.map((r) => r.id);
const newOrder = [...previousOrder];
const [moved] = newOrder.splice(fromIndex, 1);
newOrder.splice(dropIndex, 0, moved);
reorderRules(newOrder);
if (client) {
try {
await saveFilters(client);
} catch {
reorderRules(previousOrder);
toast.error(tNotifications("filters_save_failed"));
}
}
},
[rules, reorderRules, client, saveFilters, tNotifications]
);
const handleDrop = useCallback(
async (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
setDragOverIndex(null);
const fromIndex = draggedIndexRef.current;
if (fromIndex === null) return;
await moveRule(fromIndex, dropIndex);
},
[moveRule]
);
const handleDragEnd = useCallback(() => {
draggedIndexRef.current = null;
setDragOverIndex(null);
}, []);
if (!isSupported) {
return (
<SettingsSection title={t("title")} description={t("description")}>
<div className="text-sm text-muted-foreground py-4">
{t("not_supported")}
</div>
</SettingsSection>
);
}
// A reload in the background (another device or tab changed the script)
// must not take away a dialog the user is typing in: while one is open,
// the page keeps what it has.
const editing = showRuleModal || showSieveEditor;
if (isLoading && !editing) {
return (
<SettingsSection title={t("title")} description={t("description")}>
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t("loading")}
</div>
</SettingsSection>
);
}
if (error && !editing) {
return (
<SettingsSection title={t("title")} description={t("description")}>
<div className="text-sm text-red-600 dark:text-red-400 py-4">
{t("fetch_error")}
</div>
</SettingsSection>
);
}
return (
<div className="space-y-6">
<SettingsSection id="rules" title={t("title")} description={t("description")}>
{isOpaque && (
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p>{t("opaque_warning")}</p>
<div className="flex gap-3 mt-2">
<button
type="button"
onClick={() => setShowSieveEditor(true)}
className="text-primary hover:underline font-medium"
>
{t("open_sieve_editor")}
</button>
<button
type="button"
onClick={() => void handleResetToVisual()}
className="text-red-600 dark:text-red-400 hover:underline font-medium flex items-center gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_to_visual")}
</button>
</div>
</div>
</div>
)}
{!isOpaque && vacationEnabled && (
<button
type="button"
onClick={() => {
try { localStorage.setItem('settings-active-tab', 'vacation'); } catch { /* ignore */ }
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: 'vacation' }));
}}
className="flex items-center gap-3 w-full p-3 rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-start"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/40">
<PalmtreeIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-green-700 dark:text-green-400">
{t("vacation_active")}
</p>
<p className="text-xs text-green-600/70 dark:text-green-400/70">
{t("vacation_active_description")}
</p>
</div>
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
{t("vacation_configure")} &rarr;
</span>
</button>
)}
{!isOpaque && rules.length === 0 && !vacationEnabled && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<Filter className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">{t("no_rules")}</p>
</div>
)}
{!isOpaque && forwardsIgnored && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("forward_unavailable")}
</p>
)}
{!isOpaque && tooManyForwards && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("forward_limit", { count: redirectLimit })}
</p>
)}
{!isOpaque && rules.length > 0 && (
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
{rules.map((rule, index) => {
const readonly = isReadonlyRule(rule);
if (readonly) {
const label = rule.originLabel || t("origin_external");
const tooltip = t("managed_by_tooltip", { source: label });
const hasStructuredSummary =
rule.origin === "external" &&
rule.conditions.length > 0 &&
rule.actions.length > 0;
return (
<div
key={rule.id}
role="listitem"
className="flex items-start gap-3 p-3 rounded-md border border-border"
title={tooltip}
>
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
<Lock className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[0.625rem]">
{label}
</span>
</div>
{hasStructuredSummary ? (
expandedFilterView ? (
<VisualRuleSummary rule={rule} />
) : (
<RuleSummary rule={rule} />
)
) : rule.rawBlock ? (
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
{rule.rawBlock.trim()}
</pre>
) : null}
</div>
</div>
);
}
return (
<div
key={rule.id}
role="listitem"
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
dragOverIndex === index
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
} ${!rule.enabled ? "opacity-60" : ""}`}
>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
aria-label={t("drag_to_reorder")}
>
<GripVertical className="w-4 h-4" />
</div>
<div className="pt-0.5">
<ToggleSwitch
checked={rule.enabled}
onChange={() => handleToggle(rule.id)}
ariaLabel={rule.name}
/>
</div>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
setEditingRule(rule);
setShowRuleModal(true);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEditingRule(rule);
setShowRuleModal(true);
}
}}
>
<p className="text-sm font-medium text-foreground truncate">
{rule.name}
</p>
{expandedFilterView ? (
<VisualRuleSummary rule={rule} />
) : (
<RuleSummary rule={rule} />
)}
</div>
{/* The order decides which rule runs first: also by keyboard. */}
<MoveButtons
index={index}
count={rules.length}
name={rule.name}
onMove={(from, to) => void moveRule(from, to)}
/>
<IconButton
label={t("delete_rule")}
onClick={() => void handleDelete(rule.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</IconButton>
</div>
);
})}
</div>
)}
</SettingsSection>
<div className="flex items-center justify-between">
<div className="flex gap-2">
{!isOpaque && (
<Button
variant="outline"
size="sm"
data-search-label={t("add_rule")}
onClick={() => {
setEditingRule(undefined);
setShowRuleModal(true);
}}
>
<Plus className="w-4 h-4 me-1" />
{t("add_rule")}
</Button>
)}
<Button
variant="outline"
size="sm"
data-search-label={t("raw_editor")}
onClick={() => setShowSieveEditor(true)}
>
<Code className="w-4 h-4 me-1" />
{t("raw_editor")}
</Button>
<TermInfo id="sieve" />
</div>
<div className="flex items-center gap-3">
{isSaving && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t("saving")}
</div>
)}
{!isOpaque && rules.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("expanded_view")}</span>
<ToggleSwitch
checked={expandedFilterView}
onChange={(v) => updateSetting("expandedFilterView", v)}
ariaLabel={t("expanded_view")}
/>
</div>
)}
</div>
</div>
{showRuleModal && (
<FilterRuleModal
rule={editingRule}
mailboxes={mailboxes}
maxRedirects={sieveCapabilities?.maxNumberRedirects}
periodsSupported={supportsPeriods(sieveCapabilities?.sieveExtensions)}
forwardsBefore={forwardsOfEdited.before}
forwardsAfter={forwardsOfEdited.after}
onSave={handleSaveRule}
onClose={() => {
setShowRuleModal(false);
setEditingRule(undefined);
}}
/>
)}
{showSieveEditor && (
<SieveEditorModal
content={rawScript}
onSave={handleSaveSieve}
onClose={() => setShowSieveEditor(false)}
onValidate={handleValidate}
/>
)}
<ConfirmDialog {...confirmProps} />
</div>
);
}