diff --git a/components/mail/__tests__/mobile-tour-end.test.tsx b/components/mail/__tests__/mobile-tour-end.test.tsx new file mode 100644 index 000000000..016026ffe --- /dev/null +++ b/components/mail/__tests__/mobile-tour-end.test.tsx @@ -0,0 +1,116 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useUIStore } from '@/stores/ui-store'; +import { requestAppCommand } from '@/lib/app-commands'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// After the tour on a phone, Mail showed only the empty reading pane: no +// list, header or tab bar. The tour's composer had left the view on the +// reader, which outlives Mail when the tour moves on to Calendar. +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ onClose, requestCloseRef }: { + onClose?: () => void; + requestCloseRef?: { current: ((afterClose?: () => void) => void) | null }; + }) => { + if (requestCloseRef) requestCloseRef.current = (afterClose) => { onClose?.(); afterClose?.(); }; + return

stub composer

; + }, +})); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }), + useMediaQuery: () => false, + useIsDesktop: () => false, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const sendEmail = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'me@example.com', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never); + sendEmail.mockReset().mockResolvedValue({}); + useUIStore.setState({ activeView: 'list', sidebarOpen: false }); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + sendEmail, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +/** On a phone only one of list and reading pane shows; the other gets max-md:hidden. */ +const listHidden = () => screen.getByText('message list').closest('.max-md\\:hidden') !== null; + +describe('Mail after the tour on a phone', () => { + it('shows the list when it opens with the reader left on and nothing to read', () => { + useUIStore.setState({ activeView: 'viewer' }); + render(); + expect(listHidden()).toBe(false); + }); + + it('closes the composer the tour opened when the tour ends', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' })); + await screen.findByText('stub composer', {}, { timeout: 5000 }); + expect(listHidden()).toBe(true); + + act(() => { requestAppCommand('tour-end'); }); + + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + expect(listHidden()).toBe(false); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 61f4ea543..4a72d9266 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -397,6 +397,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { resetEmailListWidth: state.resetEmailListWidth, resetEmailListHeight: state.resetEmailListHeight, }))); + // On a phone the reading pane covers the list, the header and the tab bar. + // The view outlives this component, but the composer and the conversation + // in that pane don't: coming back to Mail after the tour opened the + // composer (or after leaving any open composer) showed only the empty + // welcome pane, with no way back. With nothing to read, show the list. + useEffect(() => { + if (!isMobile) return; + if (useUIStore.getState().activeView === "viewer" && !useEmailStore.getState().selectedEmail) { + setActiveView("list"); + } + }, [isMobile, setActiveView]); const { emails, mailboxes, @@ -1151,6 +1162,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (sidebarOpen) setSidebarOpen(false); if (!showComposer && (activeView !== "list" || selectedEmail)) handleMobileBack(); }, + // The tour is over (finished or skipped at any step): close what it + // opened, so Mail is back at its list. A composer with text asks first. + 'tour-end': () => { + const backToList = () => { + if (!isMobile) return; + if (sidebarOpen) setSidebarOpen(false); + handleMobileBack(); + }; + const requestClose = composerRequestCloseRef.current; + if (showComposer && requestClose) requestClose(backToList); + else backToList(); + }, }, isAuthenticated && !!client); // Shared mail refresh: re-fetch mailboxes (unread counts) + the active list diff --git a/components/tour/__tests__/tour-provider.test.tsx b/components/tour/__tests__/tour-provider.test.tsx index 15f00d319..28e4e7ad0 100644 --- a/components/tour/__tests__/tour-provider.test.tsx +++ b/components/tour/__tests__/tour-provider.test.tsx @@ -108,4 +108,35 @@ describe('tour run', () => { start(); expect(env.command).not.toHaveBeenCalled(); }); + + // After the tour Mail was a dead end on a phone: the composer the tour had + // opened left only the empty reading pane. Ending the tour, whichever way, + // tells Mail to close what the tour opened. + it.each([true, false])('tells Mail the tour is over when it is skipped (phone %s)', (phone) => { + env.phone = phone; + start(); + while (api.steps[api.currentStep].id !== 'compose-open') act(() => api.nextStep()); + env.command.mockClear(); + act(() => api.stopTour()); + expect(probe()).toBe('off'); + expect(env.command).toHaveBeenCalledWith('tour-end'); + }); + + it('tells Mail the tour is over when it is finished', () => { + start(); + while (api.currentStep < api.totalSteps - 1) act(() => api.nextStep()); + env.command.mockClear(); + act(() => api.nextStep()); + expect(probe()).toBe('off'); + expect(env.command).toHaveBeenCalledWith('tour-end'); + }); + + it('tells Mail the tour is over when the last step has nothing to point at', () => { + start(); + while (api.currentStep < api.totalSteps - 1) act(() => api.nextStep()); + env.command.mockClear(); + act(() => api.skipStep(1)); + expect(probe()).toBe('off'); + expect(env.command).toHaveBeenCalledWith('tour-end'); + }); }); diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index 79bf37b1a..55fe87d00 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -134,8 +134,12 @@ export function TourProvider({ children }: { children: ReactNode }) { setIsActive(true); }, [allSteps]); + // Finished or skipped at any step, Mail closes the reader and the composer + // the tour opened and is back at its list. Not on Mail, it shows the list + // the next time it opens. const stopTour = useCallback(() => { setIsActive(false); + requestAppCommand("tour-end"); try { localStorage.removeItem(TOUR_CURRENT_STEP_KEY); } catch { /* */ } @@ -143,6 +147,7 @@ export function TourProvider({ children }: { children: ReactNode }) { const completeTour = useCallback(() => { setIsActive(false); + requestAppCommand("tour-end"); setHasCompletedTour(true); updateSetting("tourCompleted", true); try { diff --git a/lib/app-commands.ts b/lib/app-commands.ts index d3bbf1c47..93ef5c6d6 100644 --- a/lib/app-commands.ts +++ b/lib/app-commands.ts @@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react'; -export type AppCommand = 'compose' | 'search' | 'all-inboxes' | 'show-list'; +export type AppCommand = 'compose' | 'search' | 'all-inboxes' | 'show-list' | 'tour-end'; export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette';