Skip to content

Commit c453d33

Browse files
authored
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
1 parent ddf221b commit c453d33

File tree

6 files changed

+124
-14
lines changed

6 files changed

+124
-14
lines changed

src/components/structures/MatrixChat.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import ThemeController from "../../settings/controllers/ThemeController";
5050
import { startAnyRegistrationFlow } from "../../Registration";
5151
import ResizeNotifier from "../../utils/ResizeNotifier";
5252
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
53-
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
53+
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
5454
import { FontWatcher } from "../../settings/watchers/FontWatcher";
5555
import { storeRoomAliasInCache } from "../../RoomAliasCache";
5656
import ToastStore from "../../stores/ToastStore";
@@ -131,6 +131,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"
131131
import { LoginSplashView } from "./auth/LoginSplashView";
132132
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
133133
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
134+
import { setTheme } from "../../theme";
134135

135136
// legacy export
136137
export { default as Views } from "../../Views";
@@ -463,6 +464,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
463464
this.themeWatcher = new ThemeWatcher();
464465
this.fontWatcher = new FontWatcher();
465466
this.themeWatcher.start();
467+
this.themeWatcher.on(ThemeWatcherEvent.Change, setTheme);
466468
this.fontWatcher.start();
467469

468470
initSentry(SdkConfig.get("sentry"));
@@ -495,6 +497,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
495497
public componentWillUnmount(): void {
496498
Lifecycle.stopMatrixClient();
497499
dis.unregister(this.dispatcherRef);
500+
this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme);
498501
this.themeWatcher?.stop();
499502
this.fontWatcher?.stop();
500503
UIStore.destroy();

src/components/views/dialogs/ModalWidgetDialog.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore";
3333
import { arrayFastClone } from "../../../utils/arrays";
3434
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
3535
import { ELEMENT_CLIENT_ID } from "../../../identifiers";
36-
import SettingsStore from "../../../settings/SettingsStore";
36+
import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher";
3737

3838
interface IProps {
3939
widgetDefinition: IModalWidgetOpenRequestData;
@@ -54,6 +54,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
5454
private readonly widget: Widget;
5555
private readonly possibleButtons: ModalButtonID[];
5656
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
57+
private readonly themeWatcher = new ThemeWatcher();
5758

5859
public state: IState = {
5960
disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter((b) => b.disabled).map((b) => b.id),
@@ -77,13 +78,19 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
7778
}
7879

7980
public componentWillUnmount(): void {
81+
this.themeWatcher.off(ThemeWatcherEvent.Change, this.onThemeChange);
82+
this.themeWatcher.stop();
8083
if (!this.state.messaging) return;
8184
this.state.messaging.off("ready", this.onReady);
8285
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
8386
this.state.messaging.stop();
8487
}
8588

8689
private onReady = (): void => {
90+
this.themeWatcher.start();
91+
this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange);
92+
// Theme may have changed while messaging was starting
93+
this.onThemeChange(this.themeWatcher.getEffectiveTheme());
8794
this.state.messaging?.sendWidgetConfig(this.props.widgetDefinition);
8895
};
8996

@@ -94,6 +101,10 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
94101
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
95102
};
96103

104+
private onThemeChange = (theme: string): void => {
105+
this.state.messaging?.updateTheme({ name: theme });
106+
};
107+
97108
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>): void => {
98109
this.props.onFinished(true, ev.detail.data);
99110
};
@@ -127,7 +138,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
127138
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
128139
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
129140
clientId: ELEMENT_CLIENT_ID,
130-
clientTheme: SettingsStore.getValue("theme"),
141+
clientTheme: this.themeWatcher.getEffectiveTheme(),
131142
clientLanguage: getUserLanguage(),
132143
baseUrl: MatrixClientPeg.safeGet().baseUrl,
133144
});

src/settings/watchers/ThemeWatcher.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,25 @@ Please see LICENSE files in the repository root for full details.
88
*/
99

1010
import { logger } from "matrix-js-sdk/src/logger";
11+
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
1112

1213
import SettingsStore from "../SettingsStore";
1314
import dis from "../../dispatcher/dispatcher";
1415
import { Action } from "../../dispatcher/actions";
1516
import ThemeController from "../controllers/ThemeController";
16-
import { findHighContrastTheme, setTheme } from "../../theme";
17+
import { findHighContrastTheme } from "../../theme";
1718
import { ActionPayload } from "../../dispatcher/payloads";
1819
import { SettingLevel } from "../SettingLevel";
1920

20-
export default class ThemeWatcher {
21+
export enum ThemeWatcherEvent {
22+
Change = "change",
23+
}
24+
25+
interface ThemeWatcherEventHandlerMap {
26+
[ThemeWatcherEvent.Change]: (theme: string) => void;
27+
}
28+
29+
export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvent, ThemeWatcherEventHandlerMap> {
2130
private themeWatchRef?: string;
2231
private systemThemeWatchRef?: string;
2332
private dispatcherRef?: string;
@@ -29,6 +38,7 @@ export default class ThemeWatcher {
2938
private currentTheme: string;
3039

3140
public constructor() {
41+
super();
3242
// we have both here as each may either match or not match, so by having both
3343
// we can get the tristate of dark/light/unsupported
3444
this.preferDark = (<any>global).matchMedia("(prefers-color-scheme: dark)");
@@ -72,9 +82,7 @@ export default class ThemeWatcher {
7282
public recheck(forceTheme?: string): void {
7383
const oldTheme = this.currentTheme;
7484
this.currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme;
75-
if (oldTheme !== this.currentTheme) {
76-
setTheme(this.currentTheme);
77-
}
85+
if (oldTheme !== this.currentTheme) this.emit(ThemeWatcherEvent.Change, this.currentTheme);
7886
}
7987

8088
public getEffectiveTheme(): string {

src/stores/widgets/StopGapWidget.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
4343
import { OwnProfileStore } from "../OwnProfileStore";
4444
import WidgetUtils from "../../utils/WidgetUtils";
4545
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
46-
import SettingsStore from "../../settings/SettingsStore";
4746
import { WidgetType } from "../../widgets/WidgetType";
4847
import ActiveWidgetStore from "../ActiveWidgetStore";
4948
import { objectShallowClone } from "../../utils/objects";
@@ -52,7 +51,7 @@ import { Action } from "../../dispatcher/actions";
5251
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
5352
import { ModalWidgetStore } from "../ModalWidgetStore";
5453
import { IApp, isAppWidget } from "../WidgetStore";
55-
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
54+
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
5655
import { getCustomTheme } from "../../theme";
5756
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
5857
import { ELEMENT_CLIENT_ID } from "../../identifiers";
@@ -163,6 +162,7 @@ export class StopGapWidget extends EventEmitter {
163162
private viewedRoomId: string | null = null;
164163
private kind: WidgetKind;
165164
private readonly virtual: boolean;
165+
private readonly themeWatcher = new ThemeWatcher();
166166
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
167167
// This promise will be called and needs to resolve before the widget will actually become sticky.
168168
private stickyPromise?: () => Promise<void>;
@@ -213,7 +213,7 @@ export class StopGapWidget extends EventEmitter {
213213
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
214214
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
215215
clientId: ELEMENT_CLIENT_ID,
216-
clientTheme: SettingsStore.getValue("theme"),
216+
clientTheme: this.themeWatcher.getEffectiveTheme(),
217217
clientLanguage: getUserLanguage(),
218218
deviceId: this.client.getDeviceId() ?? undefined,
219219
baseUrl: this.client.baseUrl,
@@ -245,6 +245,10 @@ export class StopGapWidget extends EventEmitter {
245245
return !!this.messaging;
246246
}
247247

248+
private onThemeChange = (theme: string): void => {
249+
this.messaging?.updateTheme({ name: theme });
250+
};
251+
248252
private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>): Promise<void> => {
249253
ev.preventDefault();
250254
if (ModalWidgetStore.instance.canOpenModalWidget()) {
@@ -288,9 +292,14 @@ export class StopGapWidget extends EventEmitter {
288292
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
289293
this.messaging.on("preparing", () => this.emit("preparing"));
290294
this.messaging.on("error:preparing", (err: unknown) => this.emit("error:preparing", err));
291-
this.messaging.on("ready", () => {
295+
this.messaging.once("ready", () => {
292296
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging!);
293297
this.emit("ready");
298+
299+
this.themeWatcher.start();
300+
this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange);
301+
// Theme may have changed while messaging was starting
302+
this.onThemeChange(this.themeWatcher.getEffectiveTheme());
294303
});
295304
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
296305
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
Copyright 2024 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { fireEvent, render } from "jest-matrix-react";
9+
import { ClientWidgetApi, MatrixWidgetType } from "matrix-widget-api";
10+
import React from "react";
11+
import { TooltipProvider } from "@vector-im/compound-web";
12+
import { mocked } from "jest-mock";
13+
import { findLast, last } from "lodash";
14+
15+
import ModalWidgetDialog from "../../../../src/components/views/dialogs/ModalWidgetDialog";
16+
import { stubClient } from "../../../test-utils";
17+
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
18+
import { Action } from "../../../../src/dispatcher/actions";
19+
import SettingsStore from "../../../../src/settings/SettingsStore";
20+
21+
jest.mock("matrix-widget-api", () => ({
22+
...jest.requireActual("matrix-widget-api"),
23+
ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi,
24+
}));
25+
26+
describe("ModalWidgetDialog", () => {
27+
it("informs the widget of theme changes", () => {
28+
stubClient();
29+
let theme = "light";
30+
const settingsSpy = jest
31+
.spyOn(SettingsStore, "getValue")
32+
.mockImplementation((name) => (name === "theme" ? theme : null));
33+
try {
34+
render(
35+
<TooltipProvider>
36+
<ModalWidgetDialog
37+
widgetDefinition={{ type: MatrixWidgetType.Custom, url: "https://example.org" }}
38+
sourceWidgetId=""
39+
onFinished={() => {}}
40+
/>
41+
</TooltipProvider>,
42+
);
43+
// Indicate that the widget is loaded and ready
44+
fireEvent.load(document.getElementsByTagName("iframe").item(0)!);
45+
const messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!);
46+
findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1]();
47+
48+
// Now change the theme
49+
theme = "dark";
50+
defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true);
51+
expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" });
52+
} finally {
53+
settingsSpy.mockRestore();
54+
}
55+
});
56+
});

test/unit-tests/stores/widgets/StopGapWidget-test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
77
*/
88

99
import { mocked, MockedFunction, MockedObject } from "jest-mock";
10-
import { last } from "lodash";
10+
import { findLast, last } from "lodash";
1111
import {
1212
MatrixEvent,
1313
MatrixClient,
@@ -27,10 +27,15 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
2727
import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget";
2828
import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore";
2929
import SettingsStore from "../../../../src/settings/SettingsStore";
30+
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
31+
import { Action } from "../../../../src/dispatcher/actions";
3032
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
3133
import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore";
3234

33-
jest.mock("matrix-widget-api/lib/ClientWidgetApi");
35+
jest.mock("matrix-widget-api", () => ({
36+
...jest.requireActual("matrix-widget-api"),
37+
ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi,
38+
}));
3439

3540
describe("StopGapWidget", () => {
3641
let client: MockedObject<MatrixClient>;
@@ -104,6 +109,24 @@ describe("StopGapWidget", () => {
104109
expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent());
105110
});
106111

112+
it("informs widget of theme changes", () => {
113+
let theme = "light";
114+
const settingsSpy = jest
115+
.spyOn(SettingsStore, "getValue")
116+
.mockImplementation((name) => (name === "theme" ? theme : null));
117+
try {
118+
// Indicate that the widget is ready
119+
findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1]();
120+
121+
// Now change the theme
122+
theme = "dark";
123+
defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true);
124+
expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" });
125+
} finally {
126+
settingsSpy.mockRestore();
127+
}
128+
});
129+
107130
describe("feed event", () => {
108131
let event1: MatrixEvent;
109132
let event2: MatrixEvent;

0 commit comments

Comments
 (0)