diff --git a/components/email/__tests__/viewer-list-selection-prop.test.tsx b/components/email/__tests__/viewer-list-selection-prop.test.tsx index 8462a56b5..0a29cb08f 100644 --- a/components/email/__tests__/viewer-list-selection-prop.test.tsx +++ b/components/email/__tests__/viewer-list-selection-prop.test.tsx @@ -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( {}} />); + 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( {}} withListSelection={false} />); + expect(seen.at(-1)).toMatchObject({ listSelection: null }); + }); }); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 4e3d30104..173c89c97 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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], diff --git a/components/pro/__tests__/pro-email-edit-draft.test.tsx b/components/pro/__tests__/pro-email-edit-draft.test.tsx index c3ebd7e3e..d7901a23f 100644 --- a/components/pro/__tests__/pro-email-edit-draft.test.tsx +++ b/components/pro/__tests__/pro-email-edit-draft.test.tsx @@ -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 diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index cf655ac1a..905c90d4f 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -541,6 +541,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, currentMailboxRole={currentMailboxRole} mailboxes={mailboxes} className="flex-1" + withListSelection={false} /> diff --git a/lib/plugin-sandbox/assistant/b.list-selection.ts b/lib/plugin-sandbox/assistant/b.list-selection.ts index 9cadb2816..43c8b322c 100644 --- a/lib/plugin-sandbox/assistant/b.list-selection.ts +++ b/lib/plugin-sandbox/assistant/b.list-selection.ts @@ -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]); }