mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let a plugin title its sidebar section
This commit is contained in:
@@ -24,6 +24,8 @@ vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const registry = await import('@/lib/plugin-sandbox/registry');
|
||||
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
|
||||
const { clearSectionTitle } = await import('@/lib/plugin-sandbox/section-titles');
|
||||
|
||||
const mailboxes: Mailbox[] = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } },
|
||||
@@ -52,6 +54,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
for (const p of registry.all()) registry.deregister(p.plugin.id);
|
||||
clearSectionTitle('cal');
|
||||
});
|
||||
|
||||
describe('Sidebar plugin sections', () => {
|
||||
@@ -96,4 +99,21 @@ describe('Sidebar plugin sections', () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(createSlotInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('heads a section with the title the plugin set, and with its name again after null', async () => {
|
||||
offer('cal', 'Agenda', 'sidebar-section');
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
const plugin = registry.get('cal')!.plugin;
|
||||
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSectionTitle', ['Upcoming birthdays']); });
|
||||
expect(screen.getByRole('button', { name: 'Upcoming birthdays' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Agenda' })).toBeNull();
|
||||
|
||||
await expect(dispatchApiCall(plugin, 'ui.setSectionTitle', ['x'.repeat(41)])).rejects.toThrow(/at most 40/);
|
||||
await act(async () => { await dispatchApiCall(plugin, 'ui.setSectionTitle', [null]); });
|
||||
expect(screen.getByRole('button', { name: 'Agenda' })).toBeInTheDocument();
|
||||
|
||||
const noPerm = { ...plugin, id: 'other', permissions: [], grantedPermissions: [] };
|
||||
await expect(dispatchApiCall(noPerm, 'ui.setSectionTitle', ['Mine'])).rejects.toThrow(/lacks permission "ui:sidebar-widget"/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useId, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@/components/icons';
|
||||
import { get as getActivePlugin, offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
|
||||
import { sectionTitle, subscribeSectionTitles } from '@/lib/plugin-sandbox/section-titles';
|
||||
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||
|
||||
const STORAGE_PREFIX = 'plugin-sidebar-section-collapsed:';
|
||||
@@ -27,7 +28,8 @@ function writeCollapsed(pluginId: string, collapsed: boolean): void {
|
||||
function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extraProps: Record<string, unknown> }) {
|
||||
const [collapsed, setCollapsed] = useState(() => readCollapsed(pluginId));
|
||||
const bodyId = useId();
|
||||
const name = getActivePlugin(pluginId)?.plugin.name || pluginId;
|
||||
const title = useSyncExternalStore(subscribeSectionTitles, () => sectionTitle(pluginId), () => undefined);
|
||||
const name = title || getActivePlugin(pluginId)?.plugin.name || pluginId;
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed((prev) => {
|
||||
@@ -63,7 +65,8 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr
|
||||
|
||||
/**
|
||||
* The `sidebar-section` slot: one collapsible section per plugin in the mail
|
||||
* sidebar, below the folders and tags. The header carries the plugin's name;
|
||||
* sidebar, below the folders and tags. The header carries the title the plugin
|
||||
* set (api.ui.setSectionTitle), or its name;
|
||||
* whether it is folded is remembered per plugin in this browser.
|
||||
*/
|
||||
export function PluginSidebarSections({ mailboxId }: { mailboxId: string | null }) {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
|
||||
import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose';
|
||||
import { doNotificationsShow } from './host-api-notify';
|
||||
import { setSectionTitle } from './section-titles';
|
||||
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
|
||||
|
||||
/**
|
||||
@@ -208,6 +209,7 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'ui.refreshRowBadges': null,
|
||||
'ui.setDayLabels': 'ui:calendar-action',
|
||||
'ui.setPages': 'ui:app-page',
|
||||
'ui.setSectionTitle': 'ui:sidebar-widget',
|
||||
// sieve (delivery-time classification)
|
||||
'sieve.isSupported': 'filters:read',
|
||||
'sieve.getActiveScript': 'filters:read',
|
||||
@@ -1735,6 +1737,7 @@ export async function dispatchApiCall(
|
||||
case 'ui.refreshRowBadges': refreshRowBadges(); return undefined;
|
||||
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 'storage.get': return storageGet(plugin.id, args[0] as string);
|
||||
case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from
|
||||
import { clearPluginActions, setActionDispatcher } from './actions';
|
||||
import { clearDayLabels } from './day-labels';
|
||||
import { clearPluginPages } from './pages';
|
||||
import { clearSectionTitle } from './section-titles';
|
||||
import { cancelPluginProxyRequests } from './proxy-client';
|
||||
import { cancelConnectFor } from './connect-dialog';
|
||||
import type { SlotName } from '../plugin-types';
|
||||
@@ -295,6 +296,7 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
clearPluginActions(pluginId);
|
||||
clearDayLabels(pluginId);
|
||||
clearPluginPages(pluginId);
|
||||
clearSectionTitle(pluginId);
|
||||
cancelPluginProxyRequests(pluginId);
|
||||
cancelConnectFor(pluginId);
|
||||
try { entry.background.destroy(); } catch { /* ignore */ }
|
||||
|
||||
@@ -538,6 +538,12 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
/** Full pages with a button in the navigation bar; the `app-page` slot renders them. */
|
||||
setPages: (pages: Array<{ id: string; title: string; icon?: string }>) =>
|
||||
callApi('ui.setPages', [pages]) as Promise<void>,
|
||||
/**
|
||||
* The header of this plugin's sidebar section (the host draws it like
|
||||
* Folders and Tags), e.g. a translated "Upcoming birthdays". null goes
|
||||
* back to the plugin's name. At most 40 characters.
|
||||
*/
|
||||
setSectionTitle: (title: string | null) => callApi('ui.setSectionTitle', [title]) as Promise<void>,
|
||||
},
|
||||
// Requests with an account or key the manifest declares under
|
||||
// `credentials`; the server adds the secret (permission http:proxy).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Titles plugins give their sidebar section (api.ui.setSectionTitle). The
|
||||
// host draws the header itself, like Folders and Tags; without a title it
|
||||
// shows the plugin's name.
|
||||
|
||||
export const MAX_SECTION_TITLE = 40;
|
||||
|
||||
const titles = new Map<string, string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function changed(): void {
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Check and store a title; throws with a message the plugin sees. */
|
||||
export function setSectionTitle(pluginId: string, value: unknown): void {
|
||||
if (value !== null && typeof value !== 'string') throw new Error('ui.setSectionTitle: title must be a string or null');
|
||||
// eslint-disable-next-line no-control-regex -- one line of text
|
||||
const title = typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim() : '';
|
||||
if (title.length > MAX_SECTION_TITLE) throw new Error(`ui.setSectionTitle: at most ${MAX_SECTION_TITLE} characters`);
|
||||
if (title) titles.set(pluginId, title);
|
||||
else titles.delete(pluginId);
|
||||
changed();
|
||||
}
|
||||
|
||||
export function sectionTitle(pluginId: string): string | undefined {
|
||||
return titles.get(pluginId);
|
||||
}
|
||||
|
||||
export function clearSectionTitle(pluginId: string): void {
|
||||
if (titles.delete(pluginId)) changed();
|
||||
}
|
||||
|
||||
export function subscribeSectionTitles(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
Reference in New Issue
Block a user