Skip to content

Commit

Permalink
Add webxdc notification handler (#4400)
Browse files Browse the repository at this point in the history
Handle webxdc notifications

resolves #4366

* Expose sendUpdateInterval & sendUpdateMaxSize

* Pass eventText to showNotification
  • Loading branch information
nicodh authored Dec 16, 2024
1 parent a1b2737 commit d90e328
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 36 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
115 changes: 99 additions & 16 deletions packages/frontend/src/system-integration/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,38 @@ 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)
}

type queuedNotification = {
chatId: number
messageId: number
isWebxdcInfo: boolean
eventText?: string
}

let queuedNotifications: {
Expand All @@ -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 })

Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
}
4 changes: 3 additions & 1 deletion packages/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/shared-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/target-browser/runtime-browser/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion packages/target-electron/src/deltachat/webxdc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 !== '') {
Expand Down Expand Up @@ -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`
),
Expand Down
53 changes: 38 additions & 15 deletions packages/target-electron/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -53,14 +79,24 @@ 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()
}

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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/target-electron/static/webxdc-preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit d90e328

Please sign in to comment.