mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: describe every place a plugin can show UI in one catalogue, with a permission for each area
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SLOT_CATALOG, type SlotArea } from '../plugin-slot-catalog';
|
||||
import { ALL_PERMISSIONS } from '../plugin-types';
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (['node_modules', '.next', '__tests__'].includes(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...sourceFiles(full));
|
||||
else if (/\.tsx?$/.test(entry.name) && !/\.test\./.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = ['app', 'components', 'hooks', 'lib'].flatMap((d) => sourceFiles(path.join(root, d)))
|
||||
.map((f) => fs.readFileSync(f, 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
/** A mount: `<PluginSlot name="x"`, `<PluginActions surface="x"`, or a slot passed as `slot="x"` / `slot: 'x'`. */
|
||||
function isMounted(name: string): boolean {
|
||||
const n = name.replace(/[-]/g, '\\-');
|
||||
return new RegExp(`(?:name|surface|slot)\\s*[=:]\\s*\\{?\\s*["'\`]${n}["'\`]`).test(sources)
|
||||
// A fallback name: slot={(current.slot ?? 'plugin-dialog')}
|
||||
|| new RegExp(`slot\\s*\\?\\?\\s*["']${n}["']`).test(sources);
|
||||
}
|
||||
|
||||
// Areas whose surfaces are still being mounted on this branch. Each commit
|
||||
// that finishes an area removes it here; the test then insists every
|
||||
// surface in it has a mount site.
|
||||
const NOT_YET_MOUNTED = new Set<SlotArea>([
|
||||
'app', 'navigation', 'mail-list', 'reader', 'composer', 'calendar', 'contacts', 'tasks', 'files', 'settings',
|
||||
]);
|
||||
|
||||
describe('slot catalogue', () => {
|
||||
it('gates every surface behind permissions the host knows', () => {
|
||||
const known = new Set<string>(ALL_PERMISSIONS);
|
||||
const unknown: string[] = [];
|
||||
for (const [name, spec] of Object.entries(SLOT_CATALOG)) {
|
||||
for (const perm of spec.permissions) if (!known.has(perm)) unknown.push(`${name}: ${perm}`);
|
||||
if (spec.props !== '*') {
|
||||
for (const perm of Object.values(spec.props)) if (perm && !known.has(perm)) unknown.push(`${name} prop: ${perm}`);
|
||||
}
|
||||
}
|
||||
expect(unknown).toEqual([]);
|
||||
});
|
||||
|
||||
const areas = [...new Set(Object.values(SLOT_CATALOG).map((s) => s.area))];
|
||||
it.each(areas)('mounts every %s surface somewhere in the app', (area) => {
|
||||
const missing = Object.entries(SLOT_CATALOG)
|
||||
.filter(([name, spec]) => spec.area === area && !isMounted(name))
|
||||
.map(([name]) => name);
|
||||
if (NOT_YET_MOUNTED.has(area)) {
|
||||
expect(missing.length, `${area} is fully mounted; remove it from NOT_YET_MOUNTED`).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(missing).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = new Set([
|
||||
'http:fetch',
|
||||
'http:post',
|
||||
'filters:write',
|
||||
'http:proxy',
|
||||
]);
|
||||
|
||||
/** Locale key segment for a permission: `email:read` → `email_read`. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { InstalledPlugin, Permission, SlotName } from '../plugin-types';
|
||||
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { hookSpec } from '../plugin-hook-catalog';
|
||||
import { slotSpec } from '../plugin-slot-catalog';
|
||||
|
||||
/**
|
||||
* Whether `plugin` may use `perm`: the manifest must declare it, and an
|
||||
@@ -36,70 +37,19 @@ export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): bool
|
||||
|
||||
// ─── Slots ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Permissions that allow rendering into each slot (any one suffices). Slot
|
||||
* components receive the data of the surface they sit on (the open message,
|
||||
* the draft), so they are gated like hooks.
|
||||
*/
|
||||
const SLOT_PERMISSIONS: Record<SlotName, readonly Permission[]> = {
|
||||
'toolbar-actions': ['ui:toolbar'],
|
||||
'app-top-banner': ['ui:app-top-banner'],
|
||||
'email-banner': ['ui:email-banner'],
|
||||
'email-footer': ['ui:email-footer'],
|
||||
'composer-toolbar': ['ui:composer-toolbar'],
|
||||
'composer-sidebar': ['ui:composer-sidebar'],
|
||||
'composer-sidebar-right': ['ui:composer-sidebar'],
|
||||
'composer-attachment-source': ['ui:composer-toolbar', 'ui:composer-sidebar'],
|
||||
'sidebar-widget': ['ui:sidebar-widget'],
|
||||
'email-detail-sidebar': ['ui:email-details'],
|
||||
'email-details-section': ['ui:email-details'],
|
||||
'settings-section': ['ui:settings-section'],
|
||||
'context-menu-email': ['ui:context-menu'],
|
||||
'navigation-rail-bottom': ['ui:navigation-rail'],
|
||||
'calendar-event-actions': ['ui:calendar-action'],
|
||||
'admin-plugin-page': ['ui:admin-page', 'admin:config'],
|
||||
'contact-cryptokeys': ['ui:contact-cryptokeys'],
|
||||
'attachment-actions': ['email:read'],
|
||||
// Only shown inside a dialog the plugin opened through the host API.
|
||||
'plugin-dialog': ['ui:observe'],
|
||||
};
|
||||
|
||||
/**
|
||||
* The props each slot may hand its plugin, and the permission each needs
|
||||
* (null: none beyond the slot's own). A ui:* permission only lets a plugin
|
||||
* draw somewhere; the message, event, contact or account the slot sits on is
|
||||
* data, and stays behind the matching read permission. Props not listed here
|
||||
* are dropped, so a new prop at a call site is withheld until it is added.
|
||||
*/
|
||||
const SLOT_PROPS: Record<SlotName, Record<string, Permission | null> | '*'> = {
|
||||
'toolbar-actions': {},
|
||||
'app-top-banner': { username: 'account:read', serverUrl: 'account:read' },
|
||||
'email-banner': { email: 'email:read' },
|
||||
'email-footer': {},
|
||||
'composer-toolbar': {},
|
||||
'composer-sidebar': {},
|
||||
'composer-sidebar-right': {},
|
||||
'composer-attachment-source': { onAttach: null },
|
||||
'sidebar-widget': {},
|
||||
'email-detail-sidebar': { email: 'email:read' },
|
||||
'email-details-section': { email: 'email:read', category: null },
|
||||
'settings-section': {},
|
||||
'context-menu-email': {},
|
||||
'navigation-rail-bottom': {},
|
||||
'calendar-event-actions': { eventData: 'calendar:read', setVirtualLocation: null },
|
||||
'admin-plugin-page': {},
|
||||
'contact-cryptokeys': { cryptoKeys: 'contacts:read' },
|
||||
'attachment-actions': { attachment: null },
|
||||
// The plugin chose these props itself when it opened the dialog.
|
||||
'plugin-dialog': '*',
|
||||
};
|
||||
// Which permissions let a plugin use each slot, and which props (or click
|
||||
// context) it may see, come from the slot catalogue. A ui:* permission only
|
||||
// lets a plugin draw somewhere; the message, event, contact or account the
|
||||
// slot sits on is data, and stays behind the matching read permission. Props
|
||||
// not listed in the catalogue are dropped, so a new prop at a call site is
|
||||
// withheld until the catalogue names it.
|
||||
|
||||
export function slotPropsFor(
|
||||
plugin: InstalledPlugin,
|
||||
slot: SlotName,
|
||||
props: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const allowed = SLOT_PROPS[slot];
|
||||
const allowed = slotSpec(slot)?.props;
|
||||
if (allowed === '*') return props;
|
||||
if (!allowed) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -113,8 +63,6 @@ export function slotPropsFor(
|
||||
}
|
||||
|
||||
export function mayOfferSlot(plugin: InstalledPlugin, slot: string): boolean {
|
||||
const perms = Object.prototype.hasOwnProperty.call(SLOT_PERMISSIONS, slot)
|
||||
? SLOT_PERMISSIONS[slot as SlotName]
|
||||
: null;
|
||||
const perms = slotSpec(slot)?.permissions ?? null;
|
||||
return !!perms && perms.some((perm) => pluginHasPermission(plugin, perm));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// How slot iframes are sized. Shared by the host (which sizes the iframe)
|
||||
// and the sandbox runtime (which measures the plugin's content).
|
||||
|
||||
import { SLOT_CATALOG, type SlotSpec } from '../plugin-slot-catalog';
|
||||
import type { SlotName } from '../plugin-types';
|
||||
|
||||
/**
|
||||
@@ -8,13 +9,11 @@ import type { SlotName } from '../plugin-types';
|
||||
* the event form's action row). Their iframe takes the plugin content's own
|
||||
* width; everything else fills the width of its container.
|
||||
*/
|
||||
export const INLINE_SLOTS: ReadonlySet<SlotName> = new Set<SlotName>([
|
||||
'toolbar-actions',
|
||||
'composer-toolbar',
|
||||
'composer-attachment-source',
|
||||
'attachment-actions',
|
||||
'calendar-event-actions',
|
||||
]);
|
||||
export const INLINE_SLOTS: ReadonlySet<SlotName> = new Set<SlotName>(
|
||||
(Object.entries(SLOT_CATALOG) as [SlotName, SlotSpec][])
|
||||
.filter(([, spec]) => spec.kind === 'frame' && spec.inline)
|
||||
.map(([name]) => name),
|
||||
);
|
||||
|
||||
export function isInlineSlot(slot: SlotName | string | null | undefined): boolean {
|
||||
return !!slot && INLINE_SLOTS.has(slot as SlotName);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Every place a plugin can put UI, in one table. The docs and the tests are
|
||||
// generated from it, the same way as the hook catalogue (plugin-hook-catalog.ts).
|
||||
//
|
||||
// Two kinds of surface:
|
||||
// - `frame`: the plugin renders its own React component into a sandboxed
|
||||
// iframe (`exports.slots[name]`). For panels, sections and pages.
|
||||
// - `action`: the plugin declares buttons or menu entries as data
|
||||
// (`api.ui.setActions(surface, [...])`) and the host draws them in its own
|
||||
// style; a click reaches the plugin as `onAction`. For surfaces that repeat
|
||||
// per row or item, where an iframe each would be far too heavy, and for
|
||||
// menus that must look like the rest of the app.
|
||||
// Badges on list rows are data too (the onEmailListItemRender hook).
|
||||
|
||||
import type { Permission } from './plugin-types';
|
||||
|
||||
export type SlotArea =
|
||||
| 'app' | 'mail-list' | 'reader' | 'composer' | 'navigation'
|
||||
| 'calendar' | 'contacts' | 'tasks' | 'files' | 'settings' | 'admin' | 'dialog';
|
||||
|
||||
export interface SlotSpec {
|
||||
area: SlotArea;
|
||||
kind: 'frame' | 'action';
|
||||
/** Any one of these lets a plugin use the slot. */
|
||||
permissions: readonly Permission[];
|
||||
/** Props (frame) or click context (action) and the permission each needs; null = none. */
|
||||
props: Record<string, Permission | null> | '*';
|
||||
/** Sits in a row of buttons and sizes to its content. */
|
||||
inline?: boolean;
|
||||
/** Where it appears. */
|
||||
where: string;
|
||||
}
|
||||
|
||||
const S = (
|
||||
area: SlotArea,
|
||||
kind: SlotSpec['kind'],
|
||||
permissions: readonly Permission[],
|
||||
props: SlotSpec['props'],
|
||||
where: string,
|
||||
inline = false,
|
||||
): SlotSpec => ({ area, kind, permissions, props, where, ...(inline ? { inline } : {}) });
|
||||
|
||||
export const SLOT_CATALOG = {
|
||||
// ─── App ───────────────────────────────────────────────────
|
||||
'app-top-banner': S('app', 'frame', ['ui:app-top-banner'], { username: 'account:read', serverUrl: 'account:read' }, 'a banner across the top of every page'),
|
||||
'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)'),
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────
|
||||
'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'),
|
||||
'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'], { path: null }, 'a full page of its own, with an entry in the navigation rail (api.ui.registerPage)'),
|
||||
|
||||
// ─── Message list ──────────────────────────────────────────
|
||||
'message-list-header': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null }, 'above the message list'),
|
||||
'message-list-empty': S('mail-list', 'frame', ['ui:message-list'], { mailboxId: null, mailboxRole: null, searching: null }, 'in an empty message list, below the "no messages" text'),
|
||||
'message-row-actions': S('mail-list', 'action', ['ui:message-list'], { emailId: 'email:read' }, 'hover buttons on a message row, and the row menu on phones', true),
|
||||
'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true),
|
||||
'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'),
|
||||
'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true),
|
||||
|
||||
// ─── Reader ────────────────────────────────────────────────
|
||||
'reader-header': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'at the top of the open message, above the subject'),
|
||||
'reader-below-subject': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'right under the subject and sender of the open message'),
|
||||
'email-banner': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'a banner above the message body'),
|
||||
'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'),
|
||||
'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'),
|
||||
'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' }, 'a side panel next to the open message'),
|
||||
'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),
|
||||
'attachment-menu': S('reader', 'action', ['email:read'], { emailId: 'email:read', blobId: 'email:read', name: 'email:read', type: 'email:read', size: 'email:read' }, 'entries in each attachment\'s menu (for example "Save to Nextcloud")'),
|
||||
|
||||
// ─── Composer ──────────────────────────────────────────────
|
||||
'composer-toolbar': S('composer', 'frame', ['ui:composer-toolbar'], {}, 'the composer\'s plugin button row', true),
|
||||
'composer-above-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send' }, 'between the subject and the message text'),
|
||||
'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send' }, 'below the message text, above the attachments'),
|
||||
'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel left of the composer'),
|
||||
'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel right of the composer'),
|
||||
'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null }, 'the composer\'s attach menu, as a source of files', true),
|
||||
'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send' }, 'entries in the menu next to Send'),
|
||||
'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], {}, 'entries in the attach menu (for example "From Nextcloud")'),
|
||||
|
||||
// ─── Calendar ──────────────────────────────────────────────
|
||||
'calendar-event-actions': S('calendar', 'frame', ['ui:calendar-action'], { eventData: 'calendar:read', setVirtualLocation: null }, 'buttons in the event form', true),
|
||||
'calendar-event-detail': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in an event\'s details'),
|
||||
'calendar-event-form': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in the form for a new or edited event'),
|
||||
'calendar-day-header': S('calendar', 'action', ['ui:calendar-action'], { date: null }, 'a short label under a day\'s name in the day and week views (api.ui.setDayLabels)'),
|
||||
'mini-calendar-marks': S('calendar', 'action', ['ui:calendar-action'], { date: null }, 'dots on days in the small month calendar (api.ui.setDayLabels)'),
|
||||
|
||||
// ─── Contacts ──────────────────────────────────────────────
|
||||
'contact-cryptokeys': S('contacts', 'frame', ['ui:contact-cryptokeys'], { cryptoKeys: 'contacts:read' }, 'the encryption keys section of a contact'),
|
||||
'contact-detail-section': S('contacts', 'frame', ['ui:contacts'], { contact: 'contacts:read' }, 'a section in a contact\'s details'),
|
||||
'contact-list-actions': S('contacts', 'action', ['ui:contacts'], { contactIds: 'contacts:read' }, 'buttons in the contact list toolbar', true),
|
||||
|
||||
// ─── Tasks ─────────────────────────────────────────────────
|
||||
'task-detail-section': S('tasks', 'frame', ['ui:tasks'], { task: 'tasks:read' }, 'a section in a task\'s details'),
|
||||
'task-list-actions': S('tasks', 'action', ['ui:tasks'], { taskIds: 'tasks:read' }, 'buttons in the task list toolbar', true),
|
||||
|
||||
// ─── Files ─────────────────────────────────────────────────
|
||||
'file-actions': S('files', 'action', ['ui:files'], { files: 'files:read' }, 'entries in a file\'s menu and the selection toolbar'),
|
||||
'file-preview-section': S('files', 'frame', ['ui:files'], { file: 'files:read' }, 'a section in the file preview'),
|
||||
|
||||
// ─── Settings and admin ────────────────────────────────────
|
||||
'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings'),
|
||||
'settings-page': S('settings', 'frame', ['ui:settings-section'], {}, 'a page of its own in Settings, listed under the plugin\'s name'),
|
||||
'admin-plugin-page': S('admin', 'frame', ['ui:admin-page', 'admin:config'], {}, 'the plugin\'s page in the admin dashboard'),
|
||||
} as const satisfies Record<string, SlotSpec>;
|
||||
|
||||
export type CatalogSlotName = keyof typeof SLOT_CATALOG;
|
||||
|
||||
export function slotSpec(name: string): SlotSpec | null {
|
||||
return Object.prototype.hasOwnProperty.call(SLOT_CATALOG, name)
|
||||
? SLOT_CATALOG[name as CatalogSlotName]
|
||||
: null;
|
||||
}
|
||||
+7
-25
@@ -1,4 +1,5 @@
|
||||
import type { CredentialSpec } from './plugin-secrets/credentials';
|
||||
import type { CatalogSlotName } from './plugin-slot-catalog';
|
||||
|
||||
// Plugin & Theme system types
|
||||
|
||||
@@ -293,31 +294,8 @@ export interface InstalledPlugin {
|
||||
|
||||
// ─── UI Slots ────────────────────────────────────────────────
|
||||
|
||||
export type SlotName =
|
||||
| 'toolbar-actions'
|
||||
| 'app-top-banner'
|
||||
| 'email-banner'
|
||||
| 'email-footer'
|
||||
| 'composer-toolbar'
|
||||
| 'composer-sidebar'
|
||||
| 'composer-sidebar-right'
|
||||
| 'sidebar-widget'
|
||||
| 'email-detail-sidebar'
|
||||
| 'email-details-section'
|
||||
| 'settings-section'
|
||||
| 'context-menu-email'
|
||||
| 'navigation-rail-bottom'
|
||||
| 'calendar-event-actions'
|
||||
| 'admin-plugin-page'
|
||||
| 'contact-cryptokeys'
|
||||
| 'attachment-actions'
|
||||
| 'composer-attachment-source'
|
||||
// Rendered inside a plugin-requested host dialog (see `ui.openDialog` /
|
||||
// PluginDialogHost), NOT in-page like every other slot above - this is
|
||||
// the only slot that renders inside the app-root fixed overlay instead of
|
||||
// wherever the slot is placed in the page, so it's the one to use for a
|
||||
// large, genuinely clickable custom UI a small toolbar/row slot can't fit.
|
||||
| 'plugin-dialog';
|
||||
/** Every slot and action surface; see lib/plugin-slot-catalog.ts. */
|
||||
export type SlotName = CatalogSlotName;
|
||||
|
||||
export interface SlotRegistration {
|
||||
pluginId: string;
|
||||
@@ -1141,6 +1119,10 @@ export const ALL_PERMISSIONS = [
|
||||
'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
'ui:calendar-action', 'ui:admin-page',
|
||||
// Surfaces from the slot catalogue (lib/plugin-slot-catalog.ts).
|
||||
'ui:message-list', 'ui:contacts', 'ui:tasks', 'ui:files', 'ui:commands', 'ui:app-page',
|
||||
// Requests that carry an account or API key, added by the server (lib/plugin-secrets).
|
||||
'http:proxy',
|
||||
'admin:config',
|
||||
'app:lifecycle',
|
||||
] as const;
|
||||
|
||||
+37
-9
@@ -4178,8 +4178,8 @@
|
||||
"body": "Know which page is open and react to links you click."
|
||||
},
|
||||
"ui_toolbar": {
|
||||
"title": "Add buttons to the message toolbar",
|
||||
"body": "Show its own buttons above an open message."
|
||||
"title": "Add buttons to toolbars",
|
||||
"body": "Show its own buttons in the message toolbar, on an open message and in the top bar."
|
||||
},
|
||||
"ui_app_top_banner": {
|
||||
"title": "Show a banner at the top",
|
||||
@@ -4187,7 +4187,7 @@
|
||||
},
|
||||
"ui_email_banner": {
|
||||
"title": "Show notices on messages",
|
||||
"body": "Show a notice above the text of an open message."
|
||||
"body": "Show its own notices and summaries above, inside and between the parts of an open message or conversation."
|
||||
},
|
||||
"ui_email_footer": {
|
||||
"title": "Add content below messages",
|
||||
@@ -4210,16 +4210,16 @@
|
||||
"body": "Sort your inbox into tabs such as Updates or Promotions."
|
||||
},
|
||||
"ui_composer_toolbar": {
|
||||
"title": "Add buttons to the composer",
|
||||
"body": "Show its own buttons while you write a message."
|
||||
"title": "Add to the composer",
|
||||
"body": "Show its own buttons, menu entries and sections while you write a message."
|
||||
},
|
||||
"ui_composer_sidebar": {
|
||||
"title": "Add a panel to the composer",
|
||||
"body": "Show its own panel next to the message you write."
|
||||
},
|
||||
"ui_sidebar_widget": {
|
||||
"title": "Add a box to the sidebar",
|
||||
"body": "Show its own content in the mail sidebar."
|
||||
"title": "Add to the sidebar",
|
||||
"body": "Show its own boxes and sections in the mail sidebar."
|
||||
},
|
||||
"ui_settings_section": {
|
||||
"title": "Add a settings page",
|
||||
@@ -4238,8 +4238,8 @@
|
||||
"body": "React to keyboard shortcuts it defines."
|
||||
},
|
||||
"ui_calendar_action": {
|
||||
"title": "Add buttons to events",
|
||||
"body": "Show its own buttons when you create or edit an event."
|
||||
"title": "Add to the calendar",
|
||||
"body": "Show its own buttons and sections on events, and short labels and dots on calendar days."
|
||||
},
|
||||
"ui_admin_page": {
|
||||
"title": "Add an admin page",
|
||||
@@ -4248,6 +4248,34 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Know when the app starts and stops",
|
||||
"body": "Be told when the app starts, goes offline or you sign out."
|
||||
},
|
||||
"ui_message_list": {
|
||||
"title": "Add to the message list",
|
||||
"body": "Show its own content above the message list, and buttons on messages and in the selection toolbar."
|
||||
},
|
||||
"ui_contacts": {
|
||||
"title": "Add to contacts",
|
||||
"body": "Show its own sections in a contact's details and buttons in the contact list."
|
||||
},
|
||||
"ui_tasks": {
|
||||
"title": "Add to tasks",
|
||||
"body": "Show its own sections in a task's details and buttons in the task list."
|
||||
},
|
||||
"ui_files": {
|
||||
"title": "Add to files",
|
||||
"body": "Show its own sections in the file preview and entries in a file's menu."
|
||||
},
|
||||
"ui_commands": {
|
||||
"title": "Add commands",
|
||||
"body": "Add its own entries to the command palette (Ctrl+K)."
|
||||
},
|
||||
"ui_app_page": {
|
||||
"title": "Add pages",
|
||||
"body": "Add its own pages to the app, each with a button in the navigation bar."
|
||||
},
|
||||
"http_proxy": {
|
||||
"title": "Use accounts you connect",
|
||||
"body": "Send requests through Bulwark's server with an account or key that you or your administrator set up. The plugin never sees the password, but it can do anything that account can do on its own server."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
Reference in New Issue
Block a user