From 19967fa3fba4b0dbc4b5c6e8798243d6cb126b79 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:50:52 +0200 Subject: [PATCH] feat: let plugins add options to the composer's Options menu as check items --- .../composer-plugin-surfaces.test.tsx | 46 ++++++++++++++++++- components/email/composer-toolbar-menu.tsx | 19 +++++++- components/email/email-composer.tsx | 4 +- lib/plugin-sandbox/actions.ts | 15 ++++++ 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 34b0ad4d9..73dacdb35 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -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(); + + 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(); + 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' }])); diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index 7b09d5f13..0c7c26c34 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -195,6 +195,20 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator {separator &&
} {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 ( + { void runAction(action, surface, typeof context === "function" ? context() : context); }} + > + {action.label} + + ); + } return ( ); })} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index df1e789c1..ceb8a8a59 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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({ {(close) => <> diff --git a/lib/plugin-sandbox/actions.ts b/lib/plugin-sandbox/actions.ts index ef5f81b9e..e69da38da 100644 --- a/lib/plugin-sandbox/actions.ts +++ b/lib/plugin-sandbox/actions.ts @@ -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`);