diff --git a/components/__tests__/keyboard-shortcuts-palette.test.tsx b/components/__tests__/keyboard-shortcuts-palette.test.tsx index 87567955a..b4e6c02ed 100644 --- a/components/__tests__/keyboard-shortcuts-palette.test.tsx +++ b/components/__tests__/keyboard-shortcuts-palette.test.tsx @@ -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( {}} />); + expect(screen.getByText('Assistant (Writing assistant)')).toBeInTheDocument(); + expect(screen.getByText('Ctrl+Shift+Y')).toBeInTheDocument(); + expect(screen.queryByText(/No key/)).toBeNull(); + act(() => resetActions()); + }); }); diff --git a/components/email/__tests__/composer-plugin-edit.test.tsx b/components/email/__tests__/composer-plugin-edit.test.tsx new file mode 100644 index 000000000..1d4c5dfa6 --- /dev/null +++ b/components/email/__tests__/composer-plugin-edit.test.tsx @@ -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) => 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) => 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) => 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) => 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) => 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) => 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) => 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()), + 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('

Alice Example
ACME Ltd.

'); +const QUOTE = buildQuotedHtmlBlock('

Can you send the photo?

'); +const BODY = + '

Hello Bob,

here it is photo

' + + SIGNATURE + + '

On Monday, Bob wrote:

' + + QUOTE; + +const DRAFT = { + to: 'Bob Builder ', + 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[] = []; + 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(); + 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; + const doc = document as unknown as Record; + 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('

Hi Bob, attached.

', '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('

Thanks, that works.

On Monday, Bob wrote:

' + QUOTE); + expect(handle.readBody().text).toBe('Thanks, that works.'); + act(() => { handle.replaceBody('

Thank you, that works for me.

', '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('

Hi Bob, attached.

', '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('

Dear Bob,

', '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('

x

', '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('

Suggested: Hi Bob

', '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(); + }); +}); diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index eda652956..34b0ad4d9 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -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(); }); diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index ffa1f510e..7b09d5f13 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -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 }) { + 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 ( + + ); + })} + + ); +} + /** 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); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index beaae874d..df1e789c1 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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({ 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(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')} )} - + } ) : ( @@ -3997,7 +4078,7 @@ export function EmailComposer({ activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length} testId="composer-options-menu" > - {() => <> + {(close) => <> )} + } + {/* Right side - Discard + Send (desktop) */} diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 50bdea13e..95bf049c5 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -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) => ( + + ))} diff --git a/lib/__tests__/plugin-host-composer.test.ts b/lib/__tests__/plugin-host-composer.test.ts new file mode 100644 index 000000000..38196c56a --- /dev/null +++ b/lib/__tests__/plugin-host-composer.test.ts @@ -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: '

Hello

', 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: '

Hello

', 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', '

Dear Bob,

thanks

', '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: 'not bold' }); + await call(WRITE, 'composer.replaceBody', id, { html: '

Hi

' }); + expect(handle.calls[0][1]).toBe('

<b>not bold</b>

'); + 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: '

a

' })).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(); + } + }); +}); diff --git a/lib/__tests__/plugin-permission-text.test.ts b/lib/__tests__/plugin-permission-text.test.ts new file mode 100644 index 000000000..64aa1ed4c --- /dev/null +++ b/lib/__tests__/plugin-permission-text.test.ts @@ -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(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([]); + }); +}); diff --git a/lib/plugin-sandbox/actions.ts b/lib/plugin-sandbox/actions.ts index 0538826e6..ef5f81b9e 100644 --- a/lib/plugin-sandbox/actions.ts +++ b/lib/plugin-sandbox/actions.ts @@ -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; }); } diff --git a/lib/plugin-sandbox/composer-edit.ts b/lib/plugin-sandbox/composer-edit.ts new file mode 100644 index 000000000..595129d42 --- /dev/null +++ b/lib/plugin-sandbox/composer-edit.ts @@ -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, '>'); + return text.replace(/\r\n?/g, '\n').split('\n').map((line) => `

${escape(line)}

`).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; +} diff --git a/lib/plugin-sandbox/composer-registry.ts b/lib/plugin-sandbox/composer-registry.ts new file mode 100644 index 000000000..16062e5c2 --- /dev/null +++ b/lib/plugin-sandbox/composer-registry.ts @@ -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; +} + +const handles = new Map(); + +/** 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(); +} diff --git a/lib/plugin-sandbox/host-api-composer.ts b/lib/plugin-sandbox/host-api-composer.ts new file mode 100644 index 000000000..42e4fb847 --- /dev/null +++ b/lib/plugin-sandbox/host-api-composer.ts @@ -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 { + 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 }); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index c1767e787..2fb2c01d8 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -52,6 +52,7 @@ import { import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input'; import { doComposeOpen, 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> = { '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}"`); } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 7c5f51da4..78e5d2fdb 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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, + /** 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, + /** 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, + }, toast: { success: (m: string) => { void callApi('toast.success', [m]); }, error: (m: string) => { void callApi('toast.error', [m]); }, diff --git a/lib/plugin-sandbox/shortcuts.ts b/lib/plugin-sandbox/shortcuts.ts index 3e69ce5e7..a75d48dc0 100644 --- a/lib/plugin-sandbox/shortcuts.ts +++ b/lib/plugin-sandbox/shortcuts.ts @@ -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): boolean { + const combo = normaliseCombo(keys); + return !!combo && eventMatches(ev as KeyboardEvent, combo); } function onKeyDown(ev: KeyboardEvent): void { diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index f6e307bdf..e0b9354b4 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -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), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index acdad7511..f16ab8500 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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', diff --git a/locales/ar/common.json b/locales/ar/common.json index f96c77655..a2cdc5cd6 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4253,6 +4253,14 @@ "title": "فتح محرر الرسائل لك", "body": "فتح رسالة جديدة بعد تعبئة المستلمين أو النص أو المرفقات. يبقى قرار إرسالها لك." }, + "compose_read": { + "title": "قراءة الرسالة التي تكتبها", + "body": "قراءة نص الرسالة أثناء كتابتها، عندما تستخدم الإضافة في محرر الرسائل. لا يشمل التوقيع ولا الرسالة المقتبسة." + }, + "compose_write": { + "title": "تعديل الرسالة التي تكتبها", + "body": "استبدال نص أو إضافته في الرسالة أثناء كتابتها، عندما تستخدم الإضافة في محرر الرسائل. يمكنك التراجع عن كل تغيير، وأنت من يقرر الإرسال." + }, "ui_notifications": { "title": "عرض الإشعارات", "body": "عرض إشعارات على جهازك، أو داخل التطبيق إذا لم تسمح بها. يذكر كل إشعار اسم الإضافة." diff --git a/locales/ca/common.json b/locales/ca/common.json index e512b034f..eccc0aa81 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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." diff --git a/locales/cs/common.json b/locales/cs/common.json index 0b33ef4ff..f899dbddb 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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." diff --git a/locales/da/common.json b/locales/da/common.json index 3c2f02294..76dfe73a1 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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." diff --git a/locales/de/common.json b/locales/de/common.json index 180a64c17..9dea6dbff 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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." diff --git a/locales/en/common.json b/locales/en/common.json index 1567a43fd..4e9de0c46 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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." diff --git a/locales/es/common.json b/locales/es/common.json index cda300ba3..02702762f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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." diff --git a/locales/fa/common.json b/locales/fa/common.json index b9b75bb3d..bf01060a4 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4253,6 +4253,14 @@ "title": "باز کردن ویرایشگر برای شما", "body": "باز کردن پیامی تازه با گیرندگان، متن یا پیوست‌های ازپیش‌پرشده. تصمیم ارسال همچنان با شماست." }, + "compose_read": { + "title": "خواندن پیامی که می‌نویسید", + "body": "خواندن متن پیام هنگام نوشتن آن، وقتی افزونه را در ویرایشگر به کار می‌برید. امضا و پیام نقل‌شده را نمی‌خواند." + }, + "compose_write": { + "title": "تغییر پیامی که می‌نویسید", + "body": "جایگزینی یا افزودن متن در پیام هنگام نوشتن آن، وقتی افزونه را در ویرایشگر به کار می‌برید. هر تغییر را می‌توانید برگردانید و تصمیم ارسال همچنان با شماست." + }, "ui_notifications": { "title": "نمایش اعلان‌ها", "body": "نمایش اعلان روی دستگاه شما، یا داخل برنامه اگر اجازه اعلان نداده‌اید. هر اعلان نام افزونه را نشان می‌دهد." diff --git a/locales/fr/common.json b/locales/fr/common.json index f1345cb54..4c64e430b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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." diff --git a/locales/he/common.json b/locales/he/common.json index 7cc551539..0053a6516 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -4254,6 +4254,14 @@ "title": "פתיחת העורך עבורכם", "body": "פתיחת הודעה חדשה עם נמענים, טקסט או קבצים מצורפים שכבר מולאו. ההחלטה אם לשלוח אותה נשארת שלכם." }, + "compose_read": { + "title": "קריאת ההודעה שאתם כותבים", + "body": "קריאת הטקסט של הודעה בזמן הכתיבה, כשמשתמשים בתוסף בעורך. לא החתימה ולא ההודעה המצוטטת." + }, + "compose_write": { + "title": "שינוי ההודעה שאתם כותבים", + "body": "החלפה או הוספה של טקסט בהודעה בזמן הכתיבה, כשמשתמשים בתוסף בעורך. אפשר לבטל כל שינוי, וההחלטה אם לשלוח נשארת שלכם." + }, "ui_notifications": { "title": "הצגת התראות", "body": "הצגת התראות במכשיר שלכם, או בתוך האפליקציה אם לא אישרתם התראות. כל התראה מציינת את שם התוסף." diff --git a/locales/hu/common.json b/locales/hu/common.json index 10926c508..e194011f2 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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." diff --git a/locales/it/common.json b/locales/it/common.json index 1675350ee..d114bf833 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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." diff --git a/locales/ja/common.json b/locales/ja/common.json index 8c59ba831..5a844c867 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4253,6 +4253,14 @@ "title": "作成画面を開く", "body": "宛先、本文、添付ファイルを入力した状態で新しいメッセージを開きます。送信するかどうかはあなたが決めます。" }, + "compose_read": { + "title": "作成中のメッセージを読む", + "body": "エディターでプラグインを使ったときに、作成中のメッセージの本文を読みます。署名と引用されたメッセージは含みません。" + }, + "compose_write": { + "title": "作成中のメッセージを変更する", + "body": "エディターでプラグインを使ったときに、作成中のメッセージのテキストを置き換えたり追加したりします。変更はひとつずつ元に戻せ、送信するかどうかは引き続きあなたが決めます。" + }, "ui_notifications": { "title": "通知を表示", "body": "デバイスに通知を表示します。通知を許可していない場合はアプリ内に表示します。どの通知にもプラグイン名が入ります。" diff --git a/locales/ko/common.json b/locales/ko/common.json index ae2a13357..eb0f59305 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4253,6 +4253,14 @@ "title": "작성 창 열기", "body": "받는 사람, 본문, 첨부 파일이 채워진 새 메시지를 열어요. 보낼지는 직접 결정해요." }, + "compose_read": { + "title": "작성 중인 메시지 읽기", + "body": "편집기에서 플러그인을 사용할 때 작성 중인 메시지의 본문을 읽어요. 서명과 인용된 메시지는 읽지 않아요." + }, + "compose_write": { + "title": "작성 중인 메시지 변경", + "body": "편집기에서 플러그인을 사용할 때 작성 중인 메시지의 텍스트를 바꾸거나 추가해요. 각 변경은 되돌릴 수 있고, 보낼지는 여전히 직접 결정해요." + }, "ui_notifications": { "title": "알림 표시", "body": "기기에 알림을 표시하고, 알림을 허용하지 않았다면 앱 안에 표시해요. 모든 알림에 플러그인 이름이 나와요." diff --git a/locales/lv/common.json b/locales/lv/common.json index 9522e9435..34ae3d488 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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." diff --git a/locales/mn/common.json b/locales/mn/common.json index 31250393c..a00f00815 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4253,6 +4253,14 @@ "title": "Имэйл бичих цонх нээх", "body": "Хүлээн авагч, текст эсвэл хавсралтыг нь бөглөсөн шинэ имэйл нээх. Илгээх эсэхийг та өөрөө шийднэ." }, + "compose_read": { + "title": "Бичиж буй захидлаа унших", + "body": "Засварлагч дээр плагиныг ашиглах үед бичиж буй захидлын текстийг уншина. Гарын үсэг болон иш татсан захидлыг уншихгүй." + }, + "compose_write": { + "title": "Бичиж буй захидлаа өөрчлөх", + "body": "Засварлагч дээр плагиныг ашиглах үед бичиж буй захидлын текстийг солих эсвэл нэмэх. Өөрчлөлт бүрийг буцааж болох бөгөөд илгээх эсэхийг та өөрөө шийднэ." + }, "ui_notifications": { "title": "Мэдэгдэл харуулах", "body": "Таны төхөөрөмж дээр мэдэгдэл харуулах, эсвэл мэдэгдлийг зөвшөөрөөгүй бол аппын дотор харуулах. Мэдэгдэл бүр залгаасын нэрийг заана." diff --git a/locales/nb/common.json b/locales/nb/common.json index fa4ed0c9a..4b4258b96 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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." diff --git a/locales/nl/common.json b/locales/nl/common.json index 66fb34135..ed1464ca1 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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." diff --git a/locales/pl/common.json b/locales/pl/common.json index f8607e2ed..e65a28bad 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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." diff --git a/locales/pt/common.json b/locales/pt/common.json index b60d654f1..bae77cd9d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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." diff --git a/locales/ro/common.json b/locales/ro/common.json index 4d33b2baf..f1eb05888 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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." diff --git a/locales/ru/common.json b/locales/ru/common.json index fa2c5c92d..51fc6cb43 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4253,6 +4253,14 @@ "title": "Открывать редактор письма", "body": "Открывать новое письмо с уже заполненными получателями, текстом или вложениями. Отправлять ли его, по-прежнему решаете вы." }, + "compose_read": { + "title": "Читать письмо, которое вы пишете", + "body": "Читать текст письма, пока вы его пишете, когда вы используете плагин в редакторе. Без подписи и цитируемого письма." + }, + "compose_write": { + "title": "Изменять письмо, которое вы пишете", + "body": "Заменять или добавлять текст в письме, пока вы его пишете, когда вы используете плагин в редакторе. Каждое изменение можно отменить, а отправлять ли письмо, решаете вы." + }, "ui_notifications": { "title": "Показывать уведомления", "body": "Показывать уведомления на вашем устройстве или в приложении, если вы их не разрешили. В каждом указано название плагина." diff --git a/locales/sk/common.json b/locales/sk/common.json index f3424e1fe..bf789b23d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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." diff --git a/locales/tr/common.json b/locales/tr/common.json index 66adf0df9..4b84a8b42 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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." diff --git a/locales/uk/common.json b/locales/uk/common.json index ee8d82773..178b5ab87 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4253,6 +4253,14 @@ "title": "Відкривати редактор листа", "body": "Відкривати новий лист із уже заповненими одержувачами, текстом або вкладеннями. Надсилати його чи ні, як і раніше, вирішуєте ви." }, + "compose_read": { + "title": "Читати лист, який ви пишете", + "body": "Читати текст листа, поки ви його пишете, коли ви користуєтеся плагіном у редакторі. Без підпису та цитованого листа." + }, + "compose_write": { + "title": "Змінювати лист, який ви пишете", + "body": "Замінювати або додавати текст у листі, поки ви його пишете, коли ви користуєтеся плагіном у редакторі. Кожну зміну можна скасувати, а надсилати чи ні, вирішуєте ви." + }, "ui_notifications": { "title": "Показувати сповіщення", "body": "Показувати сповіщення на вашому пристрої або в програмі, якщо ви їх не дозволили. У кожному вказано назву плагіна." diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 19289cce2..5dcdfd143 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4253,6 +4253,14 @@ "title": "為您開啟撰寫視窗", "body": "開啟一封已填好收件者、內文或附件的新郵件。是否傳送仍由您決定。" }, + "compose_read": { + "title": "讀取您正在撰寫的郵件", + "body": "在編輯器中使用外掛程式時,讀取您正在撰寫的郵件內文。不包括簽名和引用的郵件。" + }, + "compose_write": { + "title": "修改您正在撰寫的郵件", + "body": "在編輯器中使用外掛程式時,取代或新增您正在撰寫的郵件中的文字。每次修改都可以復原,是否寄出仍由您決定。" + }, "ui_notifications": { "title": "顯示通知", "body": "在您的裝置上顯示通知;如果您未允許通知,則在應用程式內顯示。每則通知都會註明外掛程式名稱。" diff --git a/locales/zh/common.json b/locales/zh/common.json index ad3c0b415..054f17f8d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4253,6 +4253,14 @@ "title": "为您打开撰写窗口", "body": "打开一封已填好收件人、正文或附件的新邮件。是否发送仍由您决定。" }, + "compose_read": { + "title": "读取您正在写的邮件", + "body": "在编辑器中使用插件时,读取您正在写的邮件正文。不包括签名和引用的邮件。" + }, + "compose_write": { + "title": "修改您正在写的邮件", + "body": "在编辑器中使用插件时,替换或添加您正在写的邮件中的文字。每次修改都可以撤销,是否发送仍由您决定。" + }, "ui_notifications": { "title": "显示通知", "body": "在您的设备上显示通知;如果您未允许通知,则在应用内显示。每条通知都会注明插件名称。"