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'),