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.
pull/28342/head
Robin 2024-10-30 12:06:31 -04:00
parent f1899b9eb1
commit 4d91e97f0f
7 changed files with 128 additions and 17 deletions

View File

@ -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<IProps, IState> {
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<IProps, IState> {
public componentWillUnmount(): void {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
this.themeWatcher?.off(ThemeWatcherEvent.Change, setTheme);
this.themeWatcher?.stop();
this.fontWatcher?.stop();
UIStore.destroy();

View File

@ -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<IProps, IStat
private readonly widget: Widget;
private readonly possibleButtons: ModalButtonID[];
private appFrame: React.RefObject<HTMLIFrameElement> = 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<IProps, IStat
}
public componentWillUnmount(): void {
this.themeWatcher.off(ThemeWatcherEvent.Change, this.onThemeChange);
this.themeWatcher.stop();
if (!this.state.messaging) return;
this.state.messaging.off("ready", this.onReady);
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
@ -84,6 +87,10 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
}
private onReady = (): void => {
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<IProps, IStat
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
};
private onThemeChange = (theme: string): void => {
this.state.messaging?.updateTheme({ name: theme });
};
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>): void => {
this.props.onFinished(true, ev.detail.data);
};
@ -127,7 +138,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
clientId: ELEMENT_CLIENT_ID,
clientTheme: SettingsStore.getValue("theme"),
clientTheme: this.themeWatcher.getEffectiveTheme(),
clientLanguage: getUserLanguage(),
baseUrl: MatrixClientPeg.safeGet().baseUrl,
});

View File

@ -8,16 +8,25 @@ Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../SettingsStore";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import ThemeController from "../controllers/ThemeController";
import { findHighContrastTheme, setTheme } from "../../theme";
import { findHighContrastTheme } from "../../theme";
import { ActionPayload } from "../../dispatcher/payloads";
import { SettingLevel } from "../SettingLevel";
export default class ThemeWatcher {
export enum ThemeWatcherEvent {
Change = "change",
}
interface ThemeWatcherEventHandlerMap {
[ThemeWatcherEvent.Change]: (theme: string) => void;
}
export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvent, ThemeWatcherEventHandlerMap> {
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 = (<any>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 {

View File

@ -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<void>;
@ -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<IModalWidgetOpenRequest>): Promise<void> => {
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);

View File

@ -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(
<TooltipProvider>
<ModalWidgetDialog
widgetDefinition={{ type: MatrixWidgetType.Custom, url: "https://example.org" }}
sourceWidgetId=""
onFinished={() => {}}
/>
</TooltipProvider>,
);
// 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();
}
});
});

View File

@ -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<MatrixClient>;
@ -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;

View File

@ -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"