mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: let a plugin open, close and toggle the panel its slot sits in
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user