Merge pull request #5252 from matrix-org/t3chguy/feat/modal-widgets

Modal Widgets - MSC2790
This commit is contained in:
Travis Ralston 2020-10-23 07:29:10 -06:00 committed by GitHub
commit 646ed4c4d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 325 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#737D8C" style="mix-blend-mode:multiply"/>
<rect x="7" y="3" width="2" height="6" rx="1" fill="white"/>
<rect x="7" y="11" width="2" height="2" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View file

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

View file

@ -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<T extends any[]> {
export interface IModal<T extends any[]> {
elem: React.ReactNode;
className?: string;
beforeClosePromise?: Promise<boolean>;
@ -38,7 +38,7 @@ interface IModal<T extends any[]> {
close(...args: T): void;
}
interface IHandle<T extends any[]> {
export interface IHandle<T extends any[]> {
finished: Promise<T>;
close(...args: T): void;
}

View file

@ -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<IProps, IState> {
private readonly widget: Widget;
private appFrame: React.RefObject<HTMLIFrameElement> = 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<IModalWidgetCloseRequest>) => {
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 <AccessibleButton key={def.id} kind={kind} onClick={onClick}>
{ def.label }
</AccessibleButton>;
});
}
return <BaseDialog
title={this.props.widgetDefinition.name || _t("Modal Widget")}
className="mx_ModalWidgetDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
>
<div className="mx_ModalWidgetDialog_warning">
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="16"
width="16"
alt=""
/>
{_t("Data on this screen is shared with %(widgetDomain)s", {
widgetDomain: parsed.hostname,
})}
</div>
<div>
<iframe
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>;
}
}

View file

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

View file

@ -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<any>;
openedFromId?: string;
}
export class ModalWidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new ModalWidgetStore();
private modalInstance: IHandle<void[]> = null;
private openSourceWidgetId: string = null;
private constructor() {
super(defaultDispatcher, {});
}
public static get instance(): ModalWidgetStore {
return ModalWidgetStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<any> {
// 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;

View file

@ -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<IGetOpenIDActionRequest>) => {
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<IModalWidgetOpenRequest>) => {
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) {