diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index d4ae6c897be..77e57e14343 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -135,6 +135,7 @@ declare global { initialise(): Promise<{ protocol: string; sessionId: string; + supportsBadgeOverlay: boolean; config: IConfigOptions; supportedSettings: Record; }>; diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 87635a421c5..c1a64848d22 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -494,15 +494,12 @@ export default abstract class BasePlatform { } private updateFavicon(): void { - let bgColor = "#d00"; - let notif: string | number = this.notificationCount; + const notif: string | number = this.notificationCount; if (this.errorDidOccur) { - notif = notif || "×"; - bgColor = "#f00"; + this.favicon.badge(notif || "×", { bgColor: "#f00" }); } - - this.favicon.badge(notif, { bgColor }); + this.favicon.badge(notif); } /** diff --git a/src/favicon.ts b/src/favicon.ts index e109c734602..7ff95e2f504 100644 --- a/src/favicon.ts +++ b/src/favicon.ts @@ -1,5 +1,5 @@ /* -Copyright 2020-2024 New Vector Ltd. +Copyright 2020-2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. @@ -28,56 +28,19 @@ const defaults: IParams = { isLeft: false, }; -// Allows dynamic rendering of a circular badge atop the loaded favicon -// supports colour, font and basic positioning parameters. -// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license] -export default class Favicon { - private readonly browser = { - ff: typeof window.InstallTrigger !== "undefined", - opera: !!window.opera || navigator.userAgent.includes("Opera"), - }; - - private readonly params: IParams; - private readonly canvas: HTMLCanvasElement; - private readonly baseImage: HTMLImageElement; - private context!: CanvasRenderingContext2D; - private icons: HTMLLinkElement[]; - - private isReady = false; - // callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown - private readyCb?: () => void; - - public constructor(params: Partial = {}) { - this.params = { ...defaults, ...params }; - - this.icons = Favicon.getIcons(); - // create work canvas +abstract class IconRenderer { + protected readonly canvas: HTMLCanvasElement; + protected readonly context: CanvasRenderingContext2D; + public constructor( + protected readonly params: IParams = defaults, + protected readonly baseImage?: HTMLImageElement, + ) { this.canvas = document.createElement("canvas"); - // create clone of favicon as a base - this.baseImage = document.createElement("img"); - - const lastIcon = this.icons[this.icons.length - 1]; - if (lastIcon.hasAttribute("href")) { - this.baseImage.setAttribute("crossOrigin", "anonymous"); - this.baseImage.onload = (): void => { - // get height and width of the favicon - this.canvas.height = this.baseImage.height > 0 ? this.baseImage.height : 32; - this.canvas.width = this.baseImage.width > 0 ? this.baseImage.width : 32; - this.context = this.canvas.getContext("2d")!; - this.ready(); - }; - this.baseImage.setAttribute("src", lastIcon.getAttribute("href")!); - } else { - this.canvas.height = this.baseImage.height = 32; - this.canvas.width = this.baseImage.width = 32; - this.context = this.canvas.getContext("2d")!; - this.ready(); + const context = this.canvas.getContext("2d"); + if (!context) { + throw Error("Could not get canvas context"); } - } - - private reset(): void { - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height); + this.context = context; } private options( @@ -125,11 +88,23 @@ export default class Favicon { return opt; } - private circle(n: number | string, opts?: Partial): void { + /** + * Draws a circualr status icon, usually over the top of the application icon. + * @param n The content of the circle. Should be a number or a single character. + * @param opts Options to adjust. + */ + protected circle(n: number | string, opts?: Partial): void { const params = { ...this.params, ...opts }; const opt = this.options(n, params); let more = false; + if (!this.baseImage) { + // If we omit the background, assume the entire canvas is our target. + opt.x = 0; + opt.y = 0; + opt.w = this.canvas.width; + opt.h = this.canvas.height; + } if (opt.len === 2) { opt.x = opt.x - opt.w * 0.4; opt.w = opt.w * 1.4; @@ -141,7 +116,9 @@ export default class Favicon { } this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height); + if (this.baseImage) { + this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height); + } this.context.beginPath(); const fontSize = Math.floor(opt.h * (typeof opt.n === "number" && opt.n > 99 ? 0.85 : 1)) + "px"; this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`; @@ -177,6 +154,86 @@ export default class Favicon { this.context.closePath(); } +} + +export class BadgeOverlayRenderer extends IconRenderer { + public constructor() { + super(); + // Overlays are 16x16 https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows + this.canvas.width = 16; + this.canvas.height = 16; + } + + /** + * Generate an overlay badge without the application icon, and export + * as an ArrayBuffer + * @param contents The content of the circle. Should be a number or a single character. + * @param bgColor Optional alternative background colo.r + * @returns An ArrayBuffer representing a 16x16 icon in `image/png` format, or `null` if no badge should be drawn. + */ + public async render(contents: number | string, bgColor?: string): Promise { + if (contents === 0) { + return null; + } + + this.circle(contents, { ...(bgColor ? { bgColor } : undefined) }); + return new Promise((resolve, reject) => { + this.canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob.arrayBuffer()); + } + reject(new Error("Could not render badge overlay as blob")); + }, + "image/png", + 1, + ); + }); + } +} + +// Allows dynamic rendering of a circular badge atop the loaded favicon +// supports colour, font and basic positioning parameters. +// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license] +export default class Favicon extends IconRenderer { + private readonly browser = { + ff: typeof window.InstallTrigger !== "undefined", + opera: !!window.opera || navigator.userAgent.includes("Opera"), + }; + + private icons: HTMLLinkElement[]; + + private isReady = false; + // callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown + private readyCb?: () => void; + + public constructor() { + const baseImage = document.createElement("img"); + super(defaults, baseImage); + + this.icons = Favicon.getIcons(); + + const lastIcon = this.icons[this.icons.length - 1]; + if (lastIcon.hasAttribute("href")) { + baseImage.setAttribute("crossOrigin", "anonymous"); + baseImage.onload = (): void => { + // get height and width of the favicon + this.canvas.height = baseImage.height > 0 ? baseImage.height : 32; + this.canvas.width = baseImage.width > 0 ? baseImage.width : 32; + this.ready(); + }; + baseImage.setAttribute("src", lastIcon.getAttribute("href")!); + } else { + this.canvas.height = baseImage.height = 32; + this.canvas.width = baseImage.width = 32; + this.ready(); + } + } + + private reset(): void { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.context.drawImage(this.baseImage!, 0, 0, this.canvas.width, this.canvas.height); + } private ready(): void { if (this.isReady) return; diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx index 2bacbe337d3..bef544d15de 100644 --- a/src/vector/platform/ElectronPlatform.tsx +++ b/src/vector/platform/ElectronPlatform.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024-2025 New Vector Ltd. Copyright 2022 Šimon Brandner Copyright 2018-2021 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> @@ -42,6 +42,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SeshatIndexManager } from "./SeshatIndexManager"; import { IPCManager } from "./IPCManager"; import { _t } from "../../languageHandler"; +import { BadgeOverlayRenderer } from "../../favicon"; interface SquirrelUpdate { releaseNotes: string; @@ -87,10 +88,11 @@ function getUpdateCheckStatus(status: boolean | string): UpdateStatus { export default class ElectronPlatform extends BasePlatform { private readonly ipc = new IPCManager("ipcCall", "ipcReply"); private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager(); - private readonly initialised: Promise; + public readonly initialised: Promise; private readonly electron: Electron; private protocol!: string; private sessionId!: string; + private badgeOverlayRenderer?: BadgeOverlayRenderer; private config!: IConfigOptions; private supportedSettings?: Record; @@ -194,11 +196,15 @@ export default class ElectronPlatform extends BasePlatform { } private async initialise(): Promise { - const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise(); + const { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay } = + await this.electron.initialise(); this.protocol = protocol; this.sessionId = sessionId; this.config = config; this.supportedSettings = supportedSettings; + if (supportsBadgeOverlay) { + this.badgeOverlayRenderer = new BadgeOverlayRenderer(); + } } public async getConfig(): Promise { @@ -249,8 +255,42 @@ export default class ElectronPlatform extends BasePlatform { public setNotificationCount(count: number): void { if (this.notificationCount === count) return; super.setNotificationCount(count); + if (this.badgeOverlayRenderer) { + this.badgeOverlayRenderer + .render(count) + .then((buffer) => { + this.electron.send("setBadgeCount", count, buffer); + }) + .catch((ex) => { + logger.warn("Unable to generate badge overlay", ex); + }); + } else { + this.electron.send("setBadgeCount", count); + } + } - this.electron.send("setBadgeCount", count); + public setErrorStatus(errorDidOccur: boolean): void { + if (!this.badgeOverlayRenderer) { + super.setErrorStatus(errorDidOccur); + return; + } + // Check before calling super so we don't override the previous state. + if (this.errorDidOccur !== errorDidOccur) { + super.setErrorStatus(errorDidOccur); + let promise: Promise; + if (errorDidOccur) { + promise = this.badgeOverlayRenderer.render(this.notificationCount || "×", "#f00"); + } else { + promise = this.badgeOverlayRenderer.render(this.notificationCount); + } + promise + .then((buffer) => { + this.electron.send("setBadgeCount", this.notificationCount, buffer, errorDidOccur); + }) + .catch((ex) => { + logger.warn("Unable to generate badge overlay", ex); + }); + } } public supportsNotifications(): boolean { diff --git a/test/unit-tests/vector/platform/ElectronPlatform-test.ts b/test/unit-tests/vector/platform/ElectronPlatform-test.ts index 40168231ac3..7b1669a04ed 100644 --- a/test/unit-tests/vector/platform/ElectronPlatform-test.ts +++ b/test/unit-tests/vector/platform/ElectronPlatform-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024-2025 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { mocked, type MockedObject } from "jest-mock"; +import { waitFor } from "jest-matrix-react"; import { UpdateCheckStatus } from "../../../../src/BasePlatform"; import { Action } from "../../../../src/dispatcher/actions"; @@ -26,18 +27,20 @@ jest.mock("../../../../src/rageshake/rageshake", () => ({ })); describe("ElectronPlatform", () => { + const initialiseValues = jest.fn().mockReturnValue({ + protocol: "io.element.desktop", + sessionId: "session-id", + config: { _config: true }, + supportedSettings: { setting1: false, setting2: true }, + supportsBadgeOverlay: false, + }); const defaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"; const mockElectron = { on: jest.fn(), send: jest.fn(), - initialise: jest.fn().mockResolvedValue({ - protocol: "io.element.desktop", - sessionId: "session-id", - config: { _config: true }, - supportedSettings: { setting1: false, setting2: true }, - }), + initialise: initialiseValues, setSettingValue: jest.fn().mockResolvedValue(undefined), getSettingValue: jest.fn().mockResolvedValue(undefined), } as unknown as MockedObject; @@ -405,4 +408,101 @@ describe("ElectronPlatform", () => { state: "connected", }); }); + + describe("Notification overlay badges", () => { + beforeEach(() => { + initialiseValues.mockReturnValue({ + protocol: "io.element.desktop", + sessionId: "session-id", + config: { _config: true }, + supportsBadgeOverlay: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should send a badge with a notification count", async () => { + const platform = new ElectronPlatform(); + await platform.initialised; + platform.setNotificationCount(1); + // Badges are sent asynchronously + await waitFor(() => { + const ipcMessage = mockElectron.send.mock.lastCall; + expect(ipcMessage?.[1]).toEqual(1); + expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true); + }); + }); + + it("should update badge and skip duplicates", async () => { + const platform = new ElectronPlatform(); + await platform.initialised; + platform.setNotificationCount(1); + platform.setNotificationCount(1); // Test that duplicates do not fire. + platform.setNotificationCount(2); + // Badges are sent asynchronously + await waitFor(() => { + const [ipcMessageA, ipcMessageB] = mockElectron.send.mock.calls.filter( + (call) => call[0] === "setBadgeCount", + ); + + expect(ipcMessageA?.[1]).toEqual(1); + expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true); + + expect(ipcMessageB?.[1]).toEqual(2); + expect(ipcMessageB?.[2] instanceof ArrayBuffer).toEqual(true); + }); + }); + it("should remove badge when notification count zeros", async () => { + const platform = new ElectronPlatform(); + await platform.initialised; + platform.setNotificationCount(1); + platform.setNotificationCount(0); // Test that duplicates do not fire. + // Badges are sent asynchronously + await waitFor(() => { + const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter( + (call) => call[0] === "setBadgeCount", + ); + + expect(ipcMessageA?.[1]).toEqual(1); + expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true); + + expect(ipcMessageB?.[1]).toEqual(0); + expect(ipcMessageB?.[2]).toBeNull(); + }); + }); + it("should show an error badge when the application errors", async () => { + const platform = new ElectronPlatform(); + await platform.initialised; + platform.setErrorStatus(true); + // Badges are sent asynchronously + await waitFor(() => { + const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "setBadgeCount"); + + expect(ipcMessage?.[1]).toEqual(0); + expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true); + expect(ipcMessage?.[3]).toEqual(true); + }); + }); + it("should restore after error is resolved", async () => { + const platform = new ElectronPlatform(); + await platform.initialised; + platform.setErrorStatus(true); + platform.setErrorStatus(false); + // Badges are sent asynchronously + await waitFor(() => { + const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter( + (call) => call[0] === "setBadgeCount", + ); + + expect(ipcMessageA?.[1]).toEqual(0); + expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true); + expect(ipcMessageA?.[3]).toEqual(true); + + expect(ipcMessageB?.[1]).toEqual(0); + expect(ipcMessageB?.[2]).toBeNull(); + }); + }); + }); });