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/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/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/components/settings/__tests__/calendar-management-phone.test.tsx b/components/settings/__tests__/calendar-management-phone.test.tsx index 79b296605..efee10a90 100644 --- a/components/settings/__tests__/calendar-management-phone.test.tsx +++ b/components/settings/__tests__/calendar-management-phone.test.tsx @@ -65,4 +65,12 @@ describe('Settings > Calendar on a phone', () => { expect(screen.queryByRole('button', { name: 'title' })).toBeNull(); expect(screen.getByRole('button', { name: 'add_calendar' })).toBeInTheDocument(); }); + + // settings-p2-21: at 800 px the three buttons ran 4 px past the edge. + it('lets the New, Import and Subscribe buttons wrap when a row is too narrow', () => { + render(); + const row = screen.getByTestId('calendar-create-buttons'); + expect(row.className).toMatch(/(^|\s)sm:flex-wrap(\s|$)/); + expect(row.querySelectorAll('button')).toHaveLength(3); + }); }); 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/__tests__/time-zone-setting.test.tsx b/components/settings/__tests__/time-zone-setting.test.tsx index 410f04fc9..f7e15b859 100644 --- a/components/settings/__tests__/time-zone-setting.test.tsx +++ b/components/settings/__tests__/time-zone-setting.test.tsx @@ -73,4 +73,27 @@ describe('time zone setting', () => { fireEvent.change(input, { target: { value: '+05:45' } }); expect(optionTexts()).toEqual([expect.stringMatching(/^Kathmandu/)]); }); + + // settings-p2-20: abbreviations and an exact offset find their zones first. + it('finds zones by abbreviation', () => { + const input = openTimeZones(); + fireEvent.change(input, { target: { value: 'CET' } }); + const found = optionTexts(); + expect(found.length).toBeGreaterThan(0); + expect(found).toEqual(expect.arrayContaining([expect.stringMatching(/^Berlin/), expect.stringMatching(/^Paris/)])); + fireEvent.change(input, { target: { value: 'pst' } }); + expect(optionTexts()[0]).toMatch(/^Los Angeles/); + }); + + it('lists the exact offset before offsets that start with the same digits', () => { + const input = openTimeZones(); + fireEvent.change(input, { target: { value: 'GMT+1' } }); + const found = optionTexts(); + const offsetOf = (text: string) => /GMT[+-]\d{2}:\d{2}/.exec(text)?.[0]; + const first = offsetOf(found[0]); + // Whatever +1 is today (winter or summer time), it's listed first. + expect(first).toMatch(/^GMT\+01:00$/); + const firstOther = found.findIndex((text) => offsetOf(text) !== first); + expect(found.slice(firstOther).every((text) => offsetOf(text) !== 'GMT+01:00')).toBe(true); + }); }); 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 }) => ( -