diff --git a/src/main/account-views/preload/gmail.ts b/src/main/account-views/preload/gmail.ts index 2d1b419b..30329b74 100644 --- a/src/main/account-views/preload/gmail.ts +++ b/src/main/account-views/preload/gmail.ts @@ -199,11 +199,109 @@ function parseNewMails(feedDocument: Document) { return newMails } -function clickElement(selector: string) { - const element = document.querySelector<HTMLDivElement>(selector) - if (element) { - element.click() +const nextTick = async () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) + +async function composeMail( + _event: IpcRendererEvent, + to?: string, + cc?: string | null, + bcc?: string | null, + subject?: string | null, + body?: string | null +) { + // Can't use element ids as selectors since they aren't stable + + const composeButton = await elementReady<HTMLDivElement>('div[gh="cm"]', { + stopOnDomReady: false, + timeout: 60000 + }) + if (!composeButton) throw new Error('No composeButton') + composeButton.click() + + // Out of the "to" block because we use the readiness of this element to + // know when we can start filling out the fields + const toElement = await elementReady<HTMLTextAreaElement>( + 'textarea[name="to"]', + { + stopOnDomReady: false, + timeout: 60000 + } + ) + const ccElement = document.querySelector<HTMLTextAreaElement>( + 'textarea[name="cc"]' + ) + const bccElement = document.querySelector<HTMLTextAreaElement>( + 'textarea[name="bcc"]' + ) + const subjectElement = document.querySelector<HTMLInputElement>( + 'input[name="subjectbox"]' + ) + const bodyElement = document.querySelector<HTMLDivElement>( + 'div[aria-label="Message Body"]' + ) + + if (!toElement) throw new Error('No toElement') + if (!ccElement) throw new Error('No ccElement') + if (!bccElement) throw new Error('No bccElement') + if (!subjectElement) throw new Error('No subjectElement') + if (!bodyElement) throw new Error('No bodyElement') + + if (to) { + toElement.focus() + toElement.value = to + await nextTick() + } + + // Why the nextTick at the end of each block? Because otherwise the + // fields that follow may fail to get focused for some reason. This + // isn't the original comment, see prior commits. + + if (cc) { + document + .querySelector<HTMLSpanElement>( + 'span[aria-label="Add Cc recipients (Ctrl-Shift-C)"]' + ) + ?.click() + ccElement.focus() + ccElement.value = cc + await nextTick() + } + + if (bcc) { + document + .querySelector<HTMLSpanElement>( + 'span[aria-label="Add Bcc recipients (Ctrl-Shift-B)"]' + ) + ?.click() + bccElement.focus() + bccElement.value = bcc + await nextTick() + } + + if (subject) { + subjectElement.focus() + subjectElement.value = subject + await nextTick() } + + if (body) { + bodyElement.focus() + bodyElement.innerHTML = body + await nextTick() + } + + /* eslint-disable no-negated-condition */ + if (!to) { + toElement.focus() + } else if (!subject) { + subjectElement.focus() + } else { + bodyElement.focus() + } + /* eslint-enable no-negated-condition */ } export function initGmail() { @@ -254,42 +352,9 @@ export function initGmail() { } ) - ipcRenderer.on('gmail:compose-mail', async (_event, to?: string) => { - clickElement('div[gh="cm"]') + ipcRenderer.on('gmail:compose-mail', composeMail) - if (!to) { - return - } - - const toElement = await elementReady<HTMLTextAreaElement>( - 'textarea[name="to"]', - { - stopOnDomReady: false, - timeout: 60000 - } - ) - - if (!toElement) { - return - } - - toElement.focus() - toElement.value = to - - const subjectElement = document.querySelector<HTMLInputElement>( - 'input[name="subjectbox"]' - ) - - if (!subjectElement) { - return - } - - // The subject input can't be focused immediately after - // settings the "to" input value for an unknown reason. - setTimeout(() => { - subjectElement.focus() - }, 200) - }) + ipcRenderer.send('gmail:ready') setInterval(() => { previousNewMails.clear() diff --git a/src/main/app.ts b/src/main/app.ts index 7ffa23b5..b7a00f22 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -1,7 +1,7 @@ import { app } from 'electron' import config, { ConfigKey } from './config' +import { handleMailto } from './mailto' import { getMainWindow } from './main-window' -import { sendToSelectedAccountView } from './account-views' import { appId } from '../constants' let isQuittingApp = false @@ -35,20 +35,20 @@ export async function initApp() { app.disableHardwareAcceleration() } - app.on('second-instance', () => { + app.on('second-instance', (_, argv) => { const mainWindow = getMainWindow() - if (mainWindow) { if (mainWindow.isMinimized()) { mainWindow.restore() } mainWindow.show() + handleMailto(argv[argv.length - 1]) } }) app.on('open-url', (_event, mailto) => { - sendToSelectedAccountView('gmail:compose-mail', mailto.split(':')[1]) + handleMailto(mailto) }) app.on('activate', () => { diff --git a/src/main/gmail.ts b/src/main/gmail.ts index 8abc432c..2c2adadd 100644 --- a/src/main/gmail.ts +++ b/src/main/gmail.ts @@ -1,12 +1,13 @@ import { app, ipcMain, Notification } from 'electron' import { getAccountIdByViewId } from './account-views' -import { getAccount, selectAccount } from './accounts' +import { getAccount, selectAccount, getSelectedAccount } from './accounts' import { getMainWindow, sendToMainWindow } from './main-window' import config, { ConfigKey } from './config' import { is } from 'electron-util' import { updateTrayUnreadStatus } from './tray' import { Mail, UnreadCounts } from '../types' import { isEnabled as isDoNotDisturbEnabled } from '@sindresorhus/do-not-disturb' +import { handleMailto } from './mailto' const unreadCounts: UnreadCounts = {} @@ -134,4 +135,13 @@ export function handleGmail() { } }) } + + let isInitialMailtoHandled = false + ipcMain.on('gmail:ready', ({ sender }) => { + if (isInitialMailtoHandled) return + const accountId = getAccountIdByViewId(sender.id) + if (!accountId || accountId !== getSelectedAccount()?.id) return + isInitialMailtoHandled = true + handleMailto(process.argv[process.argv.length - 1]) + }) } diff --git a/src/main/mailto.ts b/src/main/mailto.ts new file mode 100644 index 00000000..9b751e5a --- /dev/null +++ b/src/main/mailto.ts @@ -0,0 +1,33 @@ +import { sendToSelectedAccountView } from './account-views' +import { instanceOfNodeError } from '../utils/type-helpers' + +function parse(uri: string) { + try { + return new URL(uri) + } catch (error: unknown) { + if ( + instanceOfNodeError(error, TypeError) && + error.code === 'ERR_INVALID_URL' + ) { + return + } + + throw error + } +} + +const spaceAfterComma = (x: string | null) => x?.replaceAll(',', ', ') + +export function handleMailto(uri?: string) { + if (!uri) return // Empty string doesn't cut it either + const x = parse(uri) + if (!x) return + sendToSelectedAccountView( + 'gmail:compose-mail', + spaceAfterComma(x.pathname), + spaceAfterComma(x.searchParams.get('cc')), + spaceAfterComma(x.searchParams.get('bcc')), + x.searchParams.get('subject'), + x.searchParams.get('body') + ) +} diff --git a/src/utils/type-helpers.ts b/src/utils/type-helpers.ts new file mode 100644 index 00000000..767571c2 --- /dev/null +++ b/src/utils/type-helpers.ts @@ -0,0 +1,7 @@ +// https://dev.to/jdbar/the-problem-with-handling-node-js-errors-in-typescript-and-the-workaround-m64 +export function instanceOfNodeError<T extends new (...args: any) => Error>( + value: unknown, + errorType: T +): value is InstanceType<T> & NodeJS.ErrnoException { + return value instanceof errorType +}