From 39e43de51efbdb857664c35a2daa544ca644b2e4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:04:01 +0100 Subject: [PATCH 1/5] fix: offer to apply a filter rule to the Inbox from its dialog, not a passing toast --- components/filters/filter-rule-modal.tsx | 62 +++++++++++++--- .../filter-settings-apply-existing.test.tsx | 72 +++++++++++++------ components/settings/filter-settings.tsx | 57 +++++++++------ lib/filters/quick-rule-flow.ts | 24 ------- locales/ar/common.json | 4 ++ locales/ca/common.json | 4 ++ locales/cs/common.json | 4 ++ locales/da/common.json | 4 ++ locales/de/common.json | 4 ++ locales/en/common.json | 4 ++ locales/es/common.json | 4 ++ locales/et/common.json | 4 ++ locales/fa/common.json | 4 ++ locales/fr/common.json | 4 ++ locales/he/common.json | 4 ++ locales/hu/common.json | 4 ++ locales/it/common.json | 4 ++ locales/ja/common.json | 4 ++ locales/ko/common.json | 4 ++ locales/lv/common.json | 4 ++ locales/mn/common.json | 4 ++ locales/nb/common.json | 4 ++ locales/nl/common.json | 4 ++ locales/pl/common.json | 4 ++ locales/pt/common.json | 4 ++ locales/ro/common.json | 4 ++ locales/ru/common.json | 4 ++ locales/sk/common.json | 4 ++ locales/th/common.json | 4 ++ locales/tr/common.json | 4 ++ locales/uk/common.json | 4 ++ locales/vi/common.json | 4 ++ locales/zh-TW/common.json | 4 ++ locales/zh/common.json | 4 ++ 34 files changed, 258 insertions(+), 77 deletions(-) diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 710a88785..028550883 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo, useRef } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useLocale, useTranslations } from "next-intl"; import { sortFolderTree } from "@/lib/move-menu"; import { Button } from "@/components/ui/button"; @@ -41,6 +41,16 @@ interface FilterRuleModalProps { suggestions?: RuleSuggestion[]; /** Offer "Also apply to existing messages in this folder". */ offerApplyToExisting?: boolean; + /** + * The folder the offer is about, by name ("Inbox"), when the dialog isn't + * opened from that folder (Settings › Filters). + */ + applyExistingFolder?: string; + /** + * How many messages in that folder the rule as typed would change. The + * offer says the number, and is greyed when it is 0. + */ + countExisting?: (rule: FilterRule) => Promise; mailboxes: Mailbox[]; /** Server cap on redirects per message (Sieve `maxNumberRedirects`). */ maxRedirects?: number | null; @@ -198,6 +208,8 @@ export function FilterRuleModal({ initialRule, suggestions = [], offerApplyToExisting = false, + applyExistingFolder, + countExisting, mailboxes, maxRedirects, forwardsBefore = 0, @@ -345,6 +357,30 @@ export function FilterRuleModal({ return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok; }, [offerApplyToExisting, validConditions, validActions, periodOn]); + // How many messages the rule would change there, asked of the server a + // moment after the conditions and actions stop changing. null: not known + // (still asking, no action yet, or the server didn't answer). + const [existingCount, setExistingCount] = useState(null); + const countKey = canApplyToExisting && countExisting && validActions.length > 0 + ? JSON.stringify([matchType, validConditions, validActions]) + : null; + const countRef = useRef(countExisting); + countRef.current = countExisting; + useEffect(() => { + setExistingCount(null); + if (!countKey || !countRef.current) return; + const [type, conds, acts] = JSON.parse(countKey) as [FilterRule["matchType"], FilterCondition[], FilterAction[]]; + let stale = false; + const timer = setTimeout(() => { + countRef.current!({ id: "count", name: "", enabled: true, matchType: type, conditions: conds, actions: acts, stopProcessing: false }) + .then((count) => { if (!stale) setExistingCount(count); }) + .catch(() => { /* the offer stays, without a number */ }); + }, 400); + return () => { stale = true; clearTimeout(timer); }; + }, [countKey]); + const noneToApply = existingCount === 0; + const canTickApply = canApplyToExisting && !noneToApply; + // What keeps the rule from being saved, said under the part it is about // once Save was pressed, and updated as the user fixes it. const [showErrors, setShowErrors] = useState(false); @@ -427,8 +463,8 @@ export function FilterRuleModal({ includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined, activeFrom: activeFrom ?? undefined, activeUntil: activeUntil ?? undefined, - }, { applyToExisting: applyToExisting && canApplyToExisting }); - }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canApplyToExisting]); + }, { applyToExisting: applyToExisting && canTickApply }); + }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canTickApply]); const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id)); @@ -954,22 +990,28 @@ export function FilterRuleModal({ setApplyToExisting(e.target.checked)} className="rounded border-input disabled:opacity-50" - aria-describedby={canApplyToExisting ? undefined : "applyToExistingHint"} + aria-describedby={canTickApply ? undefined : "applyToExistingHint"} /> - {!canApplyToExisting && ( + {!canTickApply && (

- {periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")} + {!canApplyToExisting + ? (periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")) + : t("apply_existing_none", { folder: applyExistingFolder ?? "" })}

)} diff --git a/components/settings/__tests__/filter-settings-apply-existing.test.tsx b/components/settings/__tests__/filter-settings-apply-existing.test.tsx index 86767a787..ae95bc49f 100644 --- a/components/settings/__tests__/filter-settings-apply-existing.test.tsx +++ b/components/settings/__tests__/filter-settings-apply-existing.test.tsx @@ -9,8 +9,8 @@ import { useFilterStore } from '@/stores/filter-store'; import { useSettingsStore } from '@/stores/settings-store'; // settings-13 (usability pass 2026-11-04): a rule made in Settings only sees -// new mail, so after Save the page offers to apply it to the Inbox; the raw -// Sieve editor is an advanced tool. +// new mail, so its dialog offers to apply it to the Inbox; the raw Sieve +// editor is an advanced tool. const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() })); vi.mock('@/stores/toast-store', () => ({ toast })); @@ -57,6 +57,7 @@ beforeEach(() => { updateSieveScript: vi.fn(async () => {}), getSieveAccountId: () => 'primary', queryEmailFields, + batchUpdateKeywords: vi.fn(async () => {}), }; target.value = { client, clientAccountId: null, key: 'k', accountId: 'primary', sieveAccountId: 'primary', @@ -89,40 +90,71 @@ function showSettings(rules: FilterRule[] = [], extra: Record = render(); } -async function createRule(from: string, action: string) { +async function fillRule(from: string, action: string) { fireEvent.click(screen.getByText('add_rule')); fireEvent.change(screen.getByLabelText('rule_name'), { target: { value: 'News' } }); fireEvent.change(screen.getByLabelText('condition_value'), { target: { value: from } }); fireEvent.change(screen.getAllByLabelText('actions')[0], { target: { value: action } }); - fireEvent.click(screen.getByText('save')); } -describe('a new rule in Settings › Filters', () => { - it('offers to apply it to the messages already in the Inbox', async () => { +// settings-p2-6: the offer is a box in the rule's dialog, with the number of +// messages it would change, not a toast that is gone in a few seconds. +describe('a rule in Settings › Filters', () => { + it('offers, in its dialog, to apply it to the messages already in the Inbox', async () => { showSettings(); - await createRule('news@example.org', 'star'); - await waitFor(() => expect(toast.success).toHaveBeenCalled()); - const [, options] = toast.success.mock.calls.at(-1)!; - expect(options.action.label).toBe('apply_to_existing'); + await fillRule('news@example.org', 'star'); + const box = await screen.findByRole('checkbox', { name: 'apply_existing_count' }, { timeout: 2000 }); + expect(box).toBeEnabled(); expect(queryEmailFields).toHaveBeenCalled(); + fireEvent.click(box); + queryEmailFields.mockClear(); + fireEvent.click(screen.getByText('save')); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('rule_applied', expect.anything())); + // Saving ran the rule; no toast offered it. + expect(queryEmailFields).toHaveBeenCalled(); + expect(toast.success.mock.calls.some(([, options]) => options?.action)).toBe(false); }); - it('says nothing more when no message in the Inbox matches', async () => { + it('runs nothing on old mail when the box is left empty', async () => { showSettings(); - await createRule('nobody@example.com', 'star'); - await waitFor(() => expect(queryEmailFields).toHaveBeenCalled()); - await new Promise((r) => setTimeout(r, 0)); - expect(toast.success).not.toHaveBeenCalled(); - }); - - it('is not offered for a rule that can only act on arriving mail', async () => { - showSettings(); - await createRule('news@example.org', 'discard'); + await fillRule('news@example.org', 'star'); + await screen.findByRole('checkbox', { name: 'apply_existing_count' }, { timeout: 2000 }); + queryEmailFields.mockClear(); + fireEvent.click(screen.getByText('save')); await waitFor(() => expect(client.updateSieveScript).toHaveBeenCalled()); await new Promise((r) => setTimeout(r, 0)); expect(queryEmailFields).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled(); }); + + it('greys the box when no message in the Inbox matches', async () => { + showSettings(); + await fillRule('nobody@example.com', 'star'); + await waitFor(() => expect(queryEmailFields).toHaveBeenCalled(), { timeout: 2000 }); + await waitFor(() => expect(screen.getByText('apply_existing_none')).toBeInTheDocument()); + expect(screen.getByRole('checkbox', { name: 'apply_existing_in' })).toBeDisabled(); + }); + + it('is greyed for a rule that can only act on arriving mail', async () => { + showSettings(); + await fillRule('news@example.org', 'discard'); + expect(screen.getByRole('checkbox', { name: 'apply_existing_in' })).toBeDisabled(); + expect(screen.getByText('apply_existing_unsupported')).toBeInTheDocument(); + await new Promise((r) => setTimeout(r, 500)); + expect(queryEmailFields).not.toHaveBeenCalled(); + }); +}); + +describe('the rule list', () => { + it('says what its switch does', () => { + showSettings([{ + id: 'r1', name: 'News', enabled: true, matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'news@example.org' }], + actions: [{ type: 'star' }], stopProcessing: false, + } as FilterRule]); + expect(screen.getByRole('switch', { name: 'show_rule_details' })).toBeInTheDocument(); + expect(screen.queryByText('expanded_view')).toBeNull(); + }); }); describe('the raw Sieve editor button', () => { diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx index c862271e4..1b1e88d28 100644 --- a/components/settings/filter-settings.tsx +++ b/components/settings/filter-settings.tsx @@ -1,7 +1,7 @@ "use client"; import { MoveButtons } from "@/components/ui/move-buttons"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { SettingsSection, ToggleSwitch } from "./settings-section"; import { Button } from "@/components/ui/button"; @@ -24,9 +24,8 @@ import { supportsRegex } from "@/lib/sieve/regex"; import { formatDateTime } from "@/lib/utils"; import { useVacationStore } from "@/stores/vacation-store"; import { useManagedAccountStore } from "@/stores/managed-account-store"; -import { offerRuleOnFolder } from "@/lib/filters/quick-rule-flow"; +import { planOnServer, runRuleOnFolder } from "@/lib/filters/quick-rule-flow"; import { resolveMailboxRuleTarget } from "@/lib/filters/quick-rule-target"; -import { retroactiveSupport } from "@/lib/filters/retroactive"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { Plus, @@ -215,7 +214,6 @@ export function FilterSettings() { const tNotifications = useTranslations("notifications"); const tMenu = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); - const tFolders = useTranslations("uxs_folders"); // The raw script editor is for people who asked for the advanced settings, // unless the script is one only it can edit. const showAdvanced = useSettingsStore((s) => s.showAdvancedSettings); @@ -384,8 +382,24 @@ export function FilterSettings() { [client, deleteRule, saveFilters, tNotifications] ); + // A rule only sees new mail. Its dialog offers to sort what is already in + // the Inbox too, as Gmail's "Also apply filter to matching conversations" + // does: for the user's own account, where the Inbox can be searched. + const inboxOffer = useMemo(() => { + if (!isPrimaryAccount || !client) return null; + const inbox = storeMailboxes.find((m) => m.role === "inbox" && !m.isShared); + const target = inbox ? resolveMailboxRuleTarget(inbox) : null; + if (!inbox || !target || target.client !== client) return null; + const folder = localizeMailboxName(inbox.role, inbox.name, (k) => tSidebar(`mailboxes.${k}`)); + return { inbox, target, folder }; + }, [isPrimaryAccount, client, storeMailboxes, tSidebar]); + const countInInbox = useCallback(async (rule: FilterRule) => { + if (!inboxOffer) return 0; + return (await planOnServer(inboxOffer.target, rule, inboxOffer.inbox.id)).ids.length; + }, [inboxOffer]); + const handleSaveRule = useCallback( - async (rule: FilterRule) => { + async (rule: FilterRule, options?: { applyToExisting: boolean }) => { const previousRules = [...rules]; if (editingRule) { updateRule(rule.id, rule); @@ -403,25 +417,19 @@ export function FilterSettings() { toast.error(tNotifications("filters_save_failed")); return; } - // A new rule only sees new mail. Offer to sort what is already in the - // Inbox, as Gmail's "Also apply to matching conversations" does. - if (!editingRule && isPrimaryAccount && retroactiveSupport(rule).ok) { - const inbox = storeMailboxes.find((m) => m.role === "inbox" && !m.isShared); - const target = inbox ? resolveMailboxRuleTarget(inbox) : null; - if (inbox && target && target.client === client) { - const folder = localizeMailboxName(inbox.role, inbox.name, (k) => tSidebar(`mailboxes.${k}`)); - void offerRuleOnFolder( - target, - rule, - inbox.id, - { notifications: tNotifications, filters: t, menu: tMenu }, - (count) => tFolders("apply_to_existing", { count, folder }), - ); - } + // The box in the dialog: run the saved rule over the Inbox now. + if (options?.applyToExisting && inboxOffer) { + void runRuleOnFolder( + inboxOffer.target, + rule, + inboxOffer.inbox.id, + inboxOffer.folder, + { notifications: tNotifications, filters: t, menu: tMenu }, + ); } } }, - [editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications, isPrimaryAccount, storeMailboxes, tSidebar, t, tMenu, tFolders] + [editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications, inboxOffer, t, tMenu] ); const handleSaveSieve = useCallback( @@ -804,11 +812,11 @@ export function FilterSettings() { )} {!isOpaque && rules.length > 0 && (
- {t("expanded_view")} + {t("show_rule_details")} updateSetting("expandedFilterView", v)} - ariaLabel={t("expanded_view")} + ariaLabel={t("show_rule_details")} />
)} @@ -824,6 +832,9 @@ export function FilterSettings() { regexSupported={supportsRegex(sieveCapabilities?.sieveExtensions)} forwardsBefore={forwardsOfEdited.before} forwardsAfter={forwardsOfEdited.after} + offerApplyToExisting={!!inboxOffer} + applyExistingFolder={inboxOffer?.folder} + countExisting={inboxOffer ? countInInbox : undefined} onSave={handleSaveRule} onClose={() => { setShowRuleModal(false); diff --git a/lib/filters/quick-rule-flow.ts b/lib/filters/quick-rule-flow.ts index 9628f48b8..d49a85eba 100644 --- a/lib/filters/quick-rule-flow.ts +++ b/lib/filters/quick-rule-flow.ts @@ -213,30 +213,6 @@ async function applyAndReport( } } -/** - * After a rule was saved in Settings › Filters: when it would change mail - * already in `mailboxId` (the Inbox), say how much and offer to apply it - * there. Nothing is said when it would change nothing or can't be checked. - */ -export async function offerRuleOnFolder( - target: QuickRuleTarget, - rule: FilterRule, - mailboxId: string, - text: QuickRuleText, - applyLabel: (count: number) => string, -): Promise { - const plan = await planSafely(target, rule, mailboxId); - if (plan.ids.length === 0) return; - toast.success(text.notifications('rule_created'), { - message: rule.name, - duration: SAVED_TOAST_MS, - action: { - label: applyLabel(plan.ids.length), - onClick: () => { void applyAndReport(target, rule, mailboxId, text); }, - }, - }); -} - /** * Run a saved rule over the mail already in a folder, from the folder's * context menu. A progress toast stays up while the server is asked which diff --git a/locales/ar/common.json b/locales/ar/common.json index 999cafaf0..1bd640de3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "فتح محرر Sieve الخام", "fetch_error": "فشل تحميل المرشحات", "expanded_view": "عرض موسّع", + "show_rule_details": "عرض شروط كل قاعدة وإجراءاتها", "expanded_view_description": "إظهار قواعد المرشح مع كتل الشروط والإجراءات المفصّلة", "if": "إذا", "then": "عندئذٍ", @@ -2991,6 +2992,9 @@ "validation_period_empty": "أدخل تاريخ بدء أو انتهاء للفترة", "validation_period_invalid": "أدخل تاريخًا صالحًا مع الوقت للفترة، أو اترك الحقل فارغًا", "apply_existing_period": "القاعدة ذات الفترة الزمنية تعمل فقط على الرسائل التي تصل خلالها، لذا لا يمكن تطبيقها على الرسائل الموجودة.", + "apply_existing_in": "طبّقها أيضًا على الرسائل الموجودة في {folder}", + "apply_existing_count": "{count, plural, zero {طبّقها أيضًا على الرسائل الموجودة في {folder}} one {طبّقها أيضًا على الرسالة الموجودة في {folder}} two {طبّقها أيضًا على الرسالتين الموجودتين في {folder}} few {طبّقها أيضًا على # رسائل موجودة في {folder}} many {طبّقها أيضًا على # رسالة موجودة في {folder}} other {طبّقها أيضًا على # رسالة موجودة في {folder}}}", + "apply_existing_none": "لا توجد رسالة في {folder} تطابق هذه القاعدة بعد.", "period_status_scheduled": "مجدولة", "period_status_active": "نشطة", "period_status_expired": "منتهية" diff --git a/locales/ca/common.json b/locales/ca/common.json index 091cd601c..1d5b3f23c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "Obre l'editor Sieve en brut", "fetch_error": "No s'han pogut carregar els filtres", "expanded_view": "Vista ampliada", + "show_rule_details": "Mostra les condicions i les accions de cada regla", "expanded_view_description": "Mostra les regles de filtre amb blocs detallats de condicions i accions", "if": "Si", "then": "Aleshores", @@ -2991,6 +2992,9 @@ "validation_period_empty": "Introdueix una data d'inici o de finalització per al període", "validation_period_invalid": "Introdueix una data vàlida amb l'hora per al període, o deixa el camp buit", "apply_existing_period": "Una regla amb període només actua sobre els missatges que arriben dins d'aquest període, de manera que no es pot aplicar als missatges existents.", + "apply_existing_in": "Aplica-la també als missatges que ja són a {folder}", + "apply_existing_count": "{count, plural, one {Aplica-la també al # missatge que ja és a {folder}} other {Aplica-la també als # missatges que ja són a {folder}}}", + "apply_existing_none": "Encara cap missatge de {folder} no compleix aquesta regla.", "period_status_scheduled": "programada", "period_status_active": "activa", "period_status_expired": "caducada" diff --git a/locales/cs/common.json b/locales/cs/common.json index b2ae541cf..0b2185b9f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Otevřít textový editor Sieve", "fetch_error": "Nepodařilo se načíst filtry", "expanded_view": "Rozšířené zobrazení", + "show_rule_details": "Zobrazit podmínky a akce každého pravidla", "expanded_view_description": "Zobrazovat pravidla filtrů s podrobnými bloky podmínek a akcí", "if": "Pokud", "then": "Pak", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Zadejte datum zahájení nebo ukončení období", "validation_period_invalid": "Zadejte pro období platné datum včetně času, nebo pole nechte prázdné", "apply_existing_period": "Pravidlo s obdobím působí jen na zprávy, které v něm přijdou, a proto ho nelze použít na existující zprávy.", + "apply_existing_in": "Použít i na zprávy, které už jsou ve složce {folder}", + "apply_existing_count": "{count, plural, one {Použít i na # zprávu, která už je ve složce {folder}} few {Použít i na # zprávy, které už jsou ve složce {folder}} many {Použít i na # zprávy, které už jsou ve složce {folder}} other {Použít i na # zpráv, které už jsou ve složce {folder}}}", + "apply_existing_none": "Tomuto pravidlu zatím neodpovídá žádná zpráva ve složce {folder}.", "period_status_scheduled": "naplánováno", "period_status_active": "aktivní", "period_status_expired": "vypršelo" diff --git a/locales/da/common.json b/locales/da/common.json index 7d48f4b15..c851d2dff 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2909,6 +2909,7 @@ "open_sieve_editor": "Åbn raw Sieve-editor", "fetch_error": "Kunne ikke indlæse filtre", "expanded_view": "Udvidet visning", + "show_rule_details": "Vis hver regels betingelser og handlinger", "expanded_view_description": "Vis filterregler med detaljerede betingelses- og handlingsblokke", "if": "Hvis", "then": "Så", @@ -2987,6 +2988,9 @@ "validation_period_empty": "Angiv en start- eller slutdato for perioden", "validation_period_invalid": "Angiv en gyldig dato med klokkeslæt for perioden, eller lad feltet stå tomt", "apply_existing_period": "En regel med en periode virker kun på beskeder, der ankommer i perioden, og kan derfor ikke køres på eksisterende beskeder.", + "apply_existing_in": "Anvend også på beskederne, der allerede er i {folder}", + "apply_existing_count": "{count, plural, one {Anvend også på den # besked, der allerede er i {folder}} other {Anvend også på de # beskeder, der allerede er i {folder}}}", + "apply_existing_none": "Ingen beskeder i {folder} passer til reglen endnu.", "period_status_scheduled": "planlagt", "period_status_active": "aktiv", "period_status_expired": "udløbet" diff --git a/locales/de/common.json b/locales/de/common.json index e1e33c934..3bc9f148f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Sieve-Skript-Editor öffnen", "fetch_error": "Filter konnten nicht geladen werden", "expanded_view": "Erweiterte Ansicht", + "show_rule_details": "Bedingungen und Aktionen jeder Regel anzeigen", "expanded_view_description": "Filterregeln mit detaillierten Bedingungs- und Aktionsblöcken anzeigen", "if": "Wenn", "then": "Dann", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Bitte ein Start- oder Enddatum für den Zeitraum angeben", "validation_period_invalid": "Bitte für den Zeitraum ein gültiges Datum mit Uhrzeit angeben oder das Feld leer lassen", "apply_existing_period": "Eine Regel mit Zeitraum wirkt nur auf Nachrichten, die in diesem Zeitraum eintreffen, und lässt sich daher nicht auf vorhandene Nachrichten anwenden.", + "apply_existing_in": "Auch auf die Nachrichten anwenden, die schon in {folder} sind", + "apply_existing_count": "{count, plural, one {Auch auf die # Nachricht anwenden, die schon in {folder} ist} other {Auch auf die # Nachrichten anwenden, die schon in {folder} sind}}", + "apply_existing_none": "Noch keine Nachricht in {folder} passt zu dieser Regel.", "period_status_scheduled": "geplant", "period_status_active": "aktiv", "period_status_expired": "abgelaufen" diff --git a/locales/en/common.json b/locales/en/common.json index ae08611b3..1582cb279 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "Open raw Sieve editor", "fetch_error": "Couldn't load the filters", "expanded_view": "Expanded view", + "show_rule_details": "Show each rule’s conditions and actions", "expanded_view_description": "Show filter rules with detailed condition and action blocks", "if": "If", "then": "Then", @@ -2991,6 +2992,9 @@ "validation_period_empty": "Enter a start or end date for the period", "validation_period_invalid": "Enter a valid date with a time for the period, or leave the field empty", "apply_existing_period": "A rule with a period acts only on messages that arrive within it, so it cannot run on existing messages.", + "apply_existing_in": "Also apply to the messages already in {folder}", + "apply_existing_count": "{count, plural, one {Also apply to the # message already in {folder}} other {Also apply to the # messages already in {folder}}}", + "apply_existing_none": "No message in {folder} matches this rule yet.", "period_status_scheduled": "scheduled", "period_status_active": "active", "period_status_expired": "expired" diff --git a/locales/es/common.json b/locales/es/common.json index e4c6f39e9..d44f166af 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Abrir editor Sieve", "fetch_error": "Error al cargar los filtros", "expanded_view": "Vista expandida", + "show_rule_details": "Mostrar las condiciones y acciones de cada regla", "expanded_view_description": "Mostrar reglas de filtro con bloques detallados de condiciones y acciones", "if": "Si", "then": "Entonces", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Introduce una fecha de inicio o de fin para el período", "validation_period_invalid": "Introduce una fecha válida con hora para el período o deja el campo vacío", "apply_existing_period": "Una regla con período solo actúa sobre los mensajes que llegan dentro de él, por lo que no se puede aplicar a los mensajes existentes.", + "apply_existing_in": "Aplicar también a los mensajes que ya están en {folder}", + "apply_existing_count": "{count, plural, one {Aplicar también al # mensaje que ya está en {folder}} other {Aplicar también a los # mensajes que ya están en {folder}}}", + "apply_existing_none": "Ningún mensaje de {folder} coincide todavía con esta regla.", "period_status_scheduled": "programada", "period_status_active": "activa", "period_status_expired": "caducada" diff --git a/locales/et/common.json b/locales/et/common.json index 30ac1ee96..29c3c1e9e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "Ava Sieve lähtekoodi redaktor", "fetch_error": "Filtrite laadimine ebaõnnestus", "expanded_view": "Laiendatud vaade", + "show_rule_details": "Näita iga reegli tingimusi ja toiminguid", "expanded_view_description": "Näita filtrireegleid koos üksikasjalike tingimuste ja tegevuste plokkidega", "if": "Kui", "then": "Siis", @@ -2991,6 +2992,9 @@ "validation_period_empty": "Sisesta ajavahemiku algus- või lõppkuupäev", "validation_period_invalid": "Sisesta ajavahemiku jaoks kehtiv kuupäev koos kellaajaga või jäta väli tühjaks", "apply_existing_period": "Ajavahemikuga reegel toimib ainult kirjadele, mis saabuvad selle jooksul, seega ei saa seda olemasolevatele kirjadele rakendada.", + "apply_existing_in": "Rakenda ka kirjadele, mis on juba kaustas {folder}", + "apply_existing_count": "{count, plural, one {Rakenda ka # kirjale, mis on juba kaustas {folder}} other {Rakenda ka # kirjale, mis on juba kaustas {folder}}}", + "apply_existing_none": "Kaustas {folder} pole veel ühtegi kirja, mis selle reegliga sobiks.", "period_status_scheduled": "ajastatud", "period_status_active": "aktiivne", "period_status_expired": "aegunud" diff --git a/locales/fa/common.json b/locales/fa/common.json index 117abe12f..7adb7bd7c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "باز کردن ویرایشگر خام Sieve", "fetch_error": "بارگذاری فیلترها ناموفق بود", "expanded_view": "نمای گسترده", + "show_rule_details": "نمایش شرط‌ها و کارهای هر قانون", "expanded_view_description": "نمایش قوانین فیلتر با بلوک‌های شرط و عملیات جزئی", "if": "اگر", "then": "آنگاه", @@ -2991,6 +2992,9 @@ "validation_period_empty": "تاریخ شروع یا پایان بازه را وارد کنید", "validation_period_invalid": "برای بازه یک تاریخ معتبر همراه با ساعت وارد کنید یا فیلد را خالی بگذارید", "apply_existing_period": "قانونی که بازه زمانی دارد فقط روی پیام‌هایی عمل می‌کند که در آن بازه می‌رسند، بنابراین روی پیام‌های موجود اجرا نمی‌شود.", + "apply_existing_in": "روی پیام‌هایی هم که الان در {folder} هستند اعمال شود", + "apply_existing_count": "{count, plural, one {روی # پیامی هم که الان در {folder} است اعمال شود} other {روی # پیامی هم که الان در {folder} هستند اعمال شود}}", + "apply_existing_none": "هنوز هیچ پیامی در {folder} با این قانون جور نیست.", "period_status_scheduled": "زمان‌بندی‌شده", "period_status_active": "فعال", "period_status_expired": "منقضی‌شده" diff --git a/locales/fr/common.json b/locales/fr/common.json index 920bc103e..3f042bf5f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Ouvrir l'éditeur Sieve brut", "fetch_error": "Échec du chargement des filtres", "expanded_view": "Vue étendue", + "show_rule_details": "Afficher les conditions et actions de chaque règle", "expanded_view_description": "Afficher les règles de filtre avec des blocs de conditions et d'actions détaillés", "if": "Si", "then": "Alors", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Indiquez une date de début ou de fin pour la période", "validation_period_invalid": "Indiquez une date valide avec l'heure pour la période, ou laissez le champ vide", "apply_existing_period": "Une règle avec une période n'agit que sur les messages qui arrivent pendant celle-ci ; elle ne peut donc pas s'appliquer aux messages existants.", + "apply_existing_in": "Appliquer aussi aux messages déjà dans {folder}", + "apply_existing_count": "{count, plural, one {Appliquer aussi au # message déjà dans {folder}} other {Appliquer aussi aux # messages déjà dans {folder}}}", + "apply_existing_none": "Aucun message de {folder} ne correspond encore à cette règle.", "period_status_scheduled": "planifiée", "period_status_active": "active", "period_status_expired": "expirée" diff --git a/locales/he/common.json b/locales/he/common.json index 2ddbebc18..fd517e8ff 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2803,6 +2803,7 @@ "open_sieve_editor": "פתח את עורך Sieve הגולמי", "fetch_error": "טעינת המסננים נכשלה", "expanded_view": "תצוגה מורחבת", + "show_rule_details": "הצגת התנאים והפעולות של כל כלל", "expanded_view_description": "הצג כללי סינון עם תנאים מפורטים ואבני פעולה", "if": "אם", "then": "ואז", @@ -2884,6 +2885,9 @@ "validation_period_empty": "יש להזין תאריך התחלה או סיום לטווח", "validation_period_invalid": "יש להזין תאריך תקין עם שעה לטווח, או להשאיר את השדה ריק", "apply_existing_period": "כלל עם טווח זמן פועל רק על הודעות שמגיעות בתוכו, ולכן לא ניתן להחיל אותו על הודעות קיימות.", + "apply_existing_in": "להחיל גם על ההודעות שכבר נמצאות בתיקייה {folder}", + "apply_existing_count": "{count, plural, one {להחיל גם על ההודעה שכבר נמצאת בתיקייה {folder}} other {להחיל גם על # ההודעות שכבר נמצאות בתיקייה {folder}}}", + "apply_existing_none": "עדיין אין הודעה בתיקייה {folder} שמתאימה לכלל הזה.", "period_status_scheduled": "מתוזמן", "period_status_active": "פעיל", "period_status_expired": "פג תוקף" diff --git a/locales/hu/common.json b/locales/hu/common.json index 40440385b..fbdfc8d5d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2909,6 +2909,7 @@ "open_sieve_editor": "Nyers Sieve szerkesztő megnyitása", "fetch_error": "Nem sikerült betölteni a szűrőket", "expanded_view": "Részletes nézet", + "show_rule_details": "Minden szabály feltételeinek és műveleteinek megjelenítése", "expanded_view_description": "Szűrőszabályok megjelenítése részletes feltétel- és műveletblokkokkal", "if": "Ha", "then": "Akkor", @@ -2987,6 +2988,9 @@ "validation_period_empty": "Adjon meg kezdő vagy befejező dátumot az időszakhoz", "validation_period_invalid": "Adjon meg érvényes dátumot időponttal az időszakhoz, vagy hagyja üresen a mezőt", "apply_existing_period": "Az időszakhoz kötött szabály csak az abban érkező üzenetekre hat, ezért meglévő üzenetekre nem alkalmazható.", + "apply_existing_in": "Alkalmazás a már itt lévő üzenetekre is: {folder}", + "apply_existing_count": "{count, plural, one {Alkalmazás a már itt lévő # üzenetre is: {folder}} other {Alkalmazás a már itt lévő # üzenetre is: {folder}}}", + "apply_existing_none": "Itt még egy üzenet sem felel meg ennek a szabálynak: {folder}.", "period_status_scheduled": "ütemezett", "period_status_active": "aktív", "period_status_expired": "lejárt" diff --git a/locales/it/common.json b/locales/it/common.json index 4c928e274..4bca997d1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Apri editor Sieve", "fetch_error": "Impossibile caricare i filtri", "expanded_view": "Vista espansa", + "show_rule_details": "Mostra condizioni e azioni di ogni regola", "expanded_view_description": "Mostra le regole dei filtri con blocchi dettagliati di condizioni e azioni", "if": "Se", "then": "Allora", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Inserisci una data di inizio o di fine per il periodo", "validation_period_invalid": "Inserisci una data valida con l'ora per il periodo oppure lascia vuoto il campo", "apply_existing_period": "Una regola con periodo agisce solo sui messaggi che arrivano al suo interno, quindi non si può applicare ai messaggi esistenti.", + "apply_existing_in": "Applica anche ai messaggi già in {folder}", + "apply_existing_count": "{count, plural, one {Applica anche al # messaggio già in {folder}} other {Applica anche ai # messaggi già in {folder}}}", + "apply_existing_none": "Nessun messaggio in {folder} corrisponde ancora a questa regola.", "period_status_scheduled": "programmata", "period_status_active": "attiva", "period_status_expired": "scaduta" diff --git a/locales/ja/common.json b/locales/ja/common.json index a4788a73e..221d08947 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Sieveスクリプトエディタを開く", "fetch_error": "フィルターの読み込みに失敗しました", "expanded_view": "詳細表示", + "show_rule_details": "各ルールの条件と動作を表示", "expanded_view_description": "フィルタールールを条件とアクションのブロックで表示", "if": "条件", "then": "実行", @@ -2984,6 +2985,9 @@ "validation_period_empty": "期間の開始日または終了日を入力してください", "validation_period_invalid": "期間には時刻付きの有効な日付を入力するか、欄を空のままにしてください", "apply_existing_period": "期間付きのルールは期間内に届いたメッセージにのみ適用されるため、既存のメッセージには適用できません。", + "apply_existing_in": "{folder} にあるメッセージにも適用する", + "apply_existing_count": "{count, plural, other {{folder} にある # 件にも適用する}}", + "apply_existing_none": "{folder} にこのルールに合うメッセージはまだありません。", "period_status_scheduled": "予定", "period_status_active": "有効", "period_status_expired": "期限切れ" diff --git a/locales/ko/common.json b/locales/ko/common.json index 8692e53c6..bce249371 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "원본 Sieve 에디터 열기", "fetch_error": "필터를 불러오지 못했어요", "expanded_view": "자세히 보기", + "show_rule_details": "각 규칙의 조건과 동작 표시", "expanded_view_description": "필터 규칙의 조건과 동작을 모두 펼쳐서 보여줘요", "if": "만약 (If)", "then": "다음 동작 실행 (Then)", @@ -2984,6 +2985,9 @@ "validation_period_empty": "기간의 시작일 또는 종료일을 입력해 주세요", "validation_period_invalid": "기간에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요", "apply_existing_period": "기간이 설정된 규칙은 그 기간에 도착한 메시지에만 적용되므로 기존 메시지에는 적용할 수 없어요.", + "apply_existing_in": "{folder}에 이미 있는 메일에도 적용", + "apply_existing_count": "{count, plural, other {{folder}에 이미 있는 메일 #개에도 적용}}", + "apply_existing_none": "{folder}에 이 규칙에 맞는 메일이 아직 없습니다.", "period_status_scheduled": "예정", "period_status_active": "활성", "period_status_expired": "만료" diff --git a/locales/lv/common.json b/locales/lv/common.json index 30ae51e41..a068c1422 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Atvērt Sieve redaktoru", "fetch_error": "Neizdevās ielādēt filtrus", "expanded_view": "Izvērstais skats", + "show_rule_details": "Rādīt katra noteikuma nosacījumus un darbības", "expanded_view_description": "Rādīt filtra noteikumus ar detalizētiem nosacījumiem un darbībām", "if": "Ja", "then": "Tad", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Norādiet laika posma sākuma vai beigu datumu", "validation_period_invalid": "Norādiet laika posmam derīgu datumu ar laiku vai atstājiet lauku tukšu", "apply_existing_period": "Noteikums ar laika posmu darbojas tikai ar ziņojumiem, kas pienāk šajā posmā, tāpēc to nevar lietot esošajiem ziņojumiem.", + "apply_existing_in": "Piemērot arī vēstulēm, kas jau ir mapē {folder}", + "apply_existing_count": "{count, plural, zero {Piemērot arī # vēstulēm, kas jau ir mapē {folder}} one {Piemērot arī # vēstulei, kas jau ir mapē {folder}} other {Piemērot arī # vēstulēm, kas jau ir mapē {folder}}}", + "apply_existing_none": "Mapē {folder} vēl nav nevienas vēstules, kas atbilst šim noteikumam.", "period_status_scheduled": "ieplānots", "period_status_active": "aktīvs", "period_status_expired": "beidzies" diff --git a/locales/mn/common.json b/locales/mn/common.json index 677689faa..08f55f409 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "Raw Sieve редакторыг нээнэ үү", "fetch_error": "Шүүлтүүрийг ачаалж чадсангүй", "expanded_view": "Өргөтгөсөн харах", + "show_rule_details": "Дүрэм бүрийн нөхцөл, үйлдлийг харуулах", "expanded_view_description": "Шүүлтүүрийн дүрмийг нарийвчилсан нөхцөл, үйлдлийн блокоор харуулах", "if": "Хэрэв", "then": "Дараа нь", @@ -2991,6 +2992,9 @@ "validation_period_empty": "Хугацааны эхлэх эсвэл төгсгөлийн огноог оруулна уу", "validation_period_invalid": "Хугацаанд цагтай хүчинтэй огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү", "apply_existing_period": "Хугацаатай дүрэм зөвхөн тэр хугацаанд ирсэн мессежид үйлчилдэг тул одоо байгаа мессежид хэрэглэх боломжгүй.", + "apply_existing_in": "{folder} доторх байгаа захианд бас хэрэглэх", + "apply_existing_count": "{count, plural, one {{folder} доторх байгаа # захианд бас хэрэглэх} other {{folder} доторх байгаа # захианд бас хэрэглэх}}", + "apply_existing_none": "{folder} дотор энэ дүрэмд тохирох захиа одоогоор алга.", "period_status_scheduled": "төлөвлөсөн", "period_status_active": "идэвхтэй", "period_status_expired": "хугацаа дууссан" diff --git a/locales/nb/common.json b/locales/nb/common.json index bc849a939..fad34e00d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "Åpne redigereren for Sieve-kode", "fetch_error": "Kunne ikke laste inn filtre", "expanded_view": "Utvidet visning", + "show_rule_details": "Vis betingelsene og handlingene for hver regel", "expanded_view_description": "Vis filterregler med detaljerte blokker for vilkår og handlinger", "if": "Hvis", "then": "Gjør følgende", @@ -2991,6 +2992,9 @@ "validation_period_empty": "Angi en start- eller sluttdato for perioden", "validation_period_invalid": "Angi en gyldig dato med klokkeslett for perioden, eller la feltet stå tomt", "apply_existing_period": "En regel med periode virker bare på meldinger som kommer i perioden, og kan derfor ikke brukes på eksisterende meldinger.", + "apply_existing_in": "Bruk også på meldingene som allerede er i {folder}", + "apply_existing_count": "{count, plural, one {Bruk også på den # meldingen som allerede er i {folder}} other {Bruk også på de # meldingene som allerede er i {folder}}}", + "apply_existing_none": "Ingen meldinger i {folder} passer med regelen ennå.", "period_status_scheduled": "planlagt", "period_status_active": "aktiv", "period_status_expired": "utløpt" diff --git a/locales/nl/common.json b/locales/nl/common.json index 40c5b7aba..0c4a13c91 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Sieve-scripteditor openen", "fetch_error": "Filters konden niet worden geladen", "expanded_view": "Uitgebreide weergave", + "show_rule_details": "Voorwaarden en acties van elke regel tonen", "expanded_view_description": "Filterregels weergeven met gedetailleerde voorwaarde- en actieblokken", "if": "Als", "then": "Dan", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Vul een start- of einddatum voor de periode in", "validation_period_invalid": "Vul voor de periode een geldige datum met tijd in, of laat het veld leeg", "apply_existing_period": "Een regel met een periode werkt alleen op berichten die binnen die periode binnenkomen en kan dus niet op bestaande berichten worden toegepast.", + "apply_existing_in": "Ook toepassen op de berichten die al in {folder} staan", + "apply_existing_count": "{count, plural, one {Ook toepassen op het # bericht dat al in {folder} staat} other {Ook toepassen op de # berichten die al in {folder} staan}}", + "apply_existing_none": "Nog geen bericht in {folder} past bij deze regel.", "period_status_scheduled": "gepland", "period_status_active": "actief", "period_status_expired": "verlopen" diff --git a/locales/pl/common.json b/locales/pl/common.json index 977c2f348..7f0c65de7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Otwórz surowy edytor Sieve", "fetch_error": "Nie udało się załadować filtrów", "expanded_view": "Widok rozszerzony", + "show_rule_details": "Pokaż warunki i działania każdej reguły", "expanded_view_description": "Pokazuj reguły filtrów ze szczegółowymi blokami warunków i akcji", "if": "Jeśli", "then": "Wtedy", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Podaj datę rozpoczęcia lub zakończenia okresu", "validation_period_invalid": "Podaj prawidłową datę z godziną dla okresu albo pozostaw pole puste", "apply_existing_period": "Reguła z okresem działa tylko na wiadomości, które przychodzą w tym okresie, dlatego nie można jej zastosować do istniejących wiadomości.", + "apply_existing_in": "Zastosuj też do wiadomości, które już są w folderze {folder}", + "apply_existing_count": "{count, plural, one {Zastosuj też do # wiadomości, która już jest w folderze {folder}} few {Zastosuj też do # wiadomości, które już są w folderze {folder}} many {Zastosuj też do # wiadomości, które już są w folderze {folder}} other {Zastosuj też do # wiadomości, które już są w folderze {folder}}}", + "apply_existing_none": "Żadna wiadomość w folderze {folder} nie pasuje jeszcze do tej reguły.", "period_status_scheduled": "zaplanowana", "period_status_active": "aktywna", "period_status_expired": "wygasła" diff --git a/locales/pt/common.json b/locales/pt/common.json index 4b3b27d99..aa6151795 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Abrir editor Sieve", "fetch_error": "Falha ao carregar filtros", "expanded_view": "Vista expandida", + "show_rule_details": "Mostrar as condições e ações de cada regra", "expanded_view_description": "Mostrar regras de filtro com blocos detalhados de condições e ações", "if": "Se", "then": "Então", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Indique uma data de início ou de fim para o período", "validation_period_invalid": "Indique uma data válida com hora para o período ou deixe o campo vazio", "apply_existing_period": "Uma regra com período só atua sobre as mensagens que chegam dentro dele, por isso não pode ser aplicada às mensagens existentes.", + "apply_existing_in": "Aplicar também às mensagens que já estão em {folder}", + "apply_existing_count": "{count, plural, one {Aplicar também à # mensagem que já está em {folder}} other {Aplicar também às # mensagens que já estão em {folder}}}", + "apply_existing_none": "Nenhuma mensagem em {folder} corresponde ainda a esta regra.", "period_status_scheduled": "agendada", "period_status_active": "ativa", "period_status_expired": "expirada" diff --git a/locales/ro/common.json b/locales/ro/common.json index 60c77c966..2e7d95aed 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "Deschideți editorul de fișiere Sieve în format brut", "fetch_error": "Nu s-au putut încărca filtrele", "expanded_view": "Vizualizare extinsă", + "show_rule_details": "Afișați condițiile și acțiunile fiecărei reguli", "expanded_view_description": "Afișați regulile de filtrare cu blocuri detaliate de condiții și acțiuni", "if": "Dacă", "then": "Apoi", @@ -2991,6 +2992,9 @@ "validation_period_empty": "Introduceți o dată de începere sau de încheiere pentru perioadă", "validation_period_invalid": "Introduceți o dată validă cu oră pentru perioadă sau lăsați câmpul gol", "apply_existing_period": "O regulă cu perioadă acționează doar asupra mesajelor care sosesc în acea perioadă, deci nu poate fi aplicată mesajelor existente.", + "apply_existing_in": "Aplicați și mesajelor care sunt deja în {folder}", + "apply_existing_count": "{count, plural, one {Aplicați și # mesajului care este deja în {folder}} few {Aplicați și celor # mesaje care sunt deja în {folder}} other {Aplicați și celor # de mesaje care sunt deja în {folder}}}", + "apply_existing_none": "Niciun mesaj din {folder} nu se potrivește încă acestei reguli.", "period_status_scheduled": "programată", "period_status_active": "activă", "period_status_expired": "expirată" diff --git a/locales/ru/common.json b/locales/ru/common.json index 3a0c9fcec..3d4dc098f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Открыть редактор Sieve", "fetch_error": "Не удалось загрузить фильтры", "expanded_view": "Расширенный вид", + "show_rule_details": "Показывать условия и действия каждого правила", "expanded_view_description": "Показывать правила фильтрации с подробными блоками условий и действий", "if": "Если", "then": "То", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Укажите дату начала или окончания периода", "validation_period_invalid": "Укажите для периода корректную дату со временем или оставьте поле пустым", "apply_existing_period": "Правило с периодом действует только на письма, пришедшие в этот период, поэтому его нельзя применить к уже имеющимся письмам.", + "apply_existing_in": "Применить и к письмам, которые уже есть в папке {folder}", + "apply_existing_count": "{count, plural, one {Применить и к # письму, которое уже есть в папке {folder}} few {Применить и к # письмам, которые уже есть в папке {folder}} many {Применить и к # письмам, которые уже есть в папке {folder}} other {Применить и к # письма, которые уже есть в папке {folder}}}", + "apply_existing_none": "В папке {folder} пока нет писем под это правило.", "period_status_scheduled": "запланировано", "period_status_active": "активно", "period_status_expired": "истекло" diff --git a/locales/sk/common.json b/locales/sk/common.json index 502164d6d..975bfd892 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "Otvoriť textový editor Sieve", "fetch_error": "Nepodarilo sa načítať filtre", "expanded_view": "Rozšírené zobrazenie", + "show_rule_details": "Zobraziť podmienky a akcie každého pravidla", "expanded_view_description": "Zobrazovať pravidlá filtrov s podrobnými blokmi podmienok a akcií", "if": "Ak", "then": "Potom", @@ -2991,6 +2992,9 @@ "validation_period_empty": "Zadajte dátum začiatku alebo ukončenia obdobia", "validation_period_invalid": "Zadajte pre obdobie platný dátum aj s časom alebo pole nechajte prázdne", "apply_existing_period": "Pravidlo s obdobím pôsobí len na správy, ktoré v ňom prídu, preto ho nemožno použiť na existujúce správy.", + "apply_existing_in": "Použiť aj na správy, ktoré už sú v priečinku {folder}", + "apply_existing_count": "{count, plural, one {Použiť aj na # správu, ktorá už je v priečinku {folder}} few {Použiť aj na # správy, ktoré už sú v priečinku {folder}} many {Použiť aj na # správy, ktoré už sú v priečinku {folder}} other {Použiť aj na # správ, ktoré už sú v priečinku {folder}}}", + "apply_existing_none": "Tomuto pravidlu zatiaľ nezodpovedá žiadna správa v priečinku {folder}.", "period_status_scheduled": "naplánované", "period_status_active": "aktívne", "period_status_expired": "uplynulo" diff --git a/locales/th/common.json b/locales/th/common.json index 852be7f21..cf8c4dc09 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -5913,6 +5913,7 @@ "open_sieve_editor": "เปิดตัวแก้ไข Sieve โดยตรง", "fetch_error": "โหลดตัวกรองไม่สำเร็จ", "expanded_view": "มุมมองขยาย", + "show_rule_details": "แสดงเงื่อนไขและการทำงานของแต่ละกฎ", "expanded_view_description": "แสดงกฎตัวกรองพร้อมบล็อกเงื่อนไขและการดำเนินการโดยละเอียด", "if": "ถ้า (If)", "then": "แล้ว (Then)", @@ -5991,6 +5992,9 @@ "validation_period_empty": "ป้อนวันที่เริ่มต้นหรือวันที่สิ้นสุดสำหรับช่วงเวลา", "validation_period_invalid": "ป้อนวันที่และเวลาที่ถูกต้อง หรือเว้นว่างไว้", "apply_existing_period": "กฎที่มีการกำหนดช่วงเวลาจะมีผลกับข้อความที่มาถึงในช่วงเวลานั้นเท่านั้น จึงไม่สามารถใช้กับข้อความที่มีอยู่ได้", + "apply_existing_in": "ใช้กับข้อความที่อยู่ใน {folder} แล้วด้วย", + "apply_existing_count": "{count, plural, other {ใช้กับข้อความ # ฉบับที่อยู่ใน {folder} แล้วด้วย}}", + "apply_existing_none": "ยังไม่มีข้อความใน {folder} ที่ตรงกับกฎนี้", "period_status_scheduled": "กำหนดเวลาแล้ว", "period_status_active": "ใช้งานอยู่", "period_status_expired": "หมดอายุแล้ว" diff --git a/locales/tr/common.json b/locales/tr/common.json index 8e246b38f..c720854cb 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Ham Sieve düzenleyicisini aç", "fetch_error": "Filtreler yüklenemedi", "expanded_view": "Genişletilmiş görünüm", + "show_rule_details": "Her kuralın koşullarını ve işlemlerini göster", "expanded_view_description": "Filtre kurallarını ayrıntılı koşul ve işlem bloklarıyla göster", "if": "Eğer", "then": "O zaman", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Dönem için bir başlangıç veya bitiş tarihi girin", "validation_period_invalid": "Dönem için saatiyle birlikte geçerli bir tarih girin veya alanı boş bırakın", "apply_existing_period": "Dönemi olan bir kural yalnızca o dönemde gelen iletilere uygulanır, bu nedenle mevcut iletilere uygulanamaz.", + "apply_existing_in": "{folder} içindeki mevcut iletilere de uygula", + "apply_existing_count": "{count, plural, one {{folder} içindeki # mevcut iletiye de uygula} other {{folder} içindeki # mevcut iletiye de uygula}}", + "apply_existing_none": "{folder} içinde bu kurala uyan ileti henüz yok.", "period_status_scheduled": "planlandı", "period_status_active": "etkin", "period_status_expired": "süresi doldu" diff --git a/locales/uk/common.json b/locales/uk/common.json index 52bf3725a..340024098 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "Відкрийте редактор Raw Sieve", "fetch_error": "Не вдалося завантажити фільтри", "expanded_view": "Розгорнутий вигляд", + "show_rule_details": "Показувати умови й дії кожного правила", "expanded_view_description": "Показати правила фільтрації з детальними умовами та блоками дій", "if": "Якщо", "then": "Потім", @@ -2984,6 +2985,9 @@ "validation_period_empty": "Вкажіть дату початку або закінчення періоду", "validation_period_invalid": "Вкажіть для періоду дійсну дату з часом або залиште поле порожнім", "apply_existing_period": "Правило з періодом діє лише на листи, що надходять у цей період, тому його не можна застосувати до наявних листів.", + "apply_existing_in": "Застосувати й до листів, які вже є в теці {folder}", + "apply_existing_count": "{count, plural, one {Застосувати й до # листа, який уже є в теці {folder}} few {Застосувати й до # листів, які вже є в теці {folder}} many {Застосувати й до # листів, які вже є в теці {folder}} other {Застосувати й до # листа, які вже є в теці {folder}}}", + "apply_existing_none": "У теці {folder} поки немає листів під це правило.", "period_status_scheduled": "заплановано", "period_status_active": "активне", "period_status_expired": "завершено" diff --git a/locales/vi/common.json b/locales/vi/common.json index a02cf42d1..b717d7c32 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -2925,6 +2925,7 @@ "open_sieve_editor": "Mở trình chỉnh sửa mã Sieve", "fetch_error": "Không thể tải bộ lọc", "expanded_view": "Chế độ xem mở rộng", + "show_rule_details": "Hiện điều kiện và hành động của từng quy tắc", "expanded_view_description": "Hiển thị quy tắc lọc với các khối hành động và điều kiện chi tiết", "if": "Nếu", "then": "Sau đó", @@ -3003,6 +3004,9 @@ "validation_period_empty": "Nhập ngày bắt đầu hoặc ngày kết thúc cho khoảng thời gian", "validation_period_invalid": "Nhập ngày hợp lệ cùng với thời gian trong khoảng thời gian hoặc để trống trường", "apply_existing_period": "Quy tắc có giới hạn thời gian chỉ áp dụng cho thư đến trong khoảng đó, nên không thể chạy trên các thư hiện có.", + "apply_existing_in": "Áp dụng cả cho các thư đã có trong {folder}", + "apply_existing_count": "{count, plural, other {Áp dụng cả cho # thư đã có trong {folder}}}", + "apply_existing_none": "Chưa có thư nào trong {folder} khớp với quy tắc này.", "period_status_scheduled": "Theo lịch trình", "period_status_active": "Hoạt động", "period_status_expired": "Hết hạn" diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index afe32e3ed..31ec70b4b 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2913,6 +2913,7 @@ "open_sieve_editor": "開啟原始 Sieve 編輯器", "fetch_error": "無法載入篩選規則", "expanded_view": "展開檢視", + "show_rule_details": "顯示每條規則的條件和動作", "expanded_view_description": "顯示包含詳細條件與動作區塊的篩選規則", "if": "如果", "then": "然後", @@ -2991,6 +2992,9 @@ "validation_period_empty": "請輸入期間的開始日期或結束日期", "validation_period_invalid": "請為期間輸入含時間的有效日期,或將該欄位留空", "apply_existing_period": "設定了期間的規則只作用於在該期間內收到的郵件,因此無法套用到既有郵件。", + "apply_existing_in": "同時套用到{folder}中既有的郵件", + "apply_existing_count": "{count, plural, other {同時套用到{folder}中既有的 # 封郵件}}", + "apply_existing_none": "{folder}中還沒有符合此規則的郵件。", "period_status_scheduled": "尚未開始", "period_status_active": "生效中", "period_status_expired": "已過期" diff --git a/locales/zh/common.json b/locales/zh/common.json index a5fd459b2..ca69b6e4b 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2906,6 +2906,7 @@ "open_sieve_editor": "打开原始 Sieve 编辑器", "fetch_error": "加载过滤器失败", "expanded_view": "展开视图", + "show_rule_details": "显示每条规则的条件和操作", "expanded_view_description": "显示包含详细条件和操作的过滤规则", "if": "如果", "then": "然后", @@ -2984,6 +2985,9 @@ "validation_period_empty": "请输入时间段的开始日期或结束日期", "validation_period_invalid": "请为时间段输入带时间的有效日期,或将该字段留空", "apply_existing_period": "设置了时间段的规则只作用于在该时间段内收到的邮件,因此无法应用于已有邮件。", + "apply_existing_in": "同时应用于{folder}中已有的邮件", + "apply_existing_count": "{count, plural, other {同时应用于{folder}中已有的 # 封邮件}}", + "apply_existing_none": "{folder}中还没有符合此规则的邮件。", "period_status_scheduled": "未开始", "period_status_active": "生效中", "period_status_expired": "已过期" From 760a400074a1a70ee5b11bcfe3a201c14f3d3729 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:04:03 +0100 Subject: [PATCH 2/5] feat: turn out of office off from the banner in the mail sidebar --- .../sidebar-vacation-banner.test.tsx | 26 ++++++++- components/layout/sidebar.tsx | 54 +++++++++++++++---- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/vi/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 32 files changed, 99 insertions(+), 11 deletions(-) diff --git a/components/layout/__tests__/sidebar-vacation-banner.test.tsx b/components/layout/__tests__/sidebar-vacation-banner.test.tsx index 1d2c8ff25..a34168eed 100644 --- a/components/layout/__tests__/sidebar-vacation-banner.test.tsx +++ b/components/layout/__tests__/sidebar-vacation-banner.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; // "Vacation responder is active" opens Settings > Out of Office, not @@ -21,10 +21,13 @@ vi.mock('@/hooks/use-mailbox-drop', () => ({ })); vi.mock('@/hooks/use-tag-drop', () => ({ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }) })); vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); const { Sidebar } = await import('../sidebar'); const { useVacationStore } = await import('@/stores/vacation-store'); const { buildSettingsPath } = await import('@/lib/deep-links'); +const { useAuthStore } = await import('@/stores/auth-store'); describe('vacation banner', () => { it('opens the Out of Office settings', () => { @@ -34,4 +37,25 @@ describe('vacation banner', () => { expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation')); expect(buildSettingsPath('vacation')).not.toBe('/settings'); }); + + // settings-p2-12: the banner says "Out of office is on", as the setting is + // called, and turns it off without a trip to Settings, like Gmail's "End now". + it('turns out of office off on the server', async () => { + const update = vi.fn(async () => true); + useAuthStore.setState({ client: {} as never }); + useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never); + render(); + fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' })); + await waitFor(() => expect(update).toHaveBeenCalledWith(expect.anything(), { isEnabled: false }, undefined)); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('says so when turning it off fails', async () => { + const update = vi.fn(async () => { throw new Error('down'); }); + useAuthStore.setState({ client: {} as never }); + useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never); + render(); + fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('vacation_save_failed')); + }); }); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index aaf3672e5..3b41225c1 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -76,6 +76,7 @@ import { sidebarFolded, useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { unifiedLoadProgress } from "@/lib/unified-mailbox"; import { useVacationStore } from "@/stores/vacation-store"; +import { useManagedAccountStore } from "@/stores/managed-account-store"; import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store"; @@ -1070,26 +1071,59 @@ function DemoBanner({ ruled }: { ruled: boolean }) { ); } -function VacationBanner() { +export function VacationBanner() { const t = useTranslations('sidebar'); + const tNotifications = useTranslations('notifications'); + const tFilters = useTranslations('settings.filters'); const router = useRouter(); - const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported }))); + const { isEnabled, isSupported, isSaving } = useVacationStore(useShallow((state) => ({ + isEnabled: state.isEnabled, isSupported: state.isSupported, isSaving: state.isSaving, + }))); + // The store holds the reply of the account Settings last loaded it for. + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); + const client = useAuthStore((s) => s.client); + const [turningOff, setTurningOff] = useState(false); if (!isSupported || !isEnabled) return null; + // Like Gmail's "End now": off on the server, without opening Settings. + const turnOff = async () => { + if (!client || turningOff) return; + setTurningOff(true); + try { + await useVacationStore.getState().updateVacationResponse(client, { isEnabled: false }, managedAccountId ?? undefined); + } catch { + toast.error(tNotifications('vacation_save_failed')); + } finally { + setTurningOff(false); + } + }; + return ( - + + + ); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 1bd640de3..7d6d7b68c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -174,6 +174,7 @@ }, "clear_search": "مسح البحث", "vacation_active": "الرد التلقائي أثناء الغياب مفعّل", + "vacation_turn_off": "إيقاف", "demo_banner": "وضع تجريبي", "demo_reset": "إعادة تعيين", "demo_tour": "جولة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 1d5b3f23c..adcf4c6b0 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Neteja la cerca", "vacation_active": "La resposta automàtica està activa", + "vacation_turn_off": "Desactiva", "demo_banner": "Mode de demostració", "demo_reset": "Reinicia", "demo_tour": "Visita guiada", diff --git a/locales/cs/common.json b/locales/cs/common.json index 0b2185b9f..b8e8a6f83 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Vymazat hledání", "vacation_active": "Automatická odpověď je aktivní", + "vacation_turn_off": "Vypnout", "demo_banner": "Demo režim", "demo_reset": "Resetovat", "demo_tour": "Průvodce", diff --git a/locales/da/common.json b/locales/da/common.json index c851d2dff..8218e8e43 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Ryd søgning", "vacation_active": "Fraværsbeskeder er aktive", + "vacation_turn_off": "Slå fra", "demo_banner": "Demo-tilstand", "demo_reset": "Nulstil", "demo_tour": "Rundvisning", diff --git a/locales/de/common.json b/locales/de/common.json index 3bc9f148f..f1a53aff3 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Suche löschen", "vacation_active": "Abwesenheitsnotiz ist aktiv", + "vacation_turn_off": "Ausschalten", "demo_banner": "Demo-Modus", "demo_reset": "Zurücksetzen", "demo_tour": "Tour", diff --git a/locales/en/common.json b/locales/en/common.json index 1582cb279..4c02c6e91 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Clear search", "vacation_active": "Vacation responder is active", + "vacation_turn_off": "Turn off", "demo_banner": "Demo Mode", "demo_reset": "Reset", "demo_tour": "Tour", diff --git a/locales/es/common.json b/locales/es/common.json index d44f166af..62b010314 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Limpiar búsqueda", "vacation_active": "Respuesta automática activa", + "vacation_turn_off": "Desactivar", "demo_banner": "Modo demo", "demo_reset": "Restablecer", "demo_tour": "Tour", diff --git a/locales/et/common.json b/locales/et/common.json index 29c3c1e9e..e635d900e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Tühjenda otsing", "vacation_active": "Puhkuse vastaja on aktiivne", + "vacation_turn_off": "Lülita välja", "demo_banner": "Demorežiim", "demo_reset": "Lähtesta", "demo_tour": "Tutvustus", diff --git a/locales/fa/common.json b/locales/fa/common.json index 7adb7bd7c..04aafb11f 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -174,6 +174,7 @@ }, "clear_search": "پاک کردن جستجو", "vacation_active": "پاسخ‌گوی خودکار فعال است", + "vacation_turn_off": "خاموش کردن", "demo_banner": "حالت آزمایشی", "demo_reset": "بازنشانی", "demo_tour": "تور", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3f042bf5f..423000310 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Effacer la recherche", "vacation_active": "Répondeur d'absence activé", + "vacation_turn_off": "Désactiver", "demo_banner": "Mode démo", "demo_reset": "Réinitialiser", "demo_tour": "Visite", diff --git a/locales/he/common.json b/locales/he/common.json index fd517e8ff..7674fe3c4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -165,6 +165,7 @@ }, "clear_search": "נקה חיפוש", "vacation_active": "משיב חופשה פעיל", + "vacation_turn_off": "כיבוי", "demo_banner": "מצב הדגמה", "demo_reset": "אִתחוּל", "demo_tour": "סִיוּר", diff --git a/locales/hu/common.json b/locales/hu/common.json index fbdfc8d5d..5fba59ac3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Keresés törlése", "vacation_active": "Automatikus válasz aktív", + "vacation_turn_off": "Kikapcsolás", "demo_banner": "Demó mód", "demo_reset": "Visszaállítás", "demo_tour": "Bemutató", diff --git a/locales/it/common.json b/locales/it/common.json index 4bca997d1..94ded4537 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Cancella ricerca", "vacation_active": "Risponditore automatico attivo", + "vacation_turn_off": "Disattiva", "demo_banner": "Modalità demo", "demo_reset": "Reimposta", "demo_tour": "Tour", diff --git a/locales/ja/common.json b/locales/ja/common.json index 221d08947..4a5f239e9 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -174,6 +174,7 @@ }, "clear_search": "検索をクリア", "vacation_active": "不在応答が有効です", + "vacation_turn_off": "オフにする", "demo_banner": "デモモード", "demo_reset": "リセット", "demo_tour": "ツアー", diff --git a/locales/ko/common.json b/locales/ko/common.json index bce249371..1171695ee 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -174,6 +174,7 @@ }, "clear_search": "검색어 지우기", "vacation_active": "부재중 자동 응답이 켜져 있어요", + "vacation_turn_off": "끄기", "demo_banner": "데모 모드", "demo_reset": "초기화", "demo_tour": "둘러보기", diff --git a/locales/lv/common.json b/locales/lv/common.json index a068c1422..388d46f2b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Notīrīt meklēšanu", "vacation_active": "Atvaļinājuma atbilde ir aktīva", + "vacation_turn_off": "Izslēgt", "demo_banner": "Demo režīms", "demo_reset": "Atiestatīt", "demo_tour": "Ekskursija", diff --git a/locales/mn/common.json b/locales/mn/common.json index 08f55f409..9cd8d8911 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Хайлтыг арилгах", "vacation_active": "Амралтын хариулагч идэвхтэй байна", + "vacation_turn_off": "Унтраах", "demo_banner": "Демо горим", "demo_reset": "Дахин тохируулах", "demo_tour": "Аялал", diff --git a/locales/nb/common.json b/locales/nb/common.json index fad34e00d..6d3edf83e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Tøm søket", "vacation_active": "Feriesvar er aktivt", + "vacation_turn_off": "Slå av", "demo_banner": "Demomodus", "demo_reset": "Tilbakestill", "demo_tour": "Omvisning", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0c4a13c91..094fb1816 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Zoekopdracht wissen", "vacation_active": "Afwezigheidsmelder is actief", + "vacation_turn_off": "Uitzetten", "demo_banner": "Demomodus", "demo_reset": "Resetten", "demo_tour": "Rondleiding", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7f0c65de7..9d86c1be0 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Wyczyść wyszukiwanie", "vacation_active": "Autoresponder wakacyjny jest aktywny", + "vacation_turn_off": "Wyłącz", "demo_banner": "Tryb demo", "demo_reset": "Resetuj", "demo_tour": "Przewodnik", diff --git a/locales/pt/common.json b/locales/pt/common.json index aa6151795..9b6c3728d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Limpar busca", "vacation_active": "Resposta automática ativa", + "vacation_turn_off": "Desativar", "demo_banner": "Modo de demonstração", "demo_reset": "Repor", "demo_tour": "Tour", diff --git a/locales/ro/common.json b/locales/ro/common.json index 2e7d95aed..b01adf34d 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Șterge căutarea", "vacation_active": "Răspunsul automat de vacanță este activ", + "vacation_turn_off": "Dezactivați", "demo_banner": "Modul demo", "demo_reset": "Resetare", "demo_tour": "Tur de prezentare", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3d4dc098f..deacf0b3f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Очистить поиск", "vacation_active": "Автоответ на отпуск активен", + "vacation_turn_off": "Выключить", "demo_banner": "Режим демо", "demo_reset": "Сбросить", "demo_tour": "Тур", diff --git a/locales/sk/common.json b/locales/sk/common.json index 975bfd892..f977c9118 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Vymazať hľadanie", "vacation_active": "Automatická odpoveď je aktívna", + "vacation_turn_off": "Vypnúť", "demo_banner": "Demo režim", "demo_reset": "Resetovať", "demo_tour": "Sprievodca", diff --git a/locales/th/common.json b/locales/th/common.json index cf8c4dc09..c980ea48c 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -320,6 +320,7 @@ }, "clear_search": "ล้างการค้นหา", "vacation_active": "เปิดใช้งานการตอบกลับอัตโนมัติอยู่", + "vacation_turn_off": "ปิด", "demo_banner": "โหมดทดลองใช้งาน (Demo)", "demo_reset": "รีเซ็ตข้อมูล", "demo_tour": "แนะนำการใช้งาน", diff --git a/locales/tr/common.json b/locales/tr/common.json index c720854cb..61a54bed0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Aramayı temizle", "vacation_active": "Tatil otomatik yanıtı aktif", + "vacation_turn_off": "Kapat", "demo_banner": "Demo Modu", "demo_reset": "Sıfırla", "demo_tour": "Tur", diff --git a/locales/uk/common.json b/locales/uk/common.json index 340024098..f9d95697b 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Очистити пошук", "vacation_active": "Автовідповідач активний", + "vacation_turn_off": "Вимкнути", "demo_banner": "Демонстраційний режим", "demo_reset": "Скинути", "demo_tour": "Тур", diff --git a/locales/vi/common.json b/locales/vi/common.json index b717d7c32..ddc49ed4c 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -174,6 +174,7 @@ }, "clear_search": "Xóa tìm kiếm", "vacation_active": "Đang bật trả lời tự động khi vắng mặt", + "vacation_turn_off": "Tắt", "demo_banner": "Chế độ demo", "demo_reset": "Đặt lại", "demo_tour": "Tham quan", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 31ec70b4b..34962695d 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -174,6 +174,7 @@ }, "clear_search": "清除搜尋", "vacation_active": "休假自動回覆已開啟", + "vacation_turn_off": "關閉", "demo_banner": "示範模式", "demo_reset": "重設", "demo_tour": "功能導覽", diff --git a/locales/zh/common.json b/locales/zh/common.json index ca69b6e4b..7b6c15c5c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -174,6 +174,7 @@ }, "clear_search": "清除搜索", "vacation_active": "假期自动回复已开启", + "vacation_turn_off": "关闭", "demo_banner": "演示模式", "demo_reset": "重置", "demo_tour": "引导", From 17350c45ca3a96357a1bd42243b516d68fc724d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:04:06 +0100 Subject: [PATCH 3/5] fix: give the out-of-office reply a short toolbar and date fields that say what empty means --- .../rich-text-editor-short-toolbar.test.tsx | 28 +++++++++++++++ components/email/rich-text-editor.tsx | 12 +++++++ .../__tests__/vacation-forward.test.tsx | 3 ++ .../__tests__/vacation-message.test.tsx | 34 +++++++++++++++++-- components/settings/vacation-settings.tsx | 30 ++++++++++++---- locales/ar/common.json | 2 ++ locales/ca/common.json | 2 ++ locales/cs/common.json | 2 ++ locales/da/common.json | 2 ++ locales/de/common.json | 2 ++ locales/en/common.json | 2 ++ locales/es/common.json | 2 ++ locales/et/common.json | 2 ++ locales/fa/common.json | 2 ++ locales/fr/common.json | 2 ++ locales/he/common.json | 2 ++ locales/hu/common.json | 2 ++ locales/it/common.json | 2 ++ locales/ja/common.json | 2 ++ locales/ko/common.json | 2 ++ locales/lv/common.json | 2 ++ locales/mn/common.json | 2 ++ locales/nb/common.json | 2 ++ locales/nl/common.json | 2 ++ locales/pl/common.json | 2 ++ locales/pt/common.json | 2 ++ locales/ro/common.json | 2 ++ locales/ru/common.json | 2 ++ locales/sk/common.json | 2 ++ locales/th/common.json | 2 ++ locales/tr/common.json | 2 ++ locales/uk/common.json | 2 ++ locales/vi/common.json | 2 ++ locales/zh-TW/common.json | 2 ++ locales/zh/common.json | 2 ++ 35 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 components/email/__tests__/rich-text-editor-short-toolbar.test.tsx diff --git a/components/email/__tests__/rich-text-editor-short-toolbar.test.tsx b/components/email/__tests__/rich-text-editor-short-toolbar.test.tsx new file mode 100644 index 000000000..3cacf45ba --- /dev/null +++ b/components/email/__tests__/rich-text-editor-short-toolbar.test.tsx @@ -0,0 +1,28 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import React from 'react'; + +import { RichTextEditor } from '../rich-text-editor'; + +// settings-p2-19: a three-line out-of-office reply doesn't need the +// composer's headings, code block and table. The short toolbar keeps bold, +// italic, underline, the lists and link. + +async function setup(toolbar?: 'full' | 'short') { + let ready = false; + render( {}} toolbar={toolbar} onEditorReady={() => { ready = true; }} />); + await waitFor(() => expect(ready).toBe(true)); + return Array.from(screen.getByRole('toolbar', { name: 'formatting' }).querySelectorAll('[data-toolbar-item]')) + .map((el) => el.dataset.toolbarItem); +} + +describe('the short formatting toolbar', () => { + it('has only bold, italic, underline, the lists and link', async () => { + expect(await setup('short')).toEqual(['bold', 'italic', 'underline', 'bullet-list', 'ordered-list', 'link']); + }); + + it('leaves the full toolbar as it was', async () => { + const items = await setup(); + for (const name of ['font-size', 'heading-1', 'code-block', 'table', 'text-color', 'undo']) expect(items).toContain(name); + }); +}); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 952a9da30..61b48b436 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -178,6 +178,11 @@ interface RichTextEditorProps { * away entirely. */ toolbarPlacement?: 'top' | 'bottom' | 'hidden'; + /** + * "short" keeps bold, italic, underline, the lists and link: enough for a + * few lines such as an out-of-office reply. "full" is the composer's. + */ + toolbar?: 'full' | 'short'; /** The editing area's accessible name ("Message"). */ ariaLabel?: string; /** The body failed validation: exposed as aria-invalid on the editing area. */ @@ -478,6 +483,7 @@ export function RichTextEditor({ onMentionAdd, collapseToolbarOnMobile = false, toolbarPlacement = 'top', + toolbar = 'full', ariaLabel, ariaInvalid, ariaDescribedBy, @@ -911,7 +917,9 @@ export function RichTextEditor({ // A group that doesn't fit renders in the "More formatting" panel instead, in row order. const folded = new Set(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id)); const overflow: React.ReactNode[] = []; + const short = toolbar === 'short'; const place = (group: FoldableGroup, node: React.ReactNode) => { + if (short) return null; if (!folded.has(group)) return node; overflow.push({node}); return null; @@ -1016,6 +1024,7 @@ export function RichTextEditor({ > {/* Font size - two baseline-aligned A's read as "size" at a glance; once a size is chosen the button shows that number instead. */} + {!short && <>
+ } + {!short && <> )}
+ } diff --git a/components/settings/__tests__/vacation-forward.test.tsx b/components/settings/__tests__/vacation-forward.test.tsx index bf2620166..8ca2b5167 100644 --- a/components/settings/__tests__/vacation-forward.test.tsx +++ b/components/settings/__tests__/vacation-forward.test.tsx @@ -378,6 +378,9 @@ describe('the vacation period in the card', () => { it('checks the fields again on save, which a field filled in only half may never have told', async () => { const server = stalwart([rule('a')]); await openCard(server); + // Typing in it: an empty field left alone turns into a text field with a + // placeholder (settings-p2-19). + fireEvent.focus(dateField(1)); fireEvent.change(dateField(1), { target: { value: '' } }); halfFilledIn(dateField(1), true); expect(await screen.findByText('warnings.invalid_date', undefined, { timeout: 3000 })).toBeInTheDocument(); diff --git a/components/settings/__tests__/vacation-message.test.tsx b/components/settings/__tests__/vacation-message.test.tsx index 09acc342a..1ef021a01 100644 --- a/components/settings/__tests__/vacation-message.test.tsx +++ b/components/settings/__tests__/vacation-message.test.tsx @@ -18,8 +18,8 @@ vi.mock('next-intl', () => { }); // The editor hands the card its HTML as typed. vi.mock('@/components/email/rich-text-editor', () => ({ - RichTextEditor: ({ content, onChange, placeholder }: { content: string; onChange: (html: string) => void; placeholder?: string }) => ( -