mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
Merge branch 'feat/assistant-everywhere' into feat/assistant-b
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { slotPropsFor, mayOfferSlot } from '@/lib/plugin-sandbox/permissions';
|
||||
import { PANEL_SLOTS, onPanelRequest, requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
|
||||
import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
|
||||
import { ALL_PERMISSIONS, type InstalledPlugin, type Permission } from '@/lib/plugin-types';
|
||||
import { slotSpec } from '@/lib/plugin-slot-catalog';
|
||||
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
|
||||
import { runPluginAction } from '@/lib/plugin-sandbox/actions';
|
||||
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { APP_SIDE_PANEL_CONTEXT_FIELDS } from '@/lib/plugin-sandbox/assistant/f.types';
|
||||
|
||||
// The app-side-panel slot (area f of the assistant's host APIs): a side
|
||||
// panel in Calendar, Contacts, Files and Settings. Its context carries the
|
||||
// open event, contact, files or settings page, each behind the permission
|
||||
// that guards that data elsewhere.
|
||||
|
||||
const plugin = (permissions: Permission[]): InstalledPlugin => ({
|
||||
id: 'p', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions, grantedPermissions: permissions, entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
|
||||
});
|
||||
|
||||
const context = {
|
||||
event: { id: 'e1', title: 'Team offsite' },
|
||||
contact: { id: 'c1', fullName: 'Aa Bob' },
|
||||
selectedCount: 3,
|
||||
files: [{ id: 'f1', name: 'agenda.pdf' }],
|
||||
fileCount: 1,
|
||||
folder: { id: 'd1', path: '/Projects' },
|
||||
page: { id: 'reading', label: 'Reading' },
|
||||
secret: 'not in the catalogue',
|
||||
};
|
||||
|
||||
afterEach(() => resetPanelRequests());
|
||||
|
||||
describe('app-side-panel', () => {
|
||||
it('is a slot of its own permission, fillable, and a panel plugins can open', () => {
|
||||
expect(ALL_PERMISSIONS).toContain('ui:side-panel');
|
||||
expect(slotSpec('app-side-panel')?.permissions).toEqual(['ui:side-panel']);
|
||||
expect(mayOfferSlot(plugin(['ui:side-panel']), 'app-side-panel')).toBe(true);
|
||||
expect(mayOfferSlot(plugin(['ui:email-details']), 'app-side-panel')).toBe(false);
|
||||
expect(isFillableSlot('app-side-panel')).toBe(true);
|
||||
expect(PANEL_SLOTS).toContain('app-side-panel');
|
||||
expect(assistantFeatures().sidePanel).toBe(true);
|
||||
});
|
||||
|
||||
it('gives the plugin only the parts of the context it has permissions for', () => {
|
||||
const props = { view: 'calendar', context, focusRequest: 2 };
|
||||
// ui:side-panel alone: the view and the settings page, nothing of the user's data.
|
||||
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', props)).toEqual({
|
||||
view: 'calendar', context: { page: context.page }, focusRequest: 2,
|
||||
});
|
||||
expect(slotPropsFor(plugin(['ui:side-panel', 'calendar:read']), 'app-side-panel', props).context).toEqual({
|
||||
event: context.event, page: context.page,
|
||||
});
|
||||
expect(slotPropsFor(plugin(['ui:side-panel', 'contacts:read']), 'app-side-panel', props).context).toEqual({
|
||||
contact: context.contact, selectedCount: 3, page: context.page,
|
||||
});
|
||||
expect(slotPropsFor(plugin(['ui:side-panel', 'files:read']), 'app-side-panel', props).context).toEqual({
|
||||
files: context.files, fileCount: 1, folder: context.folder, page: context.page,
|
||||
});
|
||||
// A part the catalogue doesn't name never goes out, whatever the plugin may do.
|
||||
const all = slotPropsFor(plugin(['ui:side-panel', 'calendar:read', 'contacts:read', 'files:read']), 'app-side-panel', props);
|
||||
expect(all.context).not.toHaveProperty('secret');
|
||||
expect(Object.keys(all.context as object).sort()).toEqual(Object.keys(APP_SIDE_PANEL_CONTEXT_FIELDS).sort());
|
||||
});
|
||||
|
||||
it("cuts the context the same way in the click that asks the plugin for its dialog (the loader's dispatcher)", async () => {
|
||||
// Importing the loader installs its action dispatcher, as in the app.
|
||||
await import('@/lib/plugin-sandbox/loader');
|
||||
const invokeHook = vi.fn(async () => undefined);
|
||||
register({ plugin: plugin(['ui:side-panel', 'calendar:read']), code: '', tier: 'untrusted', background: { invokeHook }, slotOffers: [], hookDisposables: [] } as unknown as ActivePlugin);
|
||||
try {
|
||||
await runPluginAction('p', 'app-side-panel', 'open', { view: 'contacts', context: { contact: context.contact, page: context.page, secret: 'x' } });
|
||||
expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'app-side-panel', actionId: 'open', context: { view: 'contacts', context: { page: context.page } } }]);
|
||||
} finally {
|
||||
deregister('p');
|
||||
}
|
||||
// Garbage in the context is dropped, not passed on.
|
||||
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', { context: 'x' }).context).toEqual({});
|
||||
// The loader's import is slow under load.
|
||||
}, 30_000);
|
||||
|
||||
it('tells each plugin whether it may show the panel (sidePanel follows its grant)', () => {
|
||||
expect(assistantFeatures(plugin(['ui:side-panel'])).sidePanel).toBe(true);
|
||||
// Enabled before an update added the permission: not granted yet.
|
||||
const notGranted = { ...plugin(['ui:side-panel']), grantedPermissions: [] };
|
||||
expect(assistantFeatures(notGranted).sidePanel).toBe(false);
|
||||
expect(assistantFeatures(plugin([])).sidePanel).toBe(false);
|
||||
});
|
||||
|
||||
it('routes api.ui.openPanel for it to the view that shows it, and nowhere else', () => {
|
||||
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBeNull();
|
||||
const handler = vi.fn((request: { slot: string }) => (request.slot === 'app-side-panel' ? true : null));
|
||||
onPanelRequest(handler);
|
||||
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBe(true);
|
||||
expect(handler).toHaveBeenCalledWith({ pluginId: 'p', slot: 'app-side-panel', mode: 'open' });
|
||||
expect(requestPanel('p', 'email-detail-sidebar', 'open', undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
} from '@/lib/plugin-sandbox/host-features';
|
||||
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
|
||||
|
||||
/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top; their own tests check them. */
|
||||
const withAssistant = (core: Record<string, boolean>) => ({ ...core, ...assistantFeatures() });
|
||||
/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top, as `plugin` sees them; their own tests check them. */
|
||||
const withAssistant = (core: Record<string, boolean>, plugin?: never) => ({ ...core, ...assistantFeatures(plugin) });
|
||||
|
||||
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
|
||||
usePolicyStore.setState({
|
||||
@@ -85,21 +85,21 @@ describe('host bridge', () => {
|
||||
const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never;
|
||||
const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' });
|
||||
const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit;
|
||||
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }));
|
||||
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
|
||||
|
||||
// Before init has gone out a change lands in the payload ...
|
||||
setExternalMail({});
|
||||
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }));
|
||||
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
|
||||
|
||||
// ... after it, as a host-change message.
|
||||
const sent: unknown[] = [];
|
||||
(instance as unknown as { pendingInit: unknown }).pendingInit = null;
|
||||
(instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m);
|
||||
setExternalMail({ recipientWarning: true });
|
||||
expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }) }]);
|
||||
expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }, plugin) }]);
|
||||
// The admin turns third-party traffic off: running plugins hear it.
|
||||
setNoThirdPartyTraffic(true);
|
||||
expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }) });
|
||||
expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }, plugin) });
|
||||
setNoThirdPartyTraffic(false);
|
||||
instance.destroy();
|
||||
}, 30_000);
|
||||
@@ -172,5 +172,10 @@ describe('api.host in the sandbox runtime', () => {
|
||||
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
|
||||
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: 'yes' } });
|
||||
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
|
||||
// A flag the host no longer names turns off instead of keeping its old value.
|
||||
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: true } });
|
||||
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
|
||||
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true } });
|
||||
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Area f of the assistant's host APIs, host side (see README.md).
|
||||
//
|
||||
// Area f adds no methods: the app-side-panel slot (plugin-slot-catalog.ts,
|
||||
// gated by ui:side-panel) and its panel requests (panel-requests.ts) are
|
||||
// the whole host side, mounted by components/plugins/app-side-panel.tsx.
|
||||
|
||||
import type { InstalledPlugin, Permission } from '../../plugin-types';
|
||||
import { mayOfferSlot } from '../permissions';
|
||||
import { NOT_MINE } from './not-mine';
|
||||
import type { AreaHostFeaturesF } from './f.types';
|
||||
|
||||
@@ -12,7 +17,13 @@ export async function dispatch(_plugin: InstalledPlugin, _method: string, _args:
|
||||
return NOT_MINE;
|
||||
}
|
||||
|
||||
/** This area's flags in api.host.features, right now. */
|
||||
export function features(): AreaHostFeaturesF {
|
||||
return {};
|
||||
/**
|
||||
* This area's flags in api.host.features, right now. For one plugin,
|
||||
* sidePanel says whether it may show the panel (ui:side-panel granted; a
|
||||
* plugin enabled before an update that added it lacks it until turned off
|
||||
* and on), so it can tell the user instead of offering a switch that does
|
||||
* nothing.
|
||||
*/
|
||||
export function features(plugin?: InstalledPlugin): AreaHostFeaturesF {
|
||||
return { sidePanel: plugin ? mayOfferSlot(plugin, 'app-side-panel') : true };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,68 @@
|
||||
// Area f of the assistant's host APIs: its permissions and its flags in
|
||||
// api.host.features (see README.md). Pure: no imports from the app.
|
||||
// api.host.features (see README.md). Pure: no imports from the app (the
|
||||
// view types below are type-only).
|
||||
|
||||
import type { CalendarEventView, ContactView, FileResourceView } from '../../plugin-types';
|
||||
|
||||
/** Permissions this area adds to ALL_PERMISSIONS. */
|
||||
export const PERMISSIONS = [] as const;
|
||||
export const PERMISSIONS = ['ui:side-panel'] as const;
|
||||
|
||||
/** Flags this area adds to api.host.features. */
|
||||
export interface AreaHostFeaturesF {}
|
||||
export interface AreaHostFeaturesF {
|
||||
/**
|
||||
* The app-side-panel slot: a side panel in Calendar, Contacts, Files and
|
||||
* Settings, which api.ui.openPanel / closePanel / togglePanel answer for.
|
||||
* Missing (false) on webmails before it.
|
||||
*/
|
||||
sidePanel?: boolean;
|
||||
}
|
||||
|
||||
/** The views that show the app-side-panel. Mail has the reader's panel (email-detail-sidebar). */
|
||||
export const APP_SIDE_PANEL_VIEWS = ['calendar', 'contacts', 'files', 'settings'] as const;
|
||||
export type AppSidePanelViewName = typeof APP_SIDE_PANEL_VIEWS[number];
|
||||
|
||||
/** The most selected files the panel names; more are only counted. */
|
||||
export const MAX_SIDE_PANEL_FILES = 20;
|
||||
|
||||
/**
|
||||
* What the view shows, in the app-side-panel's `context` prop. Each field
|
||||
* reaches a plugin only with its permission (APP_SIDE_PANEL_CONTEXT_FIELDS):
|
||||
* the event with calendar:read, the contact and the selection count with
|
||||
* contacts:read, files and folder with files:read; the settings page with
|
||||
* none.
|
||||
*/
|
||||
export interface AppSidePanelContext {
|
||||
/** Calendar: the event open in the details or the editor. */
|
||||
event?: CalendarEventView;
|
||||
/** Contacts: the contact open in the details. */
|
||||
contact?: ContactView;
|
||||
/** Contacts: how many contacts are ticked in the list. */
|
||||
selectedCount?: number;
|
||||
/** Files: the selected files (at most 20). */
|
||||
files?: FileResourceView[];
|
||||
/** Files: how many files are selected, also past the 20 in `files`. */
|
||||
fileCount?: number;
|
||||
/** Files: the open folder. */
|
||||
folder?: { id: string | null; path: string };
|
||||
/** Settings: the open page, by its id and its name in the user's language. */
|
||||
page?: { id: string; label: string };
|
||||
}
|
||||
|
||||
/** The app-side-panel's props. */
|
||||
export interface AppSidePanelProps {
|
||||
view: AppSidePanelViewName;
|
||||
context: AppSidePanelContext;
|
||||
/** Counts up when the panel opens on request (the toggle, Alt+A, api.ui.openPanel). */
|
||||
focusRequest: number;
|
||||
}
|
||||
|
||||
/** Which permission each field of the app-side-panel's `context` needs (null: none). */
|
||||
export const APP_SIDE_PANEL_CONTEXT_FIELDS = {
|
||||
event: 'calendar:read',
|
||||
contact: 'contacts:read',
|
||||
selectedCount: 'contacts:read',
|
||||
files: 'files:read',
|
||||
fileCount: 'files:read',
|
||||
folder: 'files:read',
|
||||
page: null,
|
||||
} as const satisfies Record<keyof AppSidePanelContext, string | null>;
|
||||
|
||||
@@ -23,7 +23,10 @@ export async function dispatchAssistant(plugin: InstalledPlugin, method: string,
|
||||
return NOT_MINE;
|
||||
}
|
||||
|
||||
/** Every area's api.host.features flags, right now. */
|
||||
export function assistantFeatures(): AssistantHostFeatures {
|
||||
return Object.assign({}, ...AREAS.map((a) => a.features()));
|
||||
/**
|
||||
* Every area's api.host.features flags, right now; with `plugin`, as that
|
||||
* plugin's frames see them (a flag may depend on what it was granted).
|
||||
*/
|
||||
export function assistantFeatures(plugin?: InstalledPlugin): AssistantHostFeatures {
|
||||
return Object.assign({}, ...AREAS.map((a) => (a.features as (p?: InstalledPlugin) => object)(plugin)));
|
||||
}
|
||||
|
||||
@@ -2198,7 +2198,7 @@ export async function dispatchApiCall(
|
||||
// real app-root overlay, instead of a fixed confirm/prompt form - see
|
||||
// the 'plugin-dialog' SlotName / host-dialog.ts comments for why this
|
||||
// exists (a small toolbar/row slot can't show a large custom UI).
|
||||
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number };
|
||||
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number; fill?: boolean };
|
||||
if (!opts.slot || typeof opts.slot !== 'string') {
|
||||
throw new Error('ui.openDialog requires a "slot" name');
|
||||
}
|
||||
@@ -2209,6 +2209,7 @@ export async function dispatchApiCall(
|
||||
slot: opts.slot,
|
||||
extraProps: (opts.extraProps && typeof opts.extraProps === 'object') ? opts.extraProps : {},
|
||||
width: typeof opts.width === 'number' ? opts.width : undefined,
|
||||
...(opts.fill === true ? { fill: true } : {}),
|
||||
});
|
||||
}
|
||||
case 'ui.rerenderEmail': {
|
||||
|
||||
@@ -14,6 +14,7 @@ export type { RegionalSnapshot };
|
||||
import { withBasePath } from '../browser-navigation';
|
||||
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||
import { snapshotHostFeatures, watchHostFeatures } from './host-features';
|
||||
import { assistantFeatures } from './assistant';
|
||||
import { effectiveSettings } from '../plugin-settings';
|
||||
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
|
||||
import { resolveDateLocale } from '../utils';
|
||||
@@ -257,7 +258,8 @@ function pushThemeToFrames(): void {
|
||||
/** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */
|
||||
function pushHostFeaturesToFrames(features: HostFeaturesView): void {
|
||||
for (const instance of liveInstances) {
|
||||
try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ }
|
||||
// Some of the assistant's flags depend on the plugin's grants (host-features.ts).
|
||||
try { instance.setHostFeatures({ ...features, ...instance.ownHostFeatures() }); } catch { /* one broken iframe must not stop the rest */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +674,11 @@ export class SandboxInstance {
|
||||
}
|
||||
|
||||
/** Push what api.host.features says now; before init it goes into the init payload. */
|
||||
/** The flags of api.host.features that depend on this plugin. */
|
||||
ownHostFeatures(): Partial<HostFeaturesView> {
|
||||
return assistantFeatures(this.plugin);
|
||||
}
|
||||
|
||||
setHostFeatures(features: HostFeaturesView): void {
|
||||
if (this.destroyed) return;
|
||||
if (this.pendingInit) {
|
||||
@@ -782,7 +789,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
|
||||
code: opts.code,
|
||||
locale: opts.locale,
|
||||
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
|
||||
hostFeatures: snapshotHostFeatures(),
|
||||
hostFeatures: snapshotHostFeatures(opts.plugin),
|
||||
};
|
||||
return new SandboxInstance(
|
||||
opts.plugin,
|
||||
@@ -812,7 +819,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
||||
locale: opts.locale,
|
||||
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
|
||||
theme: snapshotHostTheme(),
|
||||
hostFeatures: snapshotHostFeatures(),
|
||||
hostFeatures: snapshotHostFeatures(opts.plugin),
|
||||
};
|
||||
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ export type DialogResult = boolean | Record<string, string> | null;
|
||||
|
||||
export interface DialogRequest {
|
||||
id: string;
|
||||
/**
|
||||
* For `kind === 'custom'`: the plugin's frame fills the dialog, which is as
|
||||
* tall as the window lets it be (at most 640 px), instead of growing with
|
||||
* its content. For chats, whose input stays at the bottom.
|
||||
*/
|
||||
fill?: boolean;
|
||||
pluginId: string;
|
||||
kind: DialogKind;
|
||||
title: string;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
|
||||
import type { HostFeaturesView } from '../plugin-types';
|
||||
import type { HostFeaturesView, InstalledPlugin } from '../plugin-types';
|
||||
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
|
||||
import { assistantFeatures } from './assistant';
|
||||
|
||||
@@ -26,11 +26,11 @@ export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail']
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotHostFeatures(): HostFeaturesView {
|
||||
export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView {
|
||||
return {
|
||||
...hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()),
|
||||
// The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant).
|
||||
...assistantFeatures(),
|
||||
...assistantFeatures(plugin),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
// (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.
|
||||
// dialog. The app-side-panel is the panel of Calendar, Contacts, Files and
|
||||
// Settings (components/plugins/app-side-panel.tsx). 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. Where the app-side-panel can't dock (phones, Settings
|
||||
// below 1024 px) it answers false and asks the plugin for its 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 const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-side-panel'] as const;
|
||||
export type PanelSlot = typeof PANEL_SLOTS[number];
|
||||
export type PanelMode = 'open' | 'close' | 'toggle';
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ export function slotPropsFor(
|
||||
slot: SlotName,
|
||||
props: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const allowed = slotSpec(slot)?.props;
|
||||
const spec = slotSpec(slot);
|
||||
const allowed = spec?.props;
|
||||
if (allowed === '*') return props;
|
||||
if (!allowed) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -57,7 +58,23 @@ export function slotPropsFor(
|
||||
if (!Object.prototype.hasOwnProperty.call(allowed, key)) continue;
|
||||
const perm = allowed[key];
|
||||
if (perm && !pluginHasPermission(plugin, perm)) continue;
|
||||
out[key] = value;
|
||||
const fields = spec?.fields?.[key];
|
||||
out[key] = fields ? fieldsFor(plugin, fields, value) : value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A prop made of parts with their own permissions (the app-side-panel's
|
||||
// context: an event, a contact, files): only the parts the plugin may see,
|
||||
// and none the catalogue doesn't name.
|
||||
function fieldsFor(plugin: InstalledPlugin, fields: Record<string, Permission | null>, value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [field, part] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, field)) continue;
|
||||
const perm = fields[field];
|
||||
if (perm && !pluginHasPermission(plugin, perm)) continue;
|
||||
out[field] = part;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -162,10 +162,15 @@ function applyHostFeatures(next: HostFeaturesView | undefined): void {
|
||||
hostFeaturesState.externalRecipientWarning = next.externalRecipientWarning === true;
|
||||
// A host older than 1.20 doesn't send it, and never let such a plugin run in the mode.
|
||||
hostFeaturesState.noThirdPartyTraffic = next.noThirdPartyTraffic === true;
|
||||
// The AI assistant's flags (lib/plugin-sandbox/assistant): whatever the host names, true or false.
|
||||
// The AI assistant's flags (lib/plugin-sandbox/assistant): whatever the host
|
||||
// names, true or false; one it no longer names turns false.
|
||||
const flags = hostFeaturesState as unknown as Record<string, boolean>;
|
||||
const core = new Set(['externalSenderLabel', 'externalRecipientWarning', 'noThirdPartyTraffic']);
|
||||
for (const key of Object.keys(flags)) {
|
||||
if (!core.has(key) && !(key in next)) flags[key] = false;
|
||||
}
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (key !== 'externalSenderLabel' && key !== 'externalRecipientWarning' && key !== 'noThirdPartyTraffic') flags[key] = value === true;
|
||||
if (!core.has(key)) flags[key] = value === true;
|
||||
}
|
||||
for (const listener of hostFeatureListeners) {
|
||||
try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); }
|
||||
|
||||
@@ -31,6 +31,11 @@ interface NormalisedCombo {
|
||||
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
|
||||
let listenerInstalled = false;
|
||||
|
||||
/** The keys a plugin's shortcut is bound to ("Alt+A"), or null when it has none by that id. */
|
||||
export function shortcutKeysFor(pluginId: string, shortcutId: string): string | null {
|
||||
return bindings.get(`${pluginId}:${shortcutId}`)?.keys ?? null;
|
||||
}
|
||||
|
||||
function normaliseCombo(combo: string): NormalisedCombo | null {
|
||||
if (typeof combo !== 'string') return null;
|
||||
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
|
||||
|
||||
@@ -44,7 +44,7 @@ export function clampSlotWidth(width: unknown): number | null {
|
||||
* can pin controls at the bottom, like a chat input. Elsewhere a frame grows
|
||||
* with its content.
|
||||
*/
|
||||
export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page'];
|
||||
export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-side-panel', 'app-page', 'plugin-dialog'];
|
||||
|
||||
export function isFillableSlot(slot: string): boolean {
|
||||
return FILLABLE_SLOTS.includes(slot);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// Badges on list rows are data too (the onEmailListItemRender hook).
|
||||
|
||||
import type { Permission } from './plugin-types';
|
||||
import { APP_SIDE_PANEL_CONTEXT_FIELDS } from './plugin-sandbox/assistant/f.types';
|
||||
|
||||
export type SlotArea =
|
||||
| 'app' | 'mail-list' | 'reader' | 'composer' | 'navigation'
|
||||
@@ -26,6 +27,11 @@ export interface SlotSpec {
|
||||
permissions: readonly Permission[];
|
||||
/** Props (frame) or click context (action) and the permission each needs; null = none. */
|
||||
props: Record<string, Permission | null> | '*';
|
||||
/**
|
||||
* Props that are objects whose fields need their own permission: prop →
|
||||
* field → permission (null = none). Fields not named are dropped.
|
||||
*/
|
||||
fields?: Record<string, Record<string, Permission | null>>;
|
||||
/** Sits in a row of buttons and sizes to its content. */
|
||||
inline?: boolean;
|
||||
/** Where it appears. */
|
||||
@@ -46,6 +52,7 @@ export const SLOT_CATALOG = {
|
||||
'app-top-banner': S('app', 'frame', ['ui:app-top-banner'], { username: 'account:read', serverUrl: 'account:read', impersonated: 'account:read' }, 'a banner across the top of every page; `impersonated` is true when an admin opened the mailbox through an impersonation handoff'),
|
||||
'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog'),
|
||||
'command-palette': S('app', 'action', ['ui:commands'], {}, 'entries in the command palette (Ctrl+K)'),
|
||||
'app-side-panel': { ...S('app', 'frame', ['ui:side-panel'], { view: null, context: null, focusRequest: null }, 'a side panel next to Calendar, Contacts, Files and Settings (desktop and tablet; phones and Settings below 1024 px have none: the toggle there asks the plugin for a dialog through onAction, surface `app-side-panel`, actionId `open`, context { view, context }); `view` names the view ("calendar" | "contacts" | "files" | "settings"); `context` is what it shows: { event } (calendar:read), { contact, selectedCount } (contacts:read), { files, fileCount, folder } (files:read), { page: { id, label } }; `focusRequest` counts up when the panel opens on request'), fields: { context: APP_SIDE_PANEL_CONTEXT_FIELDS } },
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────
|
||||
'navigation-rail-bottom': S('navigation', 'frame', ['ui:navigation-rail'], {}, 'the bottom of the navigation rail'),
|
||||
|
||||
Reference in New Issue
Block a user