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;