fix: keep the reader still while the list changes, and give Pro tabs no list ticks

This commit is contained in:
Linus Rath
2026-11-03 00:30:56 +01:00
parent 08bc05a20b
commit 9ce4d01807
5 changed files with 42 additions and 15 deletions
@@ -63,4 +63,24 @@ describe('the reader panel prop listSelection', () => {
act(() => { useEmailStore.setState({ emails: [row('new'), row('a'), row('b'), row('c')] }); });
expect(seen.at(-1)?.listSelection).toBe(before);
});
it('does not render the reader again when the list changes but the ticks do not', () => {
useAuthStore.setState({ client });
useEmailStore.setState({ emails: [row('a'), row('b')], selectedEmailKeys: new Set(['a', 'b']), selectedMailbox: 'inbox', mailboxes });
render(<EmailViewer email={null} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />);
const renders = seen.length;
act(() => { useEmailStore.setState({ emails: [row('new'), row('a'), row('b')] }); });
act(() => { useEmailStore.setState({ emails: [row('newer'), row('new'), row('a'), row('b')], threadEmailsCache: new Map() }); });
expect(seen.length).toBe(renders);
// A new tick does.
act(() => { useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b', 'new']) }); });
expect(seen.length).toBeGreaterThan(renders);
});
it('gives a panel outside the main list (a Pro tab) no ticks', () => {
useAuthStore.setState({ client });
useEmailStore.setState({ emails: [row('a'), row('b')], selectedEmailKeys: new Set(['a', 'b']), selectedMailbox: 'inbox', mailboxes });
render(<EmailViewer email={null} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} withListSelection={false} />);
expect(seen.at(-1)).toMatchObject({ listSelection: null });
});
});
+4 -1
View File
@@ -288,6 +288,8 @@ interface EmailViewerProps {
mailboxes?: Mailbox[];
selectedMailbox?: string;
className?: string;
/** The side panel hears of the messages ticked in the main list (false: a Pro tab, whose message isn't from that list). */
withListSelection?: boolean;
}
// Helper function to format recipients with contextual display
@@ -1313,6 +1315,7 @@ export function EmailViewer({
mailboxes = [],
selectedMailbox = "",
className,
withListSelection = true,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
const archiveActionEnabled = useArchiveActionEnabled();
@@ -1819,7 +1822,7 @@ export function EmailViewer({
}, []);
const [detailFocusRequest, setDetailFocusRequest] = useState(0);
// The messages ticked in the list, for the AI assistant (lib/plugin-sandbox/assistant).
const listSelection = useListSelection();
const listSelection = useListSelection(withListSelection);
const pluginDetailProps = useMemo(
() => ({ email: pluginEmailView, selection: bodySelection, listSelection, focusRequest: detailFocusRequest }),
[pluginEmailView, bodySelection, listSelection, detailFocusRequest],
@@ -143,6 +143,12 @@ describe('Pro email view - editing a draft', () => {
expect(initialData.draftAccountId).toBeUndefined();
});
// The ticks in the main list are another view's: the tab's side panel doesn't get them.
it('gives the side panel of an email tab no list selection', async () => {
await openAndEdit(undefined);
expect(viewerProps.current?.withListSelection).toBe(false);
});
// Pro shell with the 0.16.23 login active and the 1.0 login's Drafts
// browsed: the store reads a row of that list through the 1.0 login. A
// view that names no login can't tell, so it names none - the active one
+1
View File
@@ -541,6 +541,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
currentMailboxRole={currentMailboxRole}
mailboxes={mailboxes}
className="flex-1"
withListSelection={false}
/>
</ErrorBoundary>
<ConfirmDialog {...confirmDialogProps} />
@@ -2,7 +2,7 @@
// the reader panel's `listSelection` prop, so the assistant can work on
// several at once ("summarise these").
import { useMemo, useRef } from 'react';
import { useMemo } from 'react';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { resolveEmailBlobContext, resolveSelectedEmails, useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -33,18 +33,15 @@ export function listSelectionOf(state: SelectionState, client: IJMAPClient | nul
return { emails, total };
}
/** listSelectionOf for the list on screen; the same object while the selection stays the same. */
export function useListSelection(): ListSelectionView | null {
const selectedEmailKeys = useEmailStore((s) => s.selectedEmailKeys);
const emails = useEmailStore((s) => s.emails);
const threadEmailsCache = useEmailStore((s) => s.threadEmailsCache);
/**
* listSelectionOf for the list on screen; the same object while the
* selection stays the same. The store is read through one selector that
* answers a string, so new mail, a search or a moved message re-render the
* caller only when the ticked messages change. `enabled` false (a Pro tab,
* whose panel isn't about the main list) always answers null.
*/
export function useListSelection(enabled = true): ListSelectionView | null {
const client = useAuthStore((s) => s.client);
const last = useRef<{ key: string; value: ListSelectionView | null }>({ key: 'null', value: null });
return useMemo(() => {
const value = listSelectionOf({ selectedEmailKeys, emails, threadEmailsCache }, client);
const key = JSON.stringify(value);
// New mail arriving re-renders the list; the panel only hears of a new selection.
if (key !== last.current.key) last.current = { key, value };
return last.current.value;
}, [selectedEmailKeys, emails, threadEmailsCache, client]);
const key = useEmailStore((s) => (enabled ? JSON.stringify(listSelectionOf(s, client)) : 'null'));
return useMemo(() => JSON.parse(key) as ListSelectionView | null, [key]);
}