diff --git a/components/layout/__tests__/sidebar-plugin-section.test.tsx b/components/layout/__tests__/sidebar-plugin-section.test.tsx
index 412f2f42e..d17904faf 100644
--- a/components/layout/__tests__/sidebar-plugin-section.test.tsx
+++ b/components/layout/__tests__/sidebar-plugin-section.test.tsx
@@ -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();
+ 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"/);
+ });
});
diff --git a/components/plugins/plugin-sidebar-sections.tsx b/components/plugins/plugin-sidebar-sections.tsx
index 7476af003..f52f3bff9 100644
--- a/components/plugins/plugin-sidebar-sections.tsx
+++ b/components/plugins/plugin-sidebar-sections.tsx
@@ -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 }) {
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 }) {
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index aa48b2d32..12f7127ab 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -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> = {
'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;
diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts
index 212ee393c..80111fa5b 100644
--- a/lib/plugin-sandbox/loader.ts
+++ b/lib/plugin-sandbox/loader.ts
@@ -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 */ }
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index f71f72d4f..80d1641d6 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -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,
+ /**
+ * 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,
},
// Requests with an account or key the manifest declares under
// `credentials`; the server adds the secret (permission http:proxy).
diff --git a/lib/plugin-sandbox/section-titles.ts b/lib/plugin-sandbox/section-titles.ts
new file mode 100644
index 000000000..35f210c3b
--- /dev/null
+++ b/lib/plugin-sandbox/section-titles.ts
@@ -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();
+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); };
+}