diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index a89ee1f60..9f1ca493c 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -271,6 +271,15 @@ describe('composer-above-body and composer-below-body', () => { } }); + it('let a cloud picker put share links into the text', () => { + offerSlots('p1', ['composer-attachment-source']); + render(); + + const props = lastSlotProps('composer-attachment-source'); + expect(typeof props.onAttach).toBe('function'); + expect(typeof props.insertText).toBe('function'); + }); + it('draw nothing without a plugin', () => { const { container } = render(); expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 6b0ec8a91..cbd2c9cff 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2070,7 +2070,6 @@ export function EmailComposer({ }, [composerClient, t]); // Stable props, so a re-render of the composer doesn't post them to the // plugin's iframe again. - const pluginAttachmentSourceProps = useMemo(() => ({ onAttach: handlePluginAttachmentAdd }), [handlePluginAttachmentAdd]); const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar'); const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source'); const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0; @@ -2469,6 +2468,11 @@ export function EmailComposer({ () => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText }), [pluginInsertText, pluginSelectedText], ); + // A cloud picker attaches files, or puts share links in the text. + const pluginAttachmentSourceProps = useMemo( + () => ({ onAttach: handlePluginAttachmentAdd, insertText: pluginInsertText }), + [handlePluginAttachmentAdd, pluginInsertText], + ); const sendMenuContext = useCallback(() => ({ draft: draftSnapshotRef.current() }), []); // Trigger auto-save when content changes (only if user modified something) diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 94635dcc4..75ffc3fa6 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -63,11 +63,12 @@ const DEFAULT_TIMEOUT_MS = 5000; // reply-all, mailto, attachment upload), so they need a much longer budget // than observer / transform hooks. const INTERCEPT_TIMEOUT_MS = 60_000; -// onBeforeBlobUpload handlers may push the attachment to an external store -// before handing back a link (see ExternalAttachmentResult), which is a real -// upload over the wire - 5s would kill every offload of a file worth -// offloading, and three of those in a minute would auto-disable the plugin. -const BLOB_UPLOAD_TIMEOUT_MS = 120_000; +// onBeforeBlobUpload handlers may ask the user and then push the attachment +// to an external store before handing back a link (see +// ExternalAttachmentResult): a real upload of a file too big to mail, which +// takes minutes on a slow line. Two minutes cut off large offloads and +// counted them toward auto-disable. +const BLOB_UPLOAD_TIMEOUT_MS = 15 * 60_000; class HookTimeoutError extends Error {} diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index edaf56f89..9383a2a97 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -490,7 +490,8 @@ function buildPluginApi(manifest: PluginManifest) { openExternalUrl: (url: string, target?: string) => callApi('ui.openExternalUrl', [url, target]) as Promise, /** Downloads a file generated by the plugin. Not a user's file or attachment. */ - downloadFile: (opts: { content: string; filename: string; contentType?: string }) => + /** `content` is text, or the file's bytes (ArrayBuffer / Uint8Array). */ + downloadFile: (opts: { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }) => callApi('ui.downloadFile', [opts]) as Promise, /** * Buttons or menu entries on an action surface (see the slot catalogue: diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index ac8365b50..03d1e1213 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -81,7 +81,7 @@ export const SLOT_CATALOG = { '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-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel left of the composer'), 'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel right of the composer'), - 'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null }, 'the composer\'s attach menu, as a source of files', true), + 'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open' }, 'the composer\'s attach menu, as a source of files', true), 'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send' }, 'entries in the menu next to Send'), 'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], {}, 'entries in the attach menu (for example "From Nextcloud")'),