Skip to content

Commit 06f6a62

Browse files
committed
feat: Add unread message badge to system tray icon
Shows numbered badge (1, 2, 15, 99+) on tray icon when there are unread conversations, respecting notification settings. Features: - Canvas-based badge rendering using offscreen BrowserWindow - Respects notification settings per conversation - Event-driven updates via store.subscribe() (no polling!) (Pertially?) resolves #391 Signed-off-by: Marc Kupietz <[email protected]>
1 parent 1ff03d8 commit 06f6a62

File tree

5 files changed

+299
-48
lines changed

5 files changed

+299
-48
lines changed

src/app/app.tray.js

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,63 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
const { app, Tray, Menu } = require('electron')
7-
const path = require('path')
8-
const { getTrayIcon } = require('../shared/icons.utils.js')
6+
const { app, Tray, Menu, nativeTheme } = require('electron')
7+
const { getTrayIconPath } = require('../shared/icons.utils.js')
8+
const { createTrayIconWithBadge, clearTrayIconCache } = require('./trayBadge.utils.ts')
99

1010
let isAppQuitting = false
1111

12+
/** @type {import('electron').Tray | null} */
13+
let trayInstance = null
14+
15+
/** @type {number} */
16+
let currentBadgeCount = 0
17+
1218
/**
1319
* Allow quitting the app if requested. It minimizes to a tray otherwise.
1420
*/
1521
app.on('before-quit', () => {
1622
isAppQuitting = true
1723
})
1824

25+
/**
26+
* Update the tray icon with the current badge count
27+
*/
28+
async function refreshTrayIcon() {
29+
if (!trayInstance) {
30+
return
31+
}
32+
33+
try {
34+
const iconPath = getTrayIconPath()
35+
const icon = await createTrayIconWithBadge(iconPath, currentBadgeCount)
36+
trayInstance.setImage(icon)
37+
} catch (error) {
38+
console.error('Failed to update tray icon with badge:', error)
39+
}
40+
}
41+
42+
/**
43+
* Update the tray badge count
44+
*
45+
* @param {number} count - Number of unread messages (0 to hide badge)
46+
*/
47+
async function updateTrayBadge(count) {
48+
currentBadgeCount = count
49+
await refreshTrayIcon()
50+
}
51+
1952
/**
2053
* Setup tray with an icon that provides a context menu.
2154
*
2255
* @param {import('electron').BrowserWindow} browserWindow Browser window, associated with the tray
2356
* @return {import('electron').Tray} Tray instance
2457
*/
2558
function setupTray(browserWindow) {
26-
const icon = path.resolve(__dirname, getTrayIcon())
27-
const tray = new Tray(icon)
28-
tray.setToolTip(app.name)
29-
tray.setContextMenu(Menu.buildFromTemplate([
59+
const iconPath = getTrayIconPath()
60+
trayInstance = new Tray(iconPath)
61+
trayInstance.setToolTip(app.name)
62+
trayInstance.setContextMenu(Menu.buildFromTemplate([
3063
{
3164
label: 'Open',
3265
click: () => browserWindow.show(),
@@ -35,7 +68,13 @@ function setupTray(browserWindow) {
3568
role: 'quit',
3669
},
3770
]))
38-
tray.on('click', () => browserWindow.show())
71+
trayInstance.on('click', () => browserWindow.show())
72+
73+
// Refresh icon when theme changes (for monochrome icon support)
74+
nativeTheme.on('updated', () => {
75+
clearTrayIconCache()
76+
refreshTrayIcon()
77+
})
3978

4079
browserWindow.on('close', (event) => {
4180
if (!isAppQuitting) {
@@ -45,12 +84,16 @@ function setupTray(browserWindow) {
4584
})
4685

4786
browserWindow.on('closed', () => {
48-
tray.destroy()
87+
if (trayInstance) {
88+
trayInstance.destroy()
89+
trayInstance = null
90+
}
4991
})
5092

51-
return tray
93+
return trayInstance
5294
}
5395

5496
module.exports = {
5597
setupTray,
98+
updateTrayBadge,
5699
}

src/app/trayBadge.utils.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import {
7+
type NativeImage,
8+
9+
BrowserWindow, nativeImage,
10+
} from 'electron'
11+
import fs from 'node:fs'
12+
13+
// Cache for the base icon data
14+
let cachedBaseIconPath: string | null = null
15+
let cachedBaseIconDataUrl: string | null = null
16+
17+
/**
18+
* Create a tray icon with a badge overlay showing the unread count
19+
*
20+
* @param baseIconPath - Path to the base tray icon
21+
* @param count - Number to display in the badge (0 to hide badge)
22+
* @return NativeImage with badge overlay, or the original icon if count is 0
23+
*/
24+
export async function createTrayIconWithBadge(baseIconPath: string, count: number): Promise<NativeImage> {
25+
// If no unread messages, return the base icon
26+
if (count <= 0) {
27+
return nativeImage.createFromPath(baseIconPath)
28+
}
29+
30+
// Read and cache the base icon as data URL
31+
if (cachedBaseIconPath !== baseIconPath || !cachedBaseIconDataUrl) {
32+
cachedBaseIconPath = baseIconPath
33+
const buffer = fs.readFileSync(baseIconPath)
34+
cachedBaseIconDataUrl = `data:image/png;base64,${buffer.toString('base64')}`
35+
}
36+
37+
// Create invisible window for rendering
38+
const win = new BrowserWindow({
39+
width: 64,
40+
height: 64,
41+
show: false,
42+
frame: false,
43+
transparent: true,
44+
webPreferences: {
45+
offscreen: true,
46+
nodeIntegration: false,
47+
contextIsolation: true,
48+
},
49+
})
50+
51+
const displayText = count > 99 ? '99+' : String(count)
52+
const fontSize = count > 99 ? 11 : 16
53+
54+
// Generate HTML with canvas to draw the icon + badge
55+
const html = `
56+
<!DOCTYPE html>
57+
<html>
58+
<head>
59+
<style>
60+
* { margin: 0; padding: 0; }
61+
body { background: transparent; }
62+
canvas { display: block; }
63+
</style>
64+
</head>
65+
<body>
66+
<canvas id="canvas" width="32" height="32"></canvas>
67+
<script>
68+
const canvas = document.getElementById('canvas');
69+
const ctx = canvas.getContext('2d');
70+
const img = new Image();
71+
img.onload = () => {
72+
// Draw base icon
73+
ctx.drawImage(img, 0, 0, 32, 32);
74+
75+
// Badge dimensions
76+
const badgeRadius = 11;
77+
const badgeCenterX = 32 - badgeRadius;
78+
const badgeCenterY = 32 - badgeRadius;
79+
80+
// Draw badge background (red circle)
81+
ctx.beginPath();
82+
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius, 0, 2 * Math.PI);
83+
ctx.fillStyle = '#E53935';
84+
ctx.fill();
85+
86+
// Draw badge border
87+
ctx.strokeStyle = '#FFFFFF';
88+
ctx.lineWidth = 1;
89+
ctx.stroke();
90+
91+
// Draw count text
92+
ctx.font = 'bold ${fontSize}px sans-serif';
93+
ctx.textAlign = 'center';
94+
ctx.textBaseline = 'middle';
95+
ctx.fillStyle = '#FFFFFF';
96+
ctx.fillText('${displayText}', badgeCenterX, badgeCenterY + 1);
97+
98+
// Signal rendering complete
99+
window.renderComplete = true;
100+
};
101+
img.src = '${cachedBaseIconDataUrl}';
102+
</script>
103+
</body>
104+
</html>
105+
`
106+
107+
try {
108+
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
109+
110+
// Wait for rendering to complete
111+
await new Promise<void>((resolve) => {
112+
const checkComplete = async () => {
113+
const complete = await win.webContents.executeJavaScript('window.renderComplete')
114+
if (complete) {
115+
resolve()
116+
} else {
117+
setTimeout(checkComplete, 10)
118+
}
119+
}
120+
setTimeout(checkComplete, 50)
121+
})
122+
123+
// Capture the canvas content
124+
const image = await win.webContents.capturePage({
125+
x: 0,
126+
y: 0,
127+
width: 32,
128+
height: 32,
129+
})
130+
131+
win.destroy()
132+
return image
133+
} catch (error) {
134+
console.error('Error creating badge icon:', error)
135+
win.destroy()
136+
return nativeImage.createFromPath(baseIconPath)
137+
}
138+
}
139+
140+
/**
141+
* Clear the cached base icon (useful when theme changes)
142+
*/
143+
export function clearTrayIconCache(): void {
144+
cachedBaseIconPath = null
145+
cachedBaseIconDataUrl = null
146+
}

src/main.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { spawn } = require('node:child_process')
88
const fs = require('node:fs')
99
const path = require('node:path')
1010
const { setupMenu } = require('./app/app.menu.js')
11+
const { updateTrayBadge } = require('./app/app.tray.js')
1112
const { loadAppConfig, getAppConfig, setAppConfig } = require('./app/AppConfig.ts')
1213
const { appData } = require('./app/AppData.js')
1314
const { registerAppProtocolHandler } = require('./app/appProtocol.ts')
@@ -97,7 +98,10 @@ ipcMain.handle('app:getSystemL10n', () => ({
9798
}))
9899
ipcMain.handle('app:enableWebRequestInterceptor', (event, ...args) => enableWebRequestInterceptor(...args))
99100
ipcMain.handle('app:disableWebRequestInterceptor', (event, ...args) => disableWebRequestInterceptor(...args))
100-
ipcMain.handle('app:setBadgeCount', async (event, count) => app.setBadgeCount(count))
101+
ipcMain.handle('app:setBadgeCount', async (event, count) => {
102+
app.setBadgeCount(count)
103+
await updateTrayBadge(count)
104+
})
101105
ipcMain.on('app:relaunch', () => {
102106
app.relaunch()
103107
app.exit(0)

src/shared/icons.utils.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const icons = {
4242
}
4343

4444
/**
45-
* Get tray icon
45+
* Get tray icon (relative path for webpack)
4646
*/
4747
function getTrayIcon() {
4848
const monochrome = getAppConfig('monochromeTrayIcon')
@@ -52,6 +52,15 @@ function getTrayIcon() {
5252
return icons.tray[platform][kind]
5353
}
5454

55+
/**
56+
* Get absolute path to the tray icon for the current platform and theme
57+
*
58+
* @return {string} Absolute path to the tray icon
59+
*/
60+
function getTrayIconPath() {
61+
return path.resolve(__dirname, getTrayIcon())
62+
}
63+
5564
/**
5665
* Get BrowserWindow icon for the current platform
5766
*
@@ -68,5 +77,6 @@ function getBrowserWindowIcon() {
6877

6978
module.exports = {
7079
getTrayIcon,
80+
getTrayIconPath,
7181
getBrowserWindowIcon,
7282
}

0 commit comments

Comments
 (0)