mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
fix: bring mail back to its list after the tour on phones instead of an empty reading pane
This commit is contained in:
@@ -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<React.ComponentType<Record<string, unknown>>>) => {
|
||||
const Component = lazy(async () => ({ default: await load() }));
|
||||
return function Dynamic(props: Record<string, unknown>) {
|
||||
return <Suspense fallback={null}><Component {...props} /></Suspense>;
|
||||
};
|
||||
} }));
|
||||
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 <p>stub composer</p>;
|
||||
},
|
||||
}));
|
||||
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: () => <div>message list</div> }));
|
||||
|
||||
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(<MailApp />);
|
||||
expect(listHidden()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the composer the tour opened when the tour ends', async () => {
|
||||
render(<MailApp />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user