diff --git a/src/app/app.tray.js b/src/app/app.tray.js index 38e7bcc13..3fa202f14 100644 --- a/src/app/app.tray.js +++ b/src/app/app.tray.js @@ -3,12 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -const { app, Tray, Menu } = require('electron') -const path = require('path') -const { getTrayIcon } = require('../shared/icons.utils.js') +const { app, Tray, Menu, nativeTheme } = require('electron') +const { getTrayIconPath } = require('../shared/icons.utils.js') +const { createTrayIconWithBadge, clearTrayIconCache } = require('./trayBadge.utils.ts') let isAppQuitting = false +/** @type {import('electron').Tray | null} */ +let trayInstance = null + +/** @type {number} */ +let currentBadgeCount = 0 + /** * Allow quitting the app if requested. It minimizes to a tray otherwise. */ @@ -16,6 +22,33 @@ app.on('before-quit', () => { isAppQuitting = true }) +/** + * Update the tray icon with the current badge count + */ +async function refreshTrayIcon() { + if (!trayInstance) { + return + } + + try { + const iconPath = getTrayIconPath() + const icon = await createTrayIconWithBadge(iconPath, currentBadgeCount) + trayInstance.setImage(icon) + } catch (error) { + console.error('Failed to update tray icon with badge:', error) + } +} + +/** + * Update the tray badge count + * + * @param {number} count - Number of unread messages (0 to hide badge) + */ +async function updateTrayBadge(count) { + currentBadgeCount = count + await refreshTrayIcon() +} + /** * Setup tray with an icon that provides a context menu. * @@ -23,10 +56,10 @@ app.on('before-quit', () => { * @return {import('electron').Tray} Tray instance */ function setupTray(browserWindow) { - const icon = path.resolve(__dirname, getTrayIcon()) - const tray = new Tray(icon) - tray.setToolTip(app.name) - tray.setContextMenu(Menu.buildFromTemplate([ + const iconPath = getTrayIconPath() + trayInstance = new Tray(iconPath) + trayInstance.setToolTip(app.name) + trayInstance.setContextMenu(Menu.buildFromTemplate([ { label: 'Open', click: () => browserWindow.show(), @@ -35,7 +68,13 @@ function setupTray(browserWindow) { role: 'quit', }, ])) - tray.on('click', () => browserWindow.show()) + trayInstance.on('click', () => browserWindow.show()) + + // Refresh icon when theme changes (for monochrome icon support) + nativeTheme.on('updated', () => { + clearTrayIconCache() + refreshTrayIcon() + }) browserWindow.on('close', (event) => { if (!isAppQuitting) { @@ -45,12 +84,16 @@ function setupTray(browserWindow) { }) browserWindow.on('closed', () => { - tray.destroy() + if (trayInstance) { + trayInstance.destroy() + trayInstance = null + } }) - return tray + return trayInstance } module.exports = { setupTray, + updateTrayBadge, } diff --git a/src/app/trayBadge.utils.ts b/src/app/trayBadge.utils.ts new file mode 100644 index 000000000..8ca2dc3b2 --- /dev/null +++ b/src/app/trayBadge.utils.ts @@ -0,0 +1,146 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + type NativeImage, + + BrowserWindow, nativeImage, +} from 'electron' +import fs from 'node:fs' + +// Cache for the base icon data +let cachedBaseIconPath: string | null = null +let cachedBaseIconDataUrl: string | null = null + +/** + * Create a tray icon with a badge overlay showing the unread count + * + * @param baseIconPath - Path to the base tray icon + * @param count - Number to display in the badge (0 to hide badge) + * @return NativeImage with badge overlay, or the original icon if count is 0 + */ +export async function createTrayIconWithBadge(baseIconPath: string, count: number): Promise { + // If no unread messages, return the base icon + if (count <= 0) { + return nativeImage.createFromPath(baseIconPath) + } + + // Read and cache the base icon as data URL + if (cachedBaseIconPath !== baseIconPath || !cachedBaseIconDataUrl) { + cachedBaseIconPath = baseIconPath + const buffer = fs.readFileSync(baseIconPath) + cachedBaseIconDataUrl = `data:image/png;base64,${buffer.toString('base64')}` + } + + // Create invisible window for rendering + const win = new BrowserWindow({ + width: 64, + height: 64, + show: false, + frame: false, + transparent: true, + webPreferences: { + offscreen: true, + nodeIntegration: false, + contextIsolation: true, + }, + }) + + const displayText = count > 99 ? '99+' : String(count) + const fontSize = count > 99 ? 11 : 16 + + // Generate HTML with canvas to draw the icon + badge + const html = ` + + + + + + + + + + + ` + + try { + await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + + // Wait for rendering to complete + await new Promise((resolve) => { + const checkComplete = async () => { + const complete = await win.webContents.executeJavaScript('window.renderComplete') + if (complete) { + resolve() + } else { + setTimeout(checkComplete, 10) + } + } + setTimeout(checkComplete, 50) + }) + + // Capture the canvas content + const image = await win.webContents.capturePage({ + x: 0, + y: 0, + width: 32, + height: 32, + }) + + win.destroy() + return image + } catch (error) { + console.error('Error creating badge icon:', error) + win.destroy() + return nativeImage.createFromPath(baseIconPath) + } +} + +/** + * Clear the cached base icon (useful when theme changes) + */ +export function clearTrayIconCache(): void { + cachedBaseIconPath = null + cachedBaseIconDataUrl = null +} diff --git a/src/main.js b/src/main.js index f2cac3462..81f3a814d 100644 --- a/src/main.js +++ b/src/main.js @@ -8,6 +8,7 @@ const { spawn } = require('node:child_process') const fs = require('node:fs') const path = require('node:path') const { setupMenu } = require('./app/app.menu.js') +const { updateTrayBadge } = require('./app/app.tray.js') const { loadAppConfig, getAppConfig, setAppConfig } = require('./app/AppConfig.ts') const { appData } = require('./app/AppData.js') const { registerAppProtocolHandler } = require('./app/appProtocol.ts') @@ -97,7 +98,10 @@ ipcMain.handle('app:getSystemL10n', () => ({ })) ipcMain.handle('app:enableWebRequestInterceptor', (event, ...args) => enableWebRequestInterceptor(...args)) ipcMain.handle('app:disableWebRequestInterceptor', (event, ...args) => disableWebRequestInterceptor(...args)) -ipcMain.handle('app:setBadgeCount', async (event, count) => app.setBadgeCount(count)) +ipcMain.handle('app:setBadgeCount', async (event, count) => { + app.setBadgeCount(count) + await updateTrayBadge(count) +}) ipcMain.on('app:relaunch', () => { app.relaunch() app.exit(0) diff --git a/src/shared/icons.utils.js b/src/shared/icons.utils.js index 113f8af9e..c02a8ede3 100644 --- a/src/shared/icons.utils.js +++ b/src/shared/icons.utils.js @@ -42,7 +42,7 @@ const icons = { } /** - * Get tray icon + * Get tray icon (relative path for webpack) */ function getTrayIcon() { const monochrome = getAppConfig('monochromeTrayIcon') @@ -52,6 +52,15 @@ function getTrayIcon() { return icons.tray[platform][kind] } +/** + * Get absolute path to the tray icon for the current platform and theme + * + * @return {string} Absolute path to the tray icon + */ +function getTrayIconPath() { + return path.resolve(__dirname, getTrayIcon()) +} + /** * Get BrowserWindow icon for the current platform * @@ -68,5 +77,6 @@ function getBrowserWindowIcon() { module.exports = { getTrayIcon, + getTrayIconPath, getBrowserWindowIcon, } diff --git a/src/talk/renderer/TalkWrapper/useBadgeCountIntegration.ts b/src/talk/renderer/TalkWrapper/useBadgeCountIntegration.ts index ace51d46a..e96e0ad66 100644 --- a/src/talk/renderer/TalkWrapper/useBadgeCountIntegration.ts +++ b/src/talk/renderer/TalkWrapper/useBadgeCountIntegration.ts @@ -3,55 +3,103 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { ref, watchEffect } from 'vue' +let isInitialized = false /** * Set badge counter according to Talk unread counts + * Uses Vuex store.subscribe() to listen for conversation changes (event-driven, no polling) */ -export function useBadgeCountIntegration() { - const count = ref(0) +export function useBadgeCountIntegration(): void { + // Prevent multiple initializations + if (isInitialized) { + return + } + isInitialized = true - window.OCA.Talk.instance.$store.watch(countUnreadConversations, (newValue: number) => { - count.value = newValue - }, { immediate: true }) + // Wait for store to be available, then subscribe to mutations + const checkAndSubscribe = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = (window as any).OCA?.Talk?.instance?.$store + if (!store) { + // Store not ready yet, retry in 500ms + setTimeout(checkAndSubscribe, 500) + return + } + + // Set initial badge count + const initialCount = countUnreadConversations() + window.TALK_DESKTOP.setBadgeCount(initialCount) - watchEffect(() => { - window.TALK_DESKTOP.setBadgeCount(count.value) - }) + // Subscribe to store mutations - event-driven, no polling! + store.subscribe((mutation: { type: string }) => { + // Only recalculate when conversation-related mutations occur + if (mutation.type === 'updateUnreadMessages' || + mutation.type === 'addConversation' || + mutation.type === 'updateConversation' || + mutation.type === 'deleteConversation') { + const count = countUnreadConversations() + window.TALK_DESKTOP.setBadgeCount(count) + } + }) + } + + checkAndSubscribe() } /** - * Count conversations with unread notifications - * HOTFIX: provide Talk API instead + * Count conversations with unread notifications respecting notification settings + * + * Conversation types: 1=ONE_TO_ONE, 2=GROUP, 3=PUBLIC, 4=CHANGELOG, 5=ONE_TO_ONE_FORMER, 6=NOTE_TO_SELF + * Notification levels: 1=Always, 2=Mention, 3=Never */ -function countUnreadConversations() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return window.OCA.Talk.instance.$store.getters.conversationsList.reduce((count: number, conversation: any) => { - // Filter out archived conversations - if (conversation.isArchived) { - return count +function countUnreadConversations(): number { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const conversationsList = (window as any).OCA?.Talk?.instance?.$store?.getters?.conversationsList + if (!conversationsList) { + return 0 } - // Muted with "Never notify" - if (conversation.notificationLevel === 3) { - return count - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return conversationsList.reduce((count: number, conversation: any) => { + // Filter out archived conversations + if (conversation.isArchived) { + return count + } - // ONE_TO_ONE || ONE_TO_ONE_FORMER - if ((conversation.type === 1 || conversation.type === 5) && conversation.unreadMessages) { - return count + 1 - } + // Muted with "Never notify" - always skip + if (conversation.notificationLevel === 3) { + return count + } - // Any other group conversation - if ( - // Always notify && any unread message - (conversation.notificationLevel === 1 && conversation.unreadMessages) - // Mentioned - || conversation.unreadMention - ) { - return count + 1 - } + // No unread messages - skip + if (!conversation.unreadMessages) { + return count + } + + // ONE_TO_ONE or ONE_TO_ONE_FORMER - always count unread + if (conversation.type === 1 || conversation.type === 5) { + return count + 1 + } + + // NOTE_TO_SELF (type 6) - always count unread + if (conversation.type === 6) { + return count + 1 + } - return count - }, 0) + // Group conversations with "Always notify" - count all unread + if (conversation.notificationLevel === 1) { + return count + 1 + } + + // Group conversations with "Mention only" - only count if @mentioned + if (conversation.notificationLevel === 2 && conversation.unreadMention) { + return count + 1 + } + + return count + }, 0) + } catch { + return 0 + } }