diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index c9351bc27..eb0fb14e5 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -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();
+ 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']);
diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx
index 37d0649d3..c6952f579 100644
--- a/components/email/__tests__/reader-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx
@@ -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']);
diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx
index 46982930f..b8fc574a7 100644
--- a/components/email/composer-plugin-sidebars.tsx
+++ b/components/email/composer-plugin-sidebars.tsx
@@ -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>({ 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. */
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index af4a74f2e..c3580be91 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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(null);
- const pluginSidebars = useComposerSidebars(composerRootEl);
+ // The id plugins know this composer by (registered below, with the handle).
+ const [pluginComposerId, setPluginComposerId] = useState(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(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}
>
- pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarProps} />
+ pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarLeftProps} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
)}
- pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarProps} />
+ pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarRightProps} />
);
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 78bbf7673..4e9c5625a 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -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 */ }
diff --git a/lib/__tests__/plugin-actions-proxy-api.test.ts b/lib/__tests__/plugin-actions-proxy-api.test.ts
index 67e90e59d..5f914245f 100644
--- a/lib/__tests__/plugin-actions-proxy-api.test.ts
+++ b/lib/__tests__/plugin-actions-proxy-api.test.ts
@@ -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', [
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index eb9331e3a..71c18d504 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -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> = {
'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;
diff --git a/lib/plugin-sandbox/panel-requests.ts b/lib/plugin-sandbox/panel-requests.ts
new file mode 100644
index 000000000..fc11423e1
--- /dev/null
+++ b/lib/plugin-sandbox/panel-requests.ts
@@ -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();
+
+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();
+}
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index eae038586..20edde72a 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -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,
+ /**
+ * 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,
+ closePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'close', opts]) as Promise,
+ togglePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'toggle', opts]) as Promise,
},
// Requests with an account or key the manifest declares under
// `credentials`; the server adds the secret (permission http:proxy).
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index dc0af3f15..7df9872fb 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -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'),