fix: open files sent from Explorer's Send to and Open with in a compose window, leaving the main window where it is

This commit is contained in:
Linus Rath
2026-11-04 12:32:54 +01:00
parent df62335545
commit 03ea1ab1aa
8 changed files with 39 additions and 5 deletions
@@ -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(<MailApp />);
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(<MailApp />);
act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
+4 -1
View File
@@ -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<boolean> => {
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 } : {}),
});
};
+1
View File
@@ -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}
@@ -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');
+5 -2
View File
@@ -133,10 +133,13 @@ function handleFiles(command: Extract<DesktopOsCommand, { type: 'files' }>, 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);
+4 -1
View File
@@ -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(),
+3 -1
View File
@@ -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.
}
+5
View File
@@ -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[];
}