diff --git a/res/css/_components.scss b/res/css/_components.scss index ad3cfbdcea7..657d77974ff 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -75,6 +75,7 @@ @import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; +@import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; diff --git a/res/css/views/dialogs/_ModalWidgetDialog.scss b/res/css/views/dialogs/_ModalWidgetDialog.scss new file mode 100644 index 00000000000..aa2dd0d3958 --- /dev/null +++ b/res/css/views/dialogs/_ModalWidgetDialog.scss @@ -0,0 +1,42 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ModalWidgetDialog { + .mx_ModalWidgetDialog_warning { + margin-bottom: 24px; + + > img { + vertical-align: middle; + margin-right: 8px; + } + } + + .mx_ModalWidgetDialog_buttons { + float: right; + margin-top: 24px; + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: 8px; + } + } + + iframe { + width: 100%; + height: 450px; + border: 0; + border-radius: 8px; + } +} diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 96269cea433..9c26f8f1206 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -25,7 +25,7 @@ limitations under the License. .mx_AccessibleButton_hasKind { padding: 7px 18px; text-align: center; - border-radius: 4px; + border-radius: 8px; display: inline-block; font-size: $font-14px; } diff --git a/res/img/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg new file mode 100644 index 00000000000..ac5991f2212 --- /dev/null +++ b/res/img/element-icons/warning-badge.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index ed28a5c4791..161aa3797f7 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -34,6 +34,7 @@ import WidgetStore from "../stores/WidgetStore"; import CallHandler from "../CallHandler"; import {Analytics} from "../Analytics"; import UserActivity from "../UserActivity"; +import {ModalWidgetStore} from "../stores/ModalWidgetStore"; declare global { interface Window { @@ -60,6 +61,7 @@ declare global { mxCallHandler: CallHandler; mxAnalytics: Analytics; mxUserActivity: UserActivity; + mxModalWidgetStore: ModalWidgetStore; } interface Document { diff --git a/src/Modal.tsx b/src/Modal.tsx index b0f6ef988e5..2f761e73937 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; -interface IModal { +export interface IModal { elem: React.ReactNode; className?: string; beforeClosePromise?: Promise; @@ -38,7 +38,7 @@ interface IModal { close(...args: T): void; } -interface IHandle { +export interface IHandle { finished: Promise; close(...args: T): void; } diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx new file mode 100644 index 00000000000..6ce3230a7aa --- /dev/null +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -0,0 +1,165 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as React from 'react'; +import BaseDialog from './BaseDialog'; +import { _t } from '../../../languageHandler'; +import AccessibleButton from "../elements/AccessibleButton"; +import { + ClientWidgetApi, + IModalWidgetCloseRequest, + IModalWidgetOpenRequestData, + IModalWidgetReturnData, + ModalButtonKind, + Widget, + WidgetApiFromWidgetAction, +} from "matrix-widget-api"; +import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import RoomViewStore from "../../../stores/RoomViewStore"; +import {OwnProfileStore} from "../../../stores/OwnProfileStore"; + +interface IProps { + widgetDefinition: IModalWidgetOpenRequestData; + sourceWidgetId: string; + onFinished(success: boolean, data?: IModalWidgetReturnData): void; +} + +interface IState { + messaging?: ClientWidgetApi; +} + +const MAX_BUTTONS = 3; + +export default class ModalWidgetDialog extends React.PureComponent { + private readonly widget: Widget; + private appFrame: React.RefObject = React.createRef(); + + state: IState = {}; + + constructor(props) { + super(props); + + this.widget = new Widget({ + ...this.props.widgetDefinition, + creatorUserId: MatrixClientPeg.get().getUserId(), + id: `modal_${this.props.sourceWidgetId}`, + }); + } + + public componentDidMount() { + const driver = new StopGapWidgetDriver( []); + const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver); + this.setState({messaging}); + } + + public componentWillUnmount() { + this.state.messaging.off("ready", this.onReady); + this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose); + this.state.messaging.stop(); + } + + private onReady = () => { + this.state.messaging.sendWidgetConfig(this.props.widgetDefinition); + }; + + private onLoad = () => { + this.state.messaging.once("ready", this.onReady); + this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose); + }; + + private onWidgetClose = (ev: CustomEvent) => { + this.props.onFinished(true, ev.detail.data); + } + + public render() { + const templated = this.widget.getCompleteUrl({ + currentRoomId: RoomViewStore.getRoomId(), + currentUserId: MatrixClientPeg.get().getUserId(), + userDisplayName: OwnProfileStore.instance.displayName, + userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), + }); + + 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 = "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 = () => { + this.state.messaging.notifyModalWidgetButtonClicked(def.id); + }; + + return + { def.label } + ; + }); + } + + return + + + {_t("Data on this screen is shared with %(widgetDomain)s", { + widgetDomain: parsed.hostname, + })} + + + + + + { buttons } + + ; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 400fa247131..1548dd5c139 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1760,6 +1760,8 @@ "Verify session": "Verify session", "Your homeserver doesn't seem to support this feature.": "Your homeserver doesn't seem to support this feature.", "Message edits": "Message edits", + "Modal Widget": "Modal Widget", + "Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s", "Your account is not secure": "Your account is not secure", "Your password": "Your password", "This session, or the other session": "This session, or the other session", diff --git a/src/stores/ModalWidgetStore.ts b/src/stores/ModalWidgetStore.ts new file mode 100644 index 00000000000..0485afd1062 --- /dev/null +++ b/src/stores/ModalWidgetStore.ts @@ -0,0 +1,87 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; +import Modal, {IHandle, IModal} from "../Modal"; +import ModalWidgetDialog from "../components/views/dialogs/ModalWidgetDialog"; +import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore"; +import {IModalWidgetOpenRequestData, IModalWidgetReturnData, Widget} from "matrix-widget-api"; + +interface IState { + modal?: IModal; + openedFromId?: string; +} + +export class ModalWidgetStore extends AsyncStoreWithClient { + private static internalInstance = new ModalWidgetStore(); + private modalInstance: IHandle = null; + private openSourceWidgetId: string = null; + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): ModalWidgetStore { + return ModalWidgetStore.internalInstance; + } + + protected async onAction(payload: ActionPayload): Promise { + // nothing + } + + public canOpenModalWidget = () => { + return !this.modalInstance; + }; + + public openModalWidget = (requestData: IModalWidgetOpenRequestData, sourceWidget: Widget) => { + if (this.modalInstance) return; + this.openSourceWidgetId = sourceWidget.id; + this.modalInstance = Modal.createTrackedDialog('Modal Widget', '', ModalWidgetDialog, { + widgetDefinition: {...requestData}, + sourceWidgetId: sourceWidget.id, + onFinished: (success: boolean, data?: IModalWidgetReturnData) => { + if (!success) { + this.closeModalWidget(sourceWidget, { "m.exited": true }); + } else { + this.closeModalWidget(sourceWidget, data); + } + + this.openSourceWidgetId = null; + this.modalInstance = null; + }, + }); + }; + + public closeModalWidget = (sourceWidget: Widget, data?: IModalWidgetReturnData) => { + if (!this.modalInstance) return; + if (this.openSourceWidgetId === sourceWidget.id) { + this.openSourceWidgetId = null; + this.modalInstance.close(); + this.modalInstance = null; + + const sourceMessaging = WidgetMessagingStore.instance.getMessaging(sourceWidget); + if (!sourceMessaging) { + console.error("No source widget messaging for modal widget"); + return; + } + sourceMessaging.notifyModalWidgetClose(data); + } + }; +} + +window.mxModalWidgetStore = ModalWidgetStore.instance; diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index edce39d0336..0ae835b6031 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -32,6 +32,7 @@ import { Widget, WidgetApiToWidgetAction, WidgetApiFromWidgetAction, + IModalWidgetOpenRequest, } from "matrix-widget-api"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { EventEmitter } from "events"; @@ -49,6 +50,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import { ElementWidgetActions } from "./ElementWidgetActions"; import Modal from "../../Modal"; import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; +import {ModalWidgetStore} from "../ModalWidgetStore"; // TODO: Destroy all of this code @@ -201,7 +203,7 @@ export class StopGapWidget extends EventEmitter { } private onOpenIdReq = async (ev: CustomEvent) => { - if (ev?.detail?.widgetId !== this.widgetId) return; + ev.preventDefault(); const rawUrl = this.appTileProps.app.url; const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, rawUrl, this.appTileProps.userWidget); @@ -249,6 +251,20 @@ export class StopGapWidget extends EventEmitter { }); }; + private onOpenModal = async (ev: CustomEvent) => { + ev.preventDefault(); + if (ModalWidgetStore.instance.canOpenModalWidget()) { + ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.mockWidget); + this.messaging.transport.reply(ev.detail, {}); // ack + } else { + this.messaging.transport.reply(ev.detail, { + error: { + message: "Unable to open modal at this time", + }, + }) + } + }; + public start(iframe: HTMLIFrameElement) { if (this.started) return; const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []); @@ -256,6 +272,7 @@ export class StopGapWidget extends EventEmitter { this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); this.messaging.on(`action:${WidgetApiFromWidgetAction.GetOpenIDCredentials}`, this.onOpenIdReq); + this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging); if (!this.appTileProps.userWidget && this.appTileProps.room) {