From def60b4fea821dbe9a66c9f79335e75b46eb86b2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:11:21 +0100 Subject: [PATCH] fix: run the tour through Pro's tabs instead of bouncing between routes --- .../tour/__tests__/tour-provider.test.tsx | 37 ++++++++++++++++++- components/tour/tour-provider.tsx | 10 ++++- components/tour/tour-steps.ts | 20 +++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/components/tour/__tests__/tour-provider.test.tsx b/components/tour/__tests__/tour-provider.test.tsx index 28e4e7ad0..ba1fb5567 100644 --- a/components/tour/__tests__/tour-provider.test.tsx +++ b/components/tour/__tests__/tour-provider.test.tsx @@ -38,7 +38,8 @@ vi.mock('@/stores/settings-store', () => ({ })); const { TourProvider, useTour } = await import('../tour-provider'); -const { getTourSteps } = await import('../tour-steps'); +const { getTourSteps, isTourStepOnPage, proTabOfTourStep } = await import('../tour-steps'); +const { useProTabStore } = await import('@/stores/pro-tab-store'); let api: ReturnType; function Probe() { @@ -140,3 +141,37 @@ describe('tour run', () => { expect(env.command).toHaveBeenCalledWith('tour-end'); }); }); + +describe('tour under the Pro shell', () => { + const stepTo = (id: string) => { + for (let i = 0; i < 30 && api.steps[api.currentStep]?.id !== id; i++) act(() => api.nextStep()); + }; + + beforeEach(() => { + env.phone = false; + env.pathname = '/pro'; + useProTabStore.setState({ tabs: [{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' }], activeTabId: 'home-mail', activeSplitTabId: null, focusedPaneId: 'main', loadedTabIds: ['home-mail'] }); + }); + + it('opens the tab of each step instead of leaving the Pro route', () => { + start(); + stepTo('calendar-view'); + const focused = () => { + const s = useProTabStore.getState(); + return s.tabs.find((t) => t.id === s.activeTabId)?.kind; + }; + expect(focused()).toBe('calendar'); + expect(env.push).not.toHaveBeenCalled(); + }); + + it('counts a step as on its page there', () => { + expect(isTourStepOnPage({ page: '/calendar' }, '/pro')).toBe(true); + expect(isTourStepOnPage({ page: '/calendar' }, '/settings')).toBe(false); + }); + + it('leaves the composer step in the compose tab it just opened', () => { + expect(proTabOfTourStep({ target: '[data-tour="composer"]' })).toBeNull(); + expect(proTabOfTourStep({ target: '[data-tour="sidebar"]' })).toBe('mail'); + expect(proTabOfTourStep({ target: 'x', page: '/settings' })).toBe('settings'); + }); +}); diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index 55fe87d00..f01ac1194 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -8,7 +8,8 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { useWebDAVStore } from "@/stores/webdav-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; -import { getTourSteps, isTourStepOnPage, type TourStep } from "./tour-steps"; +import { getTourSteps, isTourStepOnPage, PRO_ROUTE, proTabOfTourStep, type TourStep } from "./tour-steps"; +import { useProTabStore } from "@/stores/pro-tab-store"; import { useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; import { requestAppCommand } from "@/lib/app-commands"; import { TourOverlay } from "./tour-overlay"; @@ -87,6 +88,13 @@ export function TourProvider({ children }: { children: ReactNode }) { // closes it first. useEffect(() => { if (!step) return; + // Under Pro every app is a tab of the one route. Asking for another route + // only comes back here, which went round for as long as the tour ran. + if (pathname === PRO_ROUTE) { + const tab = proTabOfTourStep(step); + if (tab) useProTabStore.getState().openTab(tab); + return; + } if (!isTourStepOnPage(step, pathname)) { router.push(step.page ?? "/"); } else if (phone && !step.page && step.view !== "reader") { diff --git a/components/tour/tour-steps.ts b/components/tour/tour-steps.ts index 7b1963e22..90048bc40 100644 --- a/components/tour/tour-steps.ts +++ b/components/tour/tour-steps.ts @@ -1,3 +1,8 @@ +import type { ProAppTabKind } from "@/stores/pro-tab-store"; + +/** The one route of the Pro shell. */ +export const PRO_ROUTE = "/pro"; + export interface TourStep { id: string; target: string; @@ -50,8 +55,17 @@ function isCovered(el: Element, r: DOMRect): boolean { return !!top && top !== el && !el.contains(top) && !top.contains(el); } -/** Whether the user is in the app the step is about. */ +/** The Pro tab that holds the app the step is about. */ +export function proTabOfTourStep(step: Pick): ProAppTabKind | null { + // The composer has a tab of its own, which the step just opened. + if (step.target === COMPOSER_TARGET) return null; + const page = step.page?.replace(/^\//, ""); + return page === "calendar" || page === "contacts" || page === "files" || page === "settings" ? page : "mail"; +} + +/** Whether the user is in the app the step is about. Under Pro the provider opens its tab. */ export function isTourStepOnPage(step: Pick, pathname: string): boolean { + if (pathname === PRO_ROUTE) return true; const page = step.page && step.page !== "/" ? step.page : null; if (!page) return pathname === "/" || pathname === "" || pathname === "/mail" || pathname.startsWith("/mail/"); return pathname === page || pathname.startsWith(page + "/"); @@ -62,6 +76,8 @@ function clickTourTarget(selector: string): void { (findTourTarget(selector) as HTMLElement | null)?.click(); } +const COMPOSER_TARGET = '[data-tour="composer"]'; + export const BASE_TOUR_STEPS: TourStep[] = [ { id: "sidebar", @@ -162,7 +178,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [ export const DEMO_TOUR_STEPS: TourStep[] = [ { id: "compose-open", - target: '[data-tour="composer"]', + target: COMPOSER_TARGET, titleKey: "tour.compose_open_title", descriptionKey: "tour.compose_open_desc", placement: "left",