feat: let plugins add frames above and in an empty message list and buttons to message rows and the selection toolbar

This commit is contained in:
Linus Rath
2026-10-14 04:43:22 +02:00
parent 1ba53655e5
commit 8fbc8a1c1d
7 changed files with 375 additions and 2 deletions
@@ -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<string>() });
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(<EmailList emails={[mail('a')]} />);
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(<EmailList emails={[mail('a')]} />);
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(<EmailList emails={[]} />);
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(<EmailList emails={[]} />);
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(<EmailList emails={[mail('a')]} />);
expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
unmount();
clearOffers();
render(<EmailList emails={[]} />);
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(<EmailList emails={emails} />);
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(<EmailList emails={emails} />);
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<string>() });
render(<EmailList emails={emails} />);
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(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
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(<MvListToolbar loadedCount={1} onRefresh={() => {}} />);
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
});
});
describe('message-row-actions', () => {
const row = (email: Email) => {
const [thread] = groupEmailsByThread([email]);
return <ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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<React.ComponentProps<typeof EmailContextMenu>> = {}) => render(
<EmailContextMenu
email={mail('a')}
position={{ x: 0, y: 0 }}
isOpen
onClose={props.onClose ?? (() => {})}
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();
});
});
+20
View File
@@ -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 ? (
<>
<ContextMenuSeparator />
<PluginActions
surface="message-row-actions"
variant="menu"
context={rowPluginContext}
mailboxRole={currentMailboxRole ?? null}
onRan={onClose}
/>
</>
) : 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}
<ContextMenuSeparator />
{permalinkBlock}
{rowPluginBlock}
</>
) : (
<>
@@ -581,6 +600,7 @@ export function EmailContextMenu({
{spamBlock}
<ContextMenuSeparator />
{readItem}
{rowPluginBlock}
</>
)}
</>
+21 -2
View File
@@ -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<HoverAction, {
@@ -105,6 +108,7 @@ export function EmailHoverActions({
isInJunk = false,
onUndoSpam,
spamApplicable = true,
mailboxRole = null,
}: EmailHoverActionsProps) {
const configuredHoverActions = useSettingsStore((state) => 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({
</button>
);
});
if (pluginActionCount > 0) {
actionButtons.push(
<PluginActions
key="plugin-actions"
surface="message-row-actions"
context={pluginContext}
mailboxRole={mailboxRole}
compact
className="flex-nowrap gap-0.5"
/>,
);
}
const tagPicker =
tagPickerPos && onSetTag
+26
View File
@@ -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({
<Trash2 className="w-4 h-4" />
)}
</Button>
{hasSelection && (
<PluginActions
surface="message-bulk-actions"
context={bulkActionContext}
mailboxRole={effectiveMailboxRole ?? null}
compact
/>
)}
<div className="w-px h-6 bg-border mx-1" />
<Button
variant="ghost"
@@ -708,6 +731,8 @@ export function EmailList({
</div>
)}
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
{/* Loading overlay */}
@@ -739,6 +764,7 @@ export function EmailList({
<p className="text-sm mt-1 text-muted-foreground">
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p>
<PluginSlot name="message-list-empty" className="mt-4 w-full max-w-md px-4" extraProps={emptySlotProps} />
</div>
) : (
<>
+8
View File
@@ -31,6 +31,7 @@ import { localizeMailboxName } from "@/lib/mailbox-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { getEmailTagIds } from "@/lib/thread-utils";
import { cn, type MailboxNode } from "@/lib/utils";
import { PluginActions } from "@/components/plugins/plugin-actions";
interface MvListToolbarProps {
/** Loaded conversation count, for the right-hand range readout. */
@@ -606,6 +607,13 @@ export function MvListToolbar({
</div>
)}
</div>
<PluginActions
surface="message-bulk-actions"
context={{ emailIds: selectedEmails.map((email) => email.id) }}
mailboxRole={currentRole ?? null}
compact
className="shrink-0"
/>
<span className="ms-2 text-sm text-muted-foreground truncate">
{tBatch("selected_messages", { count: selectionCount })}
</span>
+2
View File
@@ -783,6 +783,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
mailboxRole={currentMailboxRole ?? null}
/>
)}
</div>
@@ -1381,6 +1382,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
mailboxRole={currentMailboxRole ?? null}
/>
)}
</div>
@@ -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<string>();
/** 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<string, unknown> }) {
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<string, unknown> {
return JSON.parse(el.getAttribute('data-props') ?? '{}');
}