Support rendering notification badges on platforms that do their own icon overlays (#30315)

* Support rendering a seperate overlay icon on supported platforms.

* Add required globals.

* i18n-ize

* Add tests

* lint

* lint

* lint

* update copyrights

* Fix test

* lint

* Fixup

* lint

* remove unused string

* fix test
This commit is contained in:
Will Hunt 2025-07-17 13:59:17 +01:00 committed by GitHub
parent 3b0c04c2e9
commit bc1effd2a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 263 additions and 68 deletions

View File

@ -135,6 +135,7 @@ declare global {
initialise(): Promise<{
protocol: string;
sessionId: string;
supportsBadgeOverlay: boolean;
config: IConfigOptions;
supportedSettings: Record<string, boolean>;
}>;

View File

@ -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);
}
/**

View File

@ -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<IParams> = {}) {
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<IParams>): 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<IParams>): 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<ArrayBuffer | null> {
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;

View File

@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024-2025 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
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<void>;
public readonly initialised: Promise<void>;
private readonly electron: Electron;
private protocol!: string;
private sessionId!: string;
private badgeOverlayRenderer?: BadgeOverlayRenderer;
private config!: IConfigOptions;
private supportedSettings?: Record<string, boolean>;
@ -194,11 +196,15 @@ export default class ElectronPlatform extends BasePlatform {
}
private async initialise(): Promise<void> {
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<IConfigOptions | undefined> {
@ -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<ArrayBuffer | null>;
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 {

View File

@ -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<Electron>;
@ -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();
});
});
});
});