From 1614cab78bf5fa7dc74767207c6a836058e7150c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:04:02 +0200 Subject: [PATCH] fix: return to the message list after sending a new message on a phone --- .../mail/__tests__/mobile-send-view.test.tsx | 105 ++++++++++++++++++ components/mail/mail-app.tsx | 5 +- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 components/mail/__tests__/mobile-send-view.test.tsx diff --git a/components/mail/__tests__/mobile-send-view.test.tsx b/components/mail/__tests__/mobile-send-view.test.tsx new file mode 100644 index 000000000..a2ff65a80 --- /dev/null +++ b/components/mail/__tests__/mobile-send-view.test.tsx @@ -0,0 +1,105 @@ +import React, { Suspense, lazy } from 'react'; +import { fireEvent, render, screen, waitFor } 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 { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// After sending a new message on a phone, the reading pane the composer had +// taken over stayed on screen, empty, with no way back to the list. +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: ({ onSend }: { onSend?: (data: unknown) => Promise }) => ( + + ), +})); +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({}); + 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('sending from a phone', () => { + it('goes back to the message list after sending a new message', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' })); + await screen.findByText('stub send', {}, { timeout: 5000 }); + expect(listHidden()).toBe(true); + + fireEvent.click(screen.getByText('stub send')); + + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + await waitFor(() => expect(screen.queryByText('stub send')).not.toBeInTheDocument()); + expect(listHidden()).toBe(false); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c337be6be..87b74ccbb 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1886,8 +1886,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // selection like a delete does. if (selectedEmail && (selectedEmail.id === data.draftId || selectedEmail.id === pendingDraft?.draftId)) { selectEmail(null); - if (isMobile) setActiveView('list'); } + // On a phone the composer takes over the reading pane. With no message + // open behind it, that pane is empty and has no way back to the list. + if (isMobile && !useEmailStore.getState().selectedEmail) setActiveView('list'); if (result.filingError) { // The mail went out, but a post-send step (filing to Sent / // removing the old draft) was rejected - warn instead of staying @@ -3815,6 +3817,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } setShowComposer(false); setPendingDraft(null); + if (isMobile && !useEmailStore.getState().selectedEmail) setActiveView('list'); }} onClose={() => { setShowComposer(false);