diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts index ee151e6bc..f18862aa6 100644 --- a/lib/boot-skeleton/__tests__/boot-script.test.ts +++ b/lib/boot-skeleton/__tests__/boot-script.test.ts @@ -360,6 +360,102 @@ describe('boot script: Pro interface', () => { }); }); +describe('boot script: Pro tabs', () => { + const tab = (id: string, kind: string, paneId = 'main') => ({ id, kind, labelKey: kind, closeable: id !== 'home-mail', paneId }); + const proTabs = (state: Record) => + localStorage.setItem('pro-tabs', JSON.stringify({ state, version: 4 })); + const pro = () => ({ + split: attr('pro-split'), + focus: attr('pro-focus'), + kind: attr('pro-kind'), + main: [attr('pro-main-tabs'), attr('pro-main-active'), attr('pro-main-kind')], + side: [attr('pro-split-tabs'), attr('pro-split-active'), attr('pro-split-kind')], + }); + + beforeEach(() => settings({ proInterface: true })); + + it('draws the single home mail tab when nothing is stored', () => { + boot('/en/pro'); + expect(pro()).toEqual({ split: null, focus: 'main', kind: 'mail', main: ['1', '0', 'mail'], side: [null, null, null] }); + }); + + it('falls back to the home mail tab for corrupt or empty tabs', () => { + localStorage.setItem('pro-tabs', '{not json'); + boot('/en/pro'); + expect(pro().main).toEqual(['1', '0', 'mail']); + proTabs({ tabs: 'nope', activeTabId: 3 }); + boot('/en/pro'); + expect(pro().main).toEqual(['1', '0', 'mail']); + proTabs({ tabs: [null, { id: 'x' }, 7], activeTabId: 'x' }); + boot('/en/pro'); + expect(pro().main).toEqual(['1', '0', 'mail']); + }); + + it('reads the tab count, the active tab and the split', () => { + proTabs({ + tabs: [tab('home-mail', 'mail'), tab('c', 'calendar'), tab('f', 'folder'), tab('s', 'settings', 'split'), tab('e', 'email', 'split')], + activeTabId: 'c', + activeSplitTabId: 'e', + focusedPaneId: 'split', + splitSide: 'left', + }); + boot('/en/pro'); + expect(pro()).toEqual({ + split: 'left', focus: 'split', kind: 'email', + main: ['3', '1', 'calendar'], + side: ['2', '1', 'other'], + }); + }); + + it('gives the calendar and settings variants to a pane that shows them', () => { + proTabs({ tabs: [tab('home-mail', 'mail'), tab('c', 'calendar'), tab('s', 'settings', 'split')], activeTabId: 'c', activeSplitTabId: 's' }); + localStorage.setItem('calendar-storage', JSON.stringify({ state: { viewMode: 'week' }, version: 0 })); + localStorage.setItem('settings-active-tab', 'filters'); + boot('/en/pro'); + expect(attr('calendar-view')).toBe('week'); + expect(cssVar('--boot-cal-cols')).toBe('7'); + expect(attr('settings-tab')).toBe('filters'); + expect(attr('pane')).toBeNull(); + }); + + it('merges a split pane into an empty main pane', () => { + proTabs({ tabs: [tab('c', 'calendar', 'split'), tab('f', 'files', 'split')], activeTabId: 'gone', activeSplitTabId: 'f', focusedPaneId: 'split' }); + boot('/en/pro'); + expect(pro()).toEqual({ split: null, focus: 'main', kind: 'files', main: ['2', '1', 'files'], side: [null, null, null] }); + }); + + it('focuses the tab the redirect opens for a standard page', () => { + proTabs({ + tabs: [tab('home-mail', 'mail'), tab('c', 'calendar', 'split')], + activeTabId: 'home-mail', + activeSplitTabId: 'c', + focusedPaneId: 'main', + }); + boot('/en/calendar/week/2026-11-04'); + expect(pro()).toMatchObject({ split: 'right', focus: 'split', kind: 'calendar', main: ['1', '0', 'mail'], side: ['1', '0', 'calendar'] }); + expect(attr('calendar-view')).toBe('week'); + + // No contacts tab yet: a new one at the end of the focused pane. + boot('/en/contacts'); + expect(pro()).toMatchObject({ focus: 'main', kind: 'contacts', main: ['2', '1', 'contacts'], side: ['1', '0', 'calendar'] }); + }); + + it('caps the drawn tabs at eight', () => { + const many = Array.from({ length: 12 }, (_, i) => tab(`t${i}`, i === 11 ? 'files' : 'email')); + proTabs({ tabs: many, activeTabId: 't11' }); + boot('/en/pro'); + expect(pro().main).toEqual(['8', '7', 'files']); + }); + + it('sets nothing when Pro does not take the page over', () => { + proTabs({ tabs: [tab('home-mail', 'mail'), tab('c', 'calendar', 'split')], activeSplitTabId: 'c' }); + desktop(false); + boot('/en/mail'); + expect(pro()).toEqual({ split: null, focus: null, kind: null, main: [null, null, null], side: [null, null, null] }); + expect(attr('calendar-view')).toBeNull(); + }); +}); + describe('boot script: signed-out visitors', () => { it('marks a visitor with nothing stored as signed out on app pages', () => { boot('/en/mail'); diff --git a/lib/boot-skeleton/boot-script.ts b/lib/boot-skeleton/boot-script.ts index fa40c8776..6e5630c6a 100644 --- a/lib/boot-skeleton/boot-script.ts +++ b/lib/boot-skeleton/boot-script.ts @@ -174,7 +174,70 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void { var desktop = false; try { desktop = w.matchMedia('(min-width: 64rem)').matches; } catch { /* assume not */ } - mark('pro', s.proInterface === true && desktop && (surface === 'pro' || has(c.pro, surface)) ? 'on' : null); + var proOn = s.proInterface === true && desktop && (surface === 'pro' || has(c.pro, surface)); + mark('pro', proOn ? 'on' : null); + + // --- Pro tabs ---------------------------------------------------------- + // What the Pro shell will show (stores/pro-tab-store.ts, normalizeProTabState): + // each pane's tabs and its active tab, after the redirect from a standard + // page has opened or focused that page's tab (ProInterfaceRedirect, openTab). + var proShows: string[] = []; + var proTabs: { id: unknown; kind: string; pane: string }[] = []; + var proState = proOn ? stateOf('pro-tabs') : {}; + var rawTabs = Array.isArray(proState.tabs) ? (proState.tabs as unknown[]) : []; + for (var t = 0; t < rawTabs.length; t++) { + var rt = rawTabs[t] as Record | null; + if (rt && typeof rt === 'object' && typeof rt.kind === 'string') { + proTabs.push({ id: rt.id, kind: rt.kind, pane: rt.paneId === 'split' ? 'split' : 'main' }); + } + } + if (!proTabs.length) proTabs.push({ id: 'home-mail', kind: 'mail', pane: 'main' }); + var activeMain = proState.activeTabId; + var activeSplit = proState.activeSplitTabId; + var inPane = function (which: string) { + return proTabs.filter(function (tab) { return tab.pane === which; }); + }; + if (!inPane('main').length) { + // An empty main pane takes the split pane's tabs over. + for (var m = 0; m < proTabs.length; m++) proTabs[m].pane = 'main'; + activeMain = activeSplit; + activeSplit = null; + } + var proFocus = proState.focusedPaneId === 'split' && inPane('split').length ? 'split' : 'main'; + if (proOn && surface !== 'pro') { + var opened = null as { id: unknown; kind: string; pane: string } | null; + for (var o = 0; o < proTabs.length && !opened; o++) if (proTabs[o].kind === surface) opened = proTabs[o]; + if (!opened) { + opened = { id: '#boot-opened', kind: surface, pane: proFocus }; + proTabs.push(opened); + } + if (opened.pane === 'split') activeSplit = opened.id; + else activeMain = opened.id; + proFocus = opened.pane; + } + var proPane = function (which: string, activeId: unknown): string { + var list = inPane(which); + if (!proOn || !list.length) { + mark('pro-' + which + '-tabs', null); + mark('pro-' + which + '-active', null); + mark('pro-' + which + '-kind', null); + return ''; + } + var at = 0; + for (var p = 0; p < list.length; p++) if (list[p].id === activeId) at = p; + var kind = list[at].kind; + // The skeleton draws up to eight tabs; a longer strip scrolls anyway. + mark('pro-' + which + '-tabs', String(Math.min(list.length, 8))); + mark('pro-' + which + '-active', String(Math.min(at, 7))); + mark('pro-' + which + '-kind', has(c.pro, kind) ? kind : 'other'); + proShows.push(kind); + return kind; + }; + var mainKind = proPane('main', activeMain); + var splitKind = proPane('split', activeSplit); + mark('pro-split', proOn && splitKind ? (proState.splitSide === 'left' ? 'left' : 'right') : null); + mark('pro-focus', proOn ? proFocus : null); + mark('pro-kind', proOn ? (proFocus === 'split' ? splitKind : mainKind) : null); // --- Which pane is open ---------------------------------------------- var pane = ''; @@ -187,21 +250,22 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void { if (seg[1]) pane = 'detail'; } - if (surface === 'settings') { + // A Pro pane showing settings or the calendar needs their variants too. + if (surface === 'settings' || proShows.indexOf('settings') !== -1) { var migrate = function (raw: string | null): string | null { if (!raw) return null; var id = c.legacyTabs[raw] || raw; return has(c.tabs, id) ? id : null; }; - var linked = migrate(seg[1] || null) || migrate(readSession('settings-deep-link-tab')); - if (linked) pane = 'detail'; + var linked = migrate(surface === 'settings' ? seg[1] || null : null) || migrate(readSession('settings-deep-link-tab')); + if (linked && surface === 'settings') pane = 'detail'; mark('settings-tab', linked || migrate(readLocal('settings-active-tab')) || 'appearance'); } else mark('settings-tab', null); mark('pane', pane); // --- Calendar ---------------------------------------------------------- - if (surface === 'calendar') { - var view = seg[1] || ''; + if (surface === 'calendar' || proShows.indexOf('calendar') !== -1) { + var view = surface === 'calendar' ? seg[1] || '' : ''; var calView = has(c.calendarViews, view) ? view : ''; if (!calView && /^\d{4}-\d{2}-\d{2}$/.test(view)) calView = 'month'; if (!calView) {