Revert "Make EC widget theme reactive - Update widget url when the theme chan…" (#12382)

This reverts commit c42562ef39.
This commit is contained in:
Will Hunt 2024-03-28 12:04:14 +00:00 committed by GitHub
parent f23c992296
commit 75a989e409
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 23 additions and 172 deletions

View file

@ -58,7 +58,6 @@ import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingSto
import { SdkContextClass } from "../../../contexts/SDKContext"; import { SdkContextClass } from "../../../contexts/SDKContext";
import { ModuleRunner } from "../../../modules/ModuleRunner"; import { ModuleRunner } from "../../../modules/ModuleRunner";
import { parseUrl } from "../../../utils/UrlUtils"; import { parseUrl } from "../../../utils/UrlUtils";
import ThemeWatcher, { ThemeWatcherEvents } from "../../../settings/watchers/ThemeWatcher";
interface IProps { interface IProps {
app: IWidget | IApp; app: IWidget | IApp;
@ -117,7 +116,6 @@ interface IState {
menuDisplayed: boolean; menuDisplayed: boolean;
requiresClient: boolean; requiresClient: boolean;
hasContextMenuOptions: boolean; hasContextMenuOptions: boolean;
widgetUrl?: string;
} }
export default class AppTile extends React.Component<IProps, IState> { export default class AppTile extends React.Component<IProps, IState> {
@ -143,7 +141,7 @@ export default class AppTile extends React.Component<IProps, IState> {
private sgWidget: StopGapWidget | null; private sgWidget: StopGapWidget | null;
private dispatcherRef?: string; private dispatcherRef?: string;
private unmounted = false; private unmounted = false;
private themeWatcher = new ThemeWatcher();
public constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) { public constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
super(props); super(props);
this.context = context; // XXX: workaround for lack of `declare` support on `public context!:` definition this.context = context; // XXX: workaround for lack of `declare` support on `public context!:` definition
@ -273,7 +271,6 @@ export default class AppTile extends React.Component<IProps, IState> {
!newProps.userWidget, !newProps.userWidget,
newProps.onDeleteClick, newProps.onDeleteClick,
), ),
widgetUrl: this.sgWidget?.embedUrl,
}; };
} }
@ -359,8 +356,6 @@ export default class AppTile extends React.Component<IProps, IState> {
} }
private setupSgListeners(): void { private setupSgListeners(): void {
this.themeWatcher.on(ThemeWatcherEvents.ThemeChange, this.onThemeChanged);
this.themeWatcher.start();
this.sgWidget?.on("ready", this.onWidgetReady); this.sgWidget?.on("ready", this.onWidgetReady);
this.sgWidget?.on("error:preparing", this.updateRequiresClient); this.sgWidget?.on("error:preparing", this.updateRequiresClient);
// emits when the capabilities have been set up or changed // emits when the capabilities have been set up or changed
@ -368,9 +363,7 @@ export default class AppTile extends React.Component<IProps, IState> {
} }
private stopSgListeners(): void { private stopSgListeners(): void {
this.themeWatcher.stop();
if (!this.sgWidget) return; if (!this.sgWidget) return;
this.themeWatcher.off(ThemeWatcherEvents.ThemeChange, this.onThemeChanged);
this.sgWidget?.off("ready", this.onWidgetReady); this.sgWidget?.off("ready", this.onWidgetReady);
this.sgWidget.off("error:preparing", this.updateRequiresClient); this.sgWidget.off("error:preparing", this.updateRequiresClient);
this.sgWidget.off("capabilitiesNotified", this.updateRequiresClient); this.sgWidget.off("capabilitiesNotified", this.updateRequiresClient);
@ -393,7 +386,6 @@ export default class AppTile extends React.Component<IProps, IState> {
private startWidget(): void { private startWidget(): void {
this.sgWidget?.prepare().then(() => { this.sgWidget?.prepare().then(() => {
if (this.unmounted) return; if (this.unmounted) return;
if (!this.state.initialising) return;
this.setState({ initialising: false }); this.setState({ initialising: false });
}); });
} }
@ -468,17 +460,6 @@ export default class AppTile extends React.Component<IProps, IState> {
}); });
}; };
private onThemeChanged = (): void => {
// Regenerate widget url when the theme changes
// this updates the url from e.g. `theme=light` to `theme=dark`
// We only do this with EC widgets where the theme prop is in the hash. If the widget puts the
// theme template variable outside the url hash this would cause a (IFrame) page reload on every theme change.
if (WidgetType.CALL.matches(this.props.app.type)) this.setState({ widgetUrl: this.sgWidget?.embedUrl });
// TODO: This is a stop gap solution to responsively update the theme of the widget.
// A new action should be introduced and the widget driver should be called here, so it informs the widget. (or connect to this by itself)
};
private onAction = (payload: ActionPayload): void => { private onAction = (payload: ActionPayload): void => {
switch (payload.action) { switch (payload.action) {
case "m.sticker": case "m.sticker":
@ -571,9 +552,9 @@ export default class AppTile extends React.Component<IProps, IState> {
this.resetWidget(this.props); this.resetWidget(this.props);
this.startMessaging(); this.startMessaging();
if (this.iframe && this.state.widgetUrl) { if (this.iframe && this.sgWidget) {
// Reload iframe // Reload iframe
this.iframe.src = this.state.widgetUrl; this.iframe.src = this.sgWidget.embedUrl;
} }
}); });
} }
@ -642,7 +623,7 @@ export default class AppTile extends React.Component<IProps, IState> {
"mx_AppTileBody--mini": this.props.miniMode, "mx_AppTileBody--mini": this.props.miniMode,
"mx_AppTileBody--loading": this.state.loading, "mx_AppTileBody--loading": this.state.loading,
// We don't want mx_AppTileBody (rounded corners) for call widgets // We don't want mx_AppTileBody (rounded corners) for call widgets
"mx_AppTileBody--call": WidgetType.CALL.matches(this.props.app.type), "mx_AppTileBody--call": this.props.app.type === WidgetType.CALL.preferred,
}); });
const appTileBodyStyles: CSSProperties = {}; const appTileBodyStyles: CSSProperties = {};
if (this.props.pointerEvents) { if (this.props.pointerEvents) {
@ -671,7 +652,7 @@ export default class AppTile extends React.Component<IProps, IState> {
<AppPermission <AppPermission
roomId={this.props.room.roomId} roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId} creatorUserId={this.props.creatorUserId}
url={this.state.widgetUrl} url={this.sgWidget.embedUrl}
isRoomEncrypted={isEncrypted} isRoomEncrypted={isEncrypted}
onPermissionGranted={this.grantWidgetPermission} onPermissionGranted={this.grantWidgetPermission}
/> />
@ -699,7 +680,7 @@ export default class AppTile extends React.Component<IProps, IState> {
title={widgetTitle} title={widgetTitle}
allow={iframeFeatures} allow={iframeFeatures}
ref={this.iframeRefChange} ref={this.iframeRefChange}
src={this.state.widgetUrl} src={this.sgWidget.embedUrl}
allowFullScreen={true} allowFullScreen={true}
sandbox={sandboxFlags} sandbox={sandboxFlags}
/> />
@ -722,12 +703,7 @@ export default class AppTile extends React.Component<IProps, IState> {
const zIndexAboveOtherPersistentElements = 101; const zIndexAboveOtherPersistentElements = 101;
appTileBody = ( appTileBody = (
<div <div className="mx_AppTile_persistedWrapper">
className="mx_AppTile_persistedWrapper"
// We store the widget url to make it possible to test the value of the widgetUrl. since the iframe itself wont be here. (PersistedElement are in a different dom tree)
data-test-widget-url={this.state.widgetUrl}
data-testid="widget-app-tile"
>
<PersistedElement <PersistedElement
zIndex={this.props.miniMode ? zIndexAboveOtherPersistentElements : 9} zIndex={this.props.miniMode ? zIndexAboveOtherPersistentElements : 9}
persistKey={this.persistKey} persistKey={this.persistKey}

View file

@ -16,7 +16,6 @@ limitations under the License.
*/ */
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../SettingsStore"; import SettingsStore from "../SettingsStore";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
@ -26,15 +25,7 @@ import { findHighContrastTheme, setTheme } from "../../theme";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { SettingLevel } from "../SettingLevel"; import { SettingLevel } from "../SettingLevel";
export enum ThemeWatcherEvents { export default class ThemeWatcher {
ThemeChange = "theme_change",
}
type EventHandlerMap = {
[ThemeWatcherEvents.ThemeChange]: (theme: string) => void;
};
export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvents, EventHandlerMap> {
private themeWatchRef: string | null; private themeWatchRef: string | null;
private systemThemeWatchRef: string | null; private systemThemeWatchRef: string | null;
private dispatcherRef: string | null; private dispatcherRef: string | null;
@ -46,7 +37,6 @@ export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvents,
private currentTheme: string; private currentTheme: string;
public constructor() { public constructor() {
super();
this.themeWatchRef = null; this.themeWatchRef = null;
this.systemThemeWatchRef = null; this.systemThemeWatchRef = null;
this.dispatcherRef = null; this.dispatcherRef = null;
@ -96,7 +86,6 @@ export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvents,
this.currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme; this.currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme;
if (oldTheme !== this.currentTheme) { if (oldTheme !== this.currentTheme) {
setTheme(this.currentTheme); setTheme(this.currentTheme);
this.emit(ThemeWatcherEvents.ThemeChange, this.currentTheme);
} }
} }

View file

@ -45,6 +45,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
import { OwnProfileStore } from "../OwnProfileStore"; import { OwnProfileStore } from "../OwnProfileStore";
import WidgetUtils from "../../utils/WidgetUtils"; import WidgetUtils from "../../utils/WidgetUtils";
import { IntegrationManagers } from "../../integrations/IntegrationManagers"; import { IntegrationManagers } from "../../integrations/IntegrationManagers";
import SettingsStore from "../../settings/SettingsStore";
import { WidgetType } from "../../widgets/WidgetType"; import { WidgetType } from "../../widgets/WidgetType";
import ActiveWidgetStore from "../ActiveWidgetStore"; import ActiveWidgetStore from "../ActiveWidgetStore";
import { objectShallowClone } from "../../utils/objects"; import { objectShallowClone } from "../../utils/objects";
@ -162,7 +163,6 @@ export class StopGapWidget extends EventEmitter {
private readonly virtual: boolean; private readonly virtual: boolean;
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
private stickyPromise?: () => Promise<void>; // This promise will be called and needs to resolve before the widget will actually become sticky. private stickyPromise?: () => Promise<void>; // This promise will be called and needs to resolve before the widget will actually become sticky.
private themeWatcher = new ThemeWatcher();
public constructor(private appTileProps: IAppTileProps) { public constructor(private appTileProps: IAppTileProps) {
super(); super();
@ -213,19 +213,13 @@ export class StopGapWidget extends EventEmitter {
private runUrlTemplate(opts = { asPopout: false }): string { private runUrlTemplate(opts = { asPopout: false }): string {
const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {}; const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
let theme = this.themeWatcher.getEffectiveTheme();
if (theme.startsWith("custom-")) {
// simplify custom theme to only light/dark
const customTheme = getCustomTheme(theme.slice(7));
theme = customTheme.is_dark ? "dark" : "light";
}
const defaults: ITemplateParams = { const defaults: ITemplateParams = {
widgetRoomId: this.roomId, widgetRoomId: this.roomId,
currentUserId: this.client.getUserId()!, currentUserId: this.client.getUserId()!,
userDisplayName: OwnProfileStore.instance.displayName ?? undefined, userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
clientId: ELEMENT_CLIENT_ID, clientId: ELEMENT_CLIENT_ID,
clientTheme: theme, clientTheme: SettingsStore.getValue("theme"),
clientLanguage: getUserLanguage(), clientLanguage: getUserLanguage(),
deviceId: this.client.getDeviceId() ?? undefined, deviceId: this.client.getDeviceId() ?? undefined,
baseUrl: this.client.baseUrl, baseUrl: this.client.baseUrl,

View file

@ -121,7 +121,7 @@ function clearCustomTheme(): void {
// remove all css variables, we assume these are there because of the custom theme // remove all css variables, we assume these are there because of the custom theme
const inlineStyleProps = Object.values(document.body.style); const inlineStyleProps = Object.values(document.body.style);
for (const prop of inlineStyleProps) { for (const prop of inlineStyleProps) {
if (typeof prop === "string" && prop.startsWith("--")) { if (prop.startsWith("--")) {
document.body.style.removeProperty(prop); document.body.style.removeProperty(prop);
} }
} }

View file

@ -19,7 +19,7 @@ import { jest } from "@jest/globals";
import { Room, MatrixClient } from "matrix-js-sdk/src/matrix"; import { Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import { ClientWidgetApi, IWidget, MatrixWidgetType } from "matrix-widget-api"; import { ClientWidgetApi, IWidget, MatrixWidgetType } from "matrix-widget-api";
import { Optional } from "matrix-events-sdk"; import { Optional } from "matrix-events-sdk";
import { act, render, RenderResult, waitFor } from "@testing-library/react"; import { act, render, RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { SpiedFunction } from "jest-mock"; import { SpiedFunction } from "jest-mock";
import { import {
@ -50,8 +50,6 @@ import { ElementWidget } from "../../../../src/stores/widgets/StopGapWidget";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; import { ModuleRunner } from "../../../../src/modules/ModuleRunner";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { WidgetType } from "../../../../src/widgets/WidgetType";
jest.mock("../../../../src/stores/OwnProfileStore", () => ({ jest.mock("../../../../src/stores/OwnProfileStore", () => ({
OwnProfileStore: { OwnProfileStore: {
@ -70,7 +68,6 @@ describe("AppTile", () => {
const resizeNotifier = new ResizeNotifier(); const resizeNotifier = new ResizeNotifier();
let app1: IApp; let app1: IApp;
let app2: IApp; let app2: IApp;
let appElementCall: IApp;
const waitForRps = (roomId: string) => const waitForRps = (roomId: string) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
@ -123,17 +120,6 @@ describe("AppTile", () => {
creatorUserId: cli.getSafeUserId(), creatorUserId: cli.getSafeUserId(),
avatar_url: undefined, avatar_url: undefined,
}; };
appElementCall = {
id: "1",
eventId: "2",
roomId: "r2",
type: WidgetType.CALL.preferred,
url: "https://example.com#theme=$org.matrix.msc2873.client_theme",
name: "Element Call",
creatorUserId: cli.getSafeUserId(),
avatar_url: undefined,
};
jest.spyOn(WidgetStore.instance, "getApps").mockImplementation((roomId: string): Array<IApp> => { jest.spyOn(WidgetStore.instance, "getApps").mockImplementation((roomId: string): Array<IApp> => {
if (roomId === "r1") return [app1]; if (roomId === "r1") return [app1];
if (roomId === "r2") return [app2]; if (roomId === "r2") return [app2];
@ -453,6 +439,7 @@ describe("AppTile", () => {
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Top); expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Top);
}); });
}); });
describe("with an existing widgetApi with requiresClient = false", () => { describe("with an existing widgetApi with requiresClient = false", () => {
beforeEach(() => { beforeEach(() => {
const api = { const api = {
@ -479,68 +466,6 @@ describe("AppTile", () => {
}); });
}); });
describe("with an element call widget", () => {
beforeEach(() => {
document.body.style.setProperty("--custom-color", "red");
document.body.style.setProperty("normal-color", "blue");
});
it("should update the widget url on theme change", async () => {
const renderResult = render(
<MatrixClientContext.Provider value={cli}>
<a href="http://themeb" data-mx-theme="light">
A
</a>
<a href="http://themeA" data-mx-theme="dark">
B
</a>
<AppTile key={appElementCall.id} app={appElementCall} room={r1} />
</MatrixClientContext.Provider>,
);
await waitFor(() => {
expect(renderResult.getByTestId("widget-app-tile").dataset.testWidgetUrl).toEqual(
"https://example.com/?widgetId=1&parentUrl=http%3A%2F%2Flocalhost%2F#theme=light",
);
});
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, "dark");
await waitFor(() => {
expect(renderResult.getByTestId("widget-app-tile").dataset.testWidgetUrl).toEqual(
"https://example.com/?widgetId=1&parentUrl=http%3A%2F%2Flocalhost%2F#theme=dark",
);
});
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, "light");
await waitFor(() => {
expect(renderResult.getByTestId("widget-app-tile").dataset.testWidgetUrl).toEqual(
"https://example.com/?widgetId=1&parentUrl=http%3A%2F%2Flocalhost%2F#theme=light",
);
});
});
it("should not update the widget url for non Element Call widgets on theme change", async () => {
const appNonElementCall = { ...appElementCall, type: MatrixWidgetType.Custom };
const renderResult = render(
<MatrixClientContext.Provider value={cli}>
<a href="http://themeb" data-mx-theme="light">
A
</a>
<a href="http://themeA" data-mx-theme="dark">
B
</a>
<AppTile key={appNonElementCall.id} app={appNonElementCall} room={r1} />
</MatrixClientContext.Provider>,
);
await waitFor(() => {
expect(renderResult.getByTestId("widget-app-tile").dataset.testWidgetUrl).toEqual(
"https://example.com/?widgetId=1&parentUrl=http%3A%2F%2Flocalhost%2F#theme=light",
);
});
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, "dark");
await waitFor(() => {
expect(renderResult.getByTestId("widget-app-tile").dataset.testWidgetUrl).toEqual(
"https://example.com/?widgetId=1&parentUrl=http%3A%2F%2Flocalhost%2F#theme=light",
);
});
});
});
describe("for a persistent app", () => { describe("for a persistent app", () => {
let renderResult: RenderResult; let renderResult: RenderResult;

View file

@ -85,8 +85,6 @@ exports[`AppTile for a persistent app should render 1`] = `
> >
<div <div
class="mx_AppTile_persistedWrapper" class="mx_AppTile_persistedWrapper"
data-test-widget-url="https://example.com/?widgetId=1&parentUrl=http%3A%2F%2Flocalhost%2F"
data-testid="widget-app-tile"
> >
<div /> <div />
</div> </div>
@ -174,8 +172,6 @@ exports[`AppTile for a pinned widget should render 1`] = `
</div> </div>
<div <div
class="mx_AppTile_persistedWrapper" class="mx_AppTile_persistedWrapper"
data-test-widget-url="https://example.com/?widgetId=1&parentUrl=http%3A%2F%2Flocalhost%2F"
data-testid="widget-app-tile"
> >
<div /> <div />
</div> </div>

View file

@ -28,7 +28,6 @@ import { VoiceBroadcastInfoEventType, VoiceBroadcastRecording } from "../../../s
import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { SdkContextClass } from "../../../src/contexts/SDKContext";
import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore"; import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore";
import SettingsStore from "../../../src/settings/SettingsStore"; import SettingsStore from "../../../src/settings/SettingsStore";
import * as Theme from "../../../src/theme";
jest.mock("matrix-widget-api/lib/ClientWidgetApi"); jest.mock("matrix-widget-api/lib/ClientWidgetApi");
@ -64,46 +63,18 @@ describe("StopGapWidget", () => {
widget.stopMessaging(); widget.stopMessaging();
}); });
describe("url template", () => {
it("should replace parameters in widget url template", () => { it("should replace parameters in widget url template", () => {
const originalGetValue = SettingsStore.getValue; const originGetValue = SettingsStore.getValue;
const spy = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { const spy = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "theme") return "my-theme-for-testing"; if (setting === "theme") return "my-theme-for-testing";
return originalGetValue(setting); return originGetValue(setting);
}); });
expect(widget.embedUrl).toBe( expect(widget.embedUrl).toBe(
"https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=my-theme-for-testing&widgetId=test&parentUrl=http%3A%2F%2Flocalhost%2F", "https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=my-theme-for-testing&widgetId=test&parentUrl=http%3A%2F%2Flocalhost%2F",
); );
spy.mockRestore(); spy.mockClear();
});
it("should replace custom theme with light/dark", () => {
const originalGetValue = SettingsStore.getValue;
const spy = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "theme") return "custom-my-theme";
return originalGetValue(setting);
});
jest.spyOn(Theme, "getCustomTheme").mockReturnValue({ is_dark: false } as unknown as Theme.CustomTheme);
expect(widget.embedUrl).toBe(
"https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=light&widgetId=test&parentUrl=http%3A%2F%2Flocalhost%2F",
);
jest.spyOn(Theme, "getCustomTheme").mockReturnValue({ is_dark: true } as unknown as Theme.CustomTheme);
expect(widget.embedUrl).toBe(
"https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=dark&widgetId=test&parentUrl=http%3A%2F%2Flocalhost%2F",
);
spy.mockRestore();
});
it("should replace parameters in widget popoutUrl template", () => {
const originalGetValue = SettingsStore.getValue;
const spy = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "theme") return "my-theme-for-testing";
return originalGetValue(setting);
});
expect(widget.popoutUrl).toBe(
"https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=my-theme-for-testing",
);
spy.mockRestore();
});
}); });
it("feeds incoming to-device messages to the widget", async () => { it("feeds incoming to-device messages to the widget", async () => {
const event = mkEvent({ const event = mkEvent({
event: true, event: true,