From 8fbc8a1c1dc3fb768bdedfb572282a0f04c6fbd2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:43:22 +0200 Subject: [PATCH] feat: let plugins add frames above and in an empty message list and buttons to message rows and the selection toolbar --- .../mail-list-plugin-surfaces.test.tsx | 255 ++++++++++++++++++ components/email/email-context-menu.tsx | 20 ++ components/email/email-hover-actions.tsx | 23 +- components/email/email-list.tsx | 26 ++ components/email/mv-list-toolbar.tsx | 8 + components/email/thread-list-item.tsx | 2 + .../plugins/__tests__/fake-plugin-offer.ts | 43 +++ 7 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/mail-list-plugin-surfaces.test.tsx create mode 100644 components/plugins/__tests__/fake-plugin-offer.ts diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx new file mode 100644 index 000000000..e228eb913 --- /dev/null +++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx @@ -0,0 +1,255 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions'; +import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer'; + +// Plugin surfaces of the message list: frames above the list and in the empty +// list, buttons on each row (hover, and the row menu), and buttons in the +// toolbar shown while messages are selected. + +vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({ + PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot, +})); +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/email/rules-menu', () => ({ RulesContextSubMenu: () => null })); +vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() })); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ + client: {}, identities: [], username: null, activeAccountId: 'acct-1', + getAllConnectedClients: () => new Map(), getClientForAccount: () => null, + })), + }; +}); +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined, accounts: [] }; + return { + useAccountStore: Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const { EmailList } = await import('../email-list'); +const { MvListToolbar } = await import('../mv-list-toolbar'); +const { ThreadListItem } = await import('../thread-list-item'); +const { EmailContextMenu } = await import('../email-context-menu'); +const { useEmailStore } = await import('@/stores/email-store'); +const { useUIStore } = await import('@/stores/ui-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { groupEmailsByThread } = await import('@/lib/thread-utils'); + +const mail = (id: string): Email => ({ + id, + threadId: `thread-${id}`, + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2026-09-30T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: `Mail ${id}`, + hasAttachment: false, +}); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const dispatch = vi.fn().mockResolvedValue(undefined); +const initialEmailState = useEmailStore.getState(); + +beforeEach(() => { + resetActions(); + dispatch.mockClear(); + setActionDispatcher(dispatch); + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set() }); + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never); +}); + +afterEach(() => { + clearOffers(); + setActionDispatcher(null); +}); + +describe('message-list-header', () => { + it('shows the plugin frame above the list with the folder', () => { + offerSlots('p1', ['message-list-header']); + render(); + + const frame = screen.getByTestId('slot-message-list-header'); + expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox' }); + // Above the scrolling list, not inside it. + expect(frame.closest('[data-tour="email-list"]')).toBeNull(); + }); + + it('draws nothing without a plugin', () => { + const { container } = render(); + expect(container.querySelector('[data-plugin-slot="message-list-header"]')).toBeNull(); + }); +}); + +describe('message-list-empty', () => { + it('shows the frame under the "no messages" text of an empty list', () => { + offerSlots('p1', ['message-list-empty']); + render(); + + const frame = screen.getByTestId('slot-message-list-empty'); + expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox', searching: false }); + const text = screen.getByText('no_emails_description'); + expect(text.compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('says when the empty list is a search', () => { + offerSlots('p1', ['message-list-empty']); + useEmailStore.setState({ searchQuery: 'invoice' }); + render(); + + expect(slotProps(screen.getByTestId('slot-message-list-empty')).searching).toBe(true); + }); + + it('is not there when the list has messages, or without a plugin', () => { + offerSlots('p1', ['message-list-empty']); + const { unmount } = render(); + expect(screen.queryByTestId('slot-message-list-empty')).toBeNull(); + unmount(); + clearOffers(); + render(); + expect(screen.queryByTestId('slot-message-list-empty')).toBeNull(); + }); +}); + +describe('message-bulk-actions', () => { + it('adds the plugin buttons to the selection toolbar and sends the selected ids', async () => { + const emails = [mail('a'), mail('b'), mail('c')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'c']) }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render(); + + const button = screen.getByRole('button', { name: 'Make tasks' }); + // In the toolbar that holds the app's own batch buttons. + const toolbar = screen.getByText('batch_actions.clear_selection', { selector: 'button' }).parentElement!; + expect(toolbar).toContainElement(button); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'c'] }); + }); + + it('has no plugin buttons without a selection or without a plugin', () => { + const emails = [mail('a')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a']) }); + const { unmount } = render(); + expect(document.querySelector('[data-plugin-actions="message-bulk-actions"]')).toBeNull(); + unmount(); + + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + useEmailStore.setState({ selectedEmailKeys: new Set() }); + render(); + expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull(); + }); + + it('adds them to the Mountain View toolbar too', async () => { + const emails = [mail('a'), mail('b')]; + useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render( {}} />); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); }); + expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['b'] }); + }); + + it('leaves the Mountain View toolbar alone without a selection', () => { + useEmailStore.setState({ emails: [mail('a')] }); + act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render( {}} />); + expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull(); + }); +}); + +describe('message-row-actions', () => { + const row = (email: Email) => { + const [thread] = groupEmailsByThread([email]); + return {}} onEmailSelect={() => {}} />; + }; + + it('adds the plugin buttons after the row\'s own hover buttons', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later', icon: 'clock' }])); + const onSelect = vi.fn(); + const [thread] = groupEmailsByThread([mail('a')]); + render( {}} onEmailSelect={onSelect} />); + + const button = screen.getByRole('button', { name: 'Later' }); + const star = screen.getByTestId('hover-action-star'); + expect(star.parentElement).toContainElement(button); + expect(star.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await act(async () => { fireEvent.click(button); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + // The click is the plugin's, not a row click that opens the message. + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('shows them even when the user turned off every quick action', () => { + useSettingsStore.setState({ hoverActions: [] } as never); + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + render(row(mail('a'))); + expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument(); + }); + + it('honours the folder roles the plugin asked for', () => { + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }])); + render(row(mail('a'))); + expect(screen.queryByRole('button', { name: 'Later' })).toBeNull(); + }); + + it('draws nothing without a plugin', () => { + render(row(mail('a'))); + expect(document.querySelector('[data-plugin-actions="message-row-actions"]')).toBeNull(); + }); + + const menu = (props: Partial> = {}) => render( + {})} + menuRef={{ current: null }} + mailboxes={mailboxes} + selectedMailbox="inbox" + currentMailboxRole="inbox" + {...props} + />, + ); + + it('puts them in the row menu, which is what a long press opens on phones', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + const onClose = vi.fn(); + menu({ onClose }); + + const item = screen.getByRole('menuitem', { name: 'Later' }); + await act(async () => { fireEvent.click(item); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => { + const { unmount } = menu(); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + unmount(); + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + menu({ isMultiSelect: true, selectedCount: 3 }); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + }); + + it('keeps the menu entries to the folder roles the plugin asked for', () => { + act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['junk'] }])); + menu(); + expect(within(document.body).queryByRole('menuitem', { name: 'Later' })).toBeNull(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 3c732be1a..26ee9d45a 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -12,6 +12,7 @@ import { ContextMenuHeader, } from "@/components/ui/context-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; import { Reply, ReplyAll, @@ -266,6 +267,23 @@ export function EmailContextMenu({ // Stable, so the Rules entry does not re-resolve its account every render. const ruleSenderEmails = useMemo(() => ruleEmails ?? [email], [ruleEmails, email]); + // The row's plugin buttons (message-row-actions). On phones the long press + // opens this menu, so it is where those buttons live there. + const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0; + const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]); + const rowPluginBlock = !showBatchActions && hasRowPluginActions ? ( + <> + + + + ) : null; + // The menu's entries, composed below in the default layout's order or, // under Mountain View, in Gmail's. const replyBlock = ( @@ -564,6 +582,7 @@ export function EmailContextMenu({ {findBlock} {permalinkBlock} + {rowPluginBlock} ) : ( <> @@ -581,6 +600,7 @@ export function EmailContextMenu({ {spamBlock} {readItem} + {rowPluginBlock} )} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 6b1e835fe..82fd8c29c 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Email } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; @@ -11,6 +11,7 @@ import { useTranslations } from "next-intl"; import { useIsMobile } from "@/hooks/use-media-query"; import { TagPicker } from "@/components/email/tag-picker"; import { getEmailTagIds } from "@/lib/thread-utils"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; interface EmailHoverActionsProps { email: Email; @@ -27,6 +28,8 @@ interface EmailHoverActionsProps { onUndoSpam?: () => void; // Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent). spamApplicable?: boolean; + /** Role of the folder the row is listed in; plugin buttons can be limited to some. */ + mailboxRole?: string | null; } const ACTION_CONFIG: Record state.hoverActions); const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); @@ -113,6 +117,9 @@ export function EmailHoverActions({ const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner); const t = useTranslations("settings.email_behavior.hover_actions"); const isMobile = useIsMobile(); + // Buttons plugins add to the row (message-row-actions), after the app's own. + const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length; + const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]); // The tag quick-action opens the same picker the context menu and the reading // pane use. It is portalled to the body because every row sits inside a @@ -159,7 +166,7 @@ export function EmailHoverActions({ const hoverBackgroundClassName = backgroundClassName; if (isMobile) return null; - if (hoverActions.length === 0) return null; + if (hoverActions.length === 0 && pluginActionCount === 0) return null; const handleAction = (e: React.MouseEvent, action: HoverAction) => { e.stopPropagation(); @@ -251,6 +258,18 @@ export function EmailHoverActions({ ); }); + if (pluginActionCount > 0) { + actionButtons.push( + , + ); + } const tagPicker = tagPickerPos && onSetTag diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 25bb80857..3c974a155 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -33,6 +33,8 @@ import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; import { getOwnAddresses } from "@/lib/filters/quick-rule-target"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { normalizeAddress } from "@/lib/filters/quick-rules"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { PluginActions } from "@/components/plugins/plugin-actions"; interface EmailListProps { emails: Email[]; @@ -381,6 +383,19 @@ export function EmailList({ ); const hasSelection = selectedEmailKeys.size > 0; + // What plugin buttons in the selection toolbar act on: the JMAP ids of the + // selected messages, thread members included. + const selectedEmailIds = useMemo( + () => resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }).map((email) => email.id), + [emails, threadEmailsCache, selectedEmailKeys], + ); + const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]); + const listSlotProps = useMemo( + () => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }), + [selectedMailbox, effectiveMailboxRole], + ); + const isSearching = !!searchQuery || !isFilterEmpty(searchFilters); + const emptySlotProps = useMemo(() => ({ ...listSlotProps, searching: isSearching }), [listSlotProps, isSearching]); const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; @@ -653,6 +668,14 @@ export function EmailList({ )} + {hasSelection && ( + + )}
)} + email.id) }} + mailboxRole={currentRole ?? null} + compact + className="shrink-0" + /> {tBatch("selected_messages", { count: selectionCount })} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 48697c5b2..5ed9264fb 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -783,6 +783,7 @@ const SingleEmailItem = React.forwardRef( onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + mailboxRole={currentMailboxRole ?? null} /> )} @@ -1381,6 +1382,7 @@ export const ThreadListItem = React.forwardRef onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + mailboxRole={currentMailboxRole ?? null} /> )} diff --git a/components/plugins/__tests__/fake-plugin-offer.ts b/components/plugins/__tests__/fake-plugin-offer.ts new file mode 100644 index 000000000..a11d8c685 --- /dev/null +++ b/components/plugins/__tests__/fake-plugin-offer.ts @@ -0,0 +1,43 @@ +// Test helpers: pretend a running plugin offers a frame slot, without booting +// a sandbox. Pair with a vi.mock of '@/components/plugins/plugin-iframe-slot' +// (FakeIframeSlot below) so the "iframe" shows the props it would receive. + +import React from 'react'; +import { register, deregister } from '@/lib/plugin-sandbox/registry'; +import type { ActivePlugin } from '@/lib/plugin-sandbox/registry'; +import type { SlotName } from '@/lib/plugin-types'; + +const offered = new Set(); + +/** Registers a fake plugin that offers the given slots. */ +export function offerSlots(pluginId: string, slots: string[]): void { + register({ + plugin: { id: pluginId, name: pluginId }, + code: '', + tier: 'untrusted', + background: {}, + slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false })), + hookDisposables: [], + } as unknown as ActivePlugin); + offered.add(pluginId); +} + +/** Removes every fake plugin registered with offerSlots. */ +export function clearOffers(): void { + for (const id of offered) deregister(id); + offered.clear(); +} + +/** Stand-in for PluginIframeSlot: a div carrying the slot name and its props as JSON. */ +export function FakeIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record }) { + return React.createElement('div', { + 'data-testid': `slot-${slot}`, + 'data-plugin': pluginId, + 'data-props': JSON.stringify(extraProps ?? {}), + }); +} + +/** The props the fake iframe of a slot received. */ +export function slotProps(el: HTMLElement): Record { + return JSON.parse(el.getAttribute('data-props') ?? '{}'); +}