From c453d33456f5a488365ead4ee1765c988e5b6db8 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 28 Jan 2025 05:53:35 -0500 Subject: [PATCH] Make themed widgets reflect the effective theme (#28342) * 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. * Remove debug log line --- 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 | 27 ++++++++- 6 files changed, 124 insertions(+), 14 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 9d3114c67c..8524938db9 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"; @@ -131,6 +131,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"; @@ -463,6 +464,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")); @@ -495,6 +497,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 c17aa81aab..6f3d554c11 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -43,7 +43,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"; @@ -52,7 +51,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"; @@ -163,6 +162,7 @@ export class StopGapWidget extends EventEmitter { private viewedRoomId: string | null = null; 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; @@ -213,7 +213,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, @@ -245,6 +245,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()) { @@ -288,9 +292,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..134aa46ad6 --- /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 : null)); + 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 61e96886b9..2422b176be 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, MockedFunction, MockedObject } from "jest-mock"; -import { last } from "lodash"; +import { findLast, last } from "lodash"; import { MatrixEvent, MatrixClient, @@ -27,10 +27,15 @@ 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"; import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore"; -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; @@ -104,6 +109,24 @@ describe("StopGapWidget", () => { expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent()); }); + it("informs widget of theme changes", () => { + let theme = "light"; + const settingsSpy = jest + .spyOn(SettingsStore, "getValue") + .mockImplementation((name) => (name === "theme" ? theme : null)); + 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 { + settingsSpy.mockRestore(); + } + }); + describe("feed event", () => { let event1: MatrixEvent; let event2: MatrixEvent;