Skip to content

Commit 146db61

Browse files
committed
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.
1 parent ebef0d3 commit 146db61

File tree

7 files changed

+128
-17
lines changed

7 files changed

+128
-17
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";
@@ -133,6 +133,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"
133133
import { LoginSplashView } from "./auth/LoginSplashView";
134134
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
135135
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
136+
import { setTheme } from "../../theme";
136137

137138
// legacy export
138139
export { default as Views } from "../../Views";
@@ -465,6 +466,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
465466
this.themeWatcher = new ThemeWatcher();
466467
this.fontWatcher = new FontWatcher();
467468
this.themeWatcher.start();
469+
this.themeWatcher.on(ThemeWatcherEvent.Change, setTheme);
468470
this.fontWatcher.start();
469471

470472
initSentry(SdkConfig.get("sentry"));
@@ -497,6 +499,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
497499
public componentWillUnmount(): void {
498500
Lifecycle.stopMatrixClient();
499501
dis.unregister(this.dispatcherRef);
502+
this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme);
500503
this.themeWatcher?.stop();
501504
this.fontWatcher?.stop();
502505
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
@@ -37,7 +37,6 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
3737
import { OwnProfileStore } from "../OwnProfileStore";
3838
import WidgetUtils from "../../utils/WidgetUtils";
3939
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
40-
import SettingsStore from "../../settings/SettingsStore";
4140
import { WidgetType } from "../../widgets/WidgetType";
4241
import ActiveWidgetStore from "../ActiveWidgetStore";
4342
import { objectShallowClone } from "../../utils/objects";
@@ -46,7 +45,7 @@ import { Action } from "../../dispatcher/actions";
4645
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
4746
import { ModalWidgetStore } from "../ModalWidgetStore";
4847
import { IApp, isAppWidget } from "../WidgetStore";
49-
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
48+
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
5049
import { getCustomTheme } from "../../theme";
5150
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
5251
import { ELEMENT_CLIENT_ID } from "../../identifiers";
@@ -153,6 +152,7 @@ export class StopGapWidget extends EventEmitter {
153152
private roomId?: string;
154153
private kind: WidgetKind;
155154
private readonly virtual: boolean;
155+
private readonly themeWatcher = new ThemeWatcher();
156156
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
157157
// This promise will be called and needs to resolve before the widget will actually become sticky.
158158
private stickyPromise?: () => Promise<void>;
@@ -214,7 +214,7 @@ export class StopGapWidget extends EventEmitter {
214214
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
215215
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
216216
clientId: ELEMENT_CLIENT_ID,
217-
clientTheme: SettingsStore.getValue("theme"),
217+
clientTheme: this.themeWatcher.getEffectiveTheme(),
218218
clientLanguage: getUserLanguage(),
219219
deviceId: this.client.getDeviceId() ?? undefined,
220220
baseUrl: this.client.baseUrl,
@@ -246,6 +246,10 @@ export class StopGapWidget extends EventEmitter {
246246
return !!this.messaging;
247247
}
248248

249+
private onThemeChange = (theme: string): void => {
250+
this.messaging?.updateTheme({ name: theme });
251+
};
252+
249253
private onOpenModal = async (ev: CustomEvent<IModalWidgetOpenRequest>): Promise<void> => {
250254
ev.preventDefault();
251255
if (ModalWidgetStore.instance.canOpenModalWidget()) {
@@ -278,9 +282,14 @@ export class StopGapWidget extends EventEmitter {
278282
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
279283
this.messaging.on("preparing", () => this.emit("preparing"));
280284
this.messaging.on("error:preparing", (err: unknown) => this.emit("error:preparing", err));
281-
this.messaging.on("ready", () => {
285+
this.messaging.once("ready", () => {
282286
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging!);
283287
this.emit("ready");
288+
289+
this.themeWatcher.start();
290+
this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange);
291+
// Theme may have changed while messaging was starting
292+
this.onThemeChange(this.themeWatcher.getEffectiveTheme());
284293
});
285294
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
286295
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: 26 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, MockedObject } from "jest-mock";
10-
import { last } from "lodash";
10+
import { findLast, last } from "lodash";
1111
import {
1212
MatrixEvent,
1313
MatrixClient,
@@ -24,8 +24,13 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
2424
import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget";
2525
import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore";
2626
import SettingsStore from "../../../../src/settings/SettingsStore";
27+
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
28+
import { Action } from "../../../../src/dispatcher/actions";
2729

28-
jest.mock("matrix-widget-api/lib/ClientWidgetApi");
30+
jest.mock("matrix-widget-api", () => ({
31+
...jest.requireActual("matrix-widget-api"),
32+
ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi,
33+
}));
2934

3035
describe("StopGapWidget", () => {
3136
let client: MockedObject<MatrixClient>;
@@ -84,6 +89,25 @@ describe("StopGapWidget", () => {
8489
expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false);
8590
});
8691

92+
it("informs widget of theme changes", () => {
93+
let theme = "light";
94+
const settingsSpy = jest
95+
.spyOn(SettingsStore, "getValue")
96+
.mockImplementation((name) => (name === "theme" ? theme : null));
97+
try {
98+
// Indicate that the widget is ready
99+
findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1]();
100+
101+
// Now change the theme
102+
theme = "dark";
103+
defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true);
104+
expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" });
105+
} finally {
106+
console.log("TEST OVER");
107+
settingsSpy.mockRestore();
108+
}
109+
});
110+
87111
describe("feed event", () => {
88112
let event1: MatrixEvent;
89113
let event2: MatrixEvent;

yarn.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8678,9 +8678,9 @@ matrix-web-i18n@^3.2.1:
86788678
walk "^2.3.15"
86798679

86808680
matrix-widget-api@^1.10.0:
8681-
version "1.10.0"
8682-
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.10.0.tgz#d31ea073a5871a1fb1a511ef900b0c125a37bf55"
8683-
integrity sha512-rkAJ29briYV7TJnfBVLVSKtpeBrBju15JZFSDP6wj8YdbCu1bdmlplJayQ+vYaw1x4fzI49Q+Nz3E85s46sRDw==
8681+
version "1.11.0"
8682+
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.11.0.tgz#2f548b11a7c0df789d5d4fdb5cc9ef7af8aef3da"
8683+
integrity sha512-ED/9hrJqDWVLeED0g1uJnYRhINh3ZTquwurdM+Hc8wLVJIQ8G/r7A7z74NC+8bBIHQ1Jo7i1Uq5CoJp/TzFYrA==
86848684
dependencies:
86858685
"@types/events" "^3.0.0"
86868686
events "^3.2.0"

0 commit comments

Comments
 (0)