diff --git a/components/mail/__tests__/mail-app-mv-composer-move.test.tsx b/components/mail/__tests__/mail-app-mv-composer-move.test.tsx new file mode 100644 index 000000000..d7e4acb55 --- /dev/null +++ b/components/mail/__tests__/mail-app-mv-composer-move.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, 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 { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// Mountain View draws the composer in a window, a card or the reading pane +// depending on the width, and React mounts it anew in each. What was typed has +// to come along, or a narrowed window silently empties the message. + +const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } })); + +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onCompose }: { onCompose: () => void }) => , +})); +// The real composer's contract: its state starts from initialData, and it hands +// its edits to onSaveState as it unmounts. +vi.mock('@/components/email/email-composer', async () => { + const { useEffect, useRef, useState } = await import('react'); + return { + EmailComposer: ({ initialData, onSaveState }: { + initialData?: { subject?: string } | null; + onSaveState: (data: { subject: string }) => void; + }) => { + const [subject, setSubject] = useState(initialData?.subject ?? ''); + const latest = useRef(subject); + latest.current = subject; + useEffect(() => () => { + if (latest.current) onSaveState({ subject: latest.current }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return setSubject(e.target.value)} />; + }, + }; +}); +vi.mock('@/components/search/mv-search-form', () => ({ + MvSearchForm: ({ onSearch }: { onSearch: () => void }) => , +})); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () =>
message viewer
})); +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/layout/mv-side-panel', () => ({ MvSidePanel: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), 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: () => device.current, + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +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', totalEmails: 0, unreadEmails: 0 } as Mailbox; + +beforeEach(() => { + device.current = { isMobile: false, isTablet: false, isDesktop: true }; + 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: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'mountain-view', mailLayout: 'focus' } as never); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false, + selectedEmailKeys: new Set(), unreadSection: null, + 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), + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('moving the Mountain View composer between layouts', () => { + it('keeps what was typed when the window narrows past the desktop width and back', async () => { + const { rerender } = render(); + fireEvent.click(screen.getByText('stub compose')); + fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Still here' } }); + + device.current = { isMobile: false, isTablet: true, isDesktop: false }; + rerender(); + await waitFor(() => expect(screen.getByLabelText('stub subject').value).toBe('Still here')); + + device.current = { isMobile: false, isTablet: false, isDesktop: true }; + rerender(); + await waitFor(() => expect(screen.getByLabelText('stub subject').value).toBe('Still here')); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d68c18c98..592c33edd 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -259,6 +259,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); + // Mountain View draws the composer in a window, a card or the reading pane + // depending on the width, and React mounts it anew in each. The composer hands + // its edits over as it unmounts, too late for the one already mounting there, + // so that one is mounted once more with them. + const [composerRemount, setComposerRemount] = useState(0); + const composerSessionRef = useRef(composerSessionId); + composerSessionRef.current = composerSessionId; + const composerHostRef = useRef('pane'); + const composerRemountHostRef = useRef('pane'); + const composerRemountSessionRef = useRef(composerSessionId); + const showComposerRef = useRef(showComposer); + showComposerRef.current = showComposer; // Fields a plugin filled in through onBeforeCompose for the composer about // to open. Dropped once the composer hands its own state back, so a // remount restores what the user wrote instead. @@ -4525,6 +4537,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut && (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail); const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply; + const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : 'pane'; + composerHostRef.current = composerHost; + if (!showComposer || composerRemountSessionRef.current !== composerSessionId) { + composerRemountSessionRef.current = composerSessionId; + composerRemountHostRef.current = composerHost; + } const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread) || Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null; // A linked message on a phone opens over the list, as one picked there does. @@ -4835,7 +4853,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }} > n + 1); + } setPendingDraft(data); }} onSend={async (data) => {