mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins edit the message being written from composer buttons and menus
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const pro = vi.hoisted(() => ({ active: false }));
|
||||
@@ -6,6 +6,7 @@ vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActiv
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
|
||||
|
||||
const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
|
||||
const { resetActions, setPluginActions } = await import('@/lib/plugin-sandbox/actions');
|
||||
|
||||
describe('keyboard shortcuts help', () => {
|
||||
it('lists Ctrl+K as the command palette outside the Pro shell', () => {
|
||||
@@ -21,4 +22,16 @@ describe('keyboard shortcuts help', () => {
|
||||
expect(screen.getByText('shortcuts.global.global_search')).toBeInTheDocument();
|
||||
expect(screen.queryByText('command_palette.open')).toBeNull();
|
||||
});
|
||||
|
||||
it("lists a plugin's composer button shortcut under the composer, with the plugin's name", () => {
|
||||
act(() => setPluginActions('assistant', 'Writing assistant', 'composer-toolbar-actions', [
|
||||
{ id: 'ask', label: 'Assistant', shortcut: 'Ctrl+Shift+Y' },
|
||||
{ id: 'quiet', label: 'No key' },
|
||||
]));
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByText('Assistant (Writing assistant)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ctrl+Shift+Y')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No key/)).toBeNull();
|
||||
act(() => resetActions());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { undoDepth } from '@tiptap/pm/history';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { buildSignatureBlock } from '../signature-block';
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
|
||||
import { composerHandle } from '@/lib/plugin-sandbox/composer-registry';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
|
||||
// The real composer around the real editor, worked on by a plugin through
|
||||
// the handle it registers for api.composer: what the plugin reads as the
|
||||
// message, and that each change is one step in the editor's history that
|
||||
// leaves the signature, the quoted reply and inline images alone.
|
||||
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
|
||||
// ─── Composer dependency mocks (as in composer-close-guard.test.tsx) ──────────
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
recipientMentionsEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onBeforeAttachmentUpload: { intercept: async () => true },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/email-sanitization')>()),
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SIGNATURE = buildSignatureBlock('<p>Alice Example<br>ACME Ltd.</p>');
|
||||
const QUOTE = buildQuotedHtmlBlock('<p>Can you send the photo?</p>');
|
||||
const BODY =
|
||||
'<p>Hello Bob,</p><p>here it is <img src="cid:photo1" data-cid="photo1" alt="photo"></p><p></p>'
|
||||
+ SIGNATURE
|
||||
+ '<p>On Monday, Bob wrote:</p>'
|
||||
+ QUOTE;
|
||||
|
||||
const DRAFT = {
|
||||
to: 'Bob Builder <bob@example.com>',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Re: Photo',
|
||||
body: BODY,
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const PLUGIN = 'assistant';
|
||||
|
||||
/** The real composer; its id comes from a click on a plugin's toolbar button, as for a plugin. */
|
||||
async function renderComposer(body = BODY) {
|
||||
const clicks: Record<string, unknown>[] = [];
|
||||
setActionDispatcher(async (_plugin, _surface, _id, context) => { clicks.push(context); });
|
||||
setPluginActions(PLUGIN, 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Ctrl+Shift+Y' }]);
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body }} mode="reply" onClose={vi.fn()} />);
|
||||
const dom = await waitFor(() => {
|
||||
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
|
||||
expect(el?.editor).toBeTruthy();
|
||||
return el!;
|
||||
});
|
||||
const editor = dom.editor!;
|
||||
await waitFor(() => expect(editor.getHTML()).toContain(body.slice(3, 12)));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
await waitFor(() => expect(clicks).toHaveLength(1));
|
||||
const id = clicks[0].composerId as string;
|
||||
expect(typeof id).toBe('string');
|
||||
return { editor, id, handle: composerHandle(id)!, clicks };
|
||||
}
|
||||
|
||||
const html = (editor: Editor) => serializeEditorContent(editor);
|
||||
|
||||
describe('plugins editing the message being written', () => {
|
||||
const range = Range.prototype as unknown as Record<string, unknown>;
|
||||
const doc = document as unknown as Record<string, unknown>;
|
||||
beforeAll(() => {
|
||||
range.getClientRects = () => [];
|
||||
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
|
||||
doc.elementFromPoint = () => null;
|
||||
});
|
||||
afterAll(() => {
|
||||
delete range.getClientRects;
|
||||
delete range.getBoundingClientRect;
|
||||
delete doc.elementFromPoint;
|
||||
});
|
||||
afterEach(() => {
|
||||
resetActions();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
it('reads only the user\'s own text: no signature, no quote header, no quote', async () => {
|
||||
const { handle } = await renderComposer();
|
||||
const body = handle.readBody();
|
||||
expect(body.text).toBe('Hello Bob,\nhere it is');
|
||||
expect(body.html).toContain('cid:photo1');
|
||||
expect(body.html).not.toContain('ACME');
|
||||
expect(body.html).not.toContain('Bob wrote');
|
||||
expect(body.html).not.toContain('Can you send');
|
||||
});
|
||||
|
||||
it('replaces the own text in place, at once, and leaves signature, quote and inline image', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
let ok = false;
|
||||
act(() => { ok = handle.replaceBody('<p>Hi Bob, attached.</p>', 'Hi Bob, attached.'); });
|
||||
expect(ok).toBe(true);
|
||||
// Applied to the editor at once, so a plugin reads its own change back.
|
||||
expect(handle.readBody().text).toBe('Hi Bob, attached.');
|
||||
|
||||
const after = html(editor);
|
||||
expect(after).not.toContain('Hello Bob');
|
||||
expect(after.indexOf('Hi Bob, attached.')).toBeLessThan(after.indexOf('data-signature-block-node'));
|
||||
expect(after).toContain('ACME Ltd.');
|
||||
expect(after).toContain('On Monday, Bob wrote:');
|
||||
expect(after).toContain('Can you send the photo?');
|
||||
expect(after).toContain('data-cid="photo1"');
|
||||
// The blank line between the text and the signature stays.
|
||||
expect(after.indexOf('data-cid="photo1"')).toBeLessThan(after.indexOf('data-signature-block-node'));
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
it("leaves a reply's quote header (On ... wrote:) with the quote when there is no signature above it", async () => {
|
||||
const { editor, handle } = await renderComposer('<p>Thanks, that works.</p><p>On Monday, Bob wrote:</p>' + QUOTE);
|
||||
expect(handle.readBody().text).toBe('Thanks, that works.');
|
||||
act(() => { handle.replaceBody('<p>Thank you, that works for me.</p>', 'Thank you, that works for me.'); });
|
||||
const after = html(editor);
|
||||
expect(after).toContain('Thank you, that works for me.');
|
||||
expect(after).toContain('On Monday, Bob wrote:');
|
||||
expect(after.indexOf('Thank you')).toBeLessThan(after.indexOf('On Monday'));
|
||||
});
|
||||
|
||||
it('is one step in the editor history: one Ctrl+Z brings the old text back', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
const depth = undoDepth(editor.state);
|
||||
act(() => { handle.replaceBody('<p>Hi Bob, attached.</p>', 'Hi Bob, attached.'); });
|
||||
expect(undoDepth(editor.state)).toBe(depth + 1);
|
||||
act(() => { editor.commands.undo(); });
|
||||
expect(html(editor)).toBe(before);
|
||||
});
|
||||
|
||||
it('replaces a selection in one step and keeps an image inside it', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
// "Hello Bob," up to and including the image.
|
||||
let imageEnd = 0;
|
||||
editor.state.doc.descendants((node, pos) => { if (node.type.name === 'image') imageEnd = pos + node.nodeSize; });
|
||||
act(() => { editor.commands.setTextSelection({ from: 1, to: imageEnd }); });
|
||||
expect(handle.readSelection().text).toContain('Hello Bob,');
|
||||
|
||||
const depth = undoDepth(editor.state);
|
||||
act(() => { handle.replaceSelection('<p>Dear Bob,</p>', 'Dear Bob,', { below: false }); });
|
||||
expect(undoDepth(editor.state)).toBe(depth + 1);
|
||||
const after = html(editor);
|
||||
expect(after).toContain('Dear Bob,');
|
||||
expect(after).not.toContain('Hello Bob');
|
||||
expect(after).toContain('data-cid="photo1"');
|
||||
expect(after).toContain('ACME Ltd.');
|
||||
|
||||
act(() => { editor.commands.undo(); });
|
||||
expect(html(editor)).toBe(before);
|
||||
});
|
||||
|
||||
it('refuses to replace a selection that holds the signature', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
act(() => { editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size }); });
|
||||
let ok = true;
|
||||
act(() => { ok = handle.replaceSelection('<p>x</p>', 'x', { below: false }); });
|
||||
expect(ok).toBe(false);
|
||||
expect(html(editor)).toBe(before);
|
||||
});
|
||||
|
||||
it('adds below the selection without touching it', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
act(() => { editor.commands.setTextSelection({ from: 1, to: 6 }); });
|
||||
act(() => { handle.replaceSelection('<p>Suggested: Hi Bob</p>', 'Suggested: Hi Bob', { below: true }); });
|
||||
const after = html(editor);
|
||||
expect(after).toContain('Hello Bob,');
|
||||
expect(after.indexOf('Hello Bob,')).toBeLessThan(after.indexOf('Suggested: Hi Bob'));
|
||||
expect(after.indexOf('Suggested: Hi Bob')).toBeLessThan(after.indexOf('data-signature-block-node'));
|
||||
});
|
||||
|
||||
it('runs a plugin button\'s shortcut while typing in the composer', async () => {
|
||||
const { editor, clicks } = await renderComposer();
|
||||
fireEvent.keyDown(editor.view.dom, { key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||
await waitFor(() => expect(clicks).toHaveLength(2));
|
||||
expect(clicks[1].composerId).toBe(clicks[0].composerId);
|
||||
});
|
||||
|
||||
it('attaches a file the plugin uploaded, and forgets the composer when it closes', async () => {
|
||||
const { handle, id } = await renderComposer();
|
||||
let ok = false;
|
||||
await act(async () => { ok = await handle.attach({ blobId: 'B1', name: 'report.pdf', type: 'application/pdf', size: 2048 }); });
|
||||
expect(ok).toBe(true);
|
||||
expect(await screen.findByText('report.pdf')).toBeTruthy();
|
||||
cleanup();
|
||||
expect(composerHandle(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -287,7 +287,7 @@ describe('composer-above-body and composer-below-body', () => {
|
||||
|
||||
const frame = screen.getByTestId('slot-composer-attachment-source');
|
||||
expect(frame.closest('[data-composer-plugin-row]')).not.toBeNull();
|
||||
expect(SLOT_CATALOG['composer-attachment-source'].where).toMatch(/plugin button row/);
|
||||
expect(SLOT_CATALOG['composer-attachment-source'].where).toMatch(/plugin row/);
|
||||
});
|
||||
|
||||
it('draw nothing without a plugin', () => {
|
||||
@@ -344,7 +344,7 @@ describe('composer-attach-menu', () => {
|
||||
expect(screen.queryByTestId('composer-attach-from-files')).toBeNull();
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'From the cloud' })); });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'composer-attach-menu', 'cloud', {});
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'composer-attach-menu', 'cloud', { composerId: expect.any(String) });
|
||||
expect(screen.queryByRole('menuitem', { name: 'From the cloud' })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,40 @@ interface PluginItemsProps {
|
||||
separator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buttons plugins declared for the composer's bottom bar
|
||||
* (composer-toolbar-actions), drawn like Attach and Templates. An action's
|
||||
* shortcut is shown in its tooltip; the composer binds it.
|
||||
*/
|
||||
export function ComposerPluginToolbarButtons({ surface, context }: { surface: "composer-toolbar-actions"; context: () => Record<string, unknown> }) {
|
||||
const actions = usePluginActions(surface);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
const tip = action.shortcut ? `${action.label} (${action.shortcut})` : action.label;
|
||||
return (
|
||||
<Button
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn("h-9 gap-1.5 px-2 sm:px-2.5", action.danger && "text-destructive")}
|
||||
title={tip}
|
||||
aria-label={action.label}
|
||||
aria-keyshortcuts={action.shortcut}
|
||||
data-plugin-action={`${action.pluginId}:${action.id}`}
|
||||
onClick={() => { void runAction(action, surface, context()); }}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline text-sm">{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Entries plugins declared for a composer menu, drawn like the menu's own. */
|
||||
export function ComposerPluginMenuItems({ surface, context, onSelect, separator }: PluginItemsProps) {
|
||||
const actions = usePluginActions(surface);
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
Reply,
|
||||
Forward,
|
||||
} from "@/components/icons";
|
||||
import { ComposerPluginMenuItems, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
|
||||
import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
|
||||
import { registerComposer } from "@/lib/plugin-sandbox/composer-registry";
|
||||
import { plainOwnRange, readOwnContent, readSelection as readEditorSelection, replaceOwnContent, replaceSelection as replaceEditorSelection, replaceTextareaRange, textToEditorHtml, type OwnTextOptions } from "@/lib/plugin-sandbox/composer-edit";
|
||||
import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { schedulePresets } from "@/lib/schedule-presets";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -2464,20 +2468,88 @@ export function EmailComposer({
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to, '\n\n');
|
||||
}, []);
|
||||
// Plugins read and change this message through api.composer, with the id
|
||||
// this composer hands out in its action clicks and slot props (see
|
||||
// composer-registry.ts). The rich editor is changed only through its own
|
||||
// transactions, so one Ctrl+Z takes an edit back; the user's own text ends
|
||||
// at the signature and the quote (composer-edit.ts).
|
||||
const pluginEditOptsRef = useRef<OwnTextOptions & { signature: string }>({ signature: '' });
|
||||
pluginEditOptsRef.current = {
|
||||
replyLike: mode === 'reply' || mode === 'replyAll' || mode === 'forward',
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
signature: getPlainTextSignature(signatureIdentity),
|
||||
};
|
||||
const pluginAttachRef = useRef(handlePluginAttachmentAdd);
|
||||
pluginAttachRef.current = handlePluginAttachmentAdd;
|
||||
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const plainRange = (value: string) => plainOwnRange(value, pluginEditOptsRef.current);
|
||||
const { id, unregister } = registerComposer({
|
||||
readBody: () => {
|
||||
if (plainTextModeRef.current) {
|
||||
const value = bodyRef.current?.value ?? '';
|
||||
const { from, to } = plainRange(value);
|
||||
const text = value.slice(from, to);
|
||||
return { text, html: textToEditorHtml(text) };
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return editor ? readOwnContent(editor, pluginEditOptsRef.current) : { html: '', text: '' };
|
||||
},
|
||||
readSelection: () => {
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
const text = textarea ? textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0) : '';
|
||||
return { text, html: textToEditorHtml(text), empty: text.length === 0 };
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return editor ? readEditorSelection(editor) : { html: '', text: '', empty: true };
|
||||
},
|
||||
replaceBody: (html, text) => {
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
if (!textarea) return false;
|
||||
const { from, to } = plainRange(textarea.value);
|
||||
return replaceTextareaRange(textarea, from, to, text);
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return !!editor && replaceOwnContent(editor, html, pluginEditOptsRef.current);
|
||||
},
|
||||
replaceSelection: (html, text, { below }) => {
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
if (!textarea) return false;
|
||||
const start = textarea.selectionStart ?? textarea.value.length;
|
||||
const end = textarea.selectionEnd ?? start;
|
||||
return below
|
||||
? replaceTextareaRange(textarea, end, end, `\n\n${text}`)
|
||||
: replaceTextareaRange(textarea, start, end, text);
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return !!editor && replaceEditorSelection(editor, html, { below });
|
||||
},
|
||||
attach: (file) => pluginAttachRef.current(file as PluginAttachmentUpload),
|
||||
});
|
||||
setPluginComposerId(id);
|
||||
return unregister;
|
||||
}, []);
|
||||
|
||||
const pluginDraftProps = useMemo(
|
||||
() => ({ draft: pluginDraft, insertText: pluginInsertText, getSelectedText: pluginSelectedText }),
|
||||
[pluginDraft, pluginInsertText, pluginSelectedText],
|
||||
() => ({ draft: pluginDraft, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
|
||||
[pluginDraft, pluginInsertText, pluginSelectedText, pluginComposerId],
|
||||
);
|
||||
const pluginToolbarProps = useMemo(
|
||||
() => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText }),
|
||||
[pluginInsertText, pluginSelectedText],
|
||||
() => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
|
||||
[pluginInsertText, pluginSelectedText, pluginComposerId],
|
||||
);
|
||||
// A cloud picker attaches files, or puts share links in the text.
|
||||
const pluginAttachmentSourceProps = useMemo(
|
||||
() => ({ onAttach: handlePluginAttachmentAdd, insertText: pluginInsertText }),
|
||||
[handlePluginAttachmentAdd, pluginInsertText],
|
||||
() => ({ onAttach: handlePluginAttachmentAdd, insertText: pluginInsertText, composerId: pluginComposerId }),
|
||||
[handlePluginAttachmentAdd, pluginInsertText, pluginComposerId],
|
||||
);
|
||||
const sendMenuContext = useCallback(() => ({ draft: draftSnapshotRef.current() }), []);
|
||||
const pluginComposerIdRef = useRef(pluginComposerId);
|
||||
pluginComposerIdRef.current = pluginComposerId;
|
||||
const sendMenuContext = useCallback(() => ({ draft: draftSnapshotRef.current(), composerId: pluginComposerIdRef.current }), []);
|
||||
const composerActionContext = useCallback(() => ({ composerId: pluginComposerIdRef.current }), []);
|
||||
|
||||
// Trigger auto-save when content changes (only if user modified something)
|
||||
useEffect(() => {
|
||||
@@ -3185,7 +3257,16 @@ export function EmailComposer({
|
||||
const hasPrimaryModifier = e.ctrlKey || e.metaKey;
|
||||
const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey;
|
||||
const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey;
|
||||
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return;
|
||||
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) {
|
||||
// A plugin's composer button with a shortcut (composer-toolbar-actions)
|
||||
// works while the composer has focus, also while typing.
|
||||
if (!e.altKey && !hasPrimaryModifier) return;
|
||||
const action = actionsFor('composer-toolbar-actions').find((a) => a.shortcut && matchesCombo(a.shortcut, e.nativeEvent));
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
void runAction(action, 'composer-toolbar-actions', composerActionContext());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
showTemplatePicker ||
|
||||
@@ -3944,7 +4025,7 @@ export function EmailComposer({
|
||||
{t('attach_from_files')}
|
||||
</ComposerToolbarMenuItem>
|
||||
)}
|
||||
<ComposerPluginMenuItems surface="composer-attach-menu" context={{}} onSelect={close} separator />
|
||||
<ComposerPluginMenuItems surface="composer-attach-menu" context={composerActionContext} onSelect={close} separator />
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
) : (
|
||||
@@ -3997,7 +4078,7 @@ export function EmailComposer({
|
||||
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length}
|
||||
testId="composer-options-menu"
|
||||
>
|
||||
{() => <>
|
||||
{(close) => <>
|
||||
<ComposerToolbarMenuCheckItem
|
||||
checked={plainTextMode}
|
||||
onToggle={togglePlainTextMode}
|
||||
@@ -4034,8 +4115,10 @@ export function EmailComposer({
|
||||
{t('option_require_tls')}
|
||||
</ComposerToolbarMenuCheckItem>
|
||||
)}
|
||||
<ComposerPluginMenuItems surface="composer-options-menu" context={composerActionContext} onSelect={close} separator />
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
<ComposerPluginToolbarButtons surface="composer-toolbar-actions" context={composerActionContext} />
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -19,6 +20,8 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
const { startTour } = useTour();
|
||||
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
|
||||
const shell = useProInterfaceActive() ? "pro" : "standard";
|
||||
// Plugin buttons in the composer that have a shortcut (composer-toolbar-actions).
|
||||
const pluginComposerShortcuts = usePluginActions("composer-toolbar-actions").filter((a) => a.shortcut);
|
||||
// Rows that belong to one interface layout (or one shell) are listed only under it.
|
||||
const shown = (entries: readonly ShortcutEntry[]) =>
|
||||
entries.filter((entry) => (!entry.layout || entry.layout === interfaceLayout) && (!entry.shell || entry.shell === shell));
|
||||
@@ -144,6 +147,13 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
description={t(shortcut.description)}
|
||||
/>
|
||||
))}
|
||||
{pluginComposerShortcuts.map((action) => (
|
||||
<ShortcutRow
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
shortcutKey={action.shortcut!}
|
||||
description={`${action.label} (${action.pluginName})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
||||
import { registerComposer, resetComposers, type ComposerHandle } from '../plugin-sandbox/composer-registry';
|
||||
import { plainOwnRange, replaceTextareaRange } from '../plugin-sandbox/composer-edit';
|
||||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } }));
|
||||
|
||||
const { dispatchApiCall } = await import('../plugin-sandbox/host-api');
|
||||
|
||||
// api.composer on the host: which permission each call needs, what it
|
||||
// accepts, and that a plugin reaches only a composer whose id it was given.
|
||||
|
||||
function plugin(perms: string[]): InstalledPlugin {
|
||||
return {
|
||||
id: 'assistant', name: 'Assistant', version: '1.0.0', author: 'test', description: '',
|
||||
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeHandle(): ComposerHandle & { calls: unknown[][] } {
|
||||
const calls: unknown[][] = [];
|
||||
return {
|
||||
calls,
|
||||
readBody: () => ({ html: '<p>Hello</p>', text: 'Hello' }),
|
||||
readSelection: () => ({ html: '', text: '', empty: true }),
|
||||
replaceBody: (...a) => { calls.push(['replaceBody', ...a]); return true; },
|
||||
replaceSelection: (...a) => { calls.push(['replaceSelection', ...a]); return true; },
|
||||
attach: async (...a) => { calls.push(['attach', ...a]); return true; },
|
||||
};
|
||||
}
|
||||
|
||||
const READ = plugin(['compose:read']);
|
||||
const WRITE = plugin(['compose:write']);
|
||||
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
|
||||
|
||||
beforeEach(() => resetPluginRateLimits());
|
||||
afterEach(() => resetComposers());
|
||||
|
||||
describe('api.composer', () => {
|
||||
it('reads the open message with compose:read only', async () => {
|
||||
const { id } = registerComposer(fakeHandle());
|
||||
expect(await call(READ, 'composer.getBody', id)).toEqual({ html: '<p>Hello</p>', text: 'Hello' });
|
||||
await expect(call(WRITE, 'composer.getBody', id)).rejects.toThrow(/lacks permission "compose:read"/);
|
||||
});
|
||||
|
||||
it('changes it with compose:write only', async () => {
|
||||
const handle = fakeHandle();
|
||||
const { id } = registerComposer(handle);
|
||||
await expect(call(READ, 'composer.replaceBody', id, { text: 'x' })).rejects.toThrow(/lacks permission "compose:write"/);
|
||||
await expect(call(READ, 'composer.replaceSelection', id, { text: 'x' })).rejects.toThrow(/lacks permission "compose:write"/);
|
||||
expect(handle.calls).toEqual([]);
|
||||
expect(await call(WRITE, 'composer.replaceSelection', id, { text: 'Dear Bob,\nthanks' }, { below: true })).toBe(true);
|
||||
expect(handle.calls[0]).toEqual(['replaceSelection', '<p>Dear Bob,</p><p>thanks</p>', 'Dear Bob,\nthanks', { below: true }]);
|
||||
});
|
||||
|
||||
it('turns plain text into escaped paragraphs and runs HTML through the sanitiser', async () => {
|
||||
const handle = fakeHandle();
|
||||
const { id } = registerComposer(handle);
|
||||
await call(WRITE, 'composer.replaceBody', id, { text: '<b>not bold</b>' });
|
||||
await call(WRITE, 'composer.replaceBody', id, { html: '<p onclick="x()">Hi<script>alert(1)</script></p>' });
|
||||
expect(handle.calls[0][1]).toBe('<p><b>not bold</b></p>');
|
||||
expect(handle.calls[1][1]).not.toMatch(/onclick|script/);
|
||||
expect(handle.calls[1][1]).toContain('Hi');
|
||||
});
|
||||
|
||||
it('reaches only a composer that is open, by the id it handed out', async () => {
|
||||
const { unregister } = registerComposer(fakeHandle());
|
||||
await expect(call(READ, 'composer.getBody', 'guess')).rejects.toThrow(/not open any more/);
|
||||
await expect(call(READ, 'composer.getBody')).rejects.toThrow(/composerId must be a string/);
|
||||
const second = registerComposer(fakeHandle());
|
||||
second.unregister();
|
||||
await expect(call(READ, 'composer.getBody', second.id)).rejects.toThrow(/not open any more/);
|
||||
unregister();
|
||||
});
|
||||
|
||||
it('refuses content that is neither or both of text and html, and odd options', async () => {
|
||||
const { id } = registerComposer(fakeHandle());
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, {})).rejects.toThrow(/either text or html/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'a', html: '<p>a</p>' })).rejects.toThrow(/either text or html/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { markdown: '*a*' })).rejects.toThrow(/unknown property "markdown"/);
|
||||
await expect(call(WRITE, 'composer.replaceSelection', id, { text: 'a' }, { at: 'end' })).rejects.toThrow(/unknown property "at"/);
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'x'.repeat(512 * 1024 + 1) })).rejects.toThrow(/at most/);
|
||||
});
|
||||
|
||||
it('attaches an uploaded blob with email:blob-write, checking the fields', async () => {
|
||||
const handle = fakeHandle();
|
||||
const { id } = registerComposer(handle);
|
||||
const attacher = plugin(['email:blob-write']);
|
||||
await expect(call(WRITE, 'composer.attach', id, { blobId: 'B1', name: 'a.pdf', size: 1 })).rejects.toThrow(/lacks permission "email:blob-write"/);
|
||||
expect(await call(attacher, 'composer.attach', id, { blobId: 'B1', name: 'a.pdf', size: 10 })).toBe(true);
|
||||
expect(handle.calls[0]).toEqual(['attach', { blobId: 'B1', name: 'a.pdf', type: 'application/octet-stream', size: 10 }]);
|
||||
await expect(call(attacher, 'composer.attach', id, { blobId: 'B1', name: 'a.pdf', size: -1 })).rejects.toThrow(/whole number/);
|
||||
await expect(call(attacher, 'composer.attach', id, { blobId: 'B1', name: '', size: 1 })).rejects.toThrow(/name/);
|
||||
});
|
||||
|
||||
it('allows 60 edits a minute', async () => {
|
||||
const { id } = registerComposer(fakeHandle());
|
||||
for (let i = 0; i < 60; i++) await call(WRITE, 'composer.replaceBody', id, { text: `${i}` });
|
||||
await expect(call(WRITE, 'composer.replaceBody', id, { text: 'one more' })).rejects.toThrow(/at most 60 edits a minute/);
|
||||
});
|
||||
|
||||
it('counts reading the draft as ordinary, not high-risk; the plugin still needs a composer id', () => {
|
||||
expect(HIGH_RISK_PERMISSIONS.has('compose:read')).toBe(false);
|
||||
expect(HIGH_RISK_PERMISSIONS.has('compose:write')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain-text composer', () => {
|
||||
it('finds the own text above the signature separator, the quote header and the quote', () => {
|
||||
const body = 'Hi Bob,\nsure.\n\n-- \nAlice\n\nOn Monday, Bob wrote:\n> Can you?';
|
||||
const { from, to } = plainOwnRange(body, { replyLike: true });
|
||||
expect(body.slice(from, to)).toBe('Hi Bob,\nsure.');
|
||||
const reply = 'Thanks!\n\nOn Monday, Bob wrote:\n> Can you?';
|
||||
expect(reply.slice(0, plainOwnRange(reply, { replyLike: true }).to)).toBe('Thanks!');
|
||||
const sig = 'Hello\n\nAlice Example\nACME';
|
||||
expect(sig.slice(0, plainOwnRange(sig, { signature: 'Alice Example\nACME' }).to)).toBe('Hello');
|
||||
const fwd = 'FYI\n\n---------- Forwarded message ----------\nFrom: Bob';
|
||||
expect(fwd.slice(0, plainOwnRange(fwd, { forwardedSeparator: '---------- Forwarded message ----------' }).to)).toBe('FYI');
|
||||
});
|
||||
|
||||
it('edits the textarea through the browser\'s editing command, so its undo keeps working', () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = 'Hello Bob,\n\n-- \nAlice';
|
||||
document.body.appendChild(textarea);
|
||||
const exec = vi.fn((_cmd: string, _ui: boolean, text: string) => {
|
||||
textarea.setRangeText(text, textarea.selectionStart, textarea.selectionEnd, 'end');
|
||||
return true;
|
||||
});
|
||||
const doc = document as unknown as { execCommand: unknown };
|
||||
const original = doc.execCommand;
|
||||
doc.execCommand = exec;
|
||||
const input = vi.fn();
|
||||
textarea.addEventListener('input', input);
|
||||
try {
|
||||
replaceTextareaRange(textarea, 0, 10, 'Hi Bob,');
|
||||
expect(exec).toHaveBeenCalledWith('insertText', false, 'Hi Bob,');
|
||||
expect(textarea.value).toBe('Hi Bob,\n\n-- \nAlice');
|
||||
} finally {
|
||||
doc.execCommand = original;
|
||||
textarea.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to setRangeText and an input event where the command is missing', () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = 'Hello';
|
||||
document.body.appendChild(textarea);
|
||||
const input = vi.fn();
|
||||
textarea.addEventListener('input', input);
|
||||
const doc = document as unknown as { execCommand: unknown };
|
||||
const original = doc.execCommand;
|
||||
doc.execCommand = () => false;
|
||||
try {
|
||||
replaceTextareaRange(textarea, 0, 5, 'Hi');
|
||||
expect(textarea.value).toBe('Hi');
|
||||
expect(input).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
doc.execCommand = original;
|
||||
textarea.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { permissionKey } from '../plugin-sandbox/permission-text';
|
||||
|
||||
// Every permission a plugin can ask for is explained to the user in the
|
||||
// consent dialog and in Settings > Plugins, in their language.
|
||||
|
||||
const LOCALES = path.resolve(__dirname, '../../locales');
|
||||
|
||||
describe('permission texts', () => {
|
||||
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
|
||||
const shown = ALL_PERMISSIONS.filter((p) => !implicit.has(p));
|
||||
const langs = readdirSync(LOCALES).filter((l) => !l.includes('.'));
|
||||
|
||||
it('covers all 27 locales', () => {
|
||||
expect(langs).toHaveLength(27);
|
||||
});
|
||||
|
||||
it.each(langs)('has a title and body for every permission in %s', (lang) => {
|
||||
const strings = JSON.parse(readFileSync(path.join(LOCALES, lang, 'common.json'), 'utf8'));
|
||||
const texts = strings.plugins?.permissions ?? {};
|
||||
const missing = shown.filter((p) => {
|
||||
const t = texts[permissionKey(p)];
|
||||
return !t || typeof t.title !== 'string' || !t.title.trim() || typeof t.body !== 'string' || !t.body.trim();
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
// surface's context, filtered by the plugin's permissions.
|
||||
|
||||
import { slotSpec } from '../plugin-slot-catalog';
|
||||
import { isAllowedPluginCombo } from './shortcuts';
|
||||
|
||||
export interface PluginAction {
|
||||
id: string;
|
||||
@@ -18,6 +19,12 @@ export interface PluginAction {
|
||||
mimeTypes?: string[];
|
||||
/** A danger action is drawn in red. */
|
||||
danger?: boolean;
|
||||
/**
|
||||
* A key combination for the action ("Ctrl+Shift+A"), on surfaces that bind
|
||||
* one (composer-toolbar-actions: while the composer has focus). Needs Alt,
|
||||
* Ctrl/Cmd+Shift or Ctrl/Cmd+Alt, like plugin shortcuts.
|
||||
*/
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export interface RegisteredAction extends PluginAction {
|
||||
@@ -70,6 +77,12 @@ export function checkActions(value: unknown): PluginAction[] {
|
||||
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
|
||||
}
|
||||
if (a.danger !== undefined) out.danger = a.danger === true;
|
||||
if (a.shortcut !== undefined) {
|
||||
if (typeof a.shortcut !== 'string' || a.shortcut.length > 30 || !isAllowedPluginCombo(a.shortcut)) {
|
||||
throw new Error(`actions[${i}].shortcut must use Alt, Ctrl+Shift or Ctrl+Alt`);
|
||||
}
|
||||
out.shortcut = a.shortcut;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Edits a plugin makes to the message being written (api.composer.*).
|
||||
//
|
||||
// The rich editor: every change is one ProseMirror transaction on the live
|
||||
// editor, so it lands in the editor's history as one step and Ctrl+Z takes
|
||||
// it back whole. Not through the composer's body state: that rebuilds the
|
||||
// whole document from HTML a render later, and in the plain-text composer
|
||||
// setting the textarea's value wipes its undo history.
|
||||
//
|
||||
// "The message" a plugin reads or replaces as a whole is the user's own
|
||||
// text: everything above the signature and the quoted original. Those, and
|
||||
// inline images, are never removed by an edit:
|
||||
// - the signature (SignatureBlock atom, or the paragraphs bracketed by
|
||||
// data-signature-block markers) and the quote (QuotedHtml atom, a
|
||||
// blockquote, or the forwarded-message header) end the user's text;
|
||||
// - in a reply the line just above the quote is its header ("On ... wrote:")
|
||||
// and goes with it;
|
||||
// - images inside a replaced range are put back right after the new text.
|
||||
//
|
||||
// The plain-text composer (a textarea) uses the same rules on the string,
|
||||
// and edits through the browser's own editing command, so the textarea's
|
||||
// undo still works.
|
||||
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { DOMParser as PMDOMParser, DOMSerializer, Fragment, type Node as PMNode } from '@tiptap/pm/model';
|
||||
import { TextSelection, type Transaction } from '@tiptap/pm/state';
|
||||
import { closeHistory } from '@tiptap/pm/history';
|
||||
|
||||
const ATOM_BOUNDARIES = new Set(['signatureBlock', 'quotedHtml']);
|
||||
|
||||
// The editor's history merges changes made within half a second of each
|
||||
// other into one undo step. A plugin's edit always starts a step of its own,
|
||||
// so Ctrl+Z never takes back what the user typed just before with it.
|
||||
const ownStep = (tr: Transaction) => closeHistory(tr);
|
||||
const QUOTE_NODES = new Set(['quotedHtml', 'blockquote']);
|
||||
const SIGNATURE_MARKER_ATTR = 'data-signature-block';
|
||||
|
||||
export interface OwnTextOptions {
|
||||
/** Reply or forward: the line above the quote is the quote's header. */
|
||||
replyLike?: boolean;
|
||||
/** The localised "---------- Forwarded message ----------" line. */
|
||||
forwardedSeparator?: string;
|
||||
}
|
||||
|
||||
export interface OwnRange {
|
||||
/** Start and end of the user's own text in the document. */
|
||||
from: number;
|
||||
to: number;
|
||||
/** Whether a signature or quote follows. */
|
||||
bounded: boolean;
|
||||
}
|
||||
|
||||
function isEmptyBlock(node: PMNode): boolean {
|
||||
if (node.textContent.trim()) return false;
|
||||
let hasLeaf = false;
|
||||
node.descendants((child) => {
|
||||
if (child.type.name === 'image' || child.type.name === 'table') hasLeaf = true;
|
||||
return !hasLeaf;
|
||||
});
|
||||
return !hasLeaf;
|
||||
}
|
||||
|
||||
function isBoundary(node: PMNode, opts: OwnTextOptions): boolean {
|
||||
if (ATOM_BOUNDARIES.has(node.type.name) || node.type.name === 'blockquote') return true;
|
||||
const marker = node.attrs?.[SIGNATURE_MARKER_ATTR];
|
||||
if (marker === 'separator' || marker === 'start') return true;
|
||||
if (opts.forwardedSeparator && node.textContent.trim().startsWith(opts.forwardedSeparator.trim())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Where the user's own text is in a document. */
|
||||
export function ownRange(doc: PMNode, opts: OwnTextOptions = {}): OwnRange {
|
||||
const children: { node: PMNode; pos: number }[] = [];
|
||||
doc.forEach((node, offset) => { children.push({ node, pos: offset }); });
|
||||
let boundary = children.findIndex((c) => isBoundary(c.node, opts));
|
||||
const bounded = boundary !== -1;
|
||||
if (!bounded) boundary = children.length;
|
||||
// A reply's quote header sits right above the quote.
|
||||
if (bounded && opts.replyLike && QUOTE_NODES.has(children[boundary].node.type.name)
|
||||
&& boundary > 0 && !isEmptyBlock(children[boundary - 1].node)) {
|
||||
boundary -= 1;
|
||||
}
|
||||
// Blank lines between the text and what follows stay where they are.
|
||||
let last = boundary - 1;
|
||||
while (last >= 0 && isEmptyBlock(children[last].node)) last--;
|
||||
if (last < 0) {
|
||||
// Nothing written yet: the first empty paragraph, if there is one, is
|
||||
// where the text goes.
|
||||
if (boundary > 0) {
|
||||
const first = children[0];
|
||||
return { from: first.pos, to: first.pos + first.node.nodeSize, bounded };
|
||||
}
|
||||
const at = children[boundary]?.pos ?? 0;
|
||||
return { from: at, to: at, bounded };
|
||||
}
|
||||
const end = children[last];
|
||||
return { from: 0, to: end.pos + end.node.nodeSize, bounded };
|
||||
}
|
||||
|
||||
function fragmentHtml(editor: Editor, fragment: Fragment): string {
|
||||
const serializer = DOMSerializer.fromSchema(editor.schema);
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(serializer.serializeFragment(fragment));
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
function textOf(doc: PMNode, from: number, to: number): string {
|
||||
return doc.textBetween(from, to, '\n', (leaf) => (leaf.type.name === 'hardBreak' ? '\n' : ''));
|
||||
}
|
||||
|
||||
/** The user's own text, as HTML and as plain text. */
|
||||
export function readOwnContent(editor: Editor, opts: OwnTextOptions = {}): { html: string; text: string } {
|
||||
const { doc } = editor.state;
|
||||
const { from, to } = ownRange(doc, opts);
|
||||
return { html: fragmentHtml(editor, doc.slice(from, to).content), text: textOf(doc, from, to).trim() };
|
||||
}
|
||||
|
||||
/** The selected text, as HTML and plain text, and whether it is empty. */
|
||||
export function readSelection(editor: Editor): { html: string; text: string; empty: boolean } {
|
||||
const { doc, selection } = editor.state;
|
||||
if (selection.empty) return { html: '', text: '', empty: true };
|
||||
return {
|
||||
html: fragmentHtml(editor, doc.slice(selection.from, selection.to).content),
|
||||
text: textOf(doc, selection.from, selection.to),
|
||||
empty: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Plain text a plugin sent, as editor HTML: one paragraph per line. */
|
||||
export function textToEditorHtml(text: string): string {
|
||||
const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return text.replace(/\r\n?/g, '\n').split('\n').map((line) => `<p>${escape(line)}</p>`).join('');
|
||||
}
|
||||
|
||||
function imagesIn(doc: PMNode, from: number, to: number): PMNode[] {
|
||||
const out: PMNode[] = [];
|
||||
doc.nodesBetween(from, to, (node) => {
|
||||
if (node.type.name === 'image') out.push(node);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function containsAtom(doc: PMNode, from: number, to: number): boolean {
|
||||
let found = false;
|
||||
doc.nodesBetween(from, to, (node) => {
|
||||
if (ATOM_BOUNDARIES.has(node.type.name) || (node.attrs?.[SIGNATURE_MARKER_ATTR] && node.isBlock)) found = true;
|
||||
return !found;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function parseBlocks(editor: Editor, html: string): Fragment {
|
||||
const dom = document.createElement('div');
|
||||
dom.innerHTML = html;
|
||||
return PMDOMParser.fromSchema(editor.schema).parse(dom).content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the user's own text with `html` (already sanitised), in one
|
||||
* transaction. Inline images that were in it follow the new text. Returns
|
||||
* false when the editor is gone.
|
||||
*/
|
||||
export function replaceOwnContent(editor: Editor, html: string, opts: OwnTextOptions = {}): boolean {
|
||||
if (editor.isDestroyed) return false;
|
||||
return editor.chain().focus().command(({ tr, state }) => {
|
||||
ownStep(tr);
|
||||
const { from, to } = ownRange(state.doc, opts);
|
||||
const images = imagesIn(state.doc, from, to);
|
||||
let content = parseBlocks(editor, html);
|
||||
if (images.length) {
|
||||
const paragraph = state.schema.nodes.paragraph;
|
||||
content = content.addToEnd(paragraph.create(null, images.map((img) => img.copy(img.content))));
|
||||
}
|
||||
tr.replaceWith(from, to, content);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(from + content.size, tr.doc.content.size)), -1));
|
||||
tr.scrollIntoView();
|
||||
return true;
|
||||
}).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the selection with `html` (or insert it at the caret), in one
|
||||
* transaction. Refused (false) when the selection holds the signature or a
|
||||
* quoted original as a whole: replacing it would delete them.
|
||||
*/
|
||||
export function replaceSelection(editor: Editor, html: string, { below = false }: { below?: boolean } = {}): boolean {
|
||||
if (editor.isDestroyed) return false;
|
||||
const { doc, selection } = editor.state;
|
||||
if (!below && containsAtom(doc, selection.from, selection.to)) return false;
|
||||
const images = below ? [] : imagesIn(doc, selection.from, selection.to);
|
||||
return editor.chain().focus().command(({ tr, commands }) => {
|
||||
ownStep(tr);
|
||||
if (below) {
|
||||
// After the block the selection ends in.
|
||||
const $to = tr.doc.resolve(selection.to);
|
||||
const after = $to.depth > 0 ? $to.after(1) : selection.to;
|
||||
tr.insert(after, parseBlocks(editor, html));
|
||||
return true;
|
||||
}
|
||||
return commands.insertContentAt({ from: selection.from, to: selection.to }, html, { updateSelection: true });
|
||||
}).command(({ tr }) => {
|
||||
if (!images.length) return true;
|
||||
const at = tr.selection.to;
|
||||
const $at = tr.doc.resolve(at);
|
||||
if ($at.parent.inlineContent) tr.insert(at, Fragment.from(images.map((img) => img.copy(img.content))));
|
||||
else tr.insert(at, editor.schema.nodes.paragraph.create(null, images.map((img) => img.copy(img.content))));
|
||||
return true;
|
||||
}).run();
|
||||
}
|
||||
|
||||
// ─── Plain text (the textarea composer) ───────────────────────
|
||||
|
||||
export interface PlainOwnOptions extends OwnTextOptions {
|
||||
/** The identity's signature as plain text. */
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
/** Where the user's own text is in a plain-text body. */
|
||||
export function plainOwnRange(body: string, opts: PlainOwnOptions = {}): { from: number; to: number } {
|
||||
const cut: number[] = [body.length];
|
||||
const sepMatch = /(^|\n)-- ?\n/.exec(body);
|
||||
if (sepMatch) cut.push(sepMatch.index + (sepMatch[1] ? 1 : 0));
|
||||
if (opts.signature?.trim()) {
|
||||
const at = body.indexOf(opts.signature.trim());
|
||||
if (at !== -1) cut.push(at);
|
||||
}
|
||||
if (opts.forwardedSeparator) {
|
||||
const at = body.indexOf(opts.forwardedSeparator.trim());
|
||||
if (at !== -1) cut.push(at);
|
||||
}
|
||||
const quote = /(^|\n)>/.exec(body);
|
||||
if (quote) {
|
||||
let at = quote.index + (quote[1] ? 1 : 0);
|
||||
if (opts.replyLike) {
|
||||
// The non-empty line above the quote is its header.
|
||||
const before = body.slice(0, Math.max(0, at - 1));
|
||||
const lineStart = before.lastIndexOf('\n') + 1;
|
||||
if (before.slice(lineStart).trim()) at = lineStart;
|
||||
}
|
||||
cut.push(at);
|
||||
}
|
||||
const boundary = Math.min(...cut);
|
||||
const own = body.slice(0, boundary).replace(/\s+$/, '');
|
||||
return { from: 0, to: own.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a range of a textarea through the browser's editing command, so
|
||||
* it lands in the textarea's undo history and fires `input` like typing
|
||||
* does. Falls back to setRangeText (no undo) where the command is missing.
|
||||
*/
|
||||
export function replaceTextareaRange(textarea: HTMLTextAreaElement, from: number, to: number, text: string): boolean {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(from, to);
|
||||
let done = false;
|
||||
try {
|
||||
done = typeof document.execCommand === 'function' && document.execCommand('insertText', false, text);
|
||||
} catch {
|
||||
done = false;
|
||||
}
|
||||
if (!done) {
|
||||
textarea.setRangeText(text, from, to, 'end');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Open composers that plugins may work on (api.composer.*).
|
||||
//
|
||||
// Each composer registers a handle under a random id while it is open. A
|
||||
// plugin learns the id only from the composer itself: the click context of
|
||||
// a composer action (Attach menu, toolbar, Options, Send menu) or the props
|
||||
// of a composer slot. So a plugin edits the message the user pointed it
|
||||
// at, never one it found by itself.
|
||||
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export interface ComposerContent {
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ComposerAttachment {
|
||||
blobId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ComposerHandle {
|
||||
/** The user's own text, without the signature and the quote. */
|
||||
readBody(): ComposerContent;
|
||||
readSelection(): ComposerContent & { empty: boolean };
|
||||
/** Replace the user's own text. */
|
||||
replaceBody(html: string, text: string): boolean;
|
||||
/** Replace the selection (or insert at the caret), or with `below` add after it. */
|
||||
replaceSelection(html: string, text: string, opts: { below: boolean }): boolean;
|
||||
/** Attach a blob the plugin uploaded, through the composer's usual checks. */
|
||||
attach(file: ComposerAttachment): Promise<boolean>;
|
||||
}
|
||||
|
||||
const handles = new Map<string, ComposerHandle>();
|
||||
|
||||
/** Register an open composer. Returns its id and a function that unregisters it. */
|
||||
export function registerComposer(handle: ComposerHandle): { id: string; unregister: () => void } {
|
||||
const id = generateUUID();
|
||||
handles.set(id, handle);
|
||||
return { id, unregister: () => { handles.delete(id); } };
|
||||
}
|
||||
|
||||
export function composerHandle(id: unknown): ComposerHandle | null {
|
||||
return typeof id === 'string' ? handles.get(id) ?? null : null;
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetComposers(): void {
|
||||
handles.clear();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Host side of api.composer: read and change the message being written, and
|
||||
// attach files to it. The composer id comes from the composer itself (an
|
||||
// action's click context or a composer slot's props; see
|
||||
// composer-registry.ts).
|
||||
//
|
||||
// composer.getBody(id) compose:read
|
||||
// composer.getSelection(id) compose:read
|
||||
// composer.replaceSelection(id, content, opts) compose:write
|
||||
// composer.replaceBody(id, content) compose:write
|
||||
// composer.attach(id, file) email:blob-write
|
||||
//
|
||||
// `content` is { text } or { html }. HTML goes through the same sanitiser as
|
||||
// compose.open bodies. Edits are single editor transactions (one Ctrl+Z) and
|
||||
// leave the signature, the quote and inline images in place.
|
||||
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { sanitizePluginBodyHtml } from '../email-sanitization';
|
||||
import { htmlToPlainText } from '../html-to-text';
|
||||
import { composerHandle, type ComposerHandle } from './composer-registry';
|
||||
import { textToEditorHtml } from './composer-edit';
|
||||
import { readBoolean, readId, readObject, readString, withinRate } from './host-api-input';
|
||||
|
||||
const MAX_CONTENT = 512 * 1024;
|
||||
const EDITS_PER_MINUTE = 60;
|
||||
|
||||
function handleFor(method: string, id: unknown): ComposerHandle {
|
||||
const handle = composerHandle(readId(id, method, 'composerId'));
|
||||
if (!handle) throw new Error(`${method}: that composer is not open any more`);
|
||||
return handle;
|
||||
}
|
||||
|
||||
function readContent(method: string, value: unknown): { html: string; text: string } {
|
||||
const data = readObject(value, method, 'content', ['text', 'html']);
|
||||
if ((data.text === undefined) === (data.html === undefined)) throw new Error(`${method}: content needs either text or html`);
|
||||
if (data.html !== undefined) {
|
||||
const html = sanitizePluginBodyHtml(readString(data.html, method, 'content.html', MAX_CONTENT));
|
||||
return { html, text: htmlToPlainText(html) };
|
||||
}
|
||||
const text = readString(data.text, method, 'content.text', MAX_CONTENT);
|
||||
return { html: textToEditorHtml(text), text };
|
||||
}
|
||||
|
||||
function rateLimited(method: string, plugin: InstalledPlugin): void {
|
||||
if (!withinRate('composer-edit', plugin.id, EDITS_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${EDITS_PER_MINUTE} edits a minute`);
|
||||
}
|
||||
}
|
||||
|
||||
export function doComposerGetBody(_plugin: InstalledPlugin, id: unknown): { html: string; text: string } {
|
||||
return handleFor('composer.getBody', id).readBody();
|
||||
}
|
||||
|
||||
export function doComposerGetSelection(_plugin: InstalledPlugin, id: unknown): { html: string; text: string; empty: boolean } {
|
||||
return handleFor('composer.getSelection', id).readSelection();
|
||||
}
|
||||
|
||||
export function doComposerReplaceSelection(plugin: InstalledPlugin, id: unknown, content: unknown, opts: unknown): boolean {
|
||||
const method = 'composer.replaceSelection';
|
||||
const handle = handleFor(method, id);
|
||||
const { html, text } = readContent(method, content);
|
||||
const o = readObject(opts, method, 'options', ['below'], true);
|
||||
const below = o.below === undefined ? false : readBoolean(o.below, method, 'options.below');
|
||||
rateLimited(method, plugin);
|
||||
return handle.replaceSelection(html, text, { below });
|
||||
}
|
||||
|
||||
export function doComposerReplaceBody(plugin: InstalledPlugin, id: unknown, content: unknown): boolean {
|
||||
const method = 'composer.replaceBody';
|
||||
const handle = handleFor(method, id);
|
||||
const { html, text } = readContent(method, content);
|
||||
rateLimited(method, plugin);
|
||||
return handle.replaceBody(html, text);
|
||||
}
|
||||
|
||||
export async function doComposerAttach(plugin: InstalledPlugin, id: unknown, file: unknown): Promise<boolean> {
|
||||
const method = 'composer.attach';
|
||||
const handle = handleFor(method, id);
|
||||
const data = readObject(file, method, 'file', ['blobId', 'name', 'type', 'size']);
|
||||
const blobId = readId(data.blobId, method, 'file.blobId');
|
||||
const name = readString(data.name, method, 'file.name', 255, { min: 1 });
|
||||
const type = data.type === undefined ? 'application/octet-stream' : readString(data.type, method, 'file.type', 255, { min: 1 });
|
||||
if (typeof data.size !== 'number' || !Number.isSafeInteger(data.size) || data.size < 0) {
|
||||
throw new Error(`${method}: file.size must be a whole number of bytes`);
|
||||
}
|
||||
rateLimited(method, plugin);
|
||||
return handle.attach({ blobId, name, type, size: data.size });
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
|
||||
import { doComposeOpen, doDraftsCreate } from './host-api-compose';
|
||||
import { doNotificationsShow } from './host-api-notify';
|
||||
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
|
||||
|
||||
/**
|
||||
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||
@@ -256,6 +257,13 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'drafts.create': 'email:write',
|
||||
// A system notification (or a toast), naming the plugin, 5 a minute.
|
||||
'notifications.show': 'ui:notifications',
|
||||
// The message being written, in a composer that handed the plugin its id.
|
||||
'composer.getBody': 'compose:read',
|
||||
'composer.getSelection': 'compose:read',
|
||||
'composer.replaceSelection': 'compose:write',
|
||||
'composer.replaceBody': 'compose:write',
|
||||
// Attach a file the plugin uploaded (jmap.uploadBlob).
|
||||
'composer.attach': 'email:blob-write',
|
||||
};
|
||||
|
||||
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
|
||||
@@ -2009,6 +2017,12 @@ export async function dispatchApiCall(
|
||||
|
||||
case 'notifications.show': return doNotificationsShow(plugin, args[0]);
|
||||
|
||||
case 'composer.getBody': return doComposerGetBody(plugin, args[0]);
|
||||
case 'composer.getSelection': return doComposerGetSelection(plugin, args[0]);
|
||||
case 'composer.replaceSelection': return doComposerReplaceSelection(plugin, args[0], args[1], args[2]);
|
||||
case 'composer.replaceBody': return doComposerReplaceBody(plugin, args[0], args[1]);
|
||||
case 'composer.attach': return doComposerAttach(plugin, args[0], args[1]);
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled method "${method}"`);
|
||||
}
|
||||
|
||||
@@ -450,6 +450,29 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
show: (notification: NotificationInput) =>
|
||||
callApi('notifications.show', [notification]) as Promise<'system' | 'toast'>,
|
||||
},
|
||||
// The message being written. `composerId` comes from the composer: the
|
||||
// context of a click on a composer action, or a composer slot's props.
|
||||
composer: {
|
||||
/** The user's own text (no signature, no quote) as { html, text }. Needs compose:read. */
|
||||
getBody: (composerId: string) =>
|
||||
callApi('composer.getBody', [composerId]) as Promise<{ html: string; text: string }>,
|
||||
/** The selection as { html, text, empty }. Needs compose:read. */
|
||||
getSelection: (composerId: string) =>
|
||||
callApi('composer.getSelection', [composerId]) as Promise<{ html: string; text: string; empty: boolean }>,
|
||||
/**
|
||||
* Replace the selection, or insert at the caret when nothing is
|
||||
* selected; `{ below: true }` adds after the selection instead. One
|
||||
* Ctrl+Z undoes it. Needs compose:write.
|
||||
*/
|
||||
replaceSelection: (composerId: string, content: { text: string } | { html: string }, options?: { below?: boolean }) =>
|
||||
callApi('composer.replaceSelection', [composerId, content, options]) as Promise<boolean>,
|
||||
/** Replace the user's own text; the signature and the quote stay. Needs compose:write. */
|
||||
replaceBody: (composerId: string, content: { text: string } | { html: string }) =>
|
||||
callApi('composer.replaceBody', [composerId, content]) as Promise<boolean>,
|
||||
/** Attach a blob from jmap.uploadBlob. Needs email:blob-write. */
|
||||
attach: (composerId: string, file: { blobId: string; name: string; type?: string; size: number }) =>
|
||||
callApi('composer.attach', [composerId, file]) as Promise<boolean>,
|
||||
},
|
||||
toast: {
|
||||
success: (m: string) => { void callApi('toast.success', [m]); },
|
||||
error: (m: string) => { void callApi('toast.error', [m]); },
|
||||
|
||||
@@ -85,7 +85,18 @@ function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
|
||||
if (combo.shift !== ev.shiftKey) return false;
|
||||
if (combo.alt !== ev.altKey) return false;
|
||||
if (!combo.ctrl && combo.meta !== ev.metaKey) return false;
|
||||
return ev.key.toLowerCase() === combo.key;
|
||||
if (ev.key.toLowerCase() === combo.key) return true;
|
||||
// With Alt held, macOS types another character (Alt+A is "å"): match the
|
||||
// physical key for letters and digits.
|
||||
if (/^[a-z]$/.test(combo.key)) return ev.code === `Key${combo.key.toUpperCase()}`;
|
||||
if (/^[0-9]$/.test(combo.key)) return ev.code === `Digit${combo.key}`;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a key event is the combo ("Ctrl+Shift+A"); Ctrl and Cmd count the same. */
|
||||
export function matchesCombo(keys: string, ev: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey'>): boolean {
|
||||
const combo = normaliseCombo(keys);
|
||||
return !!combo && eventMatches(ev as KeyboardEvent, combo);
|
||||
}
|
||||
|
||||
function onKeyDown(ev: KeyboardEvent): void {
|
||||
|
||||
@@ -76,14 +76,16 @@ export const SLOT_CATALOG = {
|
||||
'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'], { insertText: 'compose:open', getSelectedText: 'email:send' }, 'the composer\'s plugin button row', true),
|
||||
'composer-above-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send' }, 'between the subject and the message text'),
|
||||
'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send' }, 'below the message text, above the attachments'),
|
||||
'composer-toolbar': S('composer', 'frame', ['ui:composer-toolbar'], { insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'the composer\'s plugin row above its bottom bar, for controls that need their own UI (a button belongs in composer-toolbar-actions)', true),
|
||||
'composer-above-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, 'between the subject and the message text'),
|
||||
'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send', composerId: null }, '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, insertText: 'compose:open' }, 'the composer\'s plugin button row, next to composer-toolbar, as a source of files (for an entry in the Attach menu itself, use composer-attach-menu)', 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")'),
|
||||
'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open', composerId: null }, 'the composer\'s plugin row, next to composer-toolbar, as a source of files (older plugins; an entry in the Attach menu, composer-attach-menu, with api.composer.attach looks like the app\'s own)', true),
|
||||
'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send', composerId: null }, 'entries in the menu next to Send'),
|
||||
'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'entries in the Attach menu (for example "From Nextcloud"); the click\'s composerId lets api.composer.attach add files'),
|
||||
'composer-options-menu': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'entries in the composer\'s Options menu'),
|
||||
'composer-toolbar-actions': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'buttons in the composer\'s bottom bar, after Attach, Templates and Options; an action\'s shortcut works while the composer has focus', true),
|
||||
|
||||
// ─── Calendar ──────────────────────────────────────────────
|
||||
'calendar-event-actions': S('calendar', 'frame', ['ui:calendar-action'], { eventData: 'calendar:read', setVirtualLocation: null }, 'buttons in the event form', true),
|
||||
|
||||
@@ -1265,6 +1265,11 @@ export const ALL_PERMISSIONS = [
|
||||
// Open the composer filled in (api.compose.open). Low risk: the user still
|
||||
// has to press Send.
|
||||
'compose:open',
|
||||
// Read the message being written (api.composer.getBody/getSelection), in
|
||||
// a composer that handed the plugin its id.
|
||||
'compose:read',
|
||||
// Change the message being written (api.composer.replaceSelection/replaceBody).
|
||||
'compose:write',
|
||||
'calendar:read', 'calendar:write',
|
||||
'contacts:read', 'contacts:write',
|
||||
'files:read', 'files:write',
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "فتح محرر الرسائل لك",
|
||||
"body": "فتح رسالة جديدة بعد تعبئة المستلمين أو النص أو المرفقات. يبقى قرار إرسالها لك."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "قراءة الرسالة التي تكتبها",
|
||||
"body": "قراءة نص الرسالة أثناء كتابتها، عندما تستخدم الإضافة في محرر الرسائل. لا يشمل التوقيع ولا الرسالة المقتبسة."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "تعديل الرسالة التي تكتبها",
|
||||
"body": "استبدال نص أو إضافته في الرسالة أثناء كتابتها، عندما تستخدم الإضافة في محرر الرسائل. يمكنك التراجع عن كل تغيير، وأنت من يقرر الإرسال."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "عرض الإشعارات",
|
||||
"body": "عرض إشعارات على جهازك، أو داخل التطبيق إذا لم تسمح بها. يذكر كل إشعار اسم الإضافة."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Obrir el redactor per a vós",
|
||||
"body": "Obrir un missatge nou amb els destinataris, el text o els adjunts ja emplenats. Sou vós qui decidiu si s'envia."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Llegir el missatge que escriviu",
|
||||
"body": "Llegir el text d'un missatge mentre l'escriviu, quan feu servir el connector a l'editor. No la signatura ni el missatge citat."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Canviar el missatge que escriviu",
|
||||
"body": "Substituir o afegir text en un missatge mentre l'escriviu, quan feu servir el connector a l'editor. Podeu desfer cada canvi i continueu decidint si l'envieu."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrar notificacions",
|
||||
"body": "Mostrar notificacions al dispositiu, o dins l'aplicació si no les heu permeses. Cadascuna indica el nom del connector."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Otevírat editor zprávy",
|
||||
"body": "Otevře novou zprávu s vyplněnými příjemci, textem nebo přílohami. O odeslání stále rozhodujete vy."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Číst zprávu, kterou píšete",
|
||||
"body": "Číst text zprávy během psaní, když plugin použijete v editoru. Ne podpis ani citovanou zprávu."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Měnit zprávu, kterou píšete",
|
||||
"body": "Nahrazovat nebo přidávat text ve zprávě během psaní, když plugin použijete v editoru. Každou změnu můžete vrátit a o odeslání stále rozhodujete vy."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Zobrazovat oznámení",
|
||||
"body": "Zobrazuje oznámení ve vašem zařízení, nebo v aplikaci, pokud jste je nepovolili. Každé uvádí název pluginu."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Åbne komponisten for dig",
|
||||
"body": "Åbne en ny besked med modtagere, tekst eller vedhæftninger udfyldt. Du bestemmer stadig, om den sendes."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Læse den besked, du skriver",
|
||||
"body": "Læse teksten i en besked, mens du skriver den, når du bruger pluginet i editoren. Ikke signaturen eller den citerede besked."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Ændre den besked, du skriver",
|
||||
"body": "Erstatte eller tilføje tekst i en besked, mens du skriver den, når du bruger pluginet i editoren. Du kan fortryde hver ændring, og du bestemmer stadig, om den sendes."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Vise notifikationer",
|
||||
"body": "Vise notifikationer på din enhed eller i appen, hvis du ikke har tilladt dem. Hver af dem nævner pluginets navn."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Den Editor für Sie öffnen",
|
||||
"body": "Eine neue Nachricht mit bereits ausgefüllten Empfängern, Text oder Anhängen öffnen. Ob sie gesendet wird, entscheiden weiterhin Sie."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Die Nachricht lesen, die Sie schreiben",
|
||||
"body": "Den Text einer Nachricht lesen, während Sie sie schreiben, wenn Sie das Plugin im Editor benutzen. Nicht die Signatur und nicht die zitierte Nachricht."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Die Nachricht ändern, die Sie schreiben",
|
||||
"body": "Text in einer Nachricht ersetzen oder ergänzen, während Sie sie schreiben, wenn Sie das Plugin im Editor benutzen. Jede Änderung lässt sich rückgängig machen, und Sie entscheiden weiterhin, ob Sie senden."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Benachrichtigungen anzeigen",
|
||||
"body": "Benachrichtigungen auf Ihrem Gerät anzeigen oder in der App, wenn Sie sie nicht erlaubt haben. Jede nennt den Namen des Plugins."
|
||||
|
||||
@@ -4281,6 +4281,14 @@
|
||||
"title": "Open the composer for you",
|
||||
"body": "Open a new message with recipients, text or attachments filled in. You still decide whether to send it."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Read the message you are writing",
|
||||
"body": "Read the text of a message while you write it, when you use the plugin in the composer. Not the signature or the quoted message."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Change the message you are writing",
|
||||
"body": "Replace or add text in a message while you write it, when you use the plugin in the composer. You can undo each change, and you still decide whether to send."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Show notifications",
|
||||
"body": "Show notifications on your device, or in the app if you haven't allowed them. Each one names the plugin."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Abrir el editor por ti",
|
||||
"body": "Abrir un mensaje nuevo con los destinatarios, el texto o los adjuntos ya rellenados. Tú sigues decidiendo si se envía."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Leer el mensaje que escribes",
|
||||
"body": "Leer el texto de un mensaje mientras lo escribes, cuando usas el complemento en el editor. No la firma ni el mensaje citado."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Cambiar el mensaje que escribes",
|
||||
"body": "Sustituir o añadir texto en un mensaje mientras lo escribes, cuando usas el complemento en el editor. Puedes deshacer cada cambio y sigues decidiendo si lo envías."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrar notificaciones",
|
||||
"body": "Mostrar notificaciones en tu dispositivo, o dentro de la aplicación si no las has permitido. Cada una indica el nombre del complemento."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "باز کردن ویرایشگر برای شما",
|
||||
"body": "باز کردن پیامی تازه با گیرندگان، متن یا پیوستهای ازپیشپرشده. تصمیم ارسال همچنان با شماست."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "خواندن پیامی که مینویسید",
|
||||
"body": "خواندن متن پیام هنگام نوشتن آن، وقتی افزونه را در ویرایشگر به کار میبرید. امضا و پیام نقلشده را نمیخواند."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "تغییر پیامی که مینویسید",
|
||||
"body": "جایگزینی یا افزودن متن در پیام هنگام نوشتن آن، وقتی افزونه را در ویرایشگر به کار میبرید. هر تغییر را میتوانید برگردانید و تصمیم ارسال همچنان با شماست."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "نمایش اعلانها",
|
||||
"body": "نمایش اعلان روی دستگاه شما، یا داخل برنامه اگر اجازه اعلان ندادهاید. هر اعلان نام افزونه را نشان میدهد."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Ouvrir l'éditeur pour vous",
|
||||
"body": "Ouvrir un nouveau message avec les destinataires, le texte ou les pièces jointes déjà remplis. C'est toujours vous qui décidez de l'envoyer."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Lire le message que vous écrivez",
|
||||
"body": "Lire le texte d'un message pendant que vous l'écrivez, quand vous utilisez le plugin dans l'éditeur. Ni la signature ni le message cité."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Modifier le message que vous écrivez",
|
||||
"body": "Remplacer ou ajouter du texte dans un message pendant que vous l'écrivez, quand vous utilisez le plugin dans l'éditeur. Chaque modification peut être annulée, et c'est toujours vous qui décidez de l'envoyer."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Afficher des notifications",
|
||||
"body": "Afficher des notifications sur votre appareil, ou dans l'application si vous ne les avez pas autorisées. Chacune indique le nom du plugin."
|
||||
|
||||
@@ -4254,6 +4254,14 @@
|
||||
"title": "פתיחת העורך עבורכם",
|
||||
"body": "פתיחת הודעה חדשה עם נמענים, טקסט או קבצים מצורפים שכבר מולאו. ההחלטה אם לשלוח אותה נשארת שלכם."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "קריאת ההודעה שאתם כותבים",
|
||||
"body": "קריאת הטקסט של הודעה בזמן הכתיבה, כשמשתמשים בתוסף בעורך. לא החתימה ולא ההודעה המצוטטת."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "שינוי ההודעה שאתם כותבים",
|
||||
"body": "החלפה או הוספה של טקסט בהודעה בזמן הכתיבה, כשמשתמשים בתוסף בעורך. אפשר לבטל כל שינוי, וההחלטה אם לשלוח נשארת שלכם."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "הצגת התראות",
|
||||
"body": "הצגת התראות במכשיר שלכם, או בתוך האפליקציה אם לא אישרתם התראות. כל התראה מציינת את שם התוסף."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "A szerkesztő megnyitása",
|
||||
"body": "Új üzenetet nyit meg kitöltött címzettekkel, szöveggel vagy mellékletekkel. Az elküldésről továbbra is te döntesz."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Az éppen írt üzeneted olvasása",
|
||||
"body": "Az üzenet szövegének olvasása írás közben, amikor a bővítményt a szerkesztőben használod. Az aláírást és az idézett üzenetet nem."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Az éppen írt üzeneted módosítása",
|
||||
"body": "Szöveg cseréje vagy hozzáadása az üzenetben írás közben, amikor a bővítményt a szerkesztőben használod. Minden módosítás visszavonható, és továbbra is te döntöd el, elküldöd-e."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Értesítések megjelenítése",
|
||||
"body": "Értesítéseket jelenít meg az eszközödön, vagy az alkalmazásban, ha nem engedélyezted őket. Mindegyiken szerepel a bővítmény neve."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Aprire l'editor per te",
|
||||
"body": "Aprire un nuovo messaggio con destinatari, testo o allegati già compilati. Sei sempre tu a decidere se inviarlo."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Leggere il messaggio che stai scrivendo",
|
||||
"body": "Leggere il testo di un messaggio mentre lo scrivi, quando usi il plugin nell'editor. Non la firma né il messaggio citato."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Modificare il messaggio che stai scrivendo",
|
||||
"body": "Sostituire o aggiungere testo in un messaggio mentre lo scrivi, quando usi il plugin nell'editor. Puoi annullare ogni modifica e decidi sempre tu se inviarlo."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrare notifiche",
|
||||
"body": "Mostrare notifiche sul tuo dispositivo, o nell'app se non le hai consentite. Ognuna indica il nome del plugin."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "作成画面を開く",
|
||||
"body": "宛先、本文、添付ファイルを入力した状態で新しいメッセージを開きます。送信するかどうかはあなたが決めます。"
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "作成中のメッセージを読む",
|
||||
"body": "エディターでプラグインを使ったときに、作成中のメッセージの本文を読みます。署名と引用されたメッセージは含みません。"
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "作成中のメッセージを変更する",
|
||||
"body": "エディターでプラグインを使ったときに、作成中のメッセージのテキストを置き換えたり追加したりします。変更はひとつずつ元に戻せ、送信するかどうかは引き続きあなたが決めます。"
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "通知を表示",
|
||||
"body": "デバイスに通知を表示します。通知を許可していない場合はアプリ内に表示します。どの通知にもプラグイン名が入ります。"
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "작성 창 열기",
|
||||
"body": "받는 사람, 본문, 첨부 파일이 채워진 새 메시지를 열어요. 보낼지는 직접 결정해요."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "작성 중인 메시지 읽기",
|
||||
"body": "편집기에서 플러그인을 사용할 때 작성 중인 메시지의 본문을 읽어요. 서명과 인용된 메시지는 읽지 않아요."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "작성 중인 메시지 변경",
|
||||
"body": "편집기에서 플러그인을 사용할 때 작성 중인 메시지의 텍스트를 바꾸거나 추가해요. 각 변경은 되돌릴 수 있고, 보낼지는 여전히 직접 결정해요."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "알림 표시",
|
||||
"body": "기기에 알림을 표시하고, 알림을 허용하지 않았다면 앱 안에 표시해요. 모든 알림에 플러그인 이름이 나와요."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Atvērt redaktoru jūsu vietā",
|
||||
"body": "Atvērt jaunu vēstuli ar jau aizpildītiem adresātiem, tekstu vai pielikumiem. Vai to nosūtīt, joprojām izlemjat jūs."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Lasīt ziņojumu, ko rakstāt",
|
||||
"body": "Lasīt ziņojuma tekstu rakstīšanas laikā, kad izmantojat spraudni redaktorā. Ne parakstu, ne citēto ziņojumu."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Mainīt ziņojumu, ko rakstāt",
|
||||
"body": "Aizstāt vai pievienot tekstu ziņojumā rakstīšanas laikā, kad izmantojat spraudni redaktorā. Katru izmaiņu var atsaukt, un jūs joprojām izlemjat, vai sūtīt."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Rādīt paziņojumus",
|
||||
"body": "Rādīt paziņojumus jūsu ierīcē vai lietotnē, ja neesat tos atļāvis. Katrā ir spraudņa nosaukums."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Имэйл бичих цонх нээх",
|
||||
"body": "Хүлээн авагч, текст эсвэл хавсралтыг нь бөглөсөн шинэ имэйл нээх. Илгээх эсэхийг та өөрөө шийднэ."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Бичиж буй захидлаа унших",
|
||||
"body": "Засварлагч дээр плагиныг ашиглах үед бичиж буй захидлын текстийг уншина. Гарын үсэг болон иш татсан захидлыг уншихгүй."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Бичиж буй захидлаа өөрчлөх",
|
||||
"body": "Засварлагч дээр плагиныг ашиглах үед бичиж буй захидлын текстийг солих эсвэл нэмэх. Өөрчлөлт бүрийг буцааж болох бөгөөд илгээх эсэхийг та өөрөө шийднэ."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Мэдэгдэл харуулах",
|
||||
"body": "Таны төхөөрөмж дээр мэдэгдэл харуулах, эсвэл мэдэгдлийг зөвшөөрөөгүй бол аппын дотор харуулах. Мэдэгдэл бүр залгаасын нэрийг заана."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Åpne meldingsvinduet for deg",
|
||||
"body": "Åpne en ny melding med mottakere, tekst eller vedlegg fylt ut. Du bestemmer fortsatt om den skal sendes."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Lese meldingen du skriver",
|
||||
"body": "Lese teksten i en melding mens du skriver den, når du bruker tillegget i redigeringsvinduet. Ikke signaturen eller den siterte meldingen."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Endre meldingen du skriver",
|
||||
"body": "Erstatte eller legge til tekst i en melding mens du skriver den, når du bruker tillegget i redigeringsvinduet. Du kan angre hver endring, og du bestemmer fortsatt om den skal sendes."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Vise varsler",
|
||||
"body": "Vise varsler på enheten din, eller i appen hvis du ikke har tillatt dem. Hvert varsel viser navnet på tillegget."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "De opsteller voor je openen",
|
||||
"body": "Een nieuw bericht openen met ontvangers, tekst of bijlagen al ingevuld. Jij beslist nog steeds of het wordt verzonden."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Het bericht lezen dat je schrijft",
|
||||
"body": "De tekst van een bericht lezen terwijl je het schrijft, als je de plug-in in de editor gebruikt. Niet de handtekening of het geciteerde bericht."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Het bericht wijzigen dat je schrijft",
|
||||
"body": "Tekst in een bericht vervangen of toevoegen terwijl je het schrijft, als je de plug-in in de editor gebruikt. Je kunt elke wijziging ongedaan maken en je beslist nog steeds zelf of je het verstuurt."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Meldingen tonen",
|
||||
"body": "Meldingen op je apparaat tonen, of in de app als je ze niet hebt toegestaan. Elke melding noemt de naam van de plug-in."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Otwieranie edytora wiadomości",
|
||||
"body": "Otwiera nową wiadomość z wypełnionymi odbiorcami, treścią lub załącznikami. O wysłaniu nadal decydujesz Ty."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Czytanie pisanej wiadomości",
|
||||
"body": "Czytanie tekstu wiadomości podczas pisania, gdy używasz wtyczki w edytorze. Bez podpisu i cytowanej wiadomości."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Zmiana pisanej wiadomości",
|
||||
"body": "Zastępowanie lub dodawanie tekstu w wiadomości podczas pisania, gdy używasz wtyczki w edytorze. Każdą zmianę możesz cofnąć, a o wysłaniu nadal decydujesz Ty."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Wyświetlanie powiadomień",
|
||||
"body": "Wyświetla powiadomienia na urządzeniu lub w aplikacji, jeśli ich nie zezwolono. Każde podaje nazwę wtyczki."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Abrir o editor para você",
|
||||
"body": "Abrir uma nova mensagem com destinatários, texto ou anexos já preenchidos. Você continua decidindo se ela é enviada."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Ler a mensagem que você está escrevendo",
|
||||
"body": "Ler o texto de uma mensagem enquanto você a escreve, quando usa o plugin no editor. Não a assinatura nem a mensagem citada."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Alterar a mensagem que você está escrevendo",
|
||||
"body": "Substituir ou adicionar texto em uma mensagem enquanto você a escreve, quando usa o plugin no editor. Você pode desfazer cada alteração e continua decidindo se envia."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrar notificações",
|
||||
"body": "Mostrar notificações no seu dispositivo, ou dentro do aplicativo se você não as permitiu. Cada uma indica o nome do plugin."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Deschiderea ferestrei de redactare",
|
||||
"body": "Deschide un mesaj nou cu destinatarii, textul sau atașamentele deja completate. Dvs. decideți în continuare dacă îl trimiteți."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Citirea mesajului pe care îl scrieți",
|
||||
"body": "Citirea textului unui mesaj în timp ce îl scrieți, când folosiți pluginul în editor. Nu și semnătura sau mesajul citat."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Modificarea mesajului pe care îl scrieți",
|
||||
"body": "Înlocuirea sau adăugarea de text într-un mesaj în timp ce îl scrieți, când folosiți pluginul în editor. Puteți anula fiecare modificare și decideți în continuare dacă îl trimiteți."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Afișarea notificărilor",
|
||||
"body": "Afișează notificări pe dispozitiv sau în aplicație, dacă nu le-ați permis. Fiecare indică numele pluginului."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Открывать редактор письма",
|
||||
"body": "Открывать новое письмо с уже заполненными получателями, текстом или вложениями. Отправлять ли его, по-прежнему решаете вы."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Читать письмо, которое вы пишете",
|
||||
"body": "Читать текст письма, пока вы его пишете, когда вы используете плагин в редакторе. Без подписи и цитируемого письма."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Изменять письмо, которое вы пишете",
|
||||
"body": "Заменять или добавлять текст в письме, пока вы его пишете, когда вы используете плагин в редакторе. Каждое изменение можно отменить, а отправлять ли письмо, решаете вы."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Показывать уведомления",
|
||||
"body": "Показывать уведомления на вашем устройстве или в приложении, если вы их не разрешили. В каждом указано название плагина."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Otvárať editor správy",
|
||||
"body": "Otvorí novú správu s vyplnenými príjemcami, textom alebo prílohami. O odoslaní stále rozhodujete vy."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Čítať správu, ktorú píšete",
|
||||
"body": "Čítať text správy počas písania, keď doplnok použijete v editore. Nie podpis ani citovanú správu."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Meniť správu, ktorú píšete",
|
||||
"body": "Nahrádzať alebo pridávať text v správe počas písania, keď doplnok použijete v editore. Každú zmenu môžete vrátiť a o odoslaní stále rozhodujete vy."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Zobrazovať upozornenia",
|
||||
"body": "Zobrazuje upozornenia vo vašom zariadení alebo v aplikácii, ak ste ich nepovolili. Každé uvádza názov doplnku."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "İleti düzenleyiciyi açma",
|
||||
"body": "Alıcıları, metni veya ekleri doldurulmuş yeni bir ileti açma. Gönderip göndermemeye yine siz karar verirsiniz."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Yazdığınız iletiyi okuma",
|
||||
"body": "Eklentiyi düzenleyicide kullandığınızda, yazarken iletinin metnini okur. İmzayı ve alıntılanan iletiyi okumaz."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Yazdığınız iletiyi değiştirme",
|
||||
"body": "Eklentiyi düzenleyicide kullandığınızda, yazarken iletide metni değiştirir veya ekler. Her değişikliği geri alabilirsiniz ve göndermeye yine siz karar verirsiniz."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Bildirim gösterme",
|
||||
"body": "Cihazınızda bildirim gösterme ya da bildirimlere izin vermediyseniz uygulama içinde gösterme. Her bildirimde eklentinin adı yer alır."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "Відкривати редактор листа",
|
||||
"body": "Відкривати новий лист із уже заповненими одержувачами, текстом або вкладеннями. Надсилати його чи ні, як і раніше, вирішуєте ви."
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "Читати лист, який ви пишете",
|
||||
"body": "Читати текст листа, поки ви його пишете, коли ви користуєтеся плагіном у редакторі. Без підпису та цитованого листа."
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "Змінювати лист, який ви пишете",
|
||||
"body": "Замінювати або додавати текст у листі, поки ви його пишете, коли ви користуєтеся плагіном у редакторі. Кожну зміну можна скасувати, а надсилати чи ні, вирішуєте ви."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Показувати сповіщення",
|
||||
"body": "Показувати сповіщення на вашому пристрої або в програмі, якщо ви їх не дозволили. У кожному вказано назву плагіна."
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "為您開啟撰寫視窗",
|
||||
"body": "開啟一封已填好收件者、內文或附件的新郵件。是否傳送仍由您決定。"
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "讀取您正在撰寫的郵件",
|
||||
"body": "在編輯器中使用外掛程式時,讀取您正在撰寫的郵件內文。不包括簽名和引用的郵件。"
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "修改您正在撰寫的郵件",
|
||||
"body": "在編輯器中使用外掛程式時,取代或新增您正在撰寫的郵件中的文字。每次修改都可以復原,是否寄出仍由您決定。"
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "顯示通知",
|
||||
"body": "在您的裝置上顯示通知;如果您未允許通知,則在應用程式內顯示。每則通知都會註明外掛程式名稱。"
|
||||
|
||||
@@ -4253,6 +4253,14 @@
|
||||
"title": "为您打开撰写窗口",
|
||||
"body": "打开一封已填好收件人、正文或附件的新邮件。是否发送仍由您决定。"
|
||||
},
|
||||
"compose_read": {
|
||||
"title": "读取您正在写的邮件",
|
||||
"body": "在编辑器中使用插件时,读取您正在写的邮件正文。不包括签名和引用的邮件。"
|
||||
},
|
||||
"compose_write": {
|
||||
"title": "修改您正在写的邮件",
|
||||
"body": "在编辑器中使用插件时,替换或添加您正在写的邮件中的文字。每次修改都可以撤销,是否发送仍由您决定。"
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "显示通知",
|
||||
"body": "在您的设备上显示通知;如果您未允许通知,则在应用内显示。每条通知都会注明插件名称。"
|
||||
|
||||
Reference in New Issue
Block a user