feat: let plugins add options to the composer's Options menu as check items

This commit is contained in:
Linus Rath
2026-10-14 11:50:52 +02:00
parent 9d8f762b1d
commit 19967fa3fb
4 changed files with 81 additions and 3 deletions
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { checkActions, resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
import { useSettingsStore } from '@/stores/settings-store';
import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
@@ -334,6 +334,50 @@ describe('composer-send-menu', () => {
});
});
describe('composer-options-menu and composer-toolbar-actions', () => {
it('lists plugin options in Options, ticked ones as check items counted in the badge', async () => {
act(() => setPluginActions('smime', 'S/MIME', 'composer-options-menu', [
{ id: 'sign', label: 'Sign', checked: true, description: 'With your certificate' },
{ id: 'encrypt', label: 'Encrypt', checked: false },
{ id: 'zones', label: 'Insert time in other zones' },
]));
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
const options = screen.getByTestId('composer-options-menu');
expect(options).toHaveTextContent('1');
fireEvent.click(options);
const sign = screen.getByRole('menuitemcheckbox', { name: /Sign/ });
expect(sign).toHaveAttribute('aria-checked', 'true');
expect(sign).toHaveTextContent('With your certificate');
expect(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })).toHaveAttribute('aria-checked', 'false');
await act(async () => { fireEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })); });
expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'encrypt', { composerId: expect.any(String) });
// A check item leaves the menu open, so several options can be set.
expect(screen.getByRole('menuitemcheckbox', { name: /Sign/ })).toBeInTheDocument();
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Insert time in other zones' })); });
expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'zones', { composerId: expect.any(String) });
});
it('draws plugin buttons in the bottom bar like Attach, with the shortcut in the tooltip', async () => {
act(() => setPluginActions('ai', 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Alt+A' }]));
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
const button = screen.getByRole('button', { name: 'Assistant' });
expect(button).toHaveAttribute('title', 'Assistant (Alt+A)');
expect(button.className).toContain('h-9');
// Not in the plugin frame row above the bar.
expect(button.closest('[data-composer-plugin-row]')).toBeNull();
await act(async () => { fireEvent.click(button); });
expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
});
it('refuses odd values for checked and description', () => {
expect(() => setPluginActions('p', 'P', 'composer-options-menu', checkActions([{ id: 'a', label: 'A', checked: 'yes' }]))).toThrow(/checked must be true or false/);
expect(() => checkActions([{ id: 'a', label: 'A', description: 'x'.repeat(121) }])).toThrow(/description/);
});
});
describe('composer-attach-menu', () => {
it('turns Attach into a menu for plugin entries', async () => {
act(() => setPluginActions('p', 'Cloud', 'composer-attach-menu', [{ id: 'cloud', label: 'From the cloud' }]));
+18 -1
View File
@@ -195,6 +195,20 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator
{separator && <div role="separator" className="my-1 h-px bg-border" />}
{actions.map((action) => {
const Icon = iconForName(action.icon) ?? Puzzle;
if (action.checked !== undefined) {
// An option like the menu's own (plain text, read receipt): stays
// open, so several can be set in one go.
return (
<ComposerToolbarMenuCheckItem
key={`${action.pluginId}:${action.id}`}
checked={action.checked}
description={action.description}
onToggle={() => { void runAction(action, surface, typeof context === "function" ? context() : context); }}
>
{action.label}
</ComposerToolbarMenuCheckItem>
);
}
return (
<button
key={`${action.pluginId}:${action.id}`}
@@ -211,7 +225,10 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator
)}
>
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1">{action.label}</span>
<span className="flex-1">
<span className="block">{action.label}</span>
{action.description && <span className="block text-xs text-muted-foreground">{action.description}</span>}
</span>
</button>
);
})}
+3 -1
View File
@@ -2086,6 +2086,8 @@ export function EmailComposer({
// itself has nothing else to put there.
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
// Ticked plugin options count in the Options button's badge like the app's own.
const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
// Files from the Files app are already blobs on the server, so they are
// attached by blobId with no download or upload (#1179). Stalwart resolves a
@@ -4075,7 +4077,7 @@ export function EmailComposer({
<ComposerToolbarMenu
icon={SlidersHorizontal}
label={t('options')}
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length}
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length + pluginOptionsOn}
testId="composer-options-menu"
>
{(close) => <>
+15
View File
@@ -25,6 +25,13 @@ export interface PluginAction {
* Ctrl/Cmd+Shift or Ctrl/Cmd+Alt, like plugin shortcuts.
*/
shortcut?: string;
/**
* On menu surfaces (the composer's Options and Send menus): draw the entry
* as a check item, ticked or not. The plugin updates it with setActions.
*/
checked?: boolean;
/** A second line under the label, on menu surfaces. Up to 120 characters. */
description?: string;
}
export interface RegisteredAction extends PluginAction {
@@ -77,6 +84,14 @@ export function checkActions(value: unknown): PluginAction[] {
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
}
if (a.danger !== undefined) out.danger = a.danger === true;
if (a.checked !== undefined) {
if (typeof a.checked !== 'boolean') throw new Error(`actions[${i}].checked must be true or false`);
out.checked = a.checked;
}
if (a.description !== undefined) {
if (typeof a.description !== 'string' || a.description.length > 120) throw new Error(`actions[${i}].description must be a string of up to 120 characters`);
if (a.description.trim()) out.description = a.description.trim();
}
if (a.shortcut !== undefined) {
if (typeof a.shortcut !== 'string' || a.shortcut.length > 30 || !isAllowedPluginCombo(a.shortcut)) {
throw new Error(`actions[${i}].shortcut must use Alt, Ctrl+Shift or Ctrl+Alt`);