From a69d7eee331563cd97c6bc0a14bba4dd25f1a838 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:39:57 +0100 Subject: [PATCH] feat: let plugins work with team mailboxes: list filters, row badge icons, account info and message conditions What the team inbox plugin needs, kept general for other plugins: - `api.ui.setListFilters`: keyword filters drawn as chips in the list header of the folders they apply to (by account kind and folder role). A chip the user turns on is ANDed into the folder's query and comes off with one click; a keyword change a filter tests loads the list again (ui:message-list). - Actions get `accounts`, `hasKeyword` and `notKeyword`, so a button shows only on messages of group accounts, or of a keyword state; the click of message-row-actions and reader-actions carries the message's `accountId`. - `api.email.updateKeywords`: several keywords in one change, through the list for a listed message (email:write). - `api.user.getSelf`, `getMailAccounts` and `findPeople`: the user's own account id (Stalwart's principal id), which accounts are group accounts, and people from the directory (account:read). - `api.accountData`: a plugin's own entry in a mail account's shared Inbox metadata on Stalwart 1.0 (email:read / email:write). - Row badges take an `icon`. --- .../__tests__/plugin-list-filters.test.tsx | 62 +++++ .../__tests__/plugin-row-badges.test.tsx | 16 ++ components/email/email-context-menu.tsx | 10 +- components/email/email-hover-actions.tsx | 8 +- components/email/email-list.tsx | 14 +- components/email/email-viewer.tsx | 5 +- components/email/plugin-list-filters.tsx | 88 +++++++ components/email/plugin-row-badges.tsx | 47 ++-- components/email/reader-plugin-surfaces.tsx | 10 +- components/email/thread-conversation-view.tsx | 6 +- .../plugins/__tests__/plugin-actions.test.tsx | 73 ++++- components/plugins/plugin-actions.tsx | 96 ++++++- lib/__tests__/plugin-team-apis.test.ts | 249 ++++++++++++++++++ lib/plugin-sandbox/account-kind.ts | 30 +++ lib/plugin-sandbox/actions.ts | 64 ++++- lib/plugin-sandbox/host-api-account.ts | 211 +++++++++++++++ lib/plugin-sandbox/host-api.ts | 67 ++++- lib/plugin-sandbox/list-filters.ts | 219 +++++++++++++++ lib/plugin-sandbox/loader.ts | 6 +- lib/plugin-sandbox/runtime.tsx | 58 +++- lib/plugin-slot-catalog.ts | 4 +- lib/plugin-types.ts | 2 + locales/de/common.json | 6 +- locales/en/common.json | 6 +- .../email-store-plugin-list-filters.test.ts | 103 ++++++++ stores/email-store.ts | 38 ++- 26 files changed, 1442 insertions(+), 56 deletions(-) create mode 100644 components/email/__tests__/plugin-list-filters.test.tsx create mode 100644 components/email/plugin-list-filters.tsx create mode 100644 lib/__tests__/plugin-team-apis.test.ts create mode 100644 lib/plugin-sandbox/account-kind.ts create mode 100644 lib/plugin-sandbox/host-api-account.ts create mode 100644 lib/plugin-sandbox/list-filters.ts create mode 100644 stores/__tests__/email-store-plugin-list-filters.test.ts diff --git a/components/email/__tests__/plugin-list-filters.test.tsx b/components/email/__tests__/plugin-list-filters.test.tsx new file mode 100644 index 000000000..9fff13c86 --- /dev/null +++ b/components/email/__tests__/plugin-list-filters.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { PluginListFilters } from '../plugin-list-filters'; +import { + activeListFilterCondition, clearPluginListFilters, resetListFilters, setPluginListFilters, +} from '@/lib/plugin-sandbox/list-filters'; + +// The chips plugins' list filters get in the list header (api.ui.setListFilters). +// The global next-intl mock returns the key, so labels read as keys. + +const TEAM = { role: 'inbox', accountKind: 'group' as const }; + +beforeEach(() => { + resetListFilters(); + setPluginListFilters('team-inbox', 'Team inbox', [ + { id: 'mine', label: 'Assigned to me', icon: 'user', hasKeyword: '$assigned:ci', accounts: ['group'], order: 1 }, + { id: 'open', label: 'Open', notKeyword: '$done', accounts: ['group'], order: 2 }, + ]); +}); + +describe('PluginListFilters', () => { + it('shows the chips of a team folder, off at first', () => { + render( {}} />); + const chips = screen.getAllByRole('button'); + expect(chips.map((c) => c.textContent)).toEqual(['Assigned to me', 'Open']); + expect(chips.every((c) => c.getAttribute('aria-pressed') === 'false')).toBe(true); + expect(screen.getByRole('group')).toHaveAttribute('aria-label', 'filters'); + }); + + it('shows nothing in the user\'s own folders', () => { + const { container } = render( {}} />); + expect(container).toBeEmptyDOMElement(); + }); + + it('turns a filter on with a click, loads the list, and removes it with the next', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + const active = screen.getByRole('button', { name: 'remove_filter' }); + expect(active).toHaveAttribute('aria-pressed', 'true'); + expect(active.querySelector('svg')).not.toBeNull(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(activeListFilterCondition(TEAM)).toEqual({ notKeyword: '$done' }); + + fireEvent.click(active); + expect(screen.getByRole('button', { name: 'Open' })).toHaveAttribute('aria-pressed', 'false'); + expect(onChange).toHaveBeenCalledTimes(2); + expect(activeListFilterCondition(TEAM)).toBeNull(); + }); + + it('loads the list again when the plugin that offered an active filter goes away', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + onChange.mockClear(); + + act(() => clearPluginListFilters('team-inbox')); + expect(screen.queryByRole('group')).toBeNull(); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx index 149d9a559..5db64285b 100644 --- a/components/email/__tests__/plugin-row-badges.test.tsx +++ b/components/email/__tests__/plugin-row-badges.test.tsx @@ -89,6 +89,22 @@ describe('plugin badges in message-list rows', () => { expect(ctx.email).not.toHaveProperty('mailboxIds.inbox'); }); + it('draws a badge\'s icon before its label, and none for a name that isn\'t one', async () => { + renderHooks.onEmailListItemRender.register('test', async () => [ + { key: 'done', label: 'Done', icon: 'check' }, + { key: 'bad', label: 'Bad', icon: 'x">