mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: keep typed text when the Mountain View composer moves between window, card and pane
This commit is contained in:
@@ -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 }) => <button onClick={onCompose}>stub compose</button>,
|
||||
}));
|
||||
// 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 <input aria-label="stub subject" value={subject} onChange={(e) => setSubject(e.target.value)} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/search/mv-search-form', () => ({
|
||||
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
|
||||
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: () => <div>message list</div> }));
|
||||
|
||||
|
||||
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(<MailApp />);
|
||||
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(<MailApp />);
|
||||
await waitFor(() => expect(screen.getByLabelText<HTMLInputElement>('stub subject').value).toBe('Still here'));
|
||||
|
||||
device.current = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
rerender(<MailApp />);
|
||||
await waitFor(() => expect(screen.getByLabelText<HTMLInputElement>('stub subject').value).toBe('Still here'));
|
||||
});
|
||||
});
|
||||
@@ -259,6 +259,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(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 = {}) {
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
key={`${composerSessionId}:${composerRemount}`}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
composeFromAccountEmail={resolveComposeAccountEmail(
|
||||
mailboxes,
|
||||
@@ -4878,6 +4896,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (showComposerRef.current && composerSessionRef.current === composerSessionId) {
|
||||
// Still open: it moved to another host, or is the stand-in mounted
|
||||
// there before the edits arrived. The stand-in's state is out of date.
|
||||
if (composerHostRef.current === composerRemountHostRef.current) return;
|
||||
composerRemountHostRef.current = composerHostRef.current;
|
||||
setComposerRemount((n) => n + 1);
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
|
||||
Reference in New Issue
Block a user