fix: show the selection in the reading pane once rows beyond the open message are ticked

This commit is contained in:
Linus Rath
2026-11-04 11:21:35 +01:00
parent a834960d08
commit ea6b20d7ff
2 changed files with 50 additions and 6 deletions
@@ -37,8 +37,8 @@ vi.mock('@/components/ui/welcome-banner', () => ({
useWelcomeBannerDue: () => false,
}));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ emptyPaneContent, emptyPaneFooter }: { emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
<div data-testid="viewer">{emptyPaneContent ?? <p>no conversation selected</p>}{emptyPaneFooter}</div>
EmailViewer: ({ email, emptyPaneContent, emptyPaneFooter }: { email?: { id: string } | null; emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
<div data-testid="viewer">{email ? <p>reading {email.id}</p> : <>{emptyPaneContent ?? <p>no conversation selected</p>}{emptyPaneFooter}</>}</div>
),
}));
vi.mock('@/components/email/email-list', () => ({
@@ -94,7 +94,7 @@ beforeEach(() => {
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,
selectedEmailKeys: new Set(), unreadSection: null, listRows: 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),
@@ -138,6 +138,35 @@ describe('the reading pane for a selection', () => {
expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
});
it('replaces the open message once other rows are ticked too (mail-p2-3)', () => {
useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm2']) } as never);
render(<MailApp />);
expect(screen.queryByText('reading m1')).toBeNull();
expect(listProps.host).toBeInstanceOf(HTMLElement);
expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
});
it('replaces the open message when another single row is ticked', () => {
useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m2']) } as never);
render(<MailApp />);
expect(screen.queryByText('reading m1')).toBeNull();
});
it('keeps the open message when only its own row is ticked', () => {
useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1']) } as never);
render(<MailApp />);
expect(screen.getByText('reading m1')).toBeTruthy();
});
it('keeps the open message when its conversation row is ticked', () => {
useEmailStore.setState({
selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm1-reply']),
listRows: [{ key: 'm1', members: ['m1', 'm1-reply'] }, { key: 'm2', members: ['m2'] }],
} as never);
render(<MailApp />);
expect(screen.getByText('reading m1')).toBeTruthy();
});
it('says nothing is selected without a selection', () => {
render(<MailApp />);
expect(screen.getByText('no conversation selected')).toBeTruthy();
+18 -3
View File
@@ -4927,8 +4927,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId;
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
composerHostRef.current = composerHost;
// Rows ticked beyond the open message take the reading pane (mail-p2-3):
// the selection bar and the keys act on the ticked rows, so a reader
// toolbar acting on the one open message would be a second, different set.
// Unticking brings the message back. Mountain View keeps the message while
// rows are ticked (#1231); the focus layout can't tick rows while reading.
const openRowKeys = (() => {
if (!selectedEmail) return null;
const key = emailKeyFor(selectedEmail);
// A conversation row ticks all its messages: the open one's whole row.
const row = useEmailStore.getState().listRows?.find((r) => r.key === key || r.members.includes(key));
return new Set(row ? [key, ...row.members] : [key]);
})();
const selectionTakesReader = !isMobile && !isFocusedMailLayout && !mvShell && !isScheduledView
&& selectedEmailKeys.size > 0
&& !(openRowKeys && [...selectedEmailKeys].every((key) => openRowKeys.has(key)));
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
|| Boolean(selectedEmail && !(mvShell && cursorOnly) && !selectionTakesReader) || deepLinkPane !== null;
// A linked message on a phone opens over the list, as one picked there does.
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
const shouldCollapseListPane = !isMobile && isFocusedMailLayout && hasViewerContent;
@@ -6106,9 +6121,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<>
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={selectedEmail}
email={selectionTakesReader ? null : selectedEmail}
blobSource={resolveEmailBlobContext(selectedEmail, client)}
isLoading={isLoadingEmail || deepLinkPane !== null}
isLoading={!selectionTakesReader && (isLoadingEmail || deepLinkPane !== null)}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}