feat: tell plugins when the selection or the open folder changes

This commit is contained in:
Linus Rath
2026-10-14 04:00:14 +02:00
parent 58b6e914b3
commit 122ca1dc8b
2 changed files with 84 additions and 1 deletions
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
import type { Email, Mailbox } from '@/lib/jmap/types';
// onEmailSelectionChange and onMailboxChange follow the store state, so every
// path that changes the selection or the open folder fires them once.
function makeEmail(id: string, overrides: Partial<Email> = {}): Email {
return { id, threadId: `t-${id}`, keywords: {}, mailboxIds: { inbox: true }, ...overrides } as Email;
}
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox' },
{ id: 'projects', name: 'Projects' },
] as Mailbox[];
beforeEach(() => {
clearAllHooks();
useEmailStore.setState({
mailboxes,
accountMailboxes: {},
viewingAccountId: null,
selectedMailbox: 'inbox',
emails: [makeEmail('a'), makeEmail('b'), makeEmail('c', { sourceClientAccountId: 'x', sourceAccountId: 'y' })],
selectedEmailKeys: new Set(),
lastSelectedEmailKey: null,
listRows: null,
});
});
afterEach(() => clearAllHooks());
describe('onEmailSelectionChange', () => {
it('reports the selected ids each time the selection changes', () => {
const changed = vi.fn();
emailHooks.onEmailSelectionChange.register('t', changed);
const store = useEmailStore.getState();
store.toggleEmailSelection(makeEmail('a'));
store.selectRangeEmails(makeEmail('b'));
store.selectAllEmails();
store.clearSelection();
// Clearing an empty selection is no change.
store.clearSelection();
expect(changed.mock.calls).toEqual([
[{ emailIds: ['a'] }],
[{ emailIds: ['a', 'b'] }],
[{ emailIds: ['a', 'b', 'c'] }],
[{ emailIds: [] }],
]);
});
});
describe('onMailboxChange', () => {
it('reports the folder opened, its role and the one left', () => {
const changed = vi.fn();
emailHooks.onMailboxChange.register('t', changed);
useEmailStore.getState().selectMailbox('projects');
useEmailStore.getState().selectMailbox('projects');
useEmailStore.getState().selectMailbox('inbox');
expect(changed.mock.calls).toEqual([
[{ mailboxId: 'projects', role: null, previousMailboxId: 'inbox' }],
[{ mailboxId: 'inbox', role: 'inbox', previousMailboxId: 'projects' }],
]);
});
});
+14 -1
View File
@@ -7,7 +7,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from "@/lib/message-list-order";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { allowEmailMove, emitEmailMove, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks";
import { allowEmailMove, emitEmailMove, idsFromSelectionKeys, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks";
import { emailSummary } from "@/lib/plugin-projection";
import { resolveThreadRoute } from "@/lib/thread-routing";
import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
@@ -6385,6 +6385,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// every path that changes it (clicks, keyboard, a batch action clearing the
// selection, an account switch) fires them once.
useEmailStore.subscribe((state, prev) => {
if (state.selectedEmailKeys !== prev.selectedEmailKeys && emailHooks.onEmailSelectionChange.size > 0) {
const changed = state.selectedEmailKeys.size !== prev.selectedEmailKeys.size
|| [...state.selectedEmailKeys].some(key => !prev.selectedEmailKeys.has(key));
if (changed) void emailHooks.onEmailSelectionChange.emit({ emailIds: idsFromSelectionKeys(state.selectedEmailKeys) });
}
if (state.selectedMailbox !== prev.selectedMailbox && state.selectedMailbox && emailHooks.onMailboxChange.size > 0) {
const mailbox = mailboxesInView(state).find(mb => mb.id === state.selectedMailbox);
void emailHooks.onMailboxChange.emit({
mailboxId: state.selectedMailbox,
role: mailbox?.role ?? (state.isUnifiedView ? state.unifiedRole : null) ?? null,
previousMailboxId: prev.selectedMailbox || null,
});
}
if (state.quota && state.quota !== prev.quota
&& (state.quota.used !== prev.quota?.used || state.quota.total !== prev.quota?.total)) {
void emailHooks.onQuotaChange.emit(quotaToView(state.quota));