diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index b33ec0f2e..6bbda38a1 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -208,7 +208,10 @@ [data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"], [data-boot-settings-tab="files"] [data-sk-settings-tab="files"], [data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"], - [data-boot-settings-tab="backup"] [data-sk-settings-tab="backup"] + [data-boot-settings-tab="offline"] [data-sk-settings-tab="offline"], + [data-boot-settings-tab="privacy"] [data-sk-settings-tab="privacy"], + [data-boot-settings-tab="backup"] [data-sk-settings-tab="backup"], + [data-boot-settings-tab="about"] [data-sk-settings-tab="about"] )); /* Settings pages the nav listed last time (bulwark-boot-hints). Without @@ -221,6 +224,7 @@ @custom-variant boot-no-settings-contacts (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="contacts"]) *)); @custom-variant boot-no-settings-files (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="files"]) *)); @custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *)); +@custom-variant boot-no-settings-offline (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="offline"]) *)); @custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *)); /* Settings pages with one section have no section jump row (SectionNav). */ diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 18f63f935..96d175e21 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -55,7 +55,7 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) { > = { contacts: "boot-no-settings-contacts:hidden", files: "boot-no-files:hidden boot-no-settings-files:hidden", addons: "boot-no-settings-addons:hidden", + // Not in the static Lite build. + offline: "boot-no-settings-offline:hidden", }; /** A line of text as tall as its line box, so rows keep the real height. */ diff --git a/components/email/__tests__/remote-content-lock-gaps.test.tsx b/components/email/__tests__/remote-content-lock-gaps.test.tsx index 9ac15a5c7..37a61761b 100644 --- a/components/email/__tests__/remote-content-lock-gaps.test.tsx +++ b/components/email/__tests__/remote-content-lock-gaps.test.tsx @@ -31,7 +31,8 @@ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() const { EmailViewer } = await import('../email-viewer'); const { ThreadConversationView } = await import('../thread-conversation-view'); -const { ReadingSettings } = await import('@/components/settings/reading-settings'); +// Remote content and trusted senders moved from Reading to Privacy (settings-24, 2026-11-04). +const { PrivacySettings } = await import('@/components/settings/privacy-settings'); const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; const email = { @@ -123,7 +124,7 @@ describe('before the policy has loaded', () => { describe('trusted senders settings', () => { it('lets users add senders and switch the address book when nothing blocks', async () => { - render(); + render(); const toggle = screen.getByText('trusted_senders.use_address_book_label').closest('[class]')!.parentElement!; expect(toggle.querySelector('[inert]')).toBeNull(); fireEvent.click(screen.getByText('trusted_senders.count_zero')); @@ -132,7 +133,7 @@ describe('trusted senders settings', () => { it('shows the list read-only and the address book switch as the organisation\'s while it blocks', async () => { restrict({ locked: true, value: 'block' }); - render(); + render(); expect(screen.getAllByText('trusted_senders_note').length).toBeGreaterThan(0); const switchRow = screen.getByText('trusted_senders.use_address_book_label').closest('div')!.parentElement!.parentElement!; expect(switchRow.querySelector('[inert]')).not.toBeNull(); @@ -145,7 +146,7 @@ describe('trusted senders settings', () => { it('still lets users take a sender off the list while it blocks', async () => { restrict({ locked: true, value: 'block' }); useSettingsStore.setState({ trustedSenders: ['news@shop.example'], trustedSendersAddressBook: false }); - render(); + render(); fireEvent.click(screen.getByText('trusted_senders.count_one')); await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); fireEvent.click(screen.getByTitle('remove')); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx index c72d05329..73581da76 100644 --- a/components/mail-transfer/local-export-dialog.tsx +++ b/components/mail-transfer/local-export-dialog.tsx @@ -57,7 +57,7 @@ async function freeRoom(): Promise { } } -/** Settings > Backup & reset > Offline > Export kept mail: from the local store, also with no server. */ +/** Settings > Offline > Export kept mail: from the local store, also with no server. */ export function LocalExportDialog({ request }: { request: LocalExportRequest }) { const t = useTranslations("offlineExport"); const tExport = useTranslations("mail_export"); diff --git a/components/offline/__tests__/account-storage.test.tsx b/components/offline/__tests__/account-storage.test.tsx index 429ed93ca..ff6dda7ec 100644 --- a/components/offline/__tests__/account-storage.test.tsx +++ b/components/offline/__tests__/account-storage.test.tsx @@ -168,6 +168,16 @@ describe('what it takes', () => { expect(line.querySelector('progress')?.getAttribute('max')).toBe('5000'); }); + // settings-26 (2026-11-04): "Saving your mail on this device…" stood above + // the progress line, which said the same thing again. + it('says a large sync is running once, in the progress line', () => { + const now = Date.now(); + useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now, startedDone: 0, at: now } } }); + render(); + expect(screen.getByTestId('offline-progress')).toBeTruthy(); + expect(document.body.textContent).not.toContain('syncing'); + }); + it('keeps a small catch-up quiet', () => { const now = Date.now(); useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 1, total: 3, startedAt: now, startedDone: 0, at: now } } }); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index 80666f60c..4a3ef0ae2 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -63,7 +63,7 @@ function useUnsent(account: string) { return { unsent, read }; } -/** One account in Settings > Backup & reset > Offline. */ +/** One account in Settings > Offline. */ export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) { const t = useTranslations('offline.settings'); const tStorage = useTranslations('offlineStorage'); @@ -164,14 +164,18 @@ export function AccountStorage({ account, primary, heading }: { account: string; )}
-

- {syncing - ? t('syncing') - : lastSynced - ? t('usage_updated', { size: formatFileSize(usage?.bytes ?? 0), time: format.relativeTime(lastSynced, now) }) - : t('usage', { size: formatFileSize(usage?.bytes ?? 0) })} -

- {syncing && progressWorthShowing(progress) && } + {/* A sync with a progress line says so once, in that line. */} + {syncing && progressWorthShowing(progress) ? ( + + ) : ( +

+ {syncing + ? t('syncing') + : lastSynced + ? t('usage_updated', { size: formatFileSize(usage?.bytes ?? 0), time: format.relativeTime(lastSynced, now) }) + : t('usage', { size: formatFileSize(usage?.bytes ?? 0) })} +

+ )} {usage && usage.parts.length > 0 && (
{usage.parts.filter((part) => part.items > 0).map((part) => ( diff --git a/components/offline/kept-files.tsx b/components/offline/kept-files.tsx index f38bf9059..596e0ec87 100644 --- a/components/offline/kept-files.tsx +++ b/components/offline/kept-files.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { formatFileSize } from '@/lib/utils'; import { useKeptFiles } from '@/hooks/use-kept-files'; -/** The files this account keeps until told otherwise, in Settings > Backup & reset > Offline. */ +/** The files this account keeps until told otherwise, in Settings > Offline. */ export function KeptFiles({ account }: { account: string }) { const t = useTranslations('offlineStorage'); const { pinned, unpin } = useKeptFiles(account); diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 244db009c..d987c1dd8 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -43,7 +43,7 @@ export function OfflineHost() { useEffect(() => { const show = (notice: OfflineNotice) => { const tr = tRef.current; - const openStorage = { label: tStorageRef.current('open_settings'), onClick: () => routerRef.current.push(buildSettingsPath('backup', 'offline')) }; + const openStorage = { label: tStorageRef.current('open_settings'), onClick: () => routerRef.current.push(buildSettingsPath('offline', 'offline')) }; switch (notice.kind) { case 'queued': toast.info(tr('toast.queued')); diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 0ae50fbc3..e68c57ac7 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -19,13 +19,14 @@ import { KeyProtectionNotes } from './key-protection-notes'; import { WipeDeviceSection } from './wipe-device-section'; /** - * Settings > Backup & reset > Offline (#523): for each account signed in here, - * whether it keeps mail on this device, how much, what that takes, and the way - * to clear it. Above that what the browser may do to it, below it the outbox - * and whatever didn't go through. Not in Lite. + * Settings > Offline (#523), a page of its own: for each account signed in + * here, whether it keeps mail on this device, how much, what that takes, and + * the way to clear it. Above that what the browser may do to it; in a section + * below it the outbox and whatever didn't go through. Not in Lite. */ export function OfflineSettings() { const t = useTranslations('offline.settings'); + const tOutbox = useTranslations('offline.outbox'); const tStorage = useTranslations('offlineStorage'); const active = useAuthStore((s) => s.activeAccountId); useAuthStore((s) => s.connectedAccountsRevision); @@ -55,39 +56,43 @@ export function OfflineSettings() { }; return ( - - {blocked ? ( -

{tNotes('store_private')}

- ) : !offlineSupported() ? ( -

{t('unsupported')}

- ) : ( - <> -
-
- - {enabledCount > 0 && persist === 'refused' && ( -
-

{tStorage('persist_refused')}

- +
+ + {blocked ? ( +

{tNotes('store_private')}

+ ) : !offlineSupported() ? ( +

{t('unsupported')}

+ ) : ( + <> +
+
- )} - {enabledCount > 0 && safariMayDelete() && ( -

{tStorage('safari_hint')}

- )} - {accounts.map((account, index) => ( - entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null} - /> - ))} - {[active, ...accounts].some((account) => offlineAllowed(account)) && } - - )} - -
+ + {enabledCount > 0 && persist === 'refused' && ( +
+

{tStorage('persist_refused')}

+ +
+ )} + {enabledCount > 0 && safariMayDelete() && ( +

{tStorage('safari_hint')}

+ )} + {accounts.map((account, index) => ( + entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null} + /> + ))} + {[active, ...accounts].some((account) => offlineAllowed(account)) && } + + )} + + + + +
); } diff --git a/components/offline/wipe-device-section.tsx b/components/offline/wipe-device-section.tsx index d620843cf..5276198c7 100644 --- a/components/offline/wipe-device-section.tsx +++ b/components/offline/wipe-device-section.tsx @@ -33,7 +33,7 @@ export function WipeDeviceSection() { }; return ( -
+

{t('settings_title')}

{t('settings_description')}

diff --git a/components/settings/__tests__/accessibility-settings.test.tsx b/components/settings/__tests__/accessibility-settings.test.tsx index cc876edea..3afc266a0 100644 --- a/components/settings/__tests__/accessibility-settings.test.tsx +++ b/components/settings/__tests__/accessibility-settings.test.tsx @@ -154,3 +154,49 @@ describe('read aloud voice', () => { } }); }); + +// settings-10 (2026-11-04): Accessibility repeated about twenty settings of +// other pages, two rows of it called "Notification sound". Only access +// settings stay; a link row in each section leads to the page with the rest. +describe('one setting, one place', () => { + const borrowed = [ + 'settings.appearance.list_density.label', + 'settings.appearance.toolbar_labels.label', + 'settings.email_behavior.always_light_mode.label', + 'calendar.settings.hover_preview', + 'settings.email_behavior.group_conversations.label', + 'settings.email_behavior.show_preview.label', + 'settings.advanced.sender_favicons.label', + 'settings.email_behavior.mark_read.label', + 'settings.email_behavior.return_to_list_after_action.label', + 'settings.email_behavior.send_delay.label', + 'settings.email_behavior.confirm_bulk_delete.label', + 'settings.notifications.email.sound', + 'settings.notifications.calendar.sound', + 'settings.notifications.sound_selection.choose', + 'settings.appearance.favicon_unread_badge.label', + ]; + + it('draws no row that belongs to another page', () => { + const { container } = render(); + const rows = Array.from(container.querySelectorAll('[data-search-label]')).map((el) => el.getAttribute('data-search-label')); + expect(rows.filter((row) => borrowed.includes(row ?? ''))).toEqual([]); + }); + + it('links to the pages that have them', () => { + const goTo: unknown[] = []; + const listener = (e: Event) => goTo.push((e as CustomEvent).detail); + window.addEventListener('settings-tab-change', listener); + try { + render(); + for (const link of ['text', 'light_mode', 'list', 'reading', 'bulk_delete', 'sounds']) { + expect(screen.getByText(`uxs_structure.a11y_links.${link}.label`)).toBeTruthy(); + } + const soundsRow = screen.getByText('uxs_structure.a11y_links.sounds.label').closest('[data-search-label]') as HTMLElement; + fireEvent.click(within(soundsRow).getByRole('button')); + expect(goTo).toEqual([{ tab: 'notifications', section: 'sound' }]); + } finally { + window.removeEventListener('settings-tab-change', listener); + } + }); +}); diff --git a/components/settings/__tests__/no-third-party-settings.test.tsx b/components/settings/__tests__/no-third-party-settings.test.tsx index 2079f301d..5cfff8969 100644 --- a/components/settings/__tests__/no-third-party-settings.test.tsx +++ b/components/settings/__tests__/no-third-party-settings.test.tsx @@ -3,7 +3,7 @@ import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; import { MessageListSettings } from '../message-list-settings'; import { NotificationSettings } from '../notification-settings'; import { PluginsSettings } from '../plugins-settings'; -import { ReadingSettings } from '../reading-settings'; +import { PrivacySettings } from '../privacy-settings'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; import { usePluginStore } from '@/stores/plugin-store'; @@ -13,7 +13,11 @@ import type { InstalledPlugin } from '@/lib/plugin-types'; // #903: the settings pages hide or explain what the no-third-party mode turns off. -vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'en' })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }), +})); vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) })); vi.mock('../settings-section', async (importOriginal) => ({ ...(await importOriginal()), @@ -46,14 +50,22 @@ beforeEach(() => { }); afterEach(() => setNoThirdPartyTraffic(false)); -describe('Settings > Message list', () => { +// Sender pictures, remote content and trusted senders moved from Message +// list and Reading to Privacy (settings-24, 2026-11-04). +describe('Settings > Privacy', () => { it('offers sender favicons only while third-party traffic is allowed', () => { - const { unmount } = render(); + const { unmount } = render(); expect(screen.getByText('sender_favicons.label')).toBeInTheDocument(); unmount(); setNoThirdPartyTraffic(true); + render(); + expect(screen.queryByText('sender_favicons.label')).toBeNull(); + }); + + it('leaves a link to Privacy on Message list', () => { render(); expect(screen.queryByText('sender_favicons.label')).toBeNull(); + expect(screen.getByText('senders_label')).toBeInTheDocument(); }); }); @@ -91,14 +103,14 @@ describe('Settings > Plugins', () => { }); }); -describe('Settings > Reading', () => { +describe('Settings > Privacy: remote content', () => { function externalContentRow() { return screen.getByText('external_content.label').closest('[data-search-label]') as HTMLElement; } it('lets the user pick the external content policy without the mode', () => { useSettingsStore.setState({ externalContentPolicy: 'ask' }); - render(); + render(); expect(externalContentRow().textContent).not.toContain('org_locked'); expect(externalContentRow().querySelector('select')?.value).toBe('ask'); }); @@ -106,7 +118,7 @@ describe('Settings > Reading', () => { it('shows the policy as blocked and set by the organisation in the mode', () => { setNoThirdPartyTraffic(true); useSettingsStore.setState({ externalContentPolicy: 'allow' }); - render(); + render(); const row = externalContentRow(); expect(row.textContent).toContain('org_locked'); expect(row.querySelector('select')?.value).toBe('block'); diff --git a/components/settings/__tests__/settings-search-anchors.test.tsx b/components/settings/__tests__/settings-search-anchors.test.tsx index 706d9ee75..90fc77af7 100644 --- a/components/settings/__tests__/settings-search-anchors.test.tsx +++ b/components/settings/__tests__/settings-search-anchors.test.tsx @@ -1,3 +1,4 @@ +import 'fake-indexeddb/auto'; import { render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; @@ -8,6 +9,8 @@ import en from '@/locales/en/common.json'; // off and in the situations some rows need (touch screen, Mountain View, // several accounts), and checks that each entry of the search index has a // row marked with its label, and is advanced exactly when the page says so. +// settings-8 (2026-11-04): and that every section a page draws can be found, +// so a new section (offline, app lock) can't be left out of the index. vi.unmock('next-intl'); vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: () => null })); @@ -33,7 +36,8 @@ const { useEmailStore } = await import('@/stores/email-store'); const { useAccountStore } = await import('@/stores/account-store'); const { TourProvider } = await import('@/components/tour/tour-provider'); const { AdvancedSettingsProvider } = await import('../settings-section'); -const { tabSearchEntries, getByPath } = await import('@/lib/settings-search'); +const { tabSearchEntries, getByPath, withAppName, settingsTabLabelKey } = await import('@/lib/settings-search'); +const { setStoreBackend } = await import('@/lib/store/client/api'); const pages = { account: (await import('../account-settings')).AccountSettings, general: (await import('../general-settings')).GeneralSettings, @@ -56,6 +60,9 @@ const pages = { files: (await import('../files-settings')).FilesSettingsComponent, addons: (await import('../addons-settings')).AddonsSettings, backup: (await import('../about-data-settings')).AboutDataSettings, + offline: (await import('@/components/offline/offline-settings')).OfflineSettings, + privacy: (await import('../privacy-settings')).PrivacySettings, + about: (await import('../about-data-settings')).AboutSettings, }; const OVERRIDES: Record unknown> = { @@ -77,19 +84,22 @@ const TAB_PAGES: Record = { message_list: ['message_list'], reading: ['reading'], composing: ['composing'], vacation: ['vacation'], filters: ['filters'], organize: ['organize'], security: ['security'], sharing: ['sharing'], recovery: ['recovery'], calendar: ['calendar', 'calendar2'], contacts: ['contacts', 'contacts2'], files: ['files'], addons: ['addons'], backup: ['backup'], + offline: ['offline'], privacy: ['privacy'], about: ['about'], }; describe('settings search index', () => { it('has a row for every entry on its page', { timeout: 120_000 }, () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network'))); - useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: CLIENT } as never); + useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: CLIENT, activeAccountId: 'acc' } as never); useVacationStore.setState({ isSupported: true, isLoading: false, forwardAvailable: true, fetchVacationResponse: vi.fn(async () => {}) } as never); useFilterStore.setState({ isSupported: true, isLoading: false, rules: [], selectAccount: vi.fn(async () => {}) } as never); useAccountSecurityStore.setState({ isStalwart: true, isProbing: false, probe: vi.fn(async () => true), fetchAll: vi.fn(), fetchAuthInfo: vi.fn(), fetchPublicKeys: vi.fn(), fetchCryptoInfo: vi.fn() } as never); useCalendarStore.setState({ calendars: [{ id: 'c', name: 'Work', color: '#3b82f6', isDefault: false, isShared: false, shareWith: null, myRights: { mayShare: true, mayDelete: true, mayWriteAll: true } }], icalSubscriptions: [] } as never); useContactStore.setState({ addressBooks: [{ id: 'b', name: 'Club', isDefault: true, isShared: false, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayDelete: true } }], contacts: [], fetchAddressBooks: vi.fn(async () => {}) } as never); useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) } as never); - const out: Record> = {}; + // jsdom has no OPFS: the store says it's there, as a browser would, so Offline draws its rows. + setStoreBackend({ supported: () => true } as never); + const out: Record> = {}; const realMatchMedia = window.matchMedia; const variants: Record void> = { base: () => {}, @@ -125,13 +135,16 @@ describe('settings search index', () => { , ); const found = Array.from(container.querySelectorAll('[data-search-label]')).map((el) => el.getAttribute('data-search-label') ?? ''); + const sections = Array.from(container.querySelectorAll('[data-settings-section]')).map((el) => el.getAttribute('data-search-label') ?? ''); unmount(); - return found; + return { found, sections }; }; try { - out[variant][tab] = { on: labels(true), off: labels(false) }; + const on = labels(true); + const off = labels(false); + out[variant][tab] = { on: on.found, off: off.found, sections: on.sections }; } catch (e) { - out[variant][tab] = { on: [], off: [], error: String(e) }; + out[variant][tab] = { on: [], off: [], sections: [], error: String(e) }; } } } @@ -140,7 +153,7 @@ describe('settings search index', () => { const problems: string[] = []; for (const [tab, entries] of Object.entries(tabSearchEntries)) { for (const entry of entries) { - const text = getByPath(en, entry.label) as string; + const text = withAppName(getByPath(en, entry.label), 'Bulwark') as string; const variant = out[entry.when ?? 'base']; const parts = TAB_PAGES[tab].map((page) => variant[page]); const errors = parts.filter((part) => part.error).map((part) => part.error); @@ -153,6 +166,17 @@ describe('settings search index', () => { } } } + // Every section a page draws has an entry, so searching its name finds it. + for (const [tab, entries] of Object.entries(tabSearchEntries)) { + const indexed = new Set(entries.map((entry) => withAppName(getByPath(en, entry.label), 'Bulwark') as string)); + // A section named like its page is the page itself, which the search lists anyway. + indexed.add(getByPath(en, settingsTabLabelKey(tab as never)) as string); + for (const page of TAB_PAGES[tab]) { + for (const section of out.base[page].sections) { + if (!indexed.has(section)) problems.push(`${tab}: section "${section}" has no search entry`); + } + } + } expect([...new Set(problems)]).toEqual([]); }); }); diff --git a/components/settings/__tests__/settings-section-links.test.tsx b/components/settings/__tests__/settings-section-links.test.tsx index 63b29cfff..e6d5d0dd3 100644 --- a/components/settings/__tests__/settings-section-links.test.tsx +++ b/components/settings/__tests__/settings-section-links.test.tsx @@ -25,6 +25,24 @@ vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: ( vi.mock('@/components/settings/about-data-settings', () => ({ AboutDataSettings: () => null, AboutFooter: () =>
, + AboutSettings: () =>
, +})); +vi.mock('@/components/offline/offline-settings', () => ({ + OfflineSettings: () => ( +
+

Offline

+

Outbox

+
+ ), +})); +vi.mock('@/components/settings/privacy-settings', () => ({ + PrivacyLinkRow: () => null, + PrivacySettings: () => ( +
+

Images

+

Read receipts

+
+ ), })); vi.mock('@/components/settings/appearance-settings',() => ({ AppearanceSettings: () =>
appearance pane
})); vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () =>
accessibility pane
})); @@ -135,10 +153,44 @@ describe('settings section links', () => { await waitFor(() => expect(scrolled).toContain('downloads')); }); - it('shows the version footer in the sidebar only with Advanced on', () => { + // settings-32 (2026-11-04): the version card in the sidebar's footer took + // room from the page list on short screens; it's a page at the end now. + it('shows the version on an About page, not in the sidebar footer', () => { render(); - expect(screen.queryByTestId('about-footer')).toBeNull(); fireEvent.click(screen.getByTestId('settings-advanced-switch')); + expect(screen.queryByTestId('about-footer')).toBeNull(); + fireEvent.click(screen.getByTestId('settings-tab-about')); + expect(screen.getByTestId('settings-tab-about')).toHaveAttribute('aria-current', 'page'); expect(screen.getByTestId('about-footer')).toBeInTheDocument(); }); + + // settings-7 and settings-24 (2026-11-04): Offline is a page of its own and + // remote images, read receipts and sender pictures moved to Privacy. Old + // links, from notices and bookmarks, land there. + it('lands an old /settings/backup/offline link on the Offline page', async () => { + render(); + expect(screen.getByTestId('settings-tab-offline')).toHaveAttribute('aria-current', 'page'); + await waitFor(() => expect(scrolled).toContain('offline')); + }); + + it('lands old links to remote images and read receipts on Privacy', async () => { + const first = render(); + expect(screen.getByTestId('settings-tab-privacy')).toHaveAttribute('aria-current', 'page'); + await waitFor(() => expect(scrolled).toContain('images')); + first.unmount(); + render(); + expect(screen.getByTestId('settings-tab-privacy')).toHaveAttribute('aria-current', 'page'); + act(() => { window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: { tab: 'composing', section: 'receipts' } })); }); + expect(screen.getByTestId('settings-tab-privacy')).toHaveAttribute('aria-current', 'page'); + await waitFor(() => expect(scrolled).toContain('receipts')); + }); + + it('lists Offline and Privacy among the first pages, and About last', () => { + render(); + const ids = Array.from(document.querySelectorAll('[data-testid^="settings-tab-"]')).map((el) => el.getAttribute('data-testid')); + expect(ids).toContain('settings-tab-offline'); + expect(ids.indexOf('settings-tab-privacy')).toBeLessThan(ids.indexOf('settings-tab-message_list')); + expect(ids.indexOf('settings-tab-offline')).toBeLessThan(ids.indexOf('settings-tab-message_list')); + expect(ids[ids.length - 1]).toBe('settings-tab-about'); + }); }); diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index e7f71d24d..a32a8c926 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -17,7 +17,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { toast } from '@/stores/toast-store'; import { DebugSettings } from './debug-settings'; import { MailBackupSettings } from '@/components/mail-transfer/mail-backup-settings'; -import { OfflineSettings } from '@/components/offline/offline-settings'; +import { productName } from '@/lib/product-name'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -55,7 +55,7 @@ function VersionUpdateTag() { } /** - * The About card, in the settings sidebar's footer: logo, version with the + * The About card, on Settings > About and at the foot of the admin's list: logo, version with the * update tag, and the source link. A modified build must offer its own * source (AGPL-3.0 §13), so a configured URL replaces the upstream link. * Three clicks on the logo open Spam Siege, as before. @@ -126,9 +126,28 @@ export function AboutFooter() { } /** - * The Backup & reset page: keep settings (sync, export, import), fix a stale - * view, start over, and (Advanced, where the admin allows it) debug logging. - * The About card moved to the settings sidebar's footer. + * Settings > About, the last entry of the page list: the About card with the + * version and the source link. It was in the sidebar's footer, where it took + * room from the page list on short screens. + */ +export function AboutSettings() { + const t = useTranslations('uxs_structure.about'); + const appName = productName(useConfig().appName); + return ( +
+ +
+ +
+
+
+ ); +} + +/** + * The Backup & reset page: keep settings (sync, export, import), bring mail + * in and save it as files, fix a stale view, start over, and (Advanced, where + * the admin allows it) debug logging. Offline has a page of its own. */ export function AboutDataSettings() { const t = useTranslations('settings.advanced'); @@ -218,8 +237,6 @@ export function AboutDataSettings() { - - - updateSetting('readReceiptResponse', value as 'ask' | 'always' | 'never')} - options={[ - { value: 'ask', label: t('read_receipt_response.ask') }, - { value: 'always', label: t('read_receipt_response.always') }, - { value: 'never', label: t('read_receipt_response.never') }, - ]} - /> - + {templatesEnabled && } diff --git a/components/settings/message-list-settings.tsx b/components/settings/message-list-settings.tsx index c004ce40c..595e8df8a 100644 --- a/components/settings/message-list-settings.tsx +++ b/components/settings/message-list-settings.tsx @@ -9,37 +9,30 @@ import type { ArchiveMode, HoverAction, MvInboxType, SwipeAction } from '@/store import { ALL_HOVER_ACTIONS } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; -import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store'; -import { toast } from '@/stores/toast-store'; import { cn } from '@/lib/utils'; -import { IS_LITE } from '@/lib/lite'; -import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { MessageListOrderSettings } from './message-list-order-settings'; -import { Button } from '@/components/ui/button'; -import { AlertTriangle, FolderSync, Loader2, RefreshCw } from '@/components/icons'; +import { PrivacyLinkRow } from './privacy-settings'; +import { AlertTriangle, FolderSync, Loader2 } from '@/components/icons'; import { usePolicyStore } from '@/stores/policy-store'; import { allowEmailMove, createMailboxWithHooks, emitEmailMove } from '@/lib/plugin-mail-hooks'; import type { Mailbox } from '@/lib/jmap/types'; import type { SearchDefaultScope } from '@/lib/search-default-scope'; /** - * The Message list page: what the list shows, who the senders look like, - * what one hover or swipe does, and what Delete and Archive mean. These - * rows used to be spread over Reading, Layout and Appearance. + * The Message list page: what the list shows, what one hover or swipe does, + * and what Delete and Archive mean. These rows used to be spread over + * Reading, Layout and Appearance. Sender pictures from the web are on + * Privacy, with a link here. */ export function MessageListSettings() { const t = useTranslations('settings.email_behavior'); - // Sender icons come from an outside service: no setting for them in the mode (#903). - const faviconsAllowed = !useNoThirdPartyTraffic(); const tPage = useTranslations('settings.message_list'); const tAppearance = useTranslations('settings.appearance'); - const tAdvanced = useTranslations('settings.advanced'); const [isReorganizing, setIsReorganizing] = useState(false); const [reorganizeResult, setReorganizeResult] = useState(null); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const archiveActionEnabled = isFeatureEnabled('archiveActionEnabled'); - const refreshFavicons = useFaviconRefreshStore((s) => s.refresh); // Swipes exist on touch screens only; a mouse user never sees these rows. const touchDevice = useMediaQuery('(pointer: coarse)'); // With the Archive action off, a stored 'archive' swipe does nothing (the row @@ -75,8 +68,6 @@ export function MessageListSettings() { interfaceLayout, mvInboxType, tintListRowsByTag, - senderFavicons, - showAvatarsInJunk, updateSetting, } = useSettingsStore(); @@ -217,33 +208,8 @@ export function MessageListSettings() { onChange={(checked) => updateSetting('showVerificationCodes', checked)} /> - - {/* Sender favicons come from the /api/favicon proxy, absent in the static Lite build (components/ui/avatar.tsx). */} - - {!IS_LITE && faviconsAllowed && ( - - updateSetting('senderFavicons', checked)} /> - - )} - - {!IS_LITE && faviconsAllowed && senderFavicons && ( - - - - )} - - - updateSetting('showAvatarsInJunk', checked)} /> - + {(isFeatureEnabled('hoverActionsConfigEnabled') || touchDevice) && ( diff --git a/components/settings/privacy-settings.tsx b/components/settings/privacy-settings.tsx new file mode 100644 index 000000000..5f643f5bd --- /dev/null +++ b/components/settings/privacy-settings.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useContactStore } from '@/stores/contact-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store'; +import { toast } from '@/stores/toast-store'; +import { useNoThirdPartyTraffic, useRemoteContentBlockedByOrg } from '@/hooks/use-third-party-mode'; +import { IS_LITE } from '@/lib/lite'; +import { TrustedSendersModal } from '@/components/trusted-senders-modal'; +import { Button } from '@/components/ui/button'; +import { ChevronRight, RefreshCw } from '@/components/icons'; +import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; +import { DesktopTelemetrySettings } from './desktop-telemetry-settings'; + +/** Opens another settings page (and section) the way deep links do. */ +export function goToSettings(tab: string, section?: string) { + window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: section ? { tab, section } : tab })); +} + +/** + * A row that only points to the page holding a setting now, so people who + * look for it in the old place still find it (one setting, one place). + */ +export function SettingsLinkRow({ label, description, button, onClick }: { label: string; description: string; button: string; onClick: () => void }) { + return ( + + + + ); +} + +/** The link to Privacy on the pages these settings came from. */ +export function PrivacyLinkRow({ topic }: { topic: 'images' | 'receipts' | 'senders' }) { + const t = useTranslations('uxs_structure.privacy_links'); + return ( + goToSettings('privacy', topic)} + /> + ); +} + +/** Remote images and trusted senders (the former Content & Senders tab, then a section of Reading). */ +function RemoteContentSection() { + // No third-party traffic (#903), or the organisation locked "block": + // remote content never loads, so the setting shows as the organisation's, + // set to block, and trusting senders has no effect. + const remoteBlockedByOrg = useRemoteContentBlockedByOrg(); + const tLock = useTranslations('remote_content_lock'); + const t = useTranslations('settings.email_behavior'); + const tPage = useTranslations('settings.reading_page'); + const [showTrustedModal, setShowTrustedModal] = useState(false); + const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); + const { externalContentPolicy, trustedSenders, trustedSendersAddressBook, updateSetting } = useSettingsStore(); + const { trustedSenderEmails, trustedSendersLoaded, loadTrustedSendersBook } = useContactStore(); + const client = useAuthStore((state) => state.client); + + // Load the address book so the count reflects the synced senders, not 0. + useEffect(() => { + if (trustedSendersAddressBook && client && !trustedSendersLoaded) { + loadTrustedSendersBook(client); + } + }, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]); + + const trustedCount = () => { + const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length; + if (count === 0) return t('trusted_senders.count_zero'); + if (count === 1) return t('trusted_senders.count_one'); + return t('trusted_senders.count_other', { count }); + }; + + return ( + + {isFeatureEnabled('externalContentEnabled') && !isSettingHidden('externalContentPolicy') && ( + + updateSetting('readReceiptResponse', value as 'ask' | 'always' | 'never')} + options={[ + { value: 'ask', label: t('read_receipt_response.ask') }, + { value: 'always', label: t('read_receipt_response.always') }, + { value: 'never', label: t('read_receipt_response.never') }, + ]} + /> + + + ); +} + +/** + * Sender pictures fetched from senders' websites (a section of Message list + * before). They come from the /api/favicon proxy, absent in the static Lite + * build, and from an outside service, so not in the no-third-party mode (#903). + */ +function SenderPicturesSection() { + const tPage = useTranslations('settings.message_list'); + const tAdvanced = useTranslations('settings.advanced'); + const faviconsAllowed = !useNoThirdPartyTraffic(); + const refreshFavicons = useFaviconRefreshStore((s) => s.refresh); + const { senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore(); + return ( + + {!IS_LITE && faviconsAllowed && ( + + updateSetting('senderFavicons', checked)} /> + + )} + + {!IS_LITE && faviconsAllowed && senderFavicons && ( + + + + )} + + + updateSetting('showAvatarsInJunk', checked)} /> + + + ); +} + +/** + * Settings > Privacy: what tells others about you or fetches from outside. + * Remote content and trusted senders, read receipts, sender pictures from + * the web, and in the desktop app usage info and crash reports. Background + * notifications through the push relay stay on Notifications, with a link. + */ +export function PrivacySettings() { + const t = useTranslations('uxs_structure.privacy'); + return ( +
+ + + + + + goToSettings('notifications', 'push')} + /> + +
+ ); +} diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index 0b5cf8222..a1a9f9a23 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -1,33 +1,23 @@ "use client"; -import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore, type AttachmentPosition } from '@/stores/settings-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { DownloadsSettings } from './downloads-settings'; -import { TrustedSendersModal } from '@/components/trusted-senders-modal'; +import { PrivacyLinkRow } from './privacy-settings'; import { ChevronRight } from '@/components/icons'; import { usePolicyStore } from '@/stores/policy-store'; -import { useContactStore } from '@/stores/contact-store'; -import { useAuthStore } from '@/stores/auth-store'; -import { useRemoteContentBlockedByOrg } from '@/hooks/use-third-party-mode'; /** * The Reading page: the open message. Opening it, how its body is drawn, - * remote images and trusted senders (the former Content & Senders tab), and * attachments, and (Advanced) how downloads are named, the former Downloads - * tab. List, delete and swipe rows live on the Message list page. + * tab. Remote images and trusted senders are on Privacy, with a link here; + * list, delete and swipe rows live on the Message list page. */ export function ReadingSettings() { - // No third-party traffic (#903), or the organisation locked "block": - // remote content never loads, so the setting shows as the organisation's, - // set to block, and trusting senders has no effect. - const remoteBlockedByOrg = useRemoteContentBlockedByOrg(); - const tLock = useTranslations('remote_content_lock'); const t = useTranslations('settings.email_behavior'); const tPage = useTranslations('settings.reading_page'); - const [showTrustedModal, setShowTrustedModal] = useState(false); - const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); + const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { markAsReadDelay, @@ -37,28 +27,9 @@ export function ReadingSettings() { hideInlineImageAttachments, attachmentImagePreviewsEnabled, messageSpacing, - externalContentPolicy, emailAlwaysLightMode, - trustedSenders, - trustedSendersAddressBook, updateSetting, } = useSettingsStore(); - const { trustedSenderEmails, trustedSendersLoaded, loadTrustedSendersBook } = useContactStore(); - const client = useAuthStore((state) => state.client); - - // Load the address book so the count reflects the synced senders, not 0. - useEffect(() => { - if (trustedSendersAddressBook && client && !trustedSendersLoaded) { - loadTrustedSendersBook(client); - } - }, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]); - - const getTrustedSendersCount = () => { - const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length; - if (count === 0) return t('trusted_senders.count_zero'); - if (count === 1) return t('trusted_senders.count_one'); - return t('trusted_senders.count_other', { count }); - }; return (
@@ -119,50 +90,8 @@ export function ReadingSettings() { onChange={(checked) => updateSetting('emailAlwaysLightMode', checked)} /> - - - {isFeatureEnabled('externalContentEnabled') && !isSettingHidden('externalContentPolicy') && ( - -