Initial Modal Widget work tweaks MSC2790

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2020-09-25 14:08:27 +01:00
parent 342f1d5b43
commit 44bc8fc67e
10 changed files with 294 additions and 243 deletions

View file

@ -74,6 +74,7 @@
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_ModalWidgetDialog.scss";
@import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss";
@import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss";
@import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss";

View file

@ -0,0 +1,23 @@
/*
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_buttons {
.mx_AccessibleButton + .mx_AccessibleButton {
margin-left: 8px;
}
}
}

View file

@ -26,8 +26,7 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {Capability, KnownWidgetActions} from "./widgets/WidgetApi"; import {Capability, KnownWidgetActions} from "./widgets/WidgetApi";
import {objectClone} from "./utils/objects"; import {objectClone} from "./utils/objects";
import {Action} from "./dispatcher/actions"; import {ModalWidgetStore} from "./stores/ModalWidgetStore";
import {TempWidgetStore} from "./stores/TempWidgetStore";
const WIDGET_API_VERSION = '0.0.2'; // Current API version const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [ const SUPPORTED_WIDGET_API_VERSIONS = [
@ -220,12 +219,16 @@ export default class FromWidgetPostMessageApi {
if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
ActiveWidgetStore.setWidgetPersistence(widgetId, val); ActiveWidgetStore.setWidgetPersistence(widgetId, val);
} }
} else if (action === 'get_openid' } else if (action === 'get_openid' || action === KnownWidgetActions.CloseModalWidget) {
|| action === KnownWidgetActions.CloseWidget) {
// Handled by caller // Handled by caller
} else if (action === KnownWidgetActions.OpenTempWidget) {
TempWidgetStore.instance.openTempWidget(event.data.data, widgetId);
this.sendResponse(event, {}); // ack this.sendResponse(event, {}); // ack
} else if (action === KnownWidgetActions.OpenModalWidget) {
if (ModalWidgetStore.instance.canOpenModalWidget()) {
ModalWidgetStore.instance.openModalWidget(event.data.data, widgetId);
this.sendResponse(event, {}); // ack
} else {
this.sendError(event, {message: 'Unable to open modal at this time'}); // nak
}
} else { } else {
console.warn('Widget postMessage event unhandled'); console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was unhandled'}); this.sendError(event, {message: 'The postMessage was unhandled'});

View file

@ -38,7 +38,7 @@ export interface IModal<T extends any[]> {
close(...args: T): void; close(...args: T): void;
} }
interface IHandle<T extends any[]> { export interface IHandle<T extends any[]> {
finished: Promise<T>; finished: Promise<T>;
close(...args: T): void; close(...args: T): void;
} }

View file

@ -147,33 +147,33 @@ export default class WidgetMessaging {
}); });
} }
sendThemeInfo(themeInfo: any) {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.UpdateThemeInfo,
data: themeInfo,
}).catch((error) => {
console.error("Failed to send theme info: ", error);
});
}
sendWidgetConfig(widgetConfig: any) { sendWidgetConfig(widgetConfig: any) {
return this.messageToWidget({ return this.messageToWidget({
api: OUTBOUND_API_NAME, api: OUTBOUND_API_NAME,
action: KnownWidgetActions.SendWidgetConfig, action: KnownWidgetActions.GetWidgetConfig,
data: widgetConfig, data: widgetConfig,
}).catch((error) => { }).catch((error) => {
console.error("Failed to send widget info: ", error); console.error("Failed to send widget info: ", error);
}); });
} }
sendTempCloseInfo(info: any) { sendModalButtonClicked(id: string) {
return this.messageToWidget({ return this.messageToWidget({
api: OUTBOUND_API_NAME, api: OUTBOUND_API_NAME,
action: KnownWidgetActions.ClosedWidgetResponse, action: KnownWidgetActions.ButtonClicked,
data: {id},
}).catch((error) => {
console.error("Failed to send modal widget button clicked: ", error);
});
}
sendModalCloseInfo(info: any) {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.CloseModalWidget,
data: info, data: info,
}).catch((error) => { }).catch((error) => {
console.error("Failed to send temp widget close info: ", error); console.error("Failed to send modal widget close info: ", error);
}); });
} }

View file

@ -0,0 +1,138 @@
/*
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 WidgetMessaging from "../../../WidgetMessaging";
import {ButtonKind, IButton, KnownWidgetActions} from "../../../widgets/WidgetApi";
import AccessibleButton from "../elements/AccessibleButton";
interface IModalWidget {
type: string;
url: string;
name: string;
data: any;
waitForIframeLoad?: boolean;
buttons?: IButton[];
}
interface IProps {
widgetDefinition: IModalWidget;
sourceWidgetId: string;
onFinished(success: boolean, data?: any): void;
}
interface IState {
messaging?: WidgetMessaging;
}
const MAX_BUTTONS = 3;
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = {};
private getWidgetId() {
return `modal_${this.props.sourceWidgetId}`;
}
public componentDidMount() {
// TODO: Don't violate every principle of widget creation
const messaging = new WidgetMessaging(
this.getWidgetId(),
this.props.widgetDefinition.url,
this.props.widgetDefinition.url, // TODO templating and such
true,
this.appFrame.current.contentWindow,
);
this.setState({messaging});
}
public componentWillUnmount() {
this.state.messaging.fromWidget.removeListener(KnownWidgetActions.CloseModalWidget, this.onWidgetClose);
this.state.messaging.stop();
}
private onLoad = () => {
this.state.messaging.getCapabilities().then(caps => {
console.log("Requested capabilities: ", caps);
this.state.messaging.sendWidgetConfig(this.props.widgetDefinition.data);
});
this.state.messaging.fromWidget.addListener(KnownWidgetActions.CloseModalWidget, this.onWidgetClose);
};
private onWidgetClose = (req) => {
this.props.onFinished(true, req.data);
}
public render() {
// TODO: Don't violate every single security principle
const widgetUrl = this.props.widgetDefinition.url
+ `?widgetId=${this.getWidgetId()}&parentUrl=${encodeURIComponent(window.location.href)}`;
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 ButtonKind.Primary:
kind = "primary";
break;
case ButtonKind.Secondary:
kind = "primary_outline";
break
case ButtonKind.Danger:
kind = "danger";
break;
}
const onClick = () => {
this.state.messaging.sendModalButtonClicked(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}
hasCancel={false}
>
<div>
<iframe
ref={this.appFrame}
sandbox="allow-forms allow-scripts"
width={700} // TODO
height={450} // TODO
src={widgetUrl}
onLoad={this.onLoad}
/>
</div>
<div className="mx_ModalWidgetDialog_buttons" style={{float: "right"}}>
{ buttons }
</div>
</BaseDialog>;
}
}

View file

@ -1,155 +0,0 @@
/*
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 { IDialogProps } from "./IDialogProps";
import WidgetMessaging from "../../../WidgetMessaging";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import Field from "../elements/Field";
import { KnownWidgetActions } from "../../../widgets/WidgetApi";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
interface IState {
messaging?: WidgetMessaging;
androidMode: boolean;
darkTheme: boolean;
accentColor: string;
}
interface IProps extends IDialogProps {
widgetDefinition: {url: string, data: any};
sourceWidgetId: string;
}
// TODO: Make a better dialog
export default class TempWidgetDialog extends React.PureComponent<IProps, IState> {
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
constructor(props) {
super(props);
this.state = {
androidMode: false,
darkTheme: false,
accentColor: "#03b381",
};
}
public componentDidMount() {
// TODO: Don't violate every principle of widget creation
const messaging = new WidgetMessaging(
"TEMP_ID",
this.props.widgetDefinition.url,
this.props.widgetDefinition.url,
false,
this.appFrame.current.contentWindow,
);
this.setState({messaging});
}
public componentWillUnmount() {
this.state.messaging.fromWidget.removeListener(KnownWidgetActions.CloseWidget, this.onWidgetClose);
this.state.messaging.stop();
}
private onLoad = () => {
this.state.messaging.getCapabilities().then(caps => {
console.log("Requested capabilities: ", caps);
this.sendTheme();
this.state.messaging.sendWidgetConfig(this.props.widgetDefinition.data);
});
this.state.messaging.fromWidget.addListener(KnownWidgetActions.CloseWidget, this.onWidgetClose);
};
private sendTheme() {
if (!this.state.messaging) return;
this.state.messaging.sendThemeInfo({
clientName: this.state.androidMode ? "element-android" : "element-web",
isDark: this.state.darkTheme,
accentColor: this.state.accentColor,
});
}
public static sendExitData(sourceWidgetId: string, success: boolean, data?: any) {
const sourceMessaging = ActiveWidgetStore.getWidgetMessaging(sourceWidgetId);
if (!sourceMessaging) {
console.error("No source widget messaging for temp widget");
return;
}
sourceMessaging.sendTempCloseInfo({success, ...data});
}
private onWidgetClose = (req) => {
this.props.onFinished(true);
TempWidgetDialog.sendExitData(this.props.sourceWidgetId, true, req.data);
}
private onClientToggleChanged = (androidMode) => {
this.setState({androidMode}, () => this.sendTheme());
};
private onDarkThemeChanged = (darkTheme) => {
this.setState({darkTheme}, () => this.sendTheme());
};
private onAccentColorChanged = (ev) => {
this.setState({accentColor: ev.target.value}, () => this.sendTheme());
};
public render() {
// TODO: Don't violate every single security principle
const widgetUrl = this.props.widgetDefinition.url
+ "?widgetId=TEMP_ID&parentUrl=" + encodeURIComponent(window.location.href);
return <BaseDialog
title={_t("Widget Proof of Concept Dashboard")}
className='mx_TempWidgetDialog'
contentId='mx_Dialog_content'
onFinished={this.props.onFinished}
hasCancel={false}
>
<div>
<LabelledToggleSwitch
label={ _t("Look like Android")}
onChange={this.onClientToggleChanged}
value={this.state.androidMode}
/>
<LabelledToggleSwitch
label={ _t("Look like dark theme")}
onChange={this.onDarkThemeChanged}
value={this.state.darkTheme}
/>
<Field
value={this.state.accentColor}
label={_t('Accent Colour')}
onChange={this.onAccentColorChanged}
/>
</div>
<div>
<iframe
ref={this.appFrame}
width={700} height={450}
src={widgetUrl}
onLoad={this.onLoad}
/>
</div>
</BaseDialog>;
}
}

View file

@ -0,0 +1,86 @@
/*
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 ActiveWidgetStore from "../stores/ActiveWidgetStore";
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: any, sourceWidgetId: string) => {
if (this.modalInstance) return;
this.openSourceWidgetId = sourceWidgetId;
this.modalInstance = Modal.createTrackedDialog('Modal Widget', '', ModalWidgetDialog, {
widgetDefinition: {...requestData},
sourceWidgetId: sourceWidgetId,
onFinished: (success: boolean, data?: any) => {
if (!success) {
this.closeModalWidget(sourceWidgetId, {
"m.exited": true,
});
} else {
this.closeModalWidget(sourceWidgetId, data);
}
this.openSourceWidgetId = null;
this.modalInstance = null;
},
});
};
public closeModalWidget = (sourceWidgetId: string, data?: any) => {
if (!this.modalInstance) return;
if (this.openSourceWidgetId === sourceWidgetId) {
this.openSourceWidgetId = null;
this.modalInstance.close();
this.modalInstance = null;
const sourceMessaging = ActiveWidgetStore.getWidgetMessaging(sourceWidgetId);
if (!sourceMessaging) {
console.error("No source widget messaging for modal widget");
return;
}
sourceMessaging.sendModalCloseInfo(data);
}
};
}

View file

@ -1,54 +0,0 @@
/*
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, { IModal } from "../Modal";
import TempWidgetDialog from "../components/views/dialogs/TempWidgetDialog";
interface IState {
modal?: IModal<any>;
openedFromId?: string;
}
export class TempWidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new TempWidgetStore();
private constructor() {
super(defaultDispatcher, {});
}
public static get instance(): TempWidgetStore {
return TempWidgetStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<any> {
// nothing
}
public openTempWidget(requestData: any, sourceWidgetId: string) {
Modal.createTrackedDialog('Temp Widget', '', TempWidgetDialog, {
widgetDefinition: {...requestData},
sourceWidgetId: sourceWidgetId,
onFinished: (success) => {
if (!success) {
TempWidgetDialog.sendExitData(sourceWidgetId, false);
}
},
});
}
}

View file

@ -25,6 +25,7 @@ export enum Capability {
Screenshot = "m.capability.screenshot", Screenshot = "m.capability.screenshot",
Sticker = "m.sticker", Sticker = "m.sticker",
AlwaysOnScreen = "m.always_on_screen", AlwaysOnScreen = "m.always_on_screen",
Modals = "m.modals",
ReceiveTerminate = "im.vector.receive_terminate", ReceiveTerminate = "im.vector.receive_terminate",
} }
@ -39,12 +40,10 @@ export enum KnownWidgetActions {
SetAlwaysOnScreen = "set_always_on_screen", SetAlwaysOnScreen = "set_always_on_screen",
ClientReady = "im.vector.ready", ClientReady = "im.vector.ready",
Terminate = "im.vector.terminate", Terminate = "im.vector.terminate",
OpenModalWidget = "open_modal",
OpenTempWidget = "io.element.start_temp", CloseModalWidget = "close_modal",
UpdateThemeInfo = "io.element.theme_info", GetWidgetConfig = "widget_config",
SendWidgetConfig = "io.element.widget_config", ButtonClicked = "button_clicked",
CloseWidget = "io.element.exit",
ClosedWidgetResponse = "io.element.exit_response",
} }
export type WidgetAction = KnownWidgetActions | string; export type WidgetAction = KnownWidgetActions | string;
@ -78,6 +77,18 @@ export interface OpenIDCredentials {
expiresIn: number; expiresIn: number;
} }
export enum ButtonKind {
Primary = "m.primary",
Secondary = "m.secondary",
Danger = "m.danger",
}
export interface IButton {
id: "m.close" | string;
label: string;
kind: ButtonKind;
}
/** /**
* Handles Element <--> Widget interactions for embedded/standalone widgets. * Handles Element <--> Widget interactions for embedded/standalone widgets.
* *
@ -140,9 +151,7 @@ export class WidgetApi extends EventEmitter {
// Save OpenID credentials // Save OpenID credentials
this.setOpenIDCredentials(<ToWidgetRequest>payload); this.setOpenIDCredentials(<ToWidgetRequest>payload);
this.replyToRequest(<ToWidgetRequest>payload, {}); this.replyToRequest(<ToWidgetRequest>payload, {});
} else if (payload.action === KnownWidgetActions.UpdateThemeInfo } else if (payload.action === KnownWidgetActions.GetWidgetConfig) {
|| payload.action === KnownWidgetActions.SendWidgetConfig
|| payload.action === KnownWidgetActions.ClosedWidgetResponse) {
// Finalization needs to be async, so postpone with a promise // Finalization needs to be async, so postpone with a promise
let finalizePromise = Promise.resolve(); let finalizePromise = Promise.resolve();
const wait = (promise) => { const wait = (promise) => {
@ -236,16 +245,16 @@ export class WidgetApi extends EventEmitter {
}); });
} }
public closeWidget(exitData: any): Promise<any> { public closeModalWidget(exitData: any): Promise<any> {
return new Promise<any>(resolve => { return new Promise<any>(resolve => {
this.callAction(KnownWidgetActions.CloseWidget, exitData, null); this.callAction(KnownWidgetActions.CloseModalWidget, exitData, null);
resolve(); resolve();
}); });
} }
public openTempWidget(url: string, data: any): Promise<any> { public openModalWidget(url: string, name: string, buttons: IButton[], data: any): Promise<any> {
return new Promise<any>(resolve => { return new Promise<any>(resolve => {
this.callAction(KnownWidgetActions.OpenTempWidget, {url, data}, null); this.callAction(KnownWidgetActions.OpenModalWidget, {url, name, buttons, data}, null);
resolve(); resolve();
}); });
} }