From d90e328618dbd5e912405c2cd945f3741d8ba94e Mon Sep 17 00:00:00 2001 From: Nico de Haen Date: Mon, 16 Dec 2024 12:41:23 +0100 Subject: [PATCH] Add webxdc notification handler (#4400) Handle webxdc notifications resolves #4366 * Expose sendUpdateInterval & sendUpdateMaxSize * Pass eventText to showNotification --- .prettierignore | 1 + CHANGELOG.md | 2 + .../src/system-integration/notifications.ts | 115 +++++++++++++++--- packages/runtime/runtime.ts | 4 +- packages/shared/shared-types.d.ts | 5 +- .../target-browser/runtime-browser/runtime.ts | 6 +- .../target-electron/src/deltachat/webxdc.ts | 5 +- packages/target-electron/src/notifications.ts | 53 +++++--- .../target-electron/static/webxdc-preload.js | 4 +- 9 files changed, 159 insertions(+), 36 deletions(-) diff --git a/.prettierignore b/.prettierignore index f79f0350ee..ddde995880 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,5 +5,6 @@ node_modules packages/target-electron/bundle_out packages/target-electron/tests/compiled packages/shared/ts-compiled-for-tests +packages/e2e-tests/test-results/* packages/target-browser/dist \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d5a09ded22..17f66df901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased][unreleased] ## Added +- show specific notifications for webxdc events #4400 +- expose sendUpdateInterval & sendUpdateMaxSize in webxdc ## Changed - Update `@deltachat/stdio-rpc-server` and `deltachat/jsonrpc-client` to `1.152.0` diff --git a/packages/frontend/src/system-integration/notifications.ts b/packages/frontend/src/system-integration/notifications.ts index d511a7374a..c5ef4df9fa 100644 --- a/packages/frontend/src/system-integration/notifications.ts +++ b/packages/frontend/src/system-integration/notifications.ts @@ -10,6 +10,29 @@ import type { T } from '@deltachat/jsonrpc-client' const log = getLogger('renderer/notifications') +/** + * Notification handling: + * + * - listens for incoming notifications + * - reflects notification settings + * - prepares notification data (DcNotification) + * - queues notifications if needed to avoid "mass" notifications + * - sends notifications to runtime (which invokes ipcBackend) + */ + +export function initNotifications() { + BackendRemote.on('IncomingMsg', (accountId, { chatId, msgId }) => { + incomingMessageHandler(accountId, chatId, msgId, false) + }) + BackendRemote.on('IncomingWebxdcNotify', (accountId, { msgId, text }) => { + // we don't have the chatId yet, but it will be fetched in flushNotifications + incomingMessageHandler(accountId, -1, msgId, true, text) + }) + BackendRemote.on('IncomingMsgBunch', accountId => { + flushNotifications(accountId) + }) +} + function isMuted(accountId: number, chatId: number) { return BackendRemote.rpc.isChatMuted(accountId, chatId) } @@ -17,6 +40,8 @@ function isMuted(accountId: number, chatId: number) { type queuedNotification = { chatId: number messageId: number + isWebxdcInfo: boolean + eventText?: string } let queuedNotifications: { @@ -26,7 +51,9 @@ let queuedNotifications: { function incomingMessageHandler( accountId: number, chatId: number, - messageId: number + messageId: number, + isWebxdcInfo: boolean, + eventText?: string ) { log.debug('incomingMessageHandler: ', { chatId, messageId }) @@ -58,13 +85,20 @@ function incomingMessageHandler( if (typeof queuedNotifications[accountId] === 'undefined') { queuedNotifications[accountId] = [] } - queuedNotifications[accountId].push({ chatId, messageId }) + queuedNotifications[accountId].push({ + chatId, + messageId, + isWebxdcInfo, + eventText, + }) } async function showNotification( accountId: number, chatId: number, - messageId: number + messageId: number, + isWebxdcInfo: boolean, + eventText?: string ) { const tx = window.static_translate @@ -81,11 +115,50 @@ async function showNotification( try { const notificationInfo = await BackendRemote.rpc.getMessageNotificationInfo(accountId, messageId) - const { chatName, summaryPrefix, summaryText } = notificationInfo + let summaryPrefix = notificationInfo.summaryPrefix + const summaryText = eventText ?? notificationInfo.summaryText + const chatName = notificationInfo.chatName + let icon = getNotificationIcon(notificationInfo) + if (isWebxdcInfo) { + /** + * messageId may refer to a webxdc message OR a wexdc-info-message! + * + * a notification might be sent even when no webxdc-info-message was + * added to the chat; in that case the msg_id refers to the webxdc instance + */ + let message = await BackendRemote.rpc.getMessage(accountId, messageId) + if ( + message.systemMessageType === 'WebxdcInfoMessage' && + message.parentId + ) { + // we have to get the parent message + // (the webxdc message which holds the webxdcInfo) + message = await BackendRemote.rpc.getMessage( + accountId, + message.parentId + ) + } + if (message.webxdcInfo) { + summaryPrefix = `${message.webxdcInfo.name}` + if (message.webxdcInfo.icon) { + const iconName = message.webxdcInfo.icon + const iconBlob = await BackendRemote.rpc.getWebxdcBlob( + accountId, + message.id, + iconName + ) + // needed for valid dataUrl + const imageExtension = iconName.split('.').pop() + icon = `data:image/${imageExtension};base64,${iconBlob}` + } + } else { + throw new Error(`no webxdcInfo in message with id ${message.id}`) + } + } runtime.showNotification({ title: chatName, body: summaryPrefix ? `${summaryPrefix}: ${summaryText}` : summaryText, - icon: getNotificationIcon(notificationInfo), + icon, chatId, messageId, accountId, @@ -174,6 +247,14 @@ async function flushNotifications(accountId: number) { let notifications = [...queuedNotifications[accountId]] queuedNotifications = [] + for await (const n of notifications) { + if (n.chatId === -1) { + // get real chatId of the webxdc message + const message = await BackendRemote.rpc.getMessage(accountId, n.messageId) + n.chatId = message.chatId + } + } + // filter out muted chats: const uniqueChats = [...new Set(notifications.map(n => n.chatId))] const mutedChats = ( @@ -202,8 +283,19 @@ async function flushNotifications(accountId: number) { if (notifications.length > notificationLimit) { showGroupedNotification(accountId, notifications) } else { - for (const { chatId, messageId } of notifications) { - await showNotification(accountId, chatId, messageId) + for (const { + chatId, + messageId, + isWebxdcInfo, + eventText, + } of notifications) { + await showNotification( + accountId, + chatId, + messageId, + isWebxdcInfo, + eventText + ) } } notificationLimit = NORMAL_LIMIT @@ -231,12 +323,3 @@ function getNotificationIcon( return null } } - -export function initNotifications() { - BackendRemote.on('IncomingMsg', (accountId, { chatId, msgId }) => { - incomingMessageHandler(accountId, chatId, msgId) - }) - BackendRemote.on('IncomingMsgBunch', accountId => { - flushNotifications(accountId) - }) -} diff --git a/packages/runtime/runtime.ts b/packages/runtime/runtime.ts index b722809a32..d3a370d4bf 100644 --- a/packages/runtime/runtime.ts +++ b/packages/runtime/runtime.ts @@ -14,7 +14,8 @@ import type { getLogger as getLoggerFunction } from '@deltachat-desktop/shared/l import type { setLogHandler as setLogHandlerFunction } from '@deltachat-desktop/shared/logger.js' /** - * Offers an abstraction Layer to make it easier to make browser client in the future + * Offers an abstraction Layer to make it easier to capsulate + * context specific functions (like electron, browser, tauri, etc) */ export interface Runtime { emitUIFullyReady(): void @@ -112,6 +113,7 @@ export interface Runtime { showNotification(data: DcNotification): void clearAllNotifications(): void clearNotifications(chatId: number): void + /** enables to set a callback (used in frontend RuntimeAdapter) */ setNotificationCallback( cb: (data: { accountId: number; chatId: number; msgId: number }) => void ): void diff --git a/packages/shared/shared-types.d.ts b/packages/shared/shared-types.d.ts index 53a807f104..e2830574c3 100644 --- a/packages/shared/shared-types.d.ts +++ b/packages/shared/shared-types.d.ts @@ -98,7 +98,10 @@ export interface BuildInfo { export interface DcNotification { title: string body: string - /** path to image that should be shown instead of icon */ + /** + * path to image that should be shown instead of icon + * (or a data url with base64 encoded data) + */ icon: string | null chatId: number messageId: number diff --git a/packages/target-browser/runtime-browser/runtime.ts b/packages/target-browser/runtime-browser/runtime.ts index adceacaf86..29f71a7d50 100644 --- a/packages/target-browser/runtime-browser/runtime.ts +++ b/packages/target-browser/runtime-browser/runtime.ts @@ -417,7 +417,11 @@ class BrowserRuntime implements Runtime { // IDEA: alternatively we could make another route that exposes the file with a random hash without authentification? // Concern: Also the current method could run into size limits because it loads the whole image, which can be large? like high ram usage in browser? try { - const response = await fetch(this.transformBlobURL(notificationIcon)) + const response = await fetch( + notificationIcon.startsWith('data:') + ? notificationIcon + : this.transformBlobURL(notificationIcon) + ) if (!response.ok) { throw new Error('request failed: code' + response.status) } diff --git a/packages/target-electron/src/deltachat/webxdc.ts b/packages/target-electron/src/deltachat/webxdc.ts index 30879e366c..471a965a16 100644 --- a/packages/target-electron/src/deltachat/webxdc.ts +++ b/packages/target-electron/src/deltachat/webxdc.ts @@ -100,6 +100,7 @@ export default class DCWebxdc extends SplitOut { ) => { const { webxdcInfo, chatName, displayname, accountId, href } = p const addr = webxdcInfo.selfAddr + const { sendUpdateInterval, sendUpdateMaxSize } = webxdcInfo let base64EncodedHref = '' const appURL = `webxdc://${accountId}.${msg_id}.webxdc` if (href && href !== '') { @@ -222,7 +223,9 @@ export default class DCWebxdc extends SplitOut { // initializes the preload script, the actual implementation of `window.webxdc` is found there: static/webxdc-preload.js return makeResponse( Buffer.from( - `window.parent.webxdc_internal.setup("${selfAddr}","${displayName}") + `window.parent.webxdc_internal.setup("${selfAddr}","${displayName}", ${Number( + sendUpdateInterval + )}, ${Number(sendUpdateMaxSize)}) window.webxdc = window.parent.webxdc window.webxdc_custom = window.parent.webxdc_custom` ), diff --git a/packages/target-electron/src/notifications.ts b/packages/target-electron/src/notifications.ts index c0e14ee10f..7b63a9750f 100644 --- a/packages/target-electron/src/notifications.ts +++ b/packages/target-electron/src/notifications.ts @@ -8,13 +8,39 @@ import { getLogger } from '../../shared/logger.js' import type { NativeImage, IpcMainInvokeEvent } from 'electron' +/** + * Notification related functions to: + * - show notifications in operating system + * - handle click on notification + * + * is triggered from renderer process (!) + * by handling events (ipcMain.handle) + * + * see: frontend/src/system-integration/notifications.ts + */ + const log = getLogger('main/notifications') const isMac = platform() === 'darwin' +if (Notification.isSupported()) { + ipcMain.handle('notifications.show', showNotification) + ipcMain.handle('notifications.clear', clearNotificationsForChat) + ipcMain.handle('notifications.clearAll', clearAll) + process.on('beforeExit', clearAll) +} else { + // Register no-op handlers for notifications to silently fail when + // no notifications are supported + ipcMain.handle('notifications.show', () => {}) + ipcMain.handle('notifications.clear', () => {}) + ipcMain.handle('notifications.clearAll', () => {}) +} + function createNotification(data: DcNotification): Notification { let icon: NativeImage | undefined = data.icon - ? nativeImage.createFromPath(data.icon) + ? data.icon.startsWith('data:') + ? nativeImage.createFromDataURL(data.icon) + : nativeImage.createFromPath(data.icon) : undefined if (!icon || icon.isEmpty()) { @@ -53,7 +79,11 @@ function onClickNotification( msgId: number, _ev: Electron.Event ) { - mainWindow.send('ClickOnNotification', { accountId, chatId, msgId }) + mainWindow.send('ClickOnNotification', { + accountId, + chatId, + msgId, + }) mainWindow.show() app.focus() mainWindow.window?.focus() @@ -61,6 +91,12 @@ function onClickNotification( const notifications: { [chatId: number]: Notification[] } = {} +/** + * triggers creation of a notification, adds appropriate + * callbacks and shows it via electron Notification API + * + * @param data is passed from renderer process + */ function showNotification(_event: IpcMainInvokeEvent, data: DcNotification) { const chatId = data.chatId @@ -121,19 +157,6 @@ function clearAll() { } } -if (Notification.isSupported()) { - ipcMain.handle('notifications.show', showNotification) - ipcMain.handle('notifications.clear', clearNotificationsForChat) - ipcMain.handle('notifications.clearAll', clearAll) - process.on('beforeExit', clearAll) -} else { - // Register no-op handlers for notifications to silently fail when - // no notifications are supported - ipcMain.handle('notifications.show', () => {}) - ipcMain.handle('notifications.clear', () => {}) - ipcMain.handle('notifications.clearAll', () => {}) -} - // Thanks to Signal for this function // https://github.com/signalapp/Signal-Desktop/blob/ae9181a4b26264ce553c7d8379a3ee5a07de018b/ts/services/notifications.ts#L485 // it is licensed AGPL-3.0-only diff --git a/packages/target-electron/static/webxdc-preload.js b/packages/target-electron/static/webxdc-preload.js index 6393d10bb9..1ad006c19c 100644 --- a/packages/target-electron/static/webxdc-preload.js +++ b/packages/target-electron/static/webxdc-preload.js @@ -247,12 +247,14 @@ class RealtimeListener { const connections = [] contextBridge.exposeInMainWorld('webxdc_internal', { - setup: (selfAddr, selfName) => { + setup: (selfAddr, selfName, sendUpdateInterval, sendUpdateMaxSize) => { if (is_ready) { return } api.selfAddr = Buffer.from(selfAddr, 'base64').toString('utf-8') api.selfName = Buffer.from(selfName, 'base64').toString('utf-8') + api.sendUpdateInterval = sendUpdateInterval + api.sendUpdateMaxSize = sendUpdateMaxSize // be sure that webxdc.js was included contextBridge.exposeInMainWorld('webxdc', api)