"use client"; import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations, useMessages } from 'next-intl'; import { ArrowLeft, ChevronRight, LogOut, Settings as SettingsIcon, Palette, Search, User, Shield, UserPen, PalmtreeIcon, Calendar, Filter, FileText, FolderOpen, Tags, HardDrive, BookUser, PanelLeftClose, Bell, Puzzle, LayoutGrid, Link as LinkIcon, BookOpen, PenLine, EyeOff, Languages, Info, Bug, SwatchBook, Download, X, type LucideIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { AppearanceSettings } from '@/components/settings/appearance-settings'; import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot'; import { LayoutSettings } from '@/components/settings/layout-settings'; import { LanguageSettings } from '@/components/settings/language-settings'; import { ReadingSettings } from '@/components/settings/reading-settings'; import { ComposingSettings } from '@/components/settings/composing-settings'; import { ContentSendersSettings } from '@/components/settings/content-senders-settings'; import { AccountSettings } from '@/components/settings/account-settings'; import { IdentitySettings } from '@/components/settings/identity-settings'; import { VacationSettings } from '@/components/settings/vacation-settings'; import { CalendarSettings } from '@/components/settings/calendar-settings'; import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings'; import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings'; import { FilterSettings } from '@/components/settings/filter-settings'; import { TemplateSettings } from '@/components/settings/template-settings'; import { AboutDataSettings } from '@/components/settings/about-data-settings'; import { DebugSettings } from '@/components/settings/debug-settings'; import { FolderSettings } from '@/components/settings/folder-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { DownloadsSettings } from '@/components/settings/downloads-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { appPath, buildSettingsPath, parseSettingsPath } from '@/lib/deep-links'; import { consumePendingDeepLink, subscribePendingDeepLink } from '@/lib/deep-link-handoff'; import { useDeepLinkUrl } from '@/hooks/use-deep-link-url'; import { useProInterfaceActive } from '@/components/pro/pro-interface-redirect'; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { usePluginStore } from '@/stores/plugin-store'; import { useThemeStore } from '@/stores/theme-store'; import { useSettingsStore } from '@/stores/settings-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useIsDesktop } from '@/hooks/use-media-query'; import { NavigationRail } from '@/components/layout/navigation-rail'; import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; import { InlineAppView } from '@/components/layout/inline-app-view'; import { useSidebarApps } from '@/hooks/use-sidebar-apps'; import { useResolvedSidebarApps } from '@/hooks/use-resolved-sidebar-apps'; import { useIsEmbedded } from '@/hooks/use-is-embedded'; import { useIsFocusedProTab } from '@/hooks/use-pane-context'; import { ResizeHandle } from '@/components/layout/resize-handle'; import { useConfig } from '@/hooks/use-config'; import { usePolicyStore } from '@/stores/policy-store'; import { cn } from '@/lib/utils'; import { type SettingsSearchTab, type SubResult, collectSubResults, flattenStrings, getByPath, tabKeywords, tabSearchPaths, } from '@/lib/settings-search'; type Tab = SettingsSearchTab; type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced'; // A plugin that exposes a `settings-section` slot gets its own first-class // Settings entry, keyed `plugin:`, so its UI (e.g. S/MIME key import) is // discoverable as a menu point rather than buried inside another panel. type PluginTabId = `plugin:${string}`; type SettingsTabId = Tab | PluginTabId; interface TabDef { id: SettingsTabId; label: string; icon: LucideIcon; group: TabGroup; } const tabIcons: Record = { account: User, language: Languages, notifications: Bell, appearance: Palette, layout: LayoutGrid, reading: BookOpen, composing: PenLine, downloads: Download, identities: UserPen, vacation: PalmtreeIcon, filters: Filter, templates: FileText, folders: FolderOpen, keywords: Tags, security: Shield, content_senders: EyeOff, calendar: Calendar, contacts: BookUser, files: HardDrive, protocol_handlers: LinkIcon, sidebar_apps: PanelLeftClose, about_data: Info, themes: SwatchBook, plugins: Puzzle, debug: Bug, }; const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced']; // Map legacy tab IDs to current ones; runs once on read of localStorage. const LEGACY_TAB_MAP: Record = { email: 'reading', advanced: 'about_data', }; function readPersistedTab(): SettingsTabId { try { // One-shot deep link from the sidebar section gears (Folders / Tags). // Used only as the initial tab and intentionally NOT written to // 'settings-active-tab', so a gear click never becomes the persisted // default that the regular Settings button lands on. Cleared on mount. const deepLink = sessionStorage.getItem('settings-deep-link-tab'); if (deepLink) { return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId; } const saved = localStorage.getItem('settings-active-tab'); if (!saved) return 'appearance'; if (saved in LEGACY_TAB_MAP) { const migrated = LEGACY_TAB_MAP[saved]; try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ } return migrated; } return saved as SettingsTabId; } catch { return 'appearance'; } } /** * The tab a `/settings/` deep link names (#733), or null when the URL * names nothing we recognise - an old link to a tab that has since been * renamed falls back to the persisted tab rather than rendering an empty pane. * Legacy ids are migrated the same way the stored value is. */ function tabFromDeepLink(segments: string[] | null | undefined): SettingsTabId | null { const raw = parseSettingsPath(segments ?? []); if (!raw) return null; const migrated = raw in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[raw] : raw; if (migrated in tabIcons) return migrated as SettingsTabId; if (migrated.startsWith('plugin:')) return migrated as SettingsTabId; return null; } // Toggling the Pro interface remounts the whole settings surface (enabling // replaces the route with /pro, which hosts its own SettingsApp; disabling // leaves /pro through a full page load back to /settings). The active tab // survives via localStorage, but the content pane's scroll offset would not - // and the toggle itself sits at the bottom of a long tab, so the user would // land back at the top of the page they were just on. Module scope covers the // client-side navigation; sessionStorage carries the offset across the full // page load, with a TTL so a stray unconsumed entry can't scroll a visit // minutes later. let parkedContentScroll: { tab: SettingsTabId; top: number; ts: number } | null = null; const SCROLL_RESTORE_KEY = 'settings-scroll-restore'; export interface SettingsAppProps { /** Path segments after `/settings` - `['']`. */ linkSegments?: string[]; } export function SettingsApp({ linkSegments }: SettingsAppProps = {}) { const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const isEmbedded = useIsEmbedded(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); const { isFeatureEnabled } = usePolicyStore(); // A tab in the URL wins over the persisted one; without it, nothing changes. const [activeTab, setActiveTab] = useState( () => tabFromDeepLink(linkSegments) ?? readPersistedTab(), ); // Active plugins that expose a `settings-section` slot — each becomes its own // Settings menu entry. Referentially stable per registry mutation, so it is // safe to feed useSyncExternalStore directly. const pluginSettingsOffers = useSyncExternalStore( pluginRegistrySubscribe, () => pluginOffersForSlot('settings-section'), () => pluginOffersForSlot('settings-section'), ); // Consume the one-shot deep-link key so a section gear only steers this one // open, never the persisted default for future Settings-button clicks. useEffect(() => { try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ } }, []); // Inside the Pro shell there is no route to read the tab from, so it arrives // through the handoff the redirect parked (#733). useEffect(() => { if (linkSegments) return; const tab = tabFromDeepLink(consumePendingDeepLink('settings')); if (tab) setActiveTab(tab); }, [linkSegments]); // Pro shell only: this surface stays mounted for the whole session, so links // arriving later (e.g. the sidebar's folder-settings gear) are delivered // live instead of being parked for a mount that already happened. useEffect(() => { if (!isEmbedded) return; return subscribePendingDeepLink('settings', (segments) => { const tab = tabFromDeepLink(segments); if (tab) setActiveTab(tab); }); }, [isEmbedded]); const [mobileShowContent, setMobileShowContent] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null); const isDesktop = useIsDesktop(); const messages = useMessages() as Record; const installedPlugins = usePluginStore((s) => s.plugins); const installedThemes = useThemeStore((s) => s.installedThemes); // Admin-pinned apps are searchable too - users can't add them, but they're listed. const sidebarAppsList = useResolvedSidebarApps(); const proInterface = useSettingsStore((s) => s.proInterface); // When set, the settings panel is scoped to a shared/group account: a reduced // tab list and a "Managing: " header. null = the user's own account. const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); const managedAccount = useManagedAccountStore((s) => s.managedAccount); const clearManagedAccount = useManagedAccountStore((s) => s.clear); // Build a per-tab haystack for fulltext search and a list of sub-results // (individual settings) per tab. Sub-results come from translation entries // that have a `label`/`title` field, plus dynamic content (installed // plugins/themes/sidebar apps). const { tabSearchHaystacks, tabSubResults } = useMemo(() => { const haystacks: Partial> = {}; const subs: Partial> = {}; const tabIds = Object.keys(tabSearchPaths) as Tab[]; for (const tabId of tabIds) { const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? '']; const list: SubResult[] = []; for (const path of tabSearchPaths[tabId]) { const node = getByPath(messages, path); flattenStrings(node, strings); collectSubResults(node, list); } // Dedupe sub-results by label const seen = new Set(); subs[tabId] = list.filter((r) => { if (seen.has(r.label)) return false; seen.add(r.label); return true; }); haystacks[tabId] = strings.join(' ').toLowerCase(); } if (installedPlugins.length) { const haystackText = installedPlugins.map((p) => { const fieldText = p.settingsSchema ? Object.values(p.settingsSchema) .map((s) => `${s.label} ${s.description ?? ''}`) .join(' ') : ''; return `${p.name} ${p.description} ${p.author} ${fieldText}`; }).join(' '); haystacks.plugins = `${haystacks.plugins ?? ''} ${haystackText}`.toLowerCase(); const pluginSubs: SubResult[] = installedPlugins.flatMap((p) => { const items: SubResult[] = [{ label: p.name, description: p.description }]; if (p.settingsSchema) { for (const schema of Object.values(p.settingsSchema)) { items.push({ label: schema.label, description: schema.description, pluginId: p.id, }); } } return items; }); subs.plugins = [...(subs.plugins ?? []), ...pluginSubs]; } if (installedThemes.length) { const text = installedThemes.map((th) => `${th.name} ${th.description} ${th.author}`).join(' '); haystacks.themes = `${haystacks.themes ?? ''} ${text}`.toLowerCase(); subs.themes = [ ...(subs.themes ?? []), ...installedThemes.map((th) => ({ label: th.name, description: th.description })), ]; } if (sidebarAppsList.length) { const text = sidebarAppsList.map((a) => `${a.name} ${a.url}`).join(' '); haystacks.sidebar_apps = `${haystacks.sidebar_apps ?? ''} ${text}`.toLowerCase(); subs.sidebar_apps = [ ...(subs.sidebar_apps ?? []), ...sidebarAppsList.map((a) => ({ label: a.name, description: a.url })), ]; } return { tabSearchHaystacks: haystacks, tabSubResults: subs }; }, [messages, installedPlugins, installedThemes, sidebarAppsList]); // Sidebar resize state const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => { try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; } }); const [isResizing, setIsResizing] = useState(false); const dragStartWidth = useRef(256); // Check auth on mount – skip when already authenticated so that navigating // between routes doesn't retrigger checkAuth's transient `{ client: null, // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { const state = useAuthStore.getState(); if (state.isAuthenticated && state.client) { setInitialCheckDone(true); return; } checkAuth().finally(() => { setInitialCheckDone(true); }); }, [checkAuth]); // Listen for tab change events from child components (with legacy migration) useEffect(() => { const handler = (e: Event) => { const raw = (e as CustomEvent).detail as string; if (!raw) return; const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab; setActiveTab(tab); try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ } }; window.addEventListener('settings-tab-change', handler); return () => window.removeEventListener('settings-tab-change', handler); }, []); // Leaving the settings panel drops any shared-account scope so it never // leaks into the next visit or another session. useEffect(() => () => clearManagedAccount(), [clearManagedAccount]); useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { saveRedirectAfterLogin(); redirectToLogin(); } }, [initialCheckDone, isAuthenticated, authLoading]); // Sync the mobile submenu view with browser history so the system back // button (or gesture) returns to the settings list before exiting /settings. useEffect(() => { if (isDesktop) return; if (typeof window === 'undefined') return; if (!mobileShowContent) return; window.history.pushState({ __settingsSubmenu: true }, ''); const handlePop = () => { setMobileShowContent(false); }; window.addEventListener('popstate', handlePop); return () => window.removeEventListener('popstate', handlePop); }, [isDesktop, mobileShowContent]); // After clicking a search sub-result, scroll the matching setting into view // and add a temporary highlight class. Some tabs fetch data and render // their SettingItems only after a loading state, so retry until the element // shows up (or we give up after ~2s). useEffect(() => { if (!pendingHighlight) return; if (pendingHighlight.tab !== activeTab) return; if (typeof window === 'undefined') return; // For plugin-setting sub-results, ask the plugins tab to expand the // matching card so the field becomes part of the DOM. Dispatched here // (not in the click handler) because PluginsSettings only mounts after // the tab switches, and its listener registers in its own useEffect - // child effects run before parent effects, so by the time we get here // the listener is guaranteed to be in place. if (pendingHighlight.pluginId) { window.dispatchEvent( new CustomEvent('settings-plugin-expand', { detail: { pluginId: pendingHighlight.pluginId } }) ); } let cancelled = false; let retryTimer: ReturnType | undefined; let cleanupTimer: ReturnType | undefined; let highlightedEl: HTMLElement | null = null; const escaped = pendingHighlight.label.replace(/"/g, '\\"'); const selector = `[data-search-label="${escaped}"]`; const deadline = Date.now() + 2000; const tryHighlight = () => { if (cancelled) return; const el = document.querySelector(selector); if (!el) { if (Date.now() < deadline) { retryTimer = setTimeout(tryHighlight, 80); } return; } el.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Remove + reflow + add restarts the CSS animation if the class was // already present (re-clicking the same sub-result). el.classList.remove('settings-search-highlight'); void el.offsetWidth; el.classList.add('settings-search-highlight'); highlightedEl = el; cleanupTimer = setTimeout(() => { el.classList.remove('settings-search-highlight'); highlightedEl = null; }, 1800); }; // First attempt next frame so the freshly-mounted tab content is in DOM. const raf = window.requestAnimationFrame(tryHighlight); // Do NOT reset pendingHighlight here - that would retrigger this effect // and the cleanup below would strip the class right after we added it. return () => { cancelled = true; window.cancelAnimationFrame(raf); if (retryTimer) clearTimeout(retryTimer); if (cleanupTimer) clearTimeout(cleanupTimer); if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight'); }; }, [pendingHighlight, activeTab]); // The permalink for the open tab (#733). Suppressed inside the Pro shell, // where /pro owns the address bar. On mobile the tab list is its own screen, // so the tab is only in the URL once the user is looking at its content. // Must sit above the early return below - it is a hook. const proInterfaceActive = useProInterfaceActive(); const isFocusedProTab = useIsFocusedProTab(); const settingsLinkPath = appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab)); useDeepLinkUrl( !isAuthenticated ? null : isEmbedded ? (isFocusedProTab ? settingsLinkPath : null) : proInterfaceActive ? null : settingsLinkPath, ); // Park the content-pane scroll offset when the Pro toggle flips (the flip // remounts this component - see parkedContentScroll above). The ref only // exists in the desktop layout, which is also the only place the remount // can happen - on mobile `el` stays null and nothing is parked. const contentScrollRef = useRef(null); const prevProInterface = useRef(proInterface); useEffect(() => { if (prevProInterface.current === proInterface) return; prevProInterface.current = proInterface; const el = contentScrollRef.current; if (!el) return; parkedContentScroll = { tab: activeTab, top: el.scrollTop, ts: Date.now() }; try { sessionStorage.setItem(SCROLL_RESTORE_KEY, JSON.stringify(parkedContentScroll)); } catch { /* ignore */ } }, [proInterface, activeTab]); // Restore when the container mounts. A callback ref rather than a mount // effect: after the Pro-off reload this component first renders without the // container (auth check pending), so a mount-time effect would consume the // parked offset before there is anything to scroll. const attachContentScroll = useCallback((el: HTMLDivElement | null) => { contentScrollRef.current = el; if (!el) return; let parked = parkedContentScroll; parkedContentScroll = null; try { const raw = sessionStorage.getItem(SCROLL_RESTORE_KEY); if (raw) { sessionStorage.removeItem(SCROLL_RESTORE_KEY); parked = parked ?? (JSON.parse(raw) as NonNullable); } } catch { /* ignore */ } if (parked && parked.tab === activeTab && Date.now() - parked.ts < 60_000) { el.scrollTop = parked.top; } }, [activeTab]); if (!isAuthenticated) { return null; } const supportsVacation = client?.supportsVacationResponse() ?? false; const supportsCalendar = client?.supportsCalendars() ?? false; const supportsSieve = client?.supportsSieve() ?? false; const supportsFiles = client?.supportsFiles() ?? false; const tabs: TabDef[] = [ // General { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, { id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' }, // Appearance { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, { id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' }, ...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []), // Mail { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, { id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []), { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' }, ...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []), // Privacy & Security ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []), { id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' }, // Apps ...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []), ...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), // Plugin-contributed settings pages: one entry per active plugin that // offers a `settings-section` slot (e.g. S/MIME key & certificate manager). ...pluginSettingsOffers.map((offer): TabDef => ({ id: `plugin:${offer.pluginId}` as PluginTabId, label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId, icon: Puzzle, group: 'apps', })), // Advanced { id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' }, ...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []), ...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []), ]; // In scoped (shared-account) mode, restrict to the account-relevant tabs the // account actually advertises. Folders is intentionally excluded (mailbox CRUD // is hardwired to the active account). Gated on both the per-account // capability and the session-level support/feature flags. const scopedTabIds: Tab[] = managedAccount ? ([ managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null, managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null, managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null, managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null, ].filter(Boolean) as Tab[]) : []; const visibleTabs = managedAccountId ? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab)) : tabs; // Group tabs by category const groupedTabs = tabGroupOrder .map((group) => ({ group, label: t(`tab_groups.${group}`), items: visibleTabs.filter((tab) => tab.group === group), })) .filter((g) => g.items.length > 0); const trimmedQuery = searchQuery.trim().toLowerCase(); const matchesQuery = (tab: TabDef) => { if (!trimmedQuery) return true; if (tab.label.toLowerCase().includes(trimmedQuery)) return true; return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false; }; const subResultsForTab = (tabId: SettingsTabId): SubResult[] => { if (!trimmedQuery) return []; const list = tabSubResults[tabId as Tab] ?? []; return list .filter((r) => r.label.toLowerCase().includes(trimmedQuery) || (r.description?.toLowerCase().includes(trimmedQuery) ?? false) ) .slice(0, 6); }; const filteredGroupedTabs = trimmedQuery ? groupedTabs .map((g) => ({ ...g, items: g.items.filter(matchesQuery) })) .filter((g) => g.items.length > 0) : groupedTabs; // If active tab is not in the visible list (e.g., feature disabled, or scoped // mode hides it), fall back. In scoped mode fall back to the first scoped tab; // otherwise the usual 'appearance' default. const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab); const effectiveActiveTab: SettingsTabId = isActiveVisible ? activeTab : (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance'); const handleTabSelect = (tabId: SettingsTabId) => { setActiveTab(tabId); try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } if (!isDesktop) { setMobileShowContent(true); } }; const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => { handleTabSelect(tabId); setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId }); }; const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const renderTabContent = () => ( <> {managedAccountId && managedAccount && ( )} {effectiveActiveTab === 'account' && } {effectiveActiveTab === 'language' && } {effectiveActiveTab === 'notifications' && } {effectiveActiveTab === 'appearance' && } {effectiveActiveTab === 'layout' && } {effectiveActiveTab === 'reading' && } {effectiveActiveTab === 'composing' && } {effectiveActiveTab === 'downloads' && } {effectiveActiveTab === 'identities' && } {effectiveActiveTab === 'vacation' && } {effectiveActiveTab === 'filters' && } {effectiveActiveTab === 'templates' && } {effectiveActiveTab === 'folders' && } {effectiveActiveTab === 'keywords' && } {effectiveActiveTab === 'security' && } {effectiveActiveTab === 'content_senders' && } {effectiveActiveTab === 'calendar' && ( managedAccountId ? : <>
)} {effectiveActiveTab === 'contacts' && ( managedAccountId ? : <>
)} {effectiveActiveTab === 'files' && } {effectiveActiveTab === 'protocol_handlers' && } {effectiveActiveTab === 'sidebar_apps' && } {effectiveActiveTab === 'about_data' && } {effectiveActiveTab === 'themes' && } {effectiveActiveTab === 'plugins' && } {effectiveActiveTab === 'debug' && } {effectiveActiveTab.startsWith('plugin:') && ( )} ); // Mobile layout if (!isDesktop) { if (mobileShowContent) { return (

{activeTabLabel}

{renderTabContent()}
{!isEmbedded && ( )}
); } return (

{t('title')}

setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} className="ps-9 pe-9 h-10" aria-label={t('search_placeholder')} /> {searchQuery && ( )}
{filteredGroupedTabs.length === 0 && (
{t('search_no_results')}
)} {filteredGroupedTabs.map((group, groupIndex) => (
{groupIndex > 0 &&
}
{group.label}
{group.items.map((tab) => { const Icon = tab.icon; const subs = subResultsForTab(tab.id); return (
{subs.map((sub) => ( ))}
); })}
))}
{!isEmbedded && ( )}
); } // Desktop layout return (
{!isEmbedded && (
)} {inlineApp && ( )} {!inlineApp && ( <>
{!proInterface && (
)}
setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} className="ps-8 pe-8 h-9 text-sm" aria-label={t('search_placeholder')} /> {searchQuery && ( )}
{filteredGroupedTabs.length === 0 && (
{t('search_no_results')}
)} {filteredGroupedTabs.map((group, groupIndex) => (
{groupIndex > 0 &&
}
{group.label}
{group.items.map((tab) => { const Icon = tab.icon; const subs = subResultsForTab(tab.id); return (
{subs.map((sub) => ( ))}
); })}
))}
{ dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }} onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} onResizeEnd={() => { setIsResizing(false); localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth)); }} onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }} />
{renderTabContent()}
)}
); }