feat: let a plugin open, close and toggle the panel its slot sits in

This commit is contained in:
Linus Rath
2026-10-20 17:21:13 +02:00
parent 8deb887e95
commit 2bb5a62ac4
10 changed files with 264 additions and 15 deletions
@@ -7,6 +7,7 @@ import { checkActions, resetActions, setActionDispatcher, setPluginActions } fro
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';
import { requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
// Plugin surfaces of the composer: frames above and below the message text
// (fed the draft, at most every 2 seconds), and entries in the Send and
@@ -220,6 +221,7 @@ beforeEach(() => {
afterEach(() => {
clearOffers();
setActionDispatcher(null);
resetPanelRequests();
vi.useRealTimers();
});
@@ -562,6 +564,40 @@ describe('composer-sidebar and composer-sidebar-right', () => {
}
});
it('open, close and toggle on a plugin\'s request, for that composer only, and tell the slot to focus', () => {
stubWidth(600);
offerSlots('p1', ['composer-sidebar-right']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
let open: boolean | null = false;
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'open', { composerId: 'someone-else' }); });
expect(open).toBeNull();
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'open', undefined); });
expect(open).toBe(true);
expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
const props = slotProps(screen.getByTestId('slot-composer-sidebar-right'));
expect(props.focusRequest).toBe(1);
const composerId = props.composerId as string;
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'toggle', { composerId }); });
expect(open).toBe(false);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'toggle', { composerId }); });
expect(open).toBe(true);
expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).focusRequest).toBe(2);
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'close', { composerId }); });
expect(open).toBe(false);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
// The left panel is not on this composer: nobody answers.
act(() => { open = requestPanel('p1', 'composer-sidebar', 'open', { composerId }); });
expect(open).toBeNull();
});
it('open a folded panel over the composer when there is no room beside it', () => {
stubWidth(600);
offerSlots('p1', ['composer-sidebar-right']);
@@ -4,6 +4,7 @@ import React from 'react';
import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
import { requestPanel } from '@/lib/plugin-sandbox/panel-requests';
// Plugin surfaces of the open message: frames above the subject, under the
// sender, between new text and quote, above a conversation; buttons in the
@@ -126,6 +127,42 @@ describe('email-detail-sidebar', () => {
expect(panel.className).toContain('absolute');
});
it('opens, closes and toggles on a plugin\'s request, and tells the slot to focus', () => {
stubWidth(584);
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
let open: boolean | null = false;
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); });
expect(open).toBe(true);
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).focusRequest).toBe(1);
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'toggle', undefined); });
expect(open).toBe(false);
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'toggle', undefined); });
expect(open).toBe(true);
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).focusRequest).toBe(2);
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'close', undefined); });
expect(open).toBe(false);
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
});
it('answers no request on phones, where there is no panel', () => {
device.isMobile = true;
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
let open: boolean | null = true;
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); });
expect(open).toBeNull();
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
});
it('docks next to the message when there is room', () => {
stubWidth(1000);
offerSlots('p1', ['email-detail-sidebar']);
+31 -4
View File
@@ -6,11 +6,12 @@
// first); opening it anyway lays it over the composer instead of squeezing
// the message into a column nobody can write in.
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { onPanelRequest, type PanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { cn } from "@/lib/utils";
/** Width of an open panel. */
@@ -68,8 +69,12 @@ function readCollapsed(side: ComposerSidebarSide): boolean {
try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; }
}
/** Layout state of both panels for a composer whose root element is `root`. */
export function useComposerSidebars(root: HTMLElement | null) {
/**
* Layout state of both panels for a composer whose root element is `root`.
* `composerId` is the id plugins know this composer by; their
* api.ui.openPanel requests for it are answered here.
*/
export function useComposerSidebars(root: HTMLElement | null, composerId: string | null = null) {
const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0;
const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0;
const [width, setWidth] = useState(0);
@@ -115,7 +120,29 @@ export function useComposerSidebars(root: HTMLElement | null) {
setOverlayOpen((prev) => ({ ...prev, [side]: false }));
};
return { modes, open, close };
// Plugins opening their panel (api.ui.openPanel): counts up per side when
// it opens, and reaches the slot as `focusRequest`.
const [focusRequests, setFocusRequests] = useState<Record<ComposerSidebarSide, number>>({ left: 0, right: 0 });
const latest = useRef({ modes, open, close, composerId, hasLeft, hasRight });
latest.current = { modes, open, close, composerId, hasLeft, hasRight };
useEffect(() => onPanelRequest((request: PanelRequest) => {
const side: ComposerSidebarSide | null = request.slot === "composer-sidebar" ? "left" : request.slot === "composer-sidebar-right" ? "right" : null;
if (!side) return null;
const state = latest.current;
if (request.composerId && request.composerId !== state.composerId) return null;
if (!(side === "left" ? state.hasLeft : state.hasRight)) return null;
const isOpen = state.modes[side] === "docked" || state.modes[side] === "overlay";
const wantOpen = request.mode === "open" ? true : request.mode === "close" ? false : !isOpen;
if (wantOpen) {
if (!isOpen) state.open(side);
setFocusRequests((prev) => ({ ...prev, [side]: prev[side] + 1 }));
} else if (isOpen) {
state.close(side);
}
return wantOpen;
}), []);
return { modes, open, close, focusRequests };
}
/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
+12 -7
View File
@@ -2279,7 +2279,9 @@ export function EmailComposer({
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
// The plugin side panels fold when the composer gets too narrow for them.
const [composerRootEl, setComposerRootEl] = useState<HTMLDivElement | null>(null);
const pluginSidebars = useComposerSidebars(composerRootEl);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId);
const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
@@ -2692,7 +2694,6 @@ export function EmailComposer({
};
const pluginAttachRef = useRef(handlePluginAttachmentAdd);
pluginAttachRef.current = handlePluginAttachmentAdd;
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
useEffect(() => {
const plainRange = (value: string) => plainOwnRange(value, pluginEditOptsRef.current);
const { id, unregister } = registerComposer({
@@ -2793,9 +2794,13 @@ export function EmailComposer({
() => ({ draft: pluginDraft, selection: pluginSelection, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
[pluginDraft, pluginSelection, pluginInsertText, pluginSelectedText, pluginComposerId],
);
const pluginSidebarProps = useMemo(
() => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId }),
[pluginDraft, pluginSelection, pluginComposerId],
const pluginSidebarLeftProps = useMemo(
() => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId, focusRequest: pluginSidebars.focusRequests.left }),
[pluginDraft, pluginSelection, pluginComposerId, pluginSidebars.focusRequests.left],
);
const pluginSidebarRightProps = useMemo(
() => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId, focusRequest: pluginSidebars.focusRequests.right }),
[pluginDraft, pluginSelection, pluginComposerId, pluginSidebars.focusRequests.right],
);
// The right-click menu on selected text, when a plugin put entries in it.
@@ -3609,7 +3614,7 @@ export function EmailComposer({
className={cn("flex h-full bg-background relative", className)}
onKeyDownCapture={handleComposerKeyDown}
>
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarProps} />
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarLeftProps} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
@@ -4762,7 +4767,7 @@ export function EmailComposer({
/>
)}
</div>
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarProps} />
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarRightProps} />
<ComposerSelectionMenu menu={selectionMenu} composerId={pluginComposerId} onClipboard={clipboardFromMenu} />
</div>
);
+26 -1
View File
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { SubMenuPanel } from "@/components/ui/context-menu";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
@@ -1520,7 +1521,11 @@ export function EmailViewer({
const onBodySelection = useCallback((text: string) => {
setBodySelection((prev) => (prev.text === text ? prev : (text ? { text } : NO_BODY_SELECTION)));
}, []);
const pluginDetailProps = useMemo(() => ({ email: pluginEmailView, selection: bodySelection }), [pluginEmailView, bodySelection]);
const [detailFocusRequest, setDetailFocusRequest] = useState(0);
const pluginDetailProps = useMemo(
() => ({ email: pluginEmailView, selection: bodySelection, focusRequest: detailFocusRequest }),
[pluginEmailView, bodySelection, detailFocusRequest],
);
const selectionMenu = useReaderSelectionMenu();
const emailIdRef = useRef(email?.id);
emailIdRef.current = email?.id;
@@ -1561,6 +1566,26 @@ export function EmailViewer({
// Opened by hand while the message is too narrow for it: the panel lies over
// the message instead of squeezing it.
const sidebarOverlay = !sidebarCollapsed && readerTooNarrow;
// Plugins opening their panel (api.ui.openPanel): the same as the user's
// click on the toggle, plus a new focusRequest for the slot.
const detailPanelRef = useRef({ hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile });
detailPanelRef.current = { hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile };
useEffect(() => onPanelRequest((request) => {
if (request.slot !== 'email-detail-sidebar') return null;
const state = detailPanelRef.current;
if (!state.hasDetailSidebar || state.isMobile) return null;
const isOpen = !state.sidebarCollapsed;
const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
if (wantOpen) {
setDetailSidebarCollapsed(false);
if (state.readerTooNarrow) setDetailSidebarForcedOpen(true);
setDetailFocusRequest((n) => n + 1);
} else {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}
return wantOpen;
}), []);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
@@ -16,6 +16,7 @@ import { dispatchApiCall } from '../plugin-sandbox/host-api';
import { actionsFor, resetActions, runAction, checkActions } from '../plugin-sandbox/actions';
import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
import { allPages, resetPages } from '../plugin-sandbox/pages';
import { onPanelRequest, resetPanelRequests } from '../plugin-sandbox/panel-requests';
import type { StreamSink } from '../plugin-sandbox/proxy-client';
import '../plugin-sandbox/loader';
@@ -35,6 +36,36 @@ beforeEach(() => {
registryEntries.length = 0;
});
describe('ui.setPanel (openPanel, closePanel, togglePanel)', () => {
beforeEach(() => { resetPanelRequests(); });
it('needs the permission of the panel\'s slot', async () => {
await expect(dispatchApiCall(plugin({ permissions: [] }), 'ui.setPanel', ['composer-sidebar-right', 'open', { composerId: 'c1' }]))
.rejects.toThrow(/may not open the panel/);
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['email-detail-sidebar', 'open', undefined]))
.rejects.toThrow(/may not open the panel/);
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-details'] }), 'ui.setPanel', ['sidebar-widget', 'open', undefined]))
.rejects.toThrow(/may not open the panel/);
});
it('delivers the request to the panel on screen and answers with its state', async () => {
const seen: unknown[] = [];
onPanelRequest((request) => { seen.push(request); return request.mode !== 'close'; });
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['composer-sidebar-right', 'toggle', { composerId: 'c1' }])).resolves.toBe(true);
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-details'] }), 'ui.setPanel', ['email-detail-sidebar', 'close', undefined])).resolves.toBe(false);
expect(seen).toEqual([
{ pluginId: 'p1', slot: 'composer-sidebar-right', mode: 'toggle', composerId: 'c1' },
{ pluginId: 'p1', slot: 'email-detail-sidebar', mode: 'close' },
]);
});
it('answers null when no panel is on screen, and refuses odd arguments', async () => {
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-details'] }), 'ui.setPanel', ['email-detail-sidebar', 'open', undefined])).resolves.toBeNull();
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['composer-sidebar', 'open', { composerId: 5 }])).rejects.toThrow(/composerId/);
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['composer-sidebar', 'sideways', undefined])).rejects.toThrow(/mode/);
});
});
describe('ui.setActions', () => {
it('puts a plugin\'s buttons on a surface it may use', async () => {
await dispatchApiCall(plugin({ permissions: ['ui:message-list'] }), 'ui.setActions', ['message-row-actions', [
+9
View File
@@ -53,6 +53,7 @@ import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } fr
import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose';
import { doNotificationsShow } from './host-api-notify';
import { setSectionTitle } from './section-titles';
import { requestPanel } from './panel-requests';
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
/**
@@ -210,6 +211,9 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
'ui.setDayLabels': 'ui:calendar-action',
'ui.setPages': 'ui:app-page',
'ui.setSectionTitle': 'ui:sidebar-widget',
// Open, close or toggle the panel the plugin's slot sits in; the panel's
// own permission is checked per call (see the slot catalogue).
'ui.setPanel': null,
// sieve (delivery-time classification)
'sieve.isSupported': 'filters:read',
'sieve.getActiveScript': 'filters:read',
@@ -1749,6 +1753,11 @@ export async function dispatchApiCall(
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
case 'ui.setPanel': {
const slot = args[0];
if (typeof slot !== 'string' || !mayOfferSlot(plugin, slot)) throw new Error(`Plugin "${plugin.id}" may not open the panel "${String(slot)}"`);
return requestPanel(plugin.id, slot, args[1], args[2]);
}
case 'storage.get': return storageGet(plugin.id, args[0] as string);
case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
+67
View File
@@ -0,0 +1,67 @@
// Plugins opening and closing the panel their slot sits in
// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels
// and the reader's detail panel. A toolbar button, a shortcut or a
// right-click entry can then bring the plugin's panel up instead of a
// dialog. The panels subscribe here while they are on screen; a request
// nobody answers (no composer open, no message open, a phone) resolves to
// null, so the plugin can fall back to a dialog.
//
// When a panel opens on request, its slot gets a new `focusRequest` number
// in its props, so the plugin can put the focus where it belongs.
export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar'] as const;
export type PanelSlot = typeof PANEL_SLOTS[number];
export type PanelMode = 'open' | 'close' | 'toggle';
export interface PanelRequest {
pluginId: string;
slot: PanelSlot;
mode: PanelMode;
/** For a composer panel: the composer it belongs to (from the plugin's click context or slot props). */
composerId?: string;
}
/** Answers with the panel's state after the request (true = open), or null when the request is not for it. */
export type PanelRequestHandler = (request: PanelRequest) => boolean | null;
const handlers = new Set<PanelRequestHandler>();
export function onPanelRequest(handler: PanelRequestHandler): () => void {
handlers.add(handler);
return () => { handlers.delete(handler); };
}
export function isPanelSlot(value: unknown): value is PanelSlot {
return typeof value === 'string' && (PANEL_SLOTS as readonly string[]).includes(value);
}
/**
* Check a plugin's request and deliver it: the panel's state afterwards, or
* null when no such panel is on screen. Throws with a message the plugin sees.
*/
export function requestPanel(pluginId: string, slot: unknown, mode: unknown, opts: unknown): boolean | null {
const method = `ui.${mode === 'open' ? 'openPanel' : mode === 'close' ? 'closePanel' : 'togglePanel'}`;
if (!isPanelSlot(slot)) throw new Error(`${method}: "${String(slot)}" is not a panel (${PANEL_SLOTS.join(', ')})`);
if (mode !== 'open' && mode !== 'close' && mode !== 'toggle') throw new Error(`${method}: unknown mode`);
let composerId: string | undefined;
if (opts !== undefined && opts !== null) {
if (typeof opts !== 'object') throw new Error(`${method}: options must be an object`);
const value = (opts as { composerId?: unknown }).composerId;
if (value !== undefined) {
if (typeof value !== 'string' || !value) throw new Error(`${method}: options.composerId must be a string`);
composerId = value;
}
}
const request: PanelRequest = { pluginId, slot, mode, ...(composerId ? { composerId } : {}) };
for (const handler of handlers) {
let answer: boolean | null = null;
try { answer = handler(request); } catch { answer = null; }
if (answer !== null) return answer;
}
return null;
}
/** Test helper. */
export function resetPanelRequests(): void {
handlers.clear();
}
+12
View File
@@ -545,6 +545,18 @@ function buildPluginApi(manifest: PluginManifest) {
* back to the plugin's name. At most 40 characters.
*/
setSectionTitle: (title: string | null) => callApi('ui.setSectionTitle', [title]) as Promise<void>,
/**
* Open, close or toggle the panel this plugin's slot sits in:
* 'composer-sidebar', 'composer-sidebar-right' (pass the composerId
* from the click context or the slot's props) or 'email-detail-sidebar'.
* Resolve to whether the panel is open afterwards, or null when no such
* panel is on screen (no composer or message open, or a phone), so the
* plugin can fall back to a dialog. A panel opened this way gets a new
* `focusRequest` prop, so the slot can focus its input.
*/
openPanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'open', opts]) as Promise<boolean | null>,
closePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'close', opts]) as Promise<boolean | null>,
togglePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'toggle', opts]) as Promise<boolean | null>,
},
// Requests with an account or key the manifest declares under
// `credentials`; the server adds the secret (permission http:proxy).
+3 -3
View File
@@ -70,7 +70,7 @@ export const SLOT_CATALOG = {
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read' }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text })'),
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read', focusRequest: null }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text }); `focusRequest` counts up when the plugin opened the panel with api.ui.openPanel'),
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
'attachment-actions': S('reader', 'frame', ['email:read'], { attachment: null }, 'buttons on each attachment', true),
'reader-selection-menu': S('reader', 'action', ['ui:context-menu', 'ui:toolbar'], { emailId: 'email:read', selection: 'email:read' }, 'entries in the right-click menu on selected text in a message (without plugin entries the browser\'s own menu stays); the click\'s selection is { text }'),
@@ -80,8 +80,8 @@ export const SLOT_CATALOG = {
'composer-toolbar': S('composer', 'frame', ['ui:composer-toolbar'], { insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'the composer\'s plugin row above its bottom bar, for controls that need their own UI (a button belongs in composer-toolbar-actions)', true),
'composer-above-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'between the subject and the message text'),
'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'below the message text, above the attachments'),
'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', composerId: null }, 'a panel left of the composer; `selection` is the selected text of the message as the user changes it ({ text, empty })'),
'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', composerId: null }, 'a panel right of the composer, with the same props as composer-sidebar'),
'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', composerId: null, focusRequest: null }, 'a panel left of the composer; `selection` is the selected text of the message as the user changes it ({ text, empty }); `focusRequest` counts up when the plugin opened the panel with api.ui.openPanel'),
'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], { draft: 'email:send', selection: 'compose:read', composerId: null, focusRequest: null }, 'a panel right of the composer, with the same props as composer-sidebar'),
'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open', composerId: null }, 'the composer\'s plugin row, next to composer-toolbar, as a source of files (older plugins; an entry in the Attach menu, composer-attach-menu, with api.composer.attach looks like the app\'s own)', true),
'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send', composerId: null }, 'entries in the menu next to Send'),
'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'entries in the Attach menu (for example "From Nextcloud"); the click\'s composerId lets api.composer.attach add files'),