From 4d91e97f0f30d47a3a65ad54c1fa2f90076346f2 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 30 Oct 2024 12:06:31 -0400 Subject: [PATCH] Make themed widgets reflect the effective theme So that widgets such as Element Call will show up in the right theme even if the app is set to match the system theme. --- src/components/structures/MatrixChat.tsx | 5 +- .../views/dialogs/ModalWidgetDialog.tsx | 15 ++++- src/settings/watchers/ThemeWatcher.ts | 18 ++++-- src/stores/widgets/StopGapWidget.ts | 17 ++++-- .../views/dialogs/ModalWidgetDialog-test.tsx | 56 +++++++++++++++++++ .../stores/widgets/StopGapWidget-test.ts | 28 +++++++++- yarn.lock | 6 +- 7 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 test/components/views/dialogs/ModalWidgetDialog-test.tsx diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index f417bd6045..14ac68fbd5 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -50,7 +50,7 @@ import ThemeController from "../../settings/controllers/ThemeController"; import { startAnyRegistrationFlow } from "../../Registration"; import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; -import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; +import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from "../../settings/watchers/FontWatcher"; import { storeRoomAliasInCache } from "../../RoomAliasCache"; import ToastStore from "../../stores/ToastStore"; @@ -133,6 +133,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView" import { LoginSplashView } from "./auth/LoginSplashView"; import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore"; +import { setTheme } from "../../theme"; // legacy export export { default as Views } from "../../Views"; @@ -465,6 +466,7 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher = new ThemeWatcher(); this.fontWatcher = new FontWatcher(); this.themeWatcher.start(); + this.themeWatcher.on(ThemeWatcherEvent.Change, setTheme); this.fontWatcher.start(); initSentry(SdkConfig.get("sentry")); @@ -497,6 +499,7 @@ export default class MatrixChat extends React.PureComponent { public componentWillUnmount(): void { Lifecycle.stopMatrixClient(); dis.unregister(this.dispatcherRef); + this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme); this.themeWatcher?.stop(); this.fontWatcher?.stop(); UIStore.destroy(); diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 58c6c92a5e..1bc123c485 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -33,7 +33,7 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; import { ELEMENT_CLIENT_ID } from "../../../identifiers"; -import SettingsStore from "../../../settings/SettingsStore"; +import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher"; interface IProps { widgetDefinition: IModalWidgetOpenRequestData; @@ -54,6 +54,7 @@ export default class ModalWidgetDialog extends React.PureComponent = React.createRef(); + private readonly themeWatcher = new ThemeWatcher(); public state: IState = { disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter((b) => b.disabled).map((b) => b.id), @@ -77,6 +78,8 @@ export default class ModalWidgetDialog extends React.PureComponent { + this.themeWatcher.start(); + this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange); + // Theme may have changed while messaging was starting + this.onThemeChange(this.themeWatcher.getEffectiveTheme()); this.state.messaging?.sendWidgetConfig(this.props.widgetDefinition); }; @@ -94,6 +101,10 @@ export default class ModalWidgetDialog extends React.PureComponent { + this.state.messaging?.updateTheme({ name: theme }); + }; + private onWidgetClose = (ev: CustomEvent): void => { this.props.onFinished(true, ev.detail.data); }; @@ -127,7 +138,7 @@ export default class ModalWidgetDialog extends React.PureComponent void; +} + +export default class ThemeWatcher extends TypedEventEmitter { private themeWatchRef?: string; private systemThemeWatchRef?: string; private dispatcherRef?: string; @@ -29,6 +38,7 @@ export default class ThemeWatcher { private currentTheme: string; public constructor() { + super(); // we have both here as each may either match or not match, so by having both // we can get the tristate of dark/light/unsupported this.preferDark = (global).matchMedia("(prefers-color-scheme: dark)"); @@ -72,9 +82,7 @@ export default class ThemeWatcher { public recheck(forceTheme?: string): void { const oldTheme = this.currentTheme; this.currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme; - if (oldTheme !== this.currentTheme) { - setTheme(this.currentTheme); - } + if (oldTheme !== this.currentTheme) this.emit(ThemeWatcherEvent.Change, this.currentTheme); } public getEffectiveTheme(): string { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 40e36473e3..59255213c6 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -37,7 +37,6 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; import WidgetUtils from "../../utils/WidgetUtils"; import { IntegrationManagers } from "../../integrations/IntegrationManagers"; -import SettingsStore from "../../settings/SettingsStore"; import { WidgetType } from "../../widgets/WidgetType"; import ActiveWidgetStore from "../ActiveWidgetStore"; import { objectShallowClone } from "../../utils/objects"; @@ -46,7 +45,7 @@ import { Action } from "../../dispatcher/actions"; import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions"; import { ModalWidgetStore } from "../ModalWidgetStore"; import { IApp, isAppWidget } from "../WidgetStore"; -import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; +import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { getCustomTheme } from "../../theme"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { ELEMENT_CLIENT_ID } from "../../identifiers"; @@ -153,6 +152,7 @@ export class StopGapWidget extends EventEmitter { private roomId?: string; private kind: WidgetKind; private readonly virtual: boolean; + private readonly themeWatcher = new ThemeWatcher(); private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID // This promise will be called and needs to resolve before the widget will actually become sticky. private stickyPromise?: () => Promise; @@ -214,7 +214,7 @@ export class StopGapWidget extends EventEmitter { userDisplayName: OwnProfileStore.instance.displayName ?? undefined, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined, clientId: ELEMENT_CLIENT_ID, - clientTheme: SettingsStore.getValue("theme"), + clientTheme: this.themeWatcher.getEffectiveTheme(), clientLanguage: getUserLanguage(), deviceId: this.client.getDeviceId() ?? undefined, baseUrl: this.client.baseUrl, @@ -246,6 +246,10 @@ export class StopGapWidget extends EventEmitter { return !!this.messaging; } + private onThemeChange = (theme: string): void => { + this.messaging?.updateTheme({ name: theme }); + }; + private onOpenModal = async (ev: CustomEvent): Promise => { ev.preventDefault(); if (ModalWidgetStore.instance.canOpenModalWidget()) { @@ -278,9 +282,14 @@ export class StopGapWidget extends EventEmitter { this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("error:preparing", (err: unknown) => this.emit("error:preparing", err)); - this.messaging.on("ready", () => { + this.messaging.once("ready", () => { WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging!); this.emit("ready"); + + this.themeWatcher.start(); + this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange); + // Theme may have changed while messaging was starting + this.onThemeChange(this.themeWatcher.getEffectiveTheme()); }); this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); diff --git a/test/components/views/dialogs/ModalWidgetDialog-test.tsx b/test/components/views/dialogs/ModalWidgetDialog-test.tsx new file mode 100644 index 0000000000..6ee5831f84 --- /dev/null +++ b/test/components/views/dialogs/ModalWidgetDialog-test.tsx @@ -0,0 +1,56 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { fireEvent, render } from "jest-matrix-react"; +import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api"; +import React from "react"; +import { TooltipProvider } from "@vector-im/compound-web"; +import { mocked } from "jest-mock"; +import { findLast, last } from "lodash"; + +import ModalWidgetDialog from "../../../../src/components/views/dialogs/ModalWidgetDialog"; +import { stubClient } from "../../../test-utils"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; +import SettingsStore from "../../../../src/settings/SettingsStore"; + +jest.mock("matrix-widget-api", () => ({ + ...jest.requireActual("matrix-widget-api"), + ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi, +})); + +describe("ModalWidgetDialog", () => { + it("informs the widget of theme changes", () => { + stubClient(); + let theme = "light"; + const settingsSpy = jest + .spyOn(SettingsStore, "getValue") + .mockImplementation((name) => (name === "theme" ? theme : undefined)); + try { + render( + + {}} + /> + , + ); + // Indicate that the widget is loaded and ready + fireEvent.load(document.getElementsByTagName("iframe").item(0)!); + const messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1](); + + // Now change the theme + theme = "dark"; + defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true); + expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" }); + } finally { + settingsSpy.mockRestore(); + } + }); +}); diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index f767c96a02..a5006db7df 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { mocked, MockedObject } from "jest-mock"; -import { last } from "lodash"; +import { findLast, last } from "lodash"; import { MatrixEvent, MatrixClient, @@ -24,8 +24,13 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget"; import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore"; import SettingsStore from "../../../../src/settings/SettingsStore"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; -jest.mock("matrix-widget-api/lib/ClientWidgetApi"); +jest.mock("matrix-widget-api", () => ({ + ...jest.requireActual("matrix-widget-api"), + ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi, +})); describe("StopGapWidget", () => { let client: MockedObject; @@ -84,6 +89,25 @@ describe("StopGapWidget", () => { expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false); }); + it("informs widget of theme changes", () => { + let theme = "light"; + const settingsSpy = jest + .spyOn(SettingsStore, "getValue") + .mockImplementation((name) => (name === "theme" ? theme : undefined)); + try { + // Indicate that the widget is ready + findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1](); + + // Now change the theme + theme = "dark"; + defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true); + expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" }); + } finally { + console.log("TEST OVER"); + settingsSpy.mockRestore(); + } + }); + describe("feed event", () => { let event1: MatrixEvent; let event2: MatrixEvent; diff --git a/yarn.lock b/yarn.lock index 3846aa17e8..17bc72ff46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8658,9 +8658,9 @@ matrix-web-i18n@^3.2.1: walk "^2.3.15" matrix-widget-api@^1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.10.0.tgz#d31ea073a5871a1fb1a511ef900b0c125a37bf55" - integrity sha512-rkAJ29briYV7TJnfBVLVSKtpeBrBju15JZFSDP6wj8YdbCu1bdmlplJayQ+vYaw1x4fzI49Q+Nz3E85s46sRDw== + version "1.11.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.11.0.tgz#2f548b11a7c0df789d5d4fdb5cc9ef7af8aef3da" + integrity sha512-ED/9hrJqDWVLeED0g1uJnYRhINh3ZTquwurdM+Hc8wLVJIQ8G/r7A7z74NC+8bBIHQ1Jo7i1Uq5CoJp/TzFYrA== dependencies: "@types/events" "^3.0.0" events "^3.2.0"