diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx index c7cb0c74f..8e0c18506 100644 --- a/components/mail/__tests__/compose-plugin-hooks.test.tsx +++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx @@ -211,6 +211,17 @@ describe('a mail link from another program in the desktop app', () => { expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); }); + it('files from Send to or Open with go to a compose window too, attached, and the main window shows no composer', async () => { + const { handOffDraft } = await import('@/lib/launch-handoff'); + render(); + const file = new File(['pdf'], 'report.pdf', { type: 'application/pdf' }); + act(() => { handOffDraft({ files: [file], fromOs: true }); }); + await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1)); + expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({ mode: 'compose', initialFiles: [file] })); + await act(() => new Promise((r) => setTimeout(r, 50))); + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + }); + it('a link inside the app still opens here', async () => { render(); act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); }); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d51bce35e..8fd320a02 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1717,7 +1717,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // as Outlook and Thunderbird do, and the main window stays where it was. Plugins get // their say first. False when it has to open here instead. const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise => { - if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false; + if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false; let prefill: ComposePrefill | null = null; if (emailHooks.onBeforeCompose.size > 0) { prefill = await runBeforeCompose(composeOptionsFor('compose', { @@ -1735,6 +1735,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { mode: 'compose', initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode), prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null, + // Send to and Open with: the files go along (desktop-p2-6). + ...(pending.initialFiles?.length ? { initialFiles: pending.initialFiles } : {}), }); return true; }, [identities, t]); @@ -1857,6 +1859,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { body: handoff.body ?? '', initialFiles: handoff.files, unedited: true, + ...(handoff.fromOs ? { fromOs: true } : {}), }); }; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 798e7517b..3670f4c83 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -516,6 +516,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: { replyTo={payload.replyTo} initialDraftText={payload.initialDraftText} initialData={payload.initialData} + initialFiles={payload.initialFiles ?? null} prefill={payload.prefill} sessionKey={sessionKey} sourceEmailId={payload.sourceEmailId ?? undefined} diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index 730477814..cd724ed85 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -132,6 +132,12 @@ describe('launches', () => { expect(takeHandoff('compose')?.files.map((file) => file.name)).toEqual(['a.pdf', 'b.png']); }); + it('files sent from another program are marked so, for a compose window of their own, as a mail link', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'a.pdf', data: text('a') }] }); + expect(takeHandoff('compose')).toMatchObject({ fromOs: true }); + }); + it('a file opened with the app asks: new message or Files, a new message first', async () => { const { takeHandoff } = await import('@/lib/launch-handoff'); const { listenForFileChoices } = await import('@/lib/file-drop/choice'); diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 9df939646..3a8628fba 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -133,10 +133,13 @@ function handleFiles(command: Extract, env: const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name)); const failed = [...command.failed]; if (command.reason === 'attach' && command.ask) { - // Opened with the app ("Open with"): the same choice as dropping it on the window. + // An older shell's "Open with": the same choice as dropping it on the window. requestFileChoice({ files: made, preferred: 'new-message' }); } else if (command.reason === 'attach') { - if (made.length > 0) handOffDraft({ files: made }); + // Send to and Open with: a compose window with the files, as a mail link gets, and the + // main window stays where it is. Signed out, the sign-in has to show. + if (!useAuthStore.getState().isAuthenticated) showWindow(); + if (made.length > 0) handOffDraft({ files: made, fromOs: true }); } else { for (const file of made) { const kind = kindForFile(file); diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts index 8f5be39ec..3e49de11d 100644 --- a/lib/launch-handoff.ts +++ b/lib/launch-handoff.ts @@ -42,6 +42,8 @@ export interface Handoff { /** New draft only. */ subject?: string; body?: string; + /** From another program through the desktop app (Send to, Open with): a compose window of its own. */ + fromOs?: boolean; createdAt: number; } @@ -307,13 +309,14 @@ export function handOffFiles(files: File[], kind: HandoffKind): void { } /** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */ -export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void { +export function handOffDraft(draft: { subject?: string; body?: string; files?: File[]; fromOs?: boolean }): void { const subject = cleanMailtoSubject(draft.subject ?? ''); const body = cleanMailtoBody(draft.body ?? ''); enqueue({ id: newId(), kind: 'compose', files: draft.files ?? [], + ...(draft.fromOs ? { fromOs: true } : {}), ...(subject ? { subject } : {}), ...(body ? { body } : {}), createdAt: Date.now(), diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts index ec0fdf85b..0a4b17335 100644 --- a/lib/popout/compose-handoff.ts +++ b/lib/popout/compose-handoff.ts @@ -109,7 +109,9 @@ export function createComposeHandoff(): ComposeHandoff | null { /** Keeps the draft with this window, so a reload starts from it. */ export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void { try { - sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload)); + // A File doesn't go into JSON: once attached, the draft holds it. + const { initialFiles: _files, ...kept } = payload; + sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(kept)); } catch { // Full or unavailable: a reload then starts from the saved draft's first state. } diff --git a/lib/popout/compose-payload.ts b/lib/popout/compose-payload.ts index 714bfc1ea..25057c441 100644 --- a/lib/popout/compose-payload.ts +++ b/lib/popout/compose-payload.ts @@ -20,4 +20,9 @@ export interface ComposePopoutPayload { sourceEmailSource?: EmailWriteSource | null; /** The mailbox a new message was started from; picks the identity (see the composer's prop). */ composeFromAccountEmail?: string; + /** + * Files to attach as it opens: Send to and Open with in the desktop app. They cross to the + * window as they are (BroadcastChannel clones a File) and aren't kept for a reload. + */ + initialFiles?: File[]; }