Files
element-web/src/components/views/dialogs/ModalWidgetDialog.tsx
Robin c453d33456 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
2025-01-28 10:53:35 +00:00

219 lines
8.9 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import * as React from "react";
import {
ClientWidgetApi,
IModalWidgetCloseRequest,
IModalWidgetOpenRequestData,
IModalWidgetReturnData,
ISetModalButtonEnabledActionRequest,
IWidgetApiAcknowledgeResponseData,
IWidgetApiErrorResponseData,
BuiltInModalButtonID,
ModalButtonID,
ModalButtonKind,
Widget,
WidgetApiFromWidgetAction,
WidgetKind,
} from "matrix-widget-api";
import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import BaseDialog from "./BaseDialog";
import { _t, getUserLanguage } from "../../../languageHandler";
import AccessibleButton, { AccessibleButtonKind } from "../elements/AccessibleButton";
import { StopGapWidgetDriver } from "../../../stores/widgets/StopGapWidgetDriver";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import { arrayFastClone } from "../../../utils/arrays";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
import { ELEMENT_CLIENT_ID } from "../../../identifiers";
import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
widgetRoomId?: string;
sourceWidgetId: string;
onFinished(success: true, data: IModalWidgetReturnData): void;
onFinished(success?: false, data?: void): void;
}
interface IState {
messaging?: ClientWidgetApi;
disabledButtonIds: ModalButtonID[];
}
const MAX_BUTTONS = 3;
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
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),
};
public constructor(props: IProps) {
super(props);
this.widget = new ElementWidget({
...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.safeGet().getSafeUserId(),
id: `modal_${this.props.sourceWidgetId}`,
});
this.possibleButtons = (this.props.widgetDefinition.buttons || []).map((b) => b.id);
}
public componentDidMount(): void {
const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal, false);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current!, driver);
this.setState({ messaging });
}
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);
this.state.messaging.stop();
}
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);
};
private onLoad = (): void => {
if (!this.state.messaging) return;
this.state.messaging.once("ready", this.onReady);
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
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);
};
private onButtonEnableToggle = (ev: CustomEvent<ISetModalButtonEnabledActionRequest>): void => {
ev.preventDefault();
const isClose = ev.detail.data.button === BuiltInModalButtonID.Close;
if (isClose || !this.possibleButtons.includes(ev.detail.data.button)) {
return this.state.messaging?.transport.reply(ev.detail, {
error: { message: "Invalid button" },
} as IWidgetApiErrorResponseData);
}
let buttonIds: ModalButtonID[];
if (ev.detail.data.enabled) {
buttonIds = arrayFastClone(this.state.disabledButtonIds).filter((i) => i !== ev.detail.data.button);
} else {
// use a set to swap the operation to avoid memory leaky arrays.
const tempSet = new Set(this.state.disabledButtonIds);
tempSet.add(ev.detail.data.button);
buttonIds = Array.from(tempSet);
}
this.setState({ disabledButtonIds: buttonIds });
this.state.messaging?.transport.reply(ev.detail, {} as IWidgetApiAcknowledgeResponseData);
};
public render(): React.ReactNode {
const templated = this.widget.getCompleteUrl({
widgetRoomId: this.props.widgetRoomId,
currentUserId: MatrixClientPeg.safeGet().getSafeUserId(),
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
clientId: ELEMENT_CLIENT_ID,
clientTheme: this.themeWatcher.getEffectiveTheme(),
clientLanguage: getUserLanguage(),
baseUrl: MatrixClientPeg.safeGet().baseUrl,
});
const parsed = new URL(templated);
// Add in some legacy support sprinkles (for non-popout widgets)
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
parsed.searchParams.set("widgetId", this.widget.id);
parsed.searchParams.set("parentUrl", window.location.href.split("#", 2)[0]);
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
const widgetUrl = parsed.toString().replace(/%24/g, "$");
let buttons;
if (this.props.widgetDefinition.buttons) {
// show first button rightmost for a more natural specification
buttons = this.props.widgetDefinition.buttons
.slice(0, MAX_BUTTONS)
.reverse()
.map((def) => {
let kind: AccessibleButtonKind = "secondary";
switch (def.kind) {
case ModalButtonKind.Primary:
kind = "primary";
break;
case ModalButtonKind.Secondary:
kind = "primary_outline";
break;
case ModalButtonKind.Danger:
kind = "danger";
break;
}
const onClick = (): void => {
this.state.messaging?.notifyModalWidgetButtonClicked(def.id);
};
const isDisabled = this.state.disabledButtonIds.includes(def.id);
return (
<AccessibleButton key={def.id} kind={kind} onClick={onClick} disabled={isDisabled}>
{def.label}
</AccessibleButton>
);
});
}
return (
<BaseDialog
title={this.props.widgetDefinition.name || _t("widget|modal_title_default")}
className="mx_ModalWidgetDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
>
<div className="mx_ModalWidgetDialog_warning">
<ErrorIcon width="16px" height="16px" />
{_t("widget|modal_data_warning", {
widgetDomain: parsed.hostname,
})}
</div>
<div>
<iframe
title={this.widget.name ?? undefined}
ref={this.appFrame}
sandbox="allow-forms allow-scripts allow-same-origin"
src={widgetUrl}
onLoad={this.onLoad}
/>
</div>
<div className="mx_ModalWidgetDialog_buttons">{buttons}</div>
</BaseDialog>
);
}
}