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