diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index c7b7825fe6..87635a421c 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -72,7 +72,7 @@ export default abstract class BasePlatform { protected _favicon?: Favicon; protected constructor() { - dis.register(this.onAction); + dis.register(this.onAction.bind(this)); this.startUpdateCheck = this.startUpdateCheck.bind(this); } @@ -85,14 +85,14 @@ export default abstract class BasePlatform { */ public abstract getDefaultDeviceDisplayName(): string; - protected onAction = (payload: ActionPayload): void => { + protected onAction(payload: ActionPayload): void { switch (payload.action) { case "on_client_not_viable": case Action.OnLoggedOut: this.setNotificationCount(0); break; } - }; + } // Used primarily for Analytics public abstract getHumanReadableName(): string; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e65d4809df..65370997f4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2457,6 +2457,10 @@ "recent_changes_heading": "Recent changes that have not yet been received", "title": "Server isn't responding" }, + "service_worker_error": { + "description": "%(brand)s requires a service worker for loading authenticated media from Matrix content repositories. This is not supported by your browser so you may experience media failing to load.", + "title": "Failed to load service worker" + }, "seshat": { "error_initialising": "Message search initialisation failed, check your settings for more information", "reset_button": "Reset event store", diff --git a/src/vector/platform/WebPlatform.ts b/src/vector/platform/WebPlatform.ts index 316f479e73..46b6a61f83 100644 --- a/src/vector/platform/WebPlatform.ts +++ b/src/vector/platform/WebPlatform.ts @@ -18,6 +18,10 @@ import { Action } from "../../dispatcher/actions"; import { type CheckUpdatesPayload } from "../../dispatcher/payloads/CheckUpdatesPayload"; import { parseQs } from "../url_utils"; import { _t } from "../../languageHandler"; +import ToastStore from "../../stores/ToastStore.ts"; +import GenericToast from "../../components/views/toasts/GenericToast.tsx"; +import SdkConfig from "../../SdkConfig.ts"; +import type { ActionPayload } from "../../dispatcher/payloads.ts"; const POKE_RATE_MS = 10 * 60 * 1000; // 10 min @@ -32,32 +36,59 @@ function getNormalizedAppVersion(version: string): string { export default class WebPlatform extends BasePlatform { private static readonly VERSION = process.env.VERSION!; // baked in by Webpack + private readonly registerServiceWorkerPromise: Promise; public constructor() { super(); // Register the service worker in the background - this.tryRegisterServiceWorker().catch((e) => console.error("Error registering/updating service worker:", e)); + this.registerServiceWorkerPromise = this.registerServiceWorker(); + this.registerServiceWorkerPromise.catch((e) => { + console.error("Error registering/updating service worker:", e); + }); } - private async tryRegisterServiceWorker(): Promise { - if (!("serviceWorker" in navigator)) { - return; // not available on this platform - don't try to register the service worker - } + protected onAction(payload: ActionPayload): void { + super.onAction(payload); + switch (payload.action) { + case "client_started": + // Defer drawing the toast until the client is started as the lifecycle methods reset the ToastStore right before + this.registerServiceWorkerPromise.catch(this.handleServiceWorkerRegistrationError); + break; + } + } + + private async registerServiceWorker(): Promise { // sw.js is exported by webpack, sourced from `/src/serviceworker/index.ts` const registration = await navigator.serviceWorker.register("sw.js"); if (!registration) { - // Registration didn't work for some reason - assume failed and ignore. - // This typically happens in Jest. - return; + throw new Error("Service worker registration failed"); } - navigator.serviceWorker.addEventListener("message", this.onServiceWorkerPostMessage.bind(this)); + navigator.serviceWorker.addEventListener("message", this.onServiceWorkerPostMessage); await registration.update(); } - private onServiceWorkerPostMessage(event: MessageEvent): void { + private handleServiceWorkerRegistrationError = (): void => { + const key = "service_worker_error"; + const brand = SdkConfig.get().brand; + ToastStore.sharedInstance().addOrReplaceToast({ + key, + title: _t("service_worker_error|title"), + props: { + description: _t("service_worker_error|description", { brand }), + primaryLabel: _t("action|ok"), + onPrimaryClick: () => { + ToastStore.sharedInstance().dismissToast(key); + }, + }, + component: GenericToast, + priority: 95, + }); + }; + + private onServiceWorkerPostMessage = (event: MessageEvent): void => { try { if (event.data?.["type"] === "userinfo" && event.data?.["responseKey"]) { const userId = localStorage.getItem("mx_user_id"); @@ -73,7 +104,7 @@ export default class WebPlatform extends BasePlatform { } catch (e) { console.error("Error responding to service worker: ", e); } - } + }; public getHumanReadableName(): string { return "Web Platform"; // no translation required: only used for analytics diff --git a/test/unit-tests/vector/platform/WebPlatform-test.ts b/test/unit-tests/vector/platform/WebPlatform-test.ts index b47f2d5537..7c1048fe05 100644 --- a/test/unit-tests/vector/platform/WebPlatform-test.ts +++ b/test/unit-tests/vector/platform/WebPlatform-test.ts @@ -12,6 +12,9 @@ import { UpdateCheckStatus } from "../../../../src/BasePlatform"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import WebPlatform from "../../../../src/vector/platform/WebPlatform"; import { setupLanguageMock } from "../../../setup/setupLanguage"; +import ToastStore from "../../../../src/stores/ToastStore.ts"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher.ts"; +import { emitPromise } from "../../../test-utils"; fetchMock.config.overwriteRoutes = true; @@ -26,11 +29,32 @@ describe("WebPlatform", () => { expect(platform.getHumanReadableName()).toEqual("Web Platform"); }); - it("registers service worker", () => { - // @ts-ignore - mocking readonly object - navigator.serviceWorker = { register: jest.fn() }; - new WebPlatform(); - expect(navigator.serviceWorker.register).toHaveBeenCalled(); + describe("service worker", () => { + it("registers successfully", () => { + // @ts-expect-error - mocking readonly object + navigator.serviceWorker = { + register: jest.fn().mockResolvedValue({ + update: jest.fn(), + }), + addEventListener: jest.fn(), + }; + new WebPlatform(); + expect(navigator.serviceWorker.register).toHaveBeenCalled(); + }); + + it("handles errors", async () => { + // @ts-expect-error - mocking readonly object + navigator.serviceWorker = { + register: undefined, + }; + new WebPlatform(); + + defaultDispatcher.dispatch({ action: "client_started" }); + await emitPromise(ToastStore.sharedInstance(), "update"); + const toasts = ToastStore.sharedInstance().getToasts(); + expect(toasts).toHaveLength(1); + expect(toasts[0].title).toEqual("Failed to load service worker"); + }); }); it("should call reload on window location object", () => { @@ -146,24 +170,20 @@ describe("WebPlatform", () => { }); describe("pollForUpdate()", () => { - it( - "should return not available and call showNoUpdate when current version " + - "matches most recent version", - async () => { - // @ts-ignore - WebPlatform.VERSION = prodVersion; - fetchMock.getOnce("/version", prodVersion); - const platform = new WebPlatform(); + it("should return not available and call showNoUpdate when current version matches most recent version", async () => { + // @ts-ignore + WebPlatform.VERSION = prodVersion; + fetchMock.getOnce("/version", prodVersion); + const platform = new WebPlatform(); - const showUpdate = jest.fn(); - const showNoUpdate = jest.fn(); - const result = await platform.pollForUpdate(showUpdate, showNoUpdate); + const showUpdate = jest.fn(); + const showNoUpdate = jest.fn(); + const result = await platform.pollForUpdate(showUpdate, showNoUpdate); - expect(result).toEqual({ status: UpdateCheckStatus.NotAvailable }); - expect(showUpdate).not.toHaveBeenCalled(); - expect(showNoUpdate).toHaveBeenCalled(); - }, - ); + expect(result).toEqual({ status: UpdateCheckStatus.NotAvailable }); + expect(showUpdate).not.toHaveBeenCalled(); + expect(showNoUpdate).toHaveBeenCalled(); + }); it("should strip v prefix from versions before comparing", async () => { // @ts-ignore