From dc0fe7d980097244dc9397875f6919e8848085fc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:33:09 +0100 Subject: [PATCH 01/27] fix: open the settings page a link names on the phone instead of the settings list --- .../settings-phone-deep-link.test.tsx | 87 +++++++++++++++++++ components/settings/settings-app.tsx | 25 +++++- 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 components/settings/__tests__/settings-phone-deep-link.test.tsx diff --git a/components/settings/__tests__/settings-phone-deep-link.test.tsx b/components/settings/__tests__/settings-phone-deep-link.test.tsx new file mode 100644 index 000000000..ee8ee0917 --- /dev/null +++ b/components/settings/__tests__/settings-phone-deep-link.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// On the phone the tab list is a screen of its own. A link to +// /settings/ (the mail drawer's folder gear, quick-rule "edit in +// settings", the OAuth redirect) must open that page, with the list one Back +// below it. + +vi.mock('next-intl', () => ({ + useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key), + useMessages: () => ({}), + useLocale: () => 'en', + useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }), +})); +vi.mock('@/hooks/use-media-query', () => ({ + useIsDesktop: () => false, + useIsMobile: () => true, + useMediaQuery: () => false, + useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }), +})); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () =>
appearance pane
})); +vi.mock('@/components/settings/organize-settings', () => ({ OrganizeSettings: () =>
organize pane
})); + +const { SettingsApp } = await import('../settings-app'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { usePluginStore } = await import('@/stores/plugin-store'); +const { useThemeStore } = await import('@/stores/theme-store'); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ } + const client = new Proxy({}, { + get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)), + }); + useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('Settings deep links on the phone', () => { + it('opens the page a /settings/ link names, with the list below it in history', () => { + window.history.replaceState(null, '', '/settings/organize/folders'); + const push = vi.spyOn(window.history, 'pushState'); + + render(); + + expect(screen.getByText('organize pane')).toBeTruthy(); + expect(screen.queryByTestId('settings-tab-appearance')).toBeNull(); + // The entry below is the list; the page sits on top under its own URL. + expect(push).toHaveBeenCalledTimes(1); + expect(window.location.pathname).toBe('/settings/organize'); + + act(() => { + window.history.replaceState(null, '', '/settings'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + expect(screen.getByTestId('settings-tab-appearance')).toBeTruthy(); + }); + + it('opens the page a settings-tab-change event names', () => { + window.history.replaceState(null, '', '/settings'); + render(); + expect(screen.getByTestId('settings-tab-organize')).toBeTruthy(); + + act(() => { + window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: 'organize' })); + }); + + expect(screen.getByText('organize pane')).toBeTruthy(); + }); + + it('still opens on the list without a link', () => { + window.history.replaceState(null, '', '/settings'); + render(); + expect(screen.getByTestId('settings-tab-appearance')).toBeTruthy(); + expect(screen.queryByText('appearance pane')).toBeNull(); + }); +}); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 9de63f354..2805df4d3 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -192,6 +192,10 @@ function migrateTarget(raw: string, section: string | null = null): { tab: strin return { tab, section: section ?? (raw in LEGACY_SECTION_MAP ? LEGACY_SECTION_MAP[raw] : null) }; } +function hasOneShotDeepLink(): boolean { + try { return !!sessionStorage.getItem('settings-deep-link-tab'); } catch { return false; } +} + function readPersistedTab(): SettingsTabId { try { // One-shot deep link from the sidebar section gears (Folders / Tags). @@ -275,9 +279,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = const target = targetFromDeepLink(linkSegments); return target?.section ? { tab: target.tab, id: target.section } : null; }); + // On the phone the tab list is a screen of its own: a link or event that + // names a page opens that page, with the list one Back below it. + const [mobileShowContent, setMobileShowContent] = useState( + () => targetFromDeepLink(linkSegments) !== null || hasOneShotDeepLink(), + ); const goTo = useCallback((target: SettingsTarget) => { setActiveTab(target.tab); setPendingSection(target.section ? { tab: target.tab, id: target.section } : null); + setMobileShowContent(true); }, []); // Active plugins that expose a `settings-section` slot — each becomes its own // Settings menu entry. Referentially stable per registry mutation, so it is @@ -317,7 +327,6 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = if (target) goTo(target); }); }, [isEmbedded, goTo]); - const [mobileShowContent, setMobileShowContent] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null); const isDesktop = useIsDesktop(); @@ -489,7 +498,19 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = if (isDesktop) return; if (typeof window === 'undefined') return; if (!mobileShowContent) return; + // The first hydration pass reports a phone layout on every screen. + if (!isEmbedded && window.matchMedia('(min-width: 1024px)').matches) return; + // A page opened straight from a link has no list below it yet: turn the + // entry it arrived on into the list, then push the page on top. Inside + // the Pro shell /pro owns the address bar, so its URL stays. + if (!isEmbedded) { + const listPath = appPath(buildSettingsPath(null)); + const here = window.location.pathname; + if (here !== listPath && here.startsWith(`${listPath}/`)) { + window.history.replaceState(window.history.state, '', listPath); + } + } window.history.pushState({ __settingsSubmenu: true }, ''); const handlePop = () => { @@ -497,7 +518,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = }; window.addEventListener('popstate', handlePop); return () => window.removeEventListener('popstate', handlePop); - }, [isDesktop, mobileShowContent]); + }, [isDesktop, isEmbedded, 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 From 0f1d226d4ccdb9fca62ef41c28784c1ac21cfc2d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:43:29 +0100 Subject: [PATCH 02/27] fix: show folder names in the phone folder settings and move the row actions into a menu --- .../folder-settings-row-menu.test.tsx | 73 ++++++++++ components/settings/folder-settings.tsx | 136 +++++++++++++----- locales/ar/common.json | 4 + locales/ca/common.json | 4 + locales/cs/common.json | 4 + locales/da/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/et/common.json | 4 + locales/fa/common.json | 4 + locales/fr/common.json | 4 + locales/he/common.json | 4 + locales/hu/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/mn/common.json | 4 + locales/nb/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ro/common.json | 4 + locales/ru/common.json | 4 + locales/sk/common.json | 4 + locales/th/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/zh-TW/common.json | 4 + locales/zh/common.json | 4 + 31 files changed, 289 insertions(+), 36 deletions(-) create mode 100644 components/settings/__tests__/folder-settings-row-menu.test.tsx diff --git a/components/settings/__tests__/folder-settings-row-menu.test.tsx b/components/settings/__tests__/folder-settings-row-menu.test.tsx new file mode 100644 index 000000000..1eb297320 --- /dev/null +++ b/components/settings/__tests__/folder-settings-row-menu.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { FolderSettings } from '../folder-settings'; +import { useEmailStore } from '@/stores/email-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { Mailbox } from '@/lib/jmap/types'; + +// On the phone the counter select and the row's icon buttons left no room +// for the folder name. They sit behind a ⋮ there, which right-click opens +// too, and the name keeps its full text in a tooltip. + +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const rights = { + mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, + mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, +}; + +function makeMailbox(overrides: Partial): Mailbox { + return { + id: 'mb', name: 'Folder', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: rights, isSubscribed: true, isShared: false, ...overrides, + } as Mailbox; +} + +describe('FolderSettings row actions menu', () => { + beforeEach(() => { + useAuthStore.setState({ client: null } as never); + useSettingsStore.setState({ folderCountModes: {} } as never); + useEmailStore.setState({ + mailboxes: [makeMailbox({ id: 'projects', name: 'Projects with a long name' })], + fetchMailboxes: vi.fn(), + } as never); + }); + + it('opens the row actions from a named ⋮ and sets the counter from it', () => { + render(); + expect(screen.getByTitle('Projects with a long name')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'actions' })); + const menu = screen.getByRole('menu'); + expect(within(menu).getByRole('menuitem', { name: 'create_subfolder' })).toBeTruthy(); + expect(within(menu).getByRole('menuitem', { name: 'rename' })).toBeTruthy(); + expect(within(menu).getByRole('menuitem', { name: 'delete' })).toBeTruthy(); + + fireEvent.click(within(menu).getByRole('menuitem', { name: 'counter' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'count_mode_none' })); + expect(useSettingsStore.getState().folderCountModes).toEqual({ projects: 'none' }); + }); + + it('opens the same menu on right-click and starts renaming from it', () => { + render(); + fireEvent.contextMenu(screen.getByTitle('Projects with a long name'), { clientX: 20, clientY: 20 }); + fireEvent.click(screen.getByRole('menuitem', { name: 'rename' })); + expect((screen.getByDisplayValue('Projects with a long name') as HTMLInputElement).tagName).toBe('INPUT'); + }); + + it('names the expand toggle of a folder with subfolders', () => { + useEmailStore.setState({ + mailboxes: [ + makeMailbox({ id: 'p', name: 'Parent' }), + makeMailbox({ id: 'c', name: 'Child', parentId: 'p' }), + ], + } as never); + render(); + const toggle = screen.getByRole('button', { name: 'expand' }); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx index 728d5a2b1..cfe298e36 100644 --- a/components/settings/folder-settings.tsx +++ b/components/settings/folder-settings.tsx @@ -14,8 +14,12 @@ import { Star, Heart, Bookmark, Tag, Flag, Briefcase, Users, Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail, AlertTriangle, NotebookPen, CalendarClock, BellOff, CheckSquare, Square, + MoreVertical, Palette, ListOrdered, type AppIcon, } from '@/components/icons'; +import { IconButton } from '@/components/ui/icon-button'; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu'; +import { useContextMenu } from '@/hooks/use-context-menu'; import { cn, buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils'; import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { @@ -201,6 +205,9 @@ export function FolderSettings() { const [moveTarget, setMoveTarget] = useState(''); const [isMoving, setIsMoving] = useState(false); + // The row's actions: the ⋮ on the phone and right-click everywhere. + const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu(); + const ownMailboxes = mailboxes.filter(mb => !mb.isShared); const folderTree = buildMailboxTree(ownMailboxes); @@ -502,7 +509,7 @@ export function FolderSettings() { return (
@@ -563,7 +570,7 @@ export function FolderSettings() { if (editingId === mb.id) { return (
-
+
-
+

{t('confirm_delete', { name: mb.name })} @@ -628,10 +635,14 @@ export function FolderSettings() {

{ + if ((e.target as HTMLElement).closest('input, select')) return; + openContextMenu(e, mb); + }} >
{mb.myRights?.mayRename ? ( @@ -659,15 +670,17 @@ export function FolderSettings() { )} {/* Expand/collapse toggle for folders with children */} {hasChildren ? ( - + ) : ( )} @@ -707,7 +720,7 @@ export function FolderSettings() { /> )}
- {mb.name} + {mb.name} {mb.role && ( {t(`role_${mb.role}`)} @@ -719,48 +732,49 @@ export function FolderSettings() { )}
-
+ {/* Phone: the counter and the row actions don't fit next to the + name, so they sit behind a ⋮ (right-click opens it too). */} + openContextMenuFrom(e.currentTarget, mb, 'end')} + > + + +
setAudienceOnly(value as 'all' | 'internal' | 'external')} + onChange={(value) => changeNow(setAudienceOnly)(value as 'all' | 'internal' | 'external')} options={[ { value: 'all', label: t('audience.all') }, { value: 'internal', label: t('audience.internal') }, @@ -420,11 +489,13 @@ export function VacationSettings() { setLocalSubject(e.target.value)} + onChange={(e) => changeTyped(setLocalSubject)(e.target.value)} placeholder={t('message.subject_placeholder')} + aria-invalid={oversize.subject} className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground" /> +