mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Merge pull request #1496 from HOLOEYE-JZ/feat/plugin-side-panel
This commit is contained in:
@@ -28,6 +28,7 @@ import { readableTextOn } from "@/lib/color-transform";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { PluginSidePanelBody, SidePanelButton, usePluginSidePanel } from "@/components/plugins/plugin-side-panels";
|
||||
|
||||
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
|
||||
|
||||
@@ -46,6 +47,18 @@ export function MvSidePanel() {
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const [open, setOpen] = useState<MvSidePanelId | null>(null);
|
||||
// Plugins' panels (the side-panel slot), their buttons below the apps'.
|
||||
// A plugin's panel opening (a click, or the plugin's openPanel) shuts the app's, and the other way round.
|
||||
const plugins = usePluginSidePanel(() => setOpen(null));
|
||||
const pluginOpen = plugins.entries.find((e) => e.pluginId === plugins.open) ?? null;
|
||||
const openApp = (id: MvSidePanelId | null) => {
|
||||
if (id) plugins.choose(null);
|
||||
setOpen(id);
|
||||
};
|
||||
const openPlugin = (pluginId: string | null) => {
|
||||
if (pluginId) setOpen(null);
|
||||
plugins.choose(pluginId);
|
||||
};
|
||||
|
||||
const showCalendar = supportsCalendar && calendarEnabled;
|
||||
const showContacts = (client?.supportsContacts() ?? false) && contactsEnabled;
|
||||
@@ -57,9 +70,11 @@ export function MvSidePanel() {
|
||||
...(showContacts ? [{ id: "contacts" as const, label: t("contacts"), icon: Users, color: "text-[#1a73e8]" }] : []),
|
||||
];
|
||||
|
||||
if (buttons.length === 0) return null;
|
||||
if (buttons.length === 0 && plugins.entries.length === 0) return null;
|
||||
// A plugin's panel without a rail button (setSidePanel's rail: false) opens on its request only.
|
||||
const pluginButtons = plugins.entries.filter((e) => e.inRail);
|
||||
// A panel whose app went away (policy change, account switch) stays shut.
|
||||
const active = open && buttons.some((b) => b.id === open) ? open : null;
|
||||
const active = open && !pluginOpen && buttons.some((b) => b.id === open) ? open : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -75,7 +90,17 @@ export function MvSidePanel() {
|
||||
{active === "contacts" && <ContactsPanel onClose={() => setOpen(null)} />}
|
||||
</aside>
|
||||
)}
|
||||
<nav
|
||||
{pluginOpen && (
|
||||
<aside
|
||||
data-simple-hide=""
|
||||
data-plugin-side-panel={pluginOpen.pluginId}
|
||||
className="mb-4 me-2 flex w-[300px] shrink-0 flex-col overflow-hidden rounded-2xl bg-background"
|
||||
aria-label={pluginOpen.title}
|
||||
>
|
||||
<PluginSidePanelBody entry={pluginOpen} focusRequest={plugins.focusRequest} onClose={() => plugins.choose(null)} />
|
||||
</aside>
|
||||
)}
|
||||
{(buttons.length > 0 || pluginButtons.length > 0) && <nav
|
||||
data-mv-side-rail=""
|
||||
className="flex w-14 shrink-0 flex-col items-center gap-2 pt-3"
|
||||
aria-label={t("mv_side_panel")}
|
||||
@@ -84,7 +109,7 @@ export function MvSidePanel() {
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setOpen((cur) => (cur === id ? null : id))}
|
||||
onClick={() => openApp(active === id ? null : id)}
|
||||
className={cn(
|
||||
"relative grid h-10 w-10 place-items-center rounded-full transition-colors hover:bg-[var(--mv-nav-hover)]",
|
||||
active === id && "bg-[var(--mv-nav-selected)]",
|
||||
@@ -96,8 +121,18 @@ export function MvSidePanel() {
|
||||
<Icon className={cn("h-5 w-5", color)} />
|
||||
</button>
|
||||
))}
|
||||
{pluginButtons.map((entry) => (
|
||||
<SidePanelButton
|
||||
key={entry.pluginId}
|
||||
entry={entry}
|
||||
pressed={pluginOpen?.pluginId === entry.pluginId}
|
||||
onClick={() => openPlugin(pluginOpen?.pluginId === entry.pluginId ? null : entry.pluginId)}
|
||||
className="h-10 w-10 rounded-full hover:bg-[var(--mv-nav-hover)]"
|
||||
pressedClassName="bg-[var(--mv-nav-selected)] text-foreground"
|
||||
/>
|
||||
))}
|
||||
<div className="my-1 h-px w-5 bg-border" aria-hidden />
|
||||
</nav>
|
||||
</nav>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useAppCommands } from "@/lib/app-commands";
|
||||
import { MvSearchForm } from "@/components/search/mv-search-form";
|
||||
import { MvSidePanel } from "@/components/layout/mv-side-panel";
|
||||
import { PluginSidePanel } from "@/components/plugins/plugin-side-panels";
|
||||
import { MvListToolbar } from "@/components/email/mv-list-toolbar";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
@@ -5791,6 +5792,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
{/* Mountain View: Gmail's side panel - calendar, tasks and contacts
|
||||
next to the mail, with their icons down the right edge. */}
|
||||
{mvShell && !inlineApp && !fullscreenReading && <MvSidePanel />}
|
||||
{/* The default layout: plugins' panels beside the mail (the
|
||||
side-panel slot), with their buttons in a rail at the right edge.
|
||||
Desktop only; Mountain View has them in its side panel. */}
|
||||
{!mvShell && !isMobile && !isTablet && !isEmbedded && !inlineApp && !fullscreenReading && <PluginSidePanel />}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
|
||||
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
|
||||
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
|
||||
|
||||
const { PluginSidePanel } = await import('../plugin-side-panels');
|
||||
const { MvSidePanel } = await import('@/components/layout/mv-side-panel');
|
||||
const registry = await import('@/lib/plugin-sandbox/registry');
|
||||
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
|
||||
const { clearSidePanel } = await import('@/lib/plugin-sandbox/side-panels');
|
||||
const { useCalendarStore } = await import('@/stores/calendar-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
function offer(id: string, name: string, opts: { slot?: string; fill?: boolean } = {}) {
|
||||
const plugin: InstalledPlugin = {
|
||||
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'], entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
registry.register({
|
||||
plugin,
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
|
||||
slotOffers: [{ name: (opts.slot ?? 'side-panel') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }],
|
||||
hookDisposables: [],
|
||||
});
|
||||
return plugin;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createSlotInstance.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const p of registry.all()) {
|
||||
clearSidePanel(p.plugin.id);
|
||||
registry.deregister(p.plugin.id);
|
||||
}
|
||||
});
|
||||
|
||||
describe('PluginSidePanel (default layout)', () => {
|
||||
it('draws nothing when no plugin offers a side panel', () => {
|
||||
offer('w', 'Widget', { slot: 'sidebar-widget' });
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('gives each plugin a button in the rail that opens and closes its panel, filled by the frame', async () => {
|
||||
offer('fu', 'Follow-ups', { fill: true });
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
const button = screen.getByRole('button', { name: 'Follow-ups' });
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('complementary', { name: 'Follow-ups' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record<string, unknown>; fill?: boolean };
|
||||
expect(opts.slot).toBe('side-panel');
|
||||
expect(opts.extraProps).toEqual({ focusRequest: 0 });
|
||||
expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
});
|
||||
|
||||
it('remembers the open panel in this browser', () => {
|
||||
offer('fu', 'Follow-ups');
|
||||
const first = render(<PluginSidePanel />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
|
||||
first.unmount();
|
||||
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('names the button and panel as the plugin set them, and by its name again after null', async () => {
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
render(<PluginSidePanel />);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag' }]); });
|
||||
expect(screen.getByRole('button', { name: 'Upcoming for me' })).toBeInTheDocument();
|
||||
|
||||
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'x'.repeat(41) }])).rejects.toThrow(/at most 40/);
|
||||
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ icon: 'no icon' }])).rejects.toThrow(/icon/);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [null]); });
|
||||
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
|
||||
|
||||
const noPerm = { ...plugin, id: 'other', permissions: [], grantedPermissions: [] };
|
||||
await expect(dispatchApiCall(noPerm, 'ui.setSidePanel', [{ title: 'Mine' }])).rejects.toThrow(/lacks permission "ui:sidebar-widget"/);
|
||||
});
|
||||
|
||||
it('leaves the button out with rail: false, the panel opened on the plugin\'s request only', async () => {
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
const { container } = render(<PluginSidePanel />);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ title: 'Upcoming for me', icon: 'flag', rail: false }]); });
|
||||
expect(screen.queryByRole('button', { name: 'Upcoming for me' })).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-side-rail]')).toBeNull();
|
||||
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
|
||||
expect(screen.getByRole('complementary', { name: 'Upcoming for me' })).toBeInTheDocument();
|
||||
|
||||
await expect(dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: 'no' }])).rejects.toThrow(/rail/);
|
||||
});
|
||||
|
||||
it('opens on the plugin\'s request with a new focusRequest, and answers null without a panel on screen', async () => {
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
const { container, unmount } = render(<PluginSidePanel />);
|
||||
let answer: unknown;
|
||||
await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
|
||||
expect(answer).toBe(true);
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
expect((createSlotInstance.mock.calls[0][0] as { extraProps: unknown }).extraProps).toEqual({ focusRequest: 1 });
|
||||
|
||||
await act(async () => { answer = await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'toggle']); });
|
||||
expect(answer).toBe(false);
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
|
||||
unmount();
|
||||
await expect(dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open'])).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MvSidePanel with plugin panels', () => {
|
||||
beforeEach(() => {
|
||||
useCalendarStore.setState({ supportsCalendar: true, calendars: [], events: [] } as never);
|
||||
useSettingsStore.setState({ enableCalendarTasks: false } as never);
|
||||
});
|
||||
|
||||
it('adds the plugins\' buttons to its rail, one panel open at a time', () => {
|
||||
offer('fu', 'Follow-ups');
|
||||
const { container } = render(<MvSidePanel />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
|
||||
expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow-ups' }));
|
||||
expect(container.querySelector('[data-mv-side-panel]')).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'calendar' }));
|
||||
expect(container.querySelector('[data-plugin-side-panel]')).toBeNull();
|
||||
expect(container.querySelector('[data-mv-side-panel="calendar"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws no rail for a plugin panel without a button alone, and opens it on request', async () => {
|
||||
useCalendarStore.setState({ supportsCalendar: false } as never);
|
||||
const plugin = offer('fu', 'Follow-ups');
|
||||
const { container } = render(<MvSidePanel />);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSidePanel', [{ rail: false }]); });
|
||||
expect(container.querySelector('[data-mv-side-rail]')).toBeNull();
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setPanel', ['side-panel', 'open']); });
|
||||
expect(container.querySelector('[data-plugin-side-panel="fu"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the rail for a plugin panel alone', () => {
|
||||
useCalendarStore.setState({ supportsCalendar: false } as never);
|
||||
offer('fu', 'Follow-ups');
|
||||
render(<MvSidePanel />);
|
||||
expect(screen.getByRole('button', { name: 'Follow-ups' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
|
||||
import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||
|
||||
// The `side-panel` slot: one panel per plugin beside the mail, opened from
|
||||
// its button in the rail at the right edge. Mountain View puts the buttons
|
||||
// in its own side panel's rail (mv-side-panel.tsx); the default layout
|
||||
// shows PluginSidePanel. At most one panel is open; which one is
|
||||
// remembered in this browser. A plugin that set `rail: false` has no
|
||||
// button: its panel opens only on its request; a rail without buttons is
|
||||
// not drawn.
|
||||
|
||||
const STORAGE_KEY = 'plugin-side-panel-open';
|
||||
|
||||
export interface SidePanelEntry {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
Icon: AppIcon;
|
||||
fill: boolean;
|
||||
/** Whether the rail shows its button (api.ui.setSidePanel's `rail`). */
|
||||
inRail: boolean;
|
||||
}
|
||||
|
||||
function readStored(): string | null {
|
||||
try { return localStorage.getItem(STORAGE_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
function writeStored(pluginId: string | null): void {
|
||||
try {
|
||||
if (pluginId) localStorage.setItem(STORAGE_KEY, pluginId);
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Storage may be blocked; the panel is just shut next time.
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugins offering a side panel, each with the title and icon it set (api.ui.setSidePanel). */
|
||||
export function usePluginSidePanelEntries(): SidePanelEntry[] {
|
||||
const offers = usePluginSlotOffers('side-panel');
|
||||
const looks = useSyncExternalStore(subscribeSidePanels, sidePanelLooks, sidePanelLooks);
|
||||
return useMemo(() => {
|
||||
return offers.map((offer) => {
|
||||
const look = looks.get(offer.pluginId);
|
||||
return {
|
||||
pluginId: offer.pluginId,
|
||||
title: look?.title || getActivePlugin(offer.pluginId)?.plugin.name || offer.pluginId,
|
||||
Icon: iconForName(look?.icon) ?? Puzzle,
|
||||
fill: !!offer.fill,
|
||||
inRail: look?.rail !== false,
|
||||
};
|
||||
});
|
||||
}, [offers, looks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plugin's side panel is open (null: none), remembered in this
|
||||
* browser, and the plugins' api.ui.openPanel('side-panel') requests;
|
||||
* `onOpen` runs when a plugin's request opens its panel.
|
||||
*/
|
||||
export function usePluginSidePanel(onOpen?: () => void) {
|
||||
const entries = usePluginSidePanelEntries();
|
||||
const [chosen, setChosen] = useState<string | null>(readStored);
|
||||
const [focusRequest, setFocusRequest] = useState(0);
|
||||
// A plugin that went away (disabled, removed) leaves its panel shut.
|
||||
const open = chosen && entries.some((e) => e.pluginId === chosen) ? chosen : null;
|
||||
const choose = useCallback((pluginId: string | null) => {
|
||||
setChosen(pluginId);
|
||||
writeStored(pluginId);
|
||||
}, []);
|
||||
const state = useRef({ entries, open, onOpen });
|
||||
state.current = { entries, open, onOpen };
|
||||
useEffect(() => onPanelRequest((request) => {
|
||||
if (request.slot !== 'side-panel') return null;
|
||||
if (!state.current.entries.some((e) => e.pluginId === request.pluginId)) return null;
|
||||
const isOpen = state.current.open === request.pluginId;
|
||||
const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
|
||||
if (wantOpen) {
|
||||
choose(request.pluginId);
|
||||
setFocusRequest((n) => n + 1);
|
||||
state.current.onOpen?.();
|
||||
} else if (isOpen) {
|
||||
choose(null);
|
||||
}
|
||||
return wantOpen;
|
||||
}), [choose]);
|
||||
return { entries, open, choose, focusRequest };
|
||||
}
|
||||
|
||||
/** A plugin's panel: the host's header (title, close) over the plugin's frame, which fills the rest. */
|
||||
export function PluginSidePanelBody({ entry, focusRequest, onClose }: {
|
||||
entry: SidePanelEntry;
|
||||
focusRequest: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tCommon = useTranslations('common');
|
||||
const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={tCommon('close')}
|
||||
aria-label={tCommon('close')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
data-plugin-slot="side-panel"
|
||||
className={cn('min-h-0 flex-1', entry.fill ? 'flex flex-col' : 'overflow-y-auto')}
|
||||
>
|
||||
<PluginIframeSlot key={entry.pluginId} pluginId={entry.pluginId} slot="side-panel" extraProps={extraProps} fill={entry.fill} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The default layout's side panel: a slim rail at the right edge with a
|
||||
* button per plugin, and the open plugin's panel left of it. Nothing
|
||||
* without a plugin offering one.
|
||||
*/
|
||||
export function PluginSidePanel() {
|
||||
const t = useTranslations('sidebar');
|
||||
const { entries, open, choose, focusRequest } = usePluginSidePanel();
|
||||
if (entries.length === 0) return null;
|
||||
const active = entries.find((e) => e.pluginId === open) ?? null;
|
||||
const inRail = entries.filter((e) => e.inRail);
|
||||
return (
|
||||
<>
|
||||
{active && (
|
||||
<aside
|
||||
data-plugin-side-panel={active.pluginId}
|
||||
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
|
||||
aria-label={active.title}
|
||||
>
|
||||
<PluginSidePanelBody entry={active} focusRequest={focusRequest} onClose={() => choose(null)} />
|
||||
</aside>
|
||||
)}
|
||||
{inRail.length > 0 && <nav
|
||||
data-plugin-side-rail=""
|
||||
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
|
||||
aria-label={t('mv_side_panel')}
|
||||
>
|
||||
{inRail.map((entry) => (
|
||||
<SidePanelButton
|
||||
key={entry.pluginId}
|
||||
entry={entry}
|
||||
pressed={open === entry.pluginId}
|
||||
onClick={() => choose(open === entry.pluginId ? null : entry.pluginId)}
|
||||
className="h-9 w-9 rounded-md hover:bg-muted"
|
||||
pressedClassName="bg-muted text-foreground"
|
||||
/>
|
||||
))}
|
||||
</nav>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A plugin's button in a side panel's rail. */
|
||||
export function SidePanelButton({ entry, pressed, onClick, className, pressedClassName }: {
|
||||
entry: SidePanelEntry;
|
||||
pressed: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
pressedClassName?: string;
|
||||
}) {
|
||||
const { Icon } = entry;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-plugin-side-panel-button={entry.pluginId}
|
||||
className={cn('grid place-items-center text-muted-foreground transition-colors', className, pressed && pressedClassName)}
|
||||
title={entry.title}
|
||||
aria-label={entry.title}
|
||||
aria-pressed={pressed}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ import { doUiPrint } from './host-api-print';
|
||||
import { doNotificationsShow } from './host-api-notify';
|
||||
import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account';
|
||||
import { setSectionTitle } from './section-titles';
|
||||
import { setSidePanel } from './side-panels';
|
||||
import { requestPanel } from './panel-requests';
|
||||
import { requestPageNavigation } from './page-navigation';
|
||||
import { requestOpenEmail } from './open-email';
|
||||
@@ -257,6 +258,8 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
// Opening a message shows its content, as reading it does.
|
||||
'ui.openEmail': 'email:read',
|
||||
'ui.setSectionTitle': 'ui:sidebar-widget',
|
||||
// The button and header of the plugin's side-panel slot beside the mail.
|
||||
'ui.setSidePanel': '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,
|
||||
@@ -2037,6 +2040,7 @@ export async function dispatchApiCall(
|
||||
case 'ui.openPage': return requestPageNavigation(plugin.id, args[0], args[1]);
|
||||
case 'ui.openEmail': return requestOpenEmail(plugin, args[0]);
|
||||
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
|
||||
case 'ui.setSidePanel': setSidePanel(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)}"`);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { clearDayLabels } from './day-labels';
|
||||
import { clearPluginListFilters } from './list-filters';
|
||||
import { clearPluginPages } from './pages';
|
||||
import { clearSectionTitle } from './section-titles';
|
||||
import { clearSidePanel } from './side-panels';
|
||||
import { cancelPluginProxyRequests } from './proxy-client';
|
||||
import { cancelConnectFor } from './connect-dialog';
|
||||
import { isHookHandedOver } from './host-features';
|
||||
@@ -396,6 +397,7 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
clearDayLabels(pluginId);
|
||||
clearPluginPages(pluginId);
|
||||
clearSectionTitle(pluginId);
|
||||
clearSidePanel(pluginId);
|
||||
cancelPluginProxyRequests(pluginId);
|
||||
cancelConnectFor(pluginId);
|
||||
try { entry.background.destroy(); } catch { /* ignore */ }
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// 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
|
||||
// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels,
|
||||
// the reader's detail panel and the panel beside the mail (side-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 const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'side-panel'] as const;
|
||||
export type PanelSlot = typeof PANEL_SLOTS[number];
|
||||
export type PanelMode = 'open' | 'close' | 'toggle';
|
||||
|
||||
|
||||
@@ -758,12 +758,23 @@ 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>,
|
||||
/**
|
||||
* The button and header of this plugin's `side-panel` slot, the panel
|
||||
* beside the mail: a title (at most 40 characters, e.g. a translated
|
||||
* "Upcoming for me") and a host icon by name (as for setPages). null
|
||||
* goes back to the plugin's name and a puzzle piece. `rail: false`
|
||||
* draws no button in the rail: the plugin opens the panel itself
|
||||
* with openPanel / togglePanel('side-panel'), e.g. from a top bar
|
||||
* action.
|
||||
*/
|
||||
setSidePanel: (look: { title?: string; icon?: string; rail?: boolean } | null) => callApi('ui.setSidePanel', [look]) 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
|
||||
* from the click context or the slot's props), 'email-detail-sidebar'
|
||||
* or 'side-panel' (this plugin's panel beside the mail). 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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// How plugins' side panels are named in the rail beside the mail
|
||||
// (api.ui.setSidePanel): a title and a host icon. The host draws the button
|
||||
// and the panel's header itself; without a title it shows the plugin's name,
|
||||
// without an icon a puzzle piece. `rail: false` leaves the button out: the
|
||||
// plugin opens its panel itself (api.ui.openPanel / togglePanel), e.g. from
|
||||
// a top bar action.
|
||||
|
||||
export interface SidePanelLook {
|
||||
title?: string;
|
||||
/** Name of a host icon (see components/icons), as for api.ui.setPages. */
|
||||
icon?: string;
|
||||
/** false: no button in the rail; the panel opens only on the plugin's request. */
|
||||
rail?: boolean;
|
||||
}
|
||||
|
||||
export const MAX_SIDE_PANEL_TITLE = 40;
|
||||
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
|
||||
|
||||
let looks: ReadonlyMap<string, SidePanelLook> = new Map();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function changed(): void {
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Check and store a panel's title and icon (null clears both); throws with a message the plugin sees. */
|
||||
export function setSidePanel(pluginId: string, value: unknown): void {
|
||||
if (value === null || value === undefined) {
|
||||
clearSidePanel(pluginId);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('ui.setSidePanel: expects { title, icon, rail } or null');
|
||||
const raw = value as Record<string, unknown>;
|
||||
const look: SidePanelLook = {};
|
||||
if (raw.title !== undefined && raw.title !== null) {
|
||||
if (typeof raw.title !== 'string') throw new Error('ui.setSidePanel: title must be a string');
|
||||
// eslint-disable-next-line no-control-regex -- one line of text
|
||||
const title = raw.title.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
||||
if (title.length > MAX_SIDE_PANEL_TITLE) throw new Error(`ui.setSidePanel: title has at most ${MAX_SIDE_PANEL_TITLE} characters`);
|
||||
if (title) look.title = title;
|
||||
}
|
||||
if (raw.icon !== undefined && raw.icon !== null) {
|
||||
if (typeof raw.icon !== 'string' || !ICON_RE.test(raw.icon)) throw new Error('ui.setSidePanel: icon is invalid');
|
||||
look.icon = raw.icon;
|
||||
}
|
||||
if (raw.rail !== undefined && raw.rail !== null) {
|
||||
if (typeof raw.rail !== 'boolean') throw new Error('ui.setSidePanel: rail must be a boolean');
|
||||
if (!raw.rail) look.rail = false;
|
||||
}
|
||||
looks = new Map(looks).set(pluginId, look);
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Every plugin's title and icon; a new map after each change, the same one between. */
|
||||
export function sidePanelLooks(): ReadonlyMap<string, SidePanelLook> {
|
||||
return looks;
|
||||
}
|
||||
|
||||
export function clearSidePanel(pluginId: string): void {
|
||||
if (!looks.has(pluginId)) return;
|
||||
const next = new Map(looks);
|
||||
next.delete(pluginId);
|
||||
looks = next;
|
||||
changed();
|
||||
}
|
||||
|
||||
export function subscribeSidePanels(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
@@ -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', 'side-panel', 'app-page'];
|
||||
|
||||
export function isFillableSlot(slot: string): boolean {
|
||||
return FILLABLE_SLOTS.includes(slot);
|
||||
|
||||
@@ -51,6 +51,7 @@ export const SLOT_CATALOG = {
|
||||
'navigation-rail-bottom': S('navigation', 'frame', ['ui:navigation-rail'], {}, 'the bottom of the navigation rail'),
|
||||
'sidebar-widget': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'a widget at the bottom of the mail sidebar'),
|
||||
'sidebar-section': S('navigation', 'frame', ['ui:sidebar-widget'], { mailboxId: null }, 'a collapsible section in the mail sidebar, below the folders'),
|
||||
'side-panel': S('navigation', 'frame', ['ui:sidebar-widget'], { focusRequest: null }, 'a panel beside the mail (list and reader), opened from its button in the rail at the right edge (Mountain View\'s side panel; in the default layout a rail of its own, desktop only); the button\'s title and icon from api.ui.setSidePanel, else the plugin\'s name, no button with its `rail: false` (then opened by api.ui.openPanel / togglePanel only); the open panel is remembered in this browser; `slots[\'side-panel\'].fill = true` makes the frame as tall as the panel; `focusRequest` counts up when the plugin opened it with api.ui.openPanel(\'side-panel\')'),
|
||||
'sidebar-section-actions': S('navigation', 'frame', ['ui:sidebar-widget'], {}, 'compact controls beside a collapsible plugin sidebar-section heading', true),
|
||||
'top-bar-actions': S('navigation', 'action', ['ui:toolbar'], {}, 'buttons in the top bar, next to search', true),
|
||||
'app-page': S('navigation', 'frame', ['ui:app-page'], { pageId: null, path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.setPages); api.ui.openPage moves the tab there'),
|
||||
|
||||
Reference in New Issue
Block a user