mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
feat: open a mail link from another program in a compose window of its own in the desktop app
This commit is contained in:
@@ -50,6 +50,20 @@ vi.mock('@/hooks/use-media-query', () => ({
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
// The desktop app's compose windows, for a mail link from another program.
|
||||
const desktopWindows = vi.hoisted(() => ({ on: false, handOver: vi.fn(async () => true), open: vi.fn() }));
|
||||
vi.mock('@/lib/popout/support', async (original) => ({
|
||||
...(await original<object>()),
|
||||
hasDesktopWindows: () => desktopWindows.on,
|
||||
popoutsAvailable: () => desktopWindows.on,
|
||||
}));
|
||||
vi.mock('@/lib/popout/open', async (original) => ({
|
||||
...(await original<object>()),
|
||||
openComposerWindow: (account?: string | null) => {
|
||||
desktopWindows.open(account);
|
||||
return { handOver: desktopWindows.handOver, cancel: vi.fn() };
|
||||
},
|
||||
}));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
@@ -164,3 +178,52 @@ describe('onMailtoIntercept', () => {
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a mail link from another program in the desktop app', () => {
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
composerProps.last = null;
|
||||
desktopWindows.on = true;
|
||||
desktopWindows.open.mockClear();
|
||||
desktopWindows.handOver.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
desktopWindows.on = false;
|
||||
});
|
||||
|
||||
const fromOs = async () => {
|
||||
const { savePendingMailto, notifyPendingMailto } = await import('@/lib/protocol-handlers/session');
|
||||
act(() => {
|
||||
savePendingMailto({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Text', fromOs: true });
|
||||
notifyPendingMailto();
|
||||
});
|
||||
};
|
||||
|
||||
it('opens in a compose window of its own, not in the main window', async () => {
|
||||
render(<MailApp />);
|
||||
await fromOs();
|
||||
await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
|
||||
expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mode: 'compose',
|
||||
initialData: expect.objectContaining({ to: 'bob@example.com', subject: 'Hi' }),
|
||||
}));
|
||||
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'); });
|
||||
await screen.findByText('stub composer', {}, { timeout: 5000 });
|
||||
expect(desktopWindows.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a plugin that says no keeps both closed', async () => {
|
||||
emailHooks.onBeforeCompose.register('test', () => false);
|
||||
render(<MailApp />);
|
||||
await fromOs();
|
||||
await act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
expect(desktopWindows.open).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,8 @@ import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { hasDesktopWindows } from "@/lib/popout/support";
|
||||
import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { composerDataFromDraft } from "@/lib/draft-composer-data";
|
||||
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
|
||||
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
|
||||
@@ -1644,7 +1645,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (isMobile) setActiveView("viewer");
|
||||
}, [composerSessionId, isMobile, setActiveView]);
|
||||
|
||||
const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', {
|
||||
// A mail link from another program, in the desktop app: a compose window of its own,
|
||||
// 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;
|
||||
let prefill: ComposePrefill | null = null;
|
||||
if (emailHooks.onBeforeCompose.size > 0) {
|
||||
prefill = await runBeforeCompose(composeOptionsFor('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)),
|
||||
replyPrefix: t('email_composer.prefix.reply'),
|
||||
forwardPrefix: t('email_composer.prefix.forward'),
|
||||
}));
|
||||
// A plugin said no: nothing opens, here or there.
|
||||
if (!prefill) return true;
|
||||
}
|
||||
const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
|
||||
if (!opened) return false;
|
||||
void opened.handOver({
|
||||
mode: 'compose',
|
||||
initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
|
||||
prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null,
|
||||
});
|
||||
return true;
|
||||
}, [identities, t]);
|
||||
|
||||
const openMailtoHere = useCallback((pending: DraftRequest) => withComposeGate('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
}, () => {
|
||||
// A live composer owns text the user has not sent. Replacing it outright
|
||||
@@ -1663,6 +1690,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
applyMailtoDraft(pending, showComposer);
|
||||
}), [applyMailtoDraft, showComposer, withComposeGate]);
|
||||
|
||||
const openMailtoDraft = useCallback(async (pending: DraftRequest): Promise<boolean> => {
|
||||
if (await openMailtoInWindow(pending)) return true;
|
||||
// Opening here after all: the shell left this window where it was.
|
||||
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
|
||||
return openMailtoHere(pending);
|
||||
}, [openMailtoInWindow, openMailtoHere]);
|
||||
|
||||
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
@@ -1691,6 +1725,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
// The question is asked in this window: bring it up.
|
||||
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,18 @@ describe('launches', () => {
|
||||
expect(push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('a mailto from the OS is marked so, opens in its own window, and leaves the main window be', async () => {
|
||||
await run({ type: 'mailto', url: 'mailto:bob@example.org' });
|
||||
expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ fromOs: true });
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signed out, the main window comes up for the sign-in', async () => {
|
||||
authenticated = false;
|
||||
await run({ type: 'mailto', url: 'mailto:bob@example.org' });
|
||||
expect(focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a mailto that is not one does nothing', async () => {
|
||||
await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) });
|
||||
expect(push).not.toHaveBeenCalledWith('/calendar');
|
||||
|
||||
@@ -164,7 +164,10 @@ export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): v
|
||||
case 'mailto': {
|
||||
const parsed = parseMailto(command.url);
|
||||
if (!parsed) return;
|
||||
savePendingMailto(parsed);
|
||||
// The shell leaves the main window where it is for a mail link. Signed out, the
|
||||
// sign-in has to show.
|
||||
if (!useAuthStore.getState().isAuthenticated) getDesktopBridge()?.windows?.focus?.();
|
||||
savePendingMailto({ ...parsed, fromOs: true });
|
||||
notifyPendingMailto();
|
||||
env.push('/');
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface ParsedMailto {
|
||||
* written from the account in use, without asking which one.
|
||||
*/
|
||||
inApp?: boolean;
|
||||
/**
|
||||
* Handed over by the OS in the desktop app: a link clicked in another program. It
|
||||
* opens in a compose window of its own, and the main window stays where it was.
|
||||
*/
|
||||
fromOs?: boolean;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
|
||||
Reference in New Issue
Block a user