From e15041bd53538a325b4d18a21c0e98b24f77938b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 2 Nov 2020 15:17:05 -0700 Subject: [PATCH 01/13] Add a custom widget API action for viewing a different room --- .../views/dialogs/ModalWidgetDialog.tsx | 2 +- src/stores/widgets/ElementWidgetActions.ts | 9 +++++ .../widgets/ElementWidgetCapabilities.ts | 19 ++++++++++ src/stores/widgets/StopGapWidget.ts | 37 +++++++++++++++++-- src/stores/widgets/StopGapWidgetDriver.ts | 29 +++++++++++++-- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 src/stores/widgets/ElementWidgetCapabilities.ts diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 6ce3230a7aa..16cf89c340d 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -61,7 +61,7 @@ export default class ModalWidgetDialog extends React.PureComponent this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); @@ -298,6 +300,35 @@ export class StopGapWidget extends EventEmitter { ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId); } + // Always attach a handler for ViewRoom, but permission check it internally + this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { + ev.preventDefault(); // stop the widget API from auto-rejecting this + + // Check up front if this is even a valid request + const targetRoomId = (ev.detail.data || {}).room_id; + if (!targetRoomId) { + return this.messaging.transport.reply(ev.detail, { + error: {message: "Invalid room ID."}, + }); + } + + // Check the widget's permission + if (!this.messaging.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { + return this.messaging.transport.reply(ev.detail, { + error: {message: "This widget does not have permission for this action (denied)."}, + }); + } + + // at this point we can change rooms, so do that + defaultDispatcher.dispatch({ + action: 'view_room', + room_id: targetRoomId, + }); + + // acknowledge so the widget doesn't freak out + this.messaging.transport.reply(ev.detail, {}); + }); + if (WidgetType.JITSI.matches(this.mockWidget.type)) { this.messaging.on("action:set_always_on_screen", (ev: CustomEvent) => { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index b54e4a5f7d2..9b455ac4816 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -14,17 +14,40 @@ * limitations under the License. */ -import { Capability, WidgetDriver } from "matrix-widget-api"; +import { Capability, WidgetDriver, WidgetType } from "matrix-widget-api"; import { iterableUnion } from "../../utils/iterables"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { arrayFastClone } from "../../utils/arrays"; +import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; // TODO: Purge this from the universe export class StopGapWidgetDriver extends WidgetDriver { - constructor(private allowedCapabilities: Capability[]) { + constructor(private allowedCapabilities: Capability[], private forType: WidgetType) { super(); } public async validateCapabilities(requested: Set): Promise> { - return new Set(iterableUnion(requested, this.allowedCapabilities)); + // TODO: All of this should be a capabilities prompt. + // See https://github.com/vector-im/element-web/issues/13111 + + // Note: None of this well-known widget permissions stuff is documented intentionally. We + // do not want to encourage people relying on this, but need to be able to support it at + // the moment. + // + // If you're a widget developer and seeing this message, please ask the Element team if + // it is safe for you to use this permissions system before trying to use it - it might + // not be here in the future. + + const wkPerms = (MatrixClientPeg.get().getClientWellKnown() || {})['io.element.widget_permissions']; + const allowedCaps = arrayFastClone(this.allowedCapabilities); + if (wkPerms) { + if (Array.isArray(wkPerms["view_room_action"])) { + if (wkPerms["view_room_action"].includes(this.forType)) { + allowedCaps.push(ElementWidgetCapabilities.CanChangeViewedRoom); + } + } + } + return new Set(iterableUnion(requested, allowedCaps)); } } From f5cd079a16abf3099de37c0521bffd655c567617 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 2 Nov 2020 21:32:49 -0700 Subject: [PATCH 02/13] Add support for sending/receiving events from widgets Part of MSC2762: https://github.com/matrix-org/matrix-doc/pull/2762 Requires: https://github.com/matrix-org/matrix-widget-api/pull/9 This is the bare minimum required to send an event to a widget and receive events from widgets. Like the view_room action, this is controlled by a well-known permission key. **Danger**: This allows widgets to potentially modify room state. Use the permissions with care. --- src/stores/widgets/StopGapWidget.ts | 32 +++++++++++++++++++++++ src/stores/widgets/StopGapWidgetDriver.ts | 26 +++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 441e037ddf2..1c26b67fafe 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -55,6 +55,8 @@ import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import {getCustomTheme} from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import ActiveRoomObserver from "../../ActiveRoomObserver"; // TODO: Destroy all of this code @@ -329,6 +331,10 @@ export class StopGapWidget extends EventEmitter { this.messaging.transport.reply(ev.detail, {}); }); + // Attach listeners for feeding events - the underlying widget classes handle permissions for us + MatrixClientPeg.get().on('event', this.onEvent); + MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted); + if (WidgetType.JITSI.matches(this.mockWidget.type)) { this.messaging.on("action:set_always_on_screen", (ev: CustomEvent) => { @@ -422,5 +428,31 @@ export class StopGapWidget extends EventEmitter { if (!this.started) return; WidgetMessagingStore.instance.stopMessaging(this.mockWidget); ActiveWidgetStore.delRoomId(this.mockWidget.id); + + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().off('event', this.onEvent); + MatrixClientPeg.get().off('Event.decrypted', this.onEventDecrypted); + } + } + + private onEvent = (ev: MatrixEvent) => { + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + this.feedEvent(ev); + }; + + private onEventDecrypted = (ev: MatrixEvent) => { + if (ev.isDecryptionFailure()) return; + if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + this.feedEvent(ev); + }; + + private feedEvent(ev: MatrixEvent) { + if (!this.messaging) return; + + const raw = ev.event; + this.messaging.feedEvent(raw).catch(e => { + console.error("Error sending event to widget: ", e); + }); } } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 9b455ac4816..5c2d1868aab 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -14,11 +14,12 @@ * limitations under the License. */ -import { Capability, WidgetDriver, WidgetType } from "matrix-widget-api"; +import { Capability, ISendEventDetails, WidgetDriver, WidgetEventCapability, WidgetType } from "matrix-widget-api"; import { iterableUnion } from "../../utils/iterables"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { arrayFastClone } from "../../utils/arrays"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; +import ActiveRoomObserver from "../../ActiveRoomObserver"; // TODO: Purge this from the universe @@ -47,7 +48,30 @@ export class StopGapWidgetDriver extends WidgetDriver { allowedCaps.push(ElementWidgetCapabilities.CanChangeViewedRoom); } } + if (Array.isArray(wkPerms["event_actions"])) { + if (wkPerms["event_actions"].includes(this.forType)) { + allowedCaps.push(...WidgetEventCapability.findEventCapabilities(requested).map(c => c.raw)); + } + } } return new Set(iterableUnion(requested, allowedCaps)); } + + public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise { + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + + if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); + + let r: {event_id: string} = null; + if (stateKey !== null) { + // state event + r = await client.sendStateEvent(roomId, eventType, content, stateKey); + } else { + // message event + r = await client.sendEvent(roomId, eventType, content); + } + + return {roomId, eventId: r.event_id}; + } } From fc90531c9f82df544a1b2229ccead326ec80bf8c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 2 Nov 2020 21:38:59 -0700 Subject: [PATCH 03/13] Appease the linter --- src/stores/widgets/ElementWidgetActions.ts | 2 +- src/stores/widgets/StopGapWidgetDriver.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index b908806069a..76390086ab6 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -25,6 +25,6 @@ export enum ElementWidgetActions { export interface IViewRoomApiRequest extends IWidgetApiRequest { data: { - room_id: string; + room_id: string; // eslint-disable-line camelcase }; } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 5c2d1868aab..e2dbf3568e5 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -63,7 +63,7 @@ export class StopGapWidgetDriver extends WidgetDriver { if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); - let r: {event_id: string} = null; + let r: {event_id: string} = null; // eslint-disable-line camelcase if (stateKey !== null) { // state event r = await client.sendStateEvent(roomId, eventType, content, stateKey); From 33b7367d820f537e7ee5e32e52720b8cbac8a381 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 12 Nov 2020 10:36:30 -0700 Subject: [PATCH 04/13] Fix room ID handling --- src/stores/widgets/StopGapWidget.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 1c26b67fafe..73399a50862 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -56,7 +56,6 @@ import {getCustomTheme} from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import ActiveRoomObserver from "../../ActiveRoomObserver"; // TODO: Destroy all of this code @@ -151,6 +150,7 @@ export class StopGapWidget extends EventEmitter { private messaging: ClientWidgetApi; private mockWidget: ElementWidget; private scalarToken: string; + private roomId?: string; constructor(private appTileProps: IAppTileProps) { super(); @@ -163,6 +163,18 @@ export class StopGapWidget extends EventEmitter { } this.mockWidget = new ElementWidget(app); + this.roomId = appTileProps.room?.roomId; + } + + private get eventListenerRoomId(): string { + // When widgets are listening to events, we need to make sure they're only + // receiving events for the right room. In particular, room widgets get locked + // to the room they were added in while account widgets listen to the currently + // active room. + + if (this.roomId) return this.roomId; + + return RoomViewStore.getRoomId(); } public get widgetApi(): ClientWidgetApi { @@ -310,7 +322,7 @@ export class StopGapWidget extends EventEmitter { const targetRoomId = (ev.detail.data || {}).room_id; if (!targetRoomId) { return this.messaging.transport.reply(ev.detail, { - error: {message: "Invalid room ID."}, + error: {message: "Room ID not supplied."}, }); } @@ -437,13 +449,13 @@ export class StopGapWidget extends EventEmitter { private onEvent = (ev: MatrixEvent) => { if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; private onEventDecrypted = (ev: MatrixEvent) => { if (ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== ActiveRoomObserver.activeRoomId) return; + if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; From a212dab84ccf5233514bc2a6d9e11b20fa31a5cb Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 17 Nov 2020 20:38:59 -0700 Subject: [PATCH 05/13] Developer design a permissions dialog --- res/css/_components.scss | 1 + .../_WidgetCapabilitiesPromptDialog.scss | 46 ++++ .../WidgetCapabilitiesPromptDialog.tsx | 252 ++++++++++++++++++ src/i18n/strings/en_EN.json | 34 ++- src/languageHandler.tsx | 4 +- src/stores/widgets/StopGapWidget.ts | 5 +- src/stores/widgets/StopGapWidgetDriver.ts | 68 +++-- src/utils/iterables.ts | 6 +- 8 files changed, 382 insertions(+), 34 deletions(-) create mode 100644 res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss create mode 100644 src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index 002f95119d1..2df4c98809e 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -91,6 +91,7 @@ @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; +@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; @import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.scss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.scss"; diff --git a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss new file mode 100644 index 00000000000..bd39bb89899 --- /dev/null +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -0,0 +1,46 @@ +/* +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_WidgetCapabilitiesPromptDialog { + .mx_Dialog_content { + margin-bottom: 16px; + } + + .mx_WidgetCapabilitiesPromptDialog_cap { + margin-top: 8px; + + .mx_WidgetCapabilitiesPromptDialog_byline { + color: $muted-fg-color; + margin-left: 26px; + } + } + + .mx_SettingsFlag { + margin-top: 24px; + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } + } +} diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx new file mode 100644 index 00000000000..7e332e6e9d5 --- /dev/null +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx @@ -0,0 +1,252 @@ +/* +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 React from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t, _td, TranslatedString } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import { Capability, EventDirection, MatrixCapabilities, Widget, WidgetEventCapability } from "matrix-widget-api"; +import { objectShallowClone } from "../../../utils/objects"; +import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidgetCapabilities"; +import { EventType, MsgType } from "matrix-js-sdk/lib/@types/event"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import DialogButtons from "../elements/DialogButtons"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; + +// TODO: These messaging things can probably get their own store of some sort +const SIMPLE_CAPABILITY_MESSAGES = { + [MatrixCapabilities.AlwaysOnScreen]: _td("Remain on your screen while running"), + [MatrixCapabilities.StickerSending]: _td("Send stickers into your active room"), + [ElementWidgetCapabilities.CanChangeViewedRoom]: _td("Change which room you're viewing"), +}; +const SEND_RECV_EVENT_CAPABILITY_MESSAGES = { + [EventType.RoomTopic]: { + // TODO: We probably want to say "this room" when we can + [EventDirection.Send]: _td("Change the topic of your active room"), + [EventDirection.Receive]: _td("See when the topic changes in your active room"), + }, + [EventType.RoomName]: { + [EventDirection.Send]: _td("Change the name of your active room"), + [EventDirection.Receive]: _td("See when the name changes in your active room"), + }, + [EventType.RoomAvatar]: { + [EventDirection.Send]: _td("Change the avatar of your active room"), + [EventDirection.Receive]: _td("See when the avatar changes in your active room"), + }, + // TODO: Add more as needed +}; +function textForEventCapabilitiy(cap: WidgetEventCapability): { primary: TranslatedString, byline: TranslatedString } { + let primary: TranslatedString; + let byline: TranslatedString; + + if (cap.isState) { + byline = cap.keyStr + ? _t("with state key %(stateKey)s", {stateKey: cap.keyStr}) + : _t("with an empty state key"); + } + + const srMessages = SEND_RECV_EVENT_CAPABILITY_MESSAGES[cap.eventType]; + if (srMessages && srMessages[cap.direction]) { + primary = _t(srMessages[cap.direction]); + } else { + if (cap.eventType === EventType.RoomMessage) { + if (cap.direction === EventDirection.Receive) { + if (!cap.keyStr) { + primary = _t("See messages sent in your active room"); + } else { + if (cap.keyStr === MsgType.Text) { + primary = _t("See text messages sent in your active room"); + } else if (cap.keyStr === MsgType.Emote) { + primary = _t("See emotes sent in your active room"); + } else if (cap.keyStr === MsgType.Image) { + primary = _t("See images sent in your active room"); + } else if (cap.keyStr === MsgType.Video) { + primary = _t("See videos sent in your active room"); + } else if (cap.keyStr === MsgType.File) { + primary = _t("See general files sent in your active room"); + } else { + primary = _t( + "See %(msgtype)s messages sent in your active room", + {msgtype: cap.keyStr}, {code: sub => {sub}}, + ); + } + } + } else { + if (!cap.keyStr) { + primary = _t("Send messages as you in your active room"); + } else { + if (cap.keyStr === MsgType.Text) { + primary = _t("Send text messages as you in your active room"); + } else if (cap.keyStr === MsgType.Emote) { + primary = _t("Send emotes as you in your active room"); + } else if (cap.keyStr === MsgType.Image) { + primary = _t("Send images as you in your active room"); + } else if (cap.keyStr === MsgType.Video) { + primary = _t("Send videos as you in your active room"); + } else if (cap.keyStr === MsgType.File) { + primary = _t("Send general files as you in your active room"); + } else { + primary = _t( + "Send %(msgtype)s messages as you in your active room", + {msgtype: cap.keyStr}, {code: sub => {sub}}, + ); + } + } + } + } else { + if (cap.direction === EventDirection.Receive) { + primary = _t( + "See %(eventType)s events sent in your active room", + {eventType: cap.eventType}, {code: sub => {sub}}, + ); + } else { + primary = _t( + "Send %(eventType)s events as you in your active room", + {eventType: cap.eventType}, {code: sub => {sub}}, + ); + } + } + } + + return {primary, byline}; +} + +export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { + return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); +} + +function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) { + localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps)); +} + +interface IProps extends IDialogProps { + requestedCapabilities: Set; + widget: Widget; +} + +interface IBooleanStates { + // @ts-ignore - TS wants a string key, but we know better + [capability: Capability]: boolean; +} + +interface IState { + booleanStates: IBooleanStates; + rememberSelection: boolean; +} + +export default class WidgetCapabilitiesPromptDialog extends React.PureComponent { + private eventPermissionsMap = new Map(); + + constructor(props: IProps) { + super(props); + + const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities); + parsedEvents.forEach(e => this.eventPermissionsMap.set(e.raw, e)); + + const states: IBooleanStates = {}; + this.props.requestedCapabilities.forEach(c => states[c] = true); + + this.state = { + booleanStates: states, + rememberSelection: true, + }; + } + + private onToggle = (capability: Capability) => { + const newStates = objectShallowClone(this.state.booleanStates); + newStates[capability] = !newStates[capability]; + this.setState({booleanStates: newStates}); + }; + + private onRememberSelectionChange = (newVal: boolean) => { + this.setState({rememberSelection: newVal}); + }; + + private onSubmit = async (ev) => { + this.closeAndTryRemember(Object.entries(this.state.booleanStates) + .filter(([_, isSelected]) => isSelected) + .map(([cap]) => cap)); + }; + + private onReject = async (ev) => { + this.closeAndTryRemember([]); // nothing was approved + }; + + private closeAndTryRemember(approved: Capability[]) { + if (this.state.rememberSelection) { + setRememberedCapabilitiesForWidget(this.props.widget, approved); + } + this.props.onFinished({approved}); + } + + public render() { + const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => { + const evCap = this.eventPermissionsMap.get(cap); + + let text: TranslatedString; + let byline: TranslatedString; + if (evCap) { + const t = textForEventCapabilitiy(evCap); + text = t.primary; + byline = t.byline; + } else if (SIMPLE_CAPABILITY_MESSAGES[cap]) { + text = _t(SIMPLE_CAPABILITY_MESSAGES[cap]); + } else { + text = _t( + "The %(capability)s capability", + {capability: cap}, {code: sub => {sub}}, + ); + } + + return ( +
+ this.onToggle(cap)} + >{text} + {byline ? {byline} : null} +
+ ); + }); + + return ( + +
+
+ {_t("This widget would like to:")} + {checkboxRows} + + +
+
+
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e6ba79295e3..d17da7be155 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2123,9 +2123,41 @@ "Upload Error": "Upload Error", "Verify other session": "Verify other session", "Verification Request": "Verification Request", + "Remain on your screen while running": "Remain on your screen while running", + "Send stickers into your active room": "Send stickers into your active room", + "Change which room you're viewing": "Change which room you're viewing", + "Change the topic of your active room": "Change the topic of your active room", + "See when the topic changes in your active room": "See when the topic changes in your active room", + "Change the name of your active room": "Change the name of your active room", + "See when the name changes in your active room": "See when the name changes in your active room", + "Change the avatar of your active room": "Change the avatar of your active room", + "See when the avatar changes in your active room": "See when the avatar changes in your active room", + "with state key %(stateKey)s": "with state key %(stateKey)s", + "with an empty state key": "with an empty state key", + "See messages sent in your active room": "See messages sent in your active room", + "See text messages sent in your active room": "See text messages sent in your active room", + "See emotes sent in your active room": "See emotes sent in your active room", + "See images sent in your active room": "See images sent in your active room", + "See videos sent in your active room": "See videos sent in your active room", + "See general files sent in your active room": "See general files sent in your active room", + "See %(msgtype)s messages sent in your active room": "See %(msgtype)s messages sent in your active room", + "Send messages as you in your active room": "Send messages as you in your active room", + "Send text messages as you in your active room": "Send text messages as you in your active room", + "Send emotes as you in your active room": "Send emotes as you in your active room", + "Send images as you in your active room": "Send images as you in your active room", + "Send videos as you in your active room": "Send videos as you in your active room", + "Send general files as you in your active room": "Send general files as you in your active room", + "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", + "See %(eventType)s events sent in your active room": "See %(eventType)s events sent in your active room", + "Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room", + "The %(capability)s capability": "The %(capability)s capability", + "Approve widget permissions": "Approve widget permissions", + "This widget would like to:": "This widget would like to:", + "Remember my selection for this widget": "Remember my selection for this widget", + "Approve": "Approve", + "Decline All": "Decline All", "A widget would like to verify your identity": "A widget would like to verify your identity", "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.", - "Remember my selection for this widget": "Remember my selection for this widget", "Allow": "Allow", "Deny": "Deny", "Wrong file type": "Wrong file type", diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 0921b65137b..b61f57d4b3c 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -103,6 +103,8 @@ export interface IVariables { type Tags = Record React.ReactNode>; +export type TranslatedString = string | React.ReactNode; + /* * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components * @param {string} text The untranslated text, e.g "click here now to %(foo)s". @@ -121,7 +123,7 @@ type Tags = Record React.ReactNode>; */ export function _t(text: string, variables?: IVariables): string; export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode; -export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode { +export function _t(text: string, variables?: IVariables, tags?: Tags): TranslatedString { // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components // However, still pass the variables to counterpart so that it can choose the correct plural if count is given // It is enough to pass the count variable, but in the future counterpart might make use of other information too diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 73399a50862..aed07ea32b8 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -32,7 +32,8 @@ import { Widget, WidgetApiToWidgetAction, WidgetApiFromWidgetAction, - IModalWidgetOpenRequest, IWidgetApiErrorResponseData, + IModalWidgetOpenRequest, + IWidgetApiErrorResponseData, } from "matrix-widget-api"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { EventEmitter } from "events"; @@ -302,7 +303,7 @@ export class StopGapWidget extends EventEmitter { public start(iframe: HTMLIFrameElement) { if (this.started) return; const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; - const driver = new StopGapWidgetDriver( allowedCapabilities, this.mockWidget.type); + const driver = new StopGapWidgetDriver( allowedCapabilities, this.mockWidget); this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index e2dbf3568e5..99b6aacb26c 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -14,47 +14,57 @@ * limitations under the License. */ -import { Capability, ISendEventDetails, WidgetDriver, WidgetEventCapability, WidgetType } from "matrix-widget-api"; -import { iterableUnion } from "../../utils/iterables"; +import { + Capability, + ISendEventDetails, + MatrixCapabilities, Widget, + WidgetDriver, +} from "matrix-widget-api"; +import { iterableDiff, iterableUnion } from "../../utils/iterables"; import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { arrayFastClone } from "../../utils/arrays"; -import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import ActiveRoomObserver from "../../ActiveRoomObserver"; +import Modal from "../../Modal"; +import WidgetCapabilitiesPromptDialog, { getRememberedCapabilitiesForWidget } from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; // TODO: Purge this from the universe export class StopGapWidgetDriver extends WidgetDriver { - constructor(private allowedCapabilities: Capability[], private forType: WidgetType) { + private allowedCapabilities: Set; + + constructor(allowedCapabilities: Capability[], private forWidget: Widget) { super(); + + // Always allow screenshots to be taken because it's a client-induced flow. The widget can't + // spew screenshots at us and can't request screenshots of us, so it's up to us to provide the + // button if the widget says it supports screenshots. + this.allowedCapabilities = new Set([...allowedCapabilities, MatrixCapabilities.Screenshots]); } public async validateCapabilities(requested: Set): Promise> { - // TODO: All of this should be a capabilities prompt. - // See https://github.com/vector-im/element-web/issues/13111 - - // Note: None of this well-known widget permissions stuff is documented intentionally. We - // do not want to encourage people relying on this, but need to be able to support it at - // the moment. - // - // If you're a widget developer and seeing this message, please ask the Element team if - // it is safe for you to use this permissions system before trying to use it - it might - // not be here in the future. - - const wkPerms = (MatrixClientPeg.get().getClientWellKnown() || {})['io.element.widget_permissions']; - const allowedCaps = arrayFastClone(this.allowedCapabilities); - if (wkPerms) { - if (Array.isArray(wkPerms["view_room_action"])) { - if (wkPerms["view_room_action"].includes(this.forType)) { - allowedCaps.push(ElementWidgetCapabilities.CanChangeViewedRoom); - } - } - if (Array.isArray(wkPerms["event_actions"])) { - if (wkPerms["event_actions"].includes(this.forType)) { - allowedCaps.push(...WidgetEventCapability.findEventCapabilities(requested).map(c => c.raw)); - } + // Check to see if any capabilities aren't automatically accepted (such as sticker pickers + // allowing stickers to be sent). If there are excess capabilities to be approved, the user + // will be prompted to accept them. + const diff = iterableDiff(requested, this.allowedCapabilities); + const missing = new Set(diff.removed); // "removed" is "in A (requested) but not in B (allowed)" + const allowedSoFar = new Set(this.allowedCapabilities); + getRememberedCapabilitiesForWidget(this.forWidget).forEach(cap => allowedSoFar.add(cap)); + // TODO: Do something when the widget requests new capabilities not yet asked for + if (missing.size > 0) { + try { + const [result] = await Modal.createTrackedDialog( + 'Approve Widget Caps', '', + WidgetCapabilitiesPromptDialog, + { + requestedCapabilities: missing, + widget: this.forWidget, + }).finished; + (result.approved || []).forEach(cap => allowedSoFar.add(cap)); + } catch (e) { + console.error("Non-fatal error getting capabilities: ", e); } } - return new Set(iterableUnion(requested, allowedCaps)); + + return new Set(iterableUnion(allowedSoFar, requested)); } public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise { diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts index 56e0bca1b75..7883b2257a1 100644 --- a/src/utils/iterables.ts +++ b/src/utils/iterables.ts @@ -14,8 +14,12 @@ * limitations under the License. */ -import { arrayUnion } from "./arrays"; +import { arrayDiff, arrayUnion } from "./arrays"; export function iterableUnion(a: Iterable, b: Iterable): Iterable { return arrayUnion(Array.from(a), Array.from(b)); } + +export function iterableDiff(a: Iterable, b: Iterable): { added: Iterable, removed: Iterable } { + return arrayDiff(Array.from(a), Array.from(b)); +} From ddd8bdc00e3fb966ec919c0b93e0785879d2d00f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 19 Nov 2020 11:15:42 -0700 Subject: [PATCH 06/13] Move all the capability copy to its own class --- .../WidgetCapabilitiesPromptDialog.tsx | 135 +------ src/i18n/strings/en_EN.json | 84 +++-- src/widgets/CapabilityText.tsx | 342 ++++++++++++++++++ 3 files changed, 413 insertions(+), 148 deletions(-) create mode 100644 src/widgets/CapabilityText.tsx diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx index 7e332e6e9d5..9e2481f5242 100644 --- a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx @@ -16,113 +16,19 @@ limitations under the License. import React from 'react'; import BaseDialog from "./BaseDialog"; -import { _t, _td, TranslatedString } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import { IDialogProps } from "./IDialogProps"; -import { Capability, EventDirection, MatrixCapabilities, Widget, WidgetEventCapability } from "matrix-widget-api"; +import { + Capability, + Widget, + WidgetEventCapability, + WidgetKind +} from "matrix-widget-api"; import { objectShallowClone } from "../../../utils/objects"; -import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidgetCapabilities"; -import { EventType, MsgType } from "matrix-js-sdk/lib/@types/event"; import StyledCheckbox from "../elements/StyledCheckbox"; import DialogButtons from "../elements/DialogButtons"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; - -// TODO: These messaging things can probably get their own store of some sort -const SIMPLE_CAPABILITY_MESSAGES = { - [MatrixCapabilities.AlwaysOnScreen]: _td("Remain on your screen while running"), - [MatrixCapabilities.StickerSending]: _td("Send stickers into your active room"), - [ElementWidgetCapabilities.CanChangeViewedRoom]: _td("Change which room you're viewing"), -}; -const SEND_RECV_EVENT_CAPABILITY_MESSAGES = { - [EventType.RoomTopic]: { - // TODO: We probably want to say "this room" when we can - [EventDirection.Send]: _td("Change the topic of your active room"), - [EventDirection.Receive]: _td("See when the topic changes in your active room"), - }, - [EventType.RoomName]: { - [EventDirection.Send]: _td("Change the name of your active room"), - [EventDirection.Receive]: _td("See when the name changes in your active room"), - }, - [EventType.RoomAvatar]: { - [EventDirection.Send]: _td("Change the avatar of your active room"), - [EventDirection.Receive]: _td("See when the avatar changes in your active room"), - }, - // TODO: Add more as needed -}; -function textForEventCapabilitiy(cap: WidgetEventCapability): { primary: TranslatedString, byline: TranslatedString } { - let primary: TranslatedString; - let byline: TranslatedString; - - if (cap.isState) { - byline = cap.keyStr - ? _t("with state key %(stateKey)s", {stateKey: cap.keyStr}) - : _t("with an empty state key"); - } - - const srMessages = SEND_RECV_EVENT_CAPABILITY_MESSAGES[cap.eventType]; - if (srMessages && srMessages[cap.direction]) { - primary = _t(srMessages[cap.direction]); - } else { - if (cap.eventType === EventType.RoomMessage) { - if (cap.direction === EventDirection.Receive) { - if (!cap.keyStr) { - primary = _t("See messages sent in your active room"); - } else { - if (cap.keyStr === MsgType.Text) { - primary = _t("See text messages sent in your active room"); - } else if (cap.keyStr === MsgType.Emote) { - primary = _t("See emotes sent in your active room"); - } else if (cap.keyStr === MsgType.Image) { - primary = _t("See images sent in your active room"); - } else if (cap.keyStr === MsgType.Video) { - primary = _t("See videos sent in your active room"); - } else if (cap.keyStr === MsgType.File) { - primary = _t("See general files sent in your active room"); - } else { - primary = _t( - "See %(msgtype)s messages sent in your active room", - {msgtype: cap.keyStr}, {code: sub => {sub}}, - ); - } - } - } else { - if (!cap.keyStr) { - primary = _t("Send messages as you in your active room"); - } else { - if (cap.keyStr === MsgType.Text) { - primary = _t("Send text messages as you in your active room"); - } else if (cap.keyStr === MsgType.Emote) { - primary = _t("Send emotes as you in your active room"); - } else if (cap.keyStr === MsgType.Image) { - primary = _t("Send images as you in your active room"); - } else if (cap.keyStr === MsgType.Video) { - primary = _t("Send videos as you in your active room"); - } else if (cap.keyStr === MsgType.File) { - primary = _t("Send general files as you in your active room"); - } else { - primary = _t( - "Send %(msgtype)s messages as you in your active room", - {msgtype: cap.keyStr}, {code: sub => {sub}}, - ); - } - } - } - } else { - if (cap.direction === EventDirection.Receive) { - primary = _t( - "See %(eventType)s events sent in your active room", - {eventType: cap.eventType}, {code: sub => {sub}}, - ); - } else { - primary = _t( - "Send %(eventType)s events as you in your active room", - {eventType: cap.eventType}, {code: sub => {sub}}, - ); - } - } - } - - return {primary, byline}; -} +import { CapabilityText } from "../../../widgets/CapabilityText"; export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); @@ -135,6 +41,7 @@ function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) interface IProps extends IDialogProps { requestedCapabilities: Set; widget: Widget; + widgetKind: WidgetKind; // TODO: Refactor into the Widget class } interface IBooleanStates { @@ -194,22 +101,10 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< public render() { const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => { - const evCap = this.eventPermissionsMap.get(cap); - - let text: TranslatedString; - let byline: TranslatedString; - if (evCap) { - const t = textForEventCapabilitiy(evCap); - text = t.primary; - byline = t.byline; - } else if (SIMPLE_CAPABILITY_MESSAGES[cap]) { - text = _t(SIMPLE_CAPABILITY_MESSAGES[cap]); - } else { - text = _t( - "The %(capability)s capability", - {capability: cap}, {code: sub => {sub}}, - ); - } + const text = CapabilityText.for(cap, this.props.widgetKind); + const byline = text.byline + ? {text.byline} + : null; return (
@@ -217,8 +112,8 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< key={cap + i} checked={isChecked} onChange={() => this.onToggle(cap)} - >{text} - {byline ? {byline} : null} + >{text.primary} + {byline}
); }); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 046190c7aff..03bdf512eee 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -569,6 +569,62 @@ "%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …", "%(names)s and %(count)s others are typing …|one": "%(names)s and one other is typing …", "%(names)s and %(lastPerson)s are typing …": "%(names)s and %(lastPerson)s are typing …", + "Remain on your screen when you leave this room (when running)": "Remain on your screen when you leave this room (when running)", + "Remain on your screen while running": "Remain on your screen while running", + "Send stickers into this room": "Send stickers into this room", + "Send stickers into your active room": "Send stickers into your active room", + "Change which room you're viewing": "Change which room you're viewing", + "Change the topic of this room": "Change the topic of this room", + "See when the topic changes in this room": "See when the topic changes in this room", + "Change the topic of your active room": "Change the topic of your active room", + "See when the topic changes in your active room": "See when the topic changes in your active room", + "Change the name of this room": "Change the name of this room", + "See when the name changes in this room": "See when the name changes in this room", + "Change the name of your active room": "Change the name of your active room", + "See when the name changes in your active room": "See when the name changes in your active room", + "Change the avatar of this room": "Change the avatar of this room", + "See when the avatar changes in this room": "See when the avatar changes in this room", + "Change the avatar of your active room": "Change the avatar of your active room", + "See when the avatar changes in your active room": "See when the avatar changes in your active room", + "Send stickers to this room as you": "Send stickers to this room as you", + "See when a sticker is posted in this room": "See when a sticker is posted in this room", + "Send stickers to your active room as you": "Send stickers to your active room as you", + "See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room", + "with an empty state key": "with an empty state key", + "with state key %(stateKey)s": "with state key %(stateKey)s", + "Send %(eventType)s events as you in this room": "Send %(eventType)s events as you in this room", + "See %(eventType)s events posted to this room": "See %(eventType)s events posted to this room", + "Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room", + "See %(eventType)s events posted to your active room": "See %(eventType)s events posted to your active room", + "The %(capability)s capability": "The %(capability)s capability", + "Send messages as you in this room": "Send messages as you in this room", + "Send messages as you in your active room": "Send messages as you in your active room", + "See messages posted to this room": "See messages posted to this room", + "See messages posted to your active room": "See messages posted to your active room", + "Send text messages as you in this room": "Send text messages as you in this room", + "Send text messages as you in your active room": "Send text messages as you in your active room", + "See text messages posted to this room": "See text messages posted to this room", + "See text messages posted to your active room": "See text messages posted to your active room", + "Send emotes as you in this room": "Send emotes as you in this room", + "Send emotes as you in your active room": "Send emotes as you in your active room", + "See emotes posted to this room": "See emotes posted to this room", + "See emotes posted to your active room": "See emotes posted to your active room", + "Send images as you in this room": "Send images as you in this room", + "Send images as you in your active room": "Send images as you in your active room", + "See images posted to this room": "See images posted to this room", + "See images posted to your active room": "See images posted to your active room", + "Send videos as you in this room": "Send videos as you in this room", + "Send videos as you in your active room": "Send videos as you in your active room", + "See videos posted to this room": "See videos posted to this room", + "See videos posted to your active room": "See videos posted to your active room", + "Send general files as you in this room": "Send general files as you in this room", + "Send general files as you in your active room": "Send general files as you in your active room", + "See general files posted to this room": "See general files posted to this room", + "See general files posted to your active room": "See general files posted to your active room", + "Send %(msgtype)s messages as you in this room": "Send %(msgtype)s messages as you in this room", + "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", + "See %(msgtype)s messages posted to this room": "See %(msgtype)s messages posted to this room", + "See %(msgtype)s messages posted to your active room": "See %(msgtype)s messages posted to your active room", "Cannot reach homeserver": "Cannot reach homeserver", "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", "Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured", @@ -2123,34 +2179,6 @@ "Upload Error": "Upload Error", "Verify other session": "Verify other session", "Verification Request": "Verification Request", - "Remain on your screen while running": "Remain on your screen while running", - "Send stickers into your active room": "Send stickers into your active room", - "Change which room you're viewing": "Change which room you're viewing", - "Change the topic of your active room": "Change the topic of your active room", - "See when the topic changes in your active room": "See when the topic changes in your active room", - "Change the name of your active room": "Change the name of your active room", - "See when the name changes in your active room": "See when the name changes in your active room", - "Change the avatar of your active room": "Change the avatar of your active room", - "See when the avatar changes in your active room": "See when the avatar changes in your active room", - "with state key %(stateKey)s": "with state key %(stateKey)s", - "with an empty state key": "with an empty state key", - "See messages sent in your active room": "See messages sent in your active room", - "See text messages sent in your active room": "See text messages sent in your active room", - "See emotes sent in your active room": "See emotes sent in your active room", - "See images sent in your active room": "See images sent in your active room", - "See videos sent in your active room": "See videos sent in your active room", - "See general files sent in your active room": "See general files sent in your active room", - "See %(msgtype)s messages sent in your active room": "See %(msgtype)s messages sent in your active room", - "Send messages as you in your active room": "Send messages as you in your active room", - "Send text messages as you in your active room": "Send text messages as you in your active room", - "Send emotes as you in your active room": "Send emotes as you in your active room", - "Send images as you in your active room": "Send images as you in your active room", - "Send videos as you in your active room": "Send videos as you in your active room", - "Send general files as you in your active room": "Send general files as you in your active room", - "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", - "See %(eventType)s events sent in your active room": "See %(eventType)s events sent in your active room", - "Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room", - "The %(capability)s capability": "The %(capability)s capability", "Approve widget permissions": "Approve widget permissions", "This widget would like to:": "This widget would like to:", "Remember my selection for this widget": "Remember my selection for this widget", diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx new file mode 100644 index 00000000000..cb407dc6869 --- /dev/null +++ b/src/widgets/CapabilityText.tsx @@ -0,0 +1,342 @@ +/* +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 { Capability, EventDirection, MatrixCapabilities, WidgetEventCapability, WidgetKind } from "matrix-widget-api"; +import { _t, _td, TranslatedString } from "../languageHandler"; +import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; +import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities"; +import React from "react"; + +type GENERIC_WIDGET_KIND = "generic"; +const GENERIC_WIDGET_KIND: GENERIC_WIDGET_KIND = "generic"; + +interface ISendRecvStaticCapText { + // @ts-ignore - TS wants the key to be a string, but we know better + [eventType: EventType]: { + // @ts-ignore - TS wants the key to be a string, but we know better + [widgetKind: WidgetKind | GENERIC_WIDGET_KIND]: { + // @ts-ignore - TS wants the key to be a string, but we know better + [direction: EventDirection]: string; + }; + }; +} + +interface IStaticCapText { + // @ts-ignore - TS wants the key to be a string, but we know better + [capability: Capability]: { + // @ts-ignore - TS wants the key to be a string, but we know better + [widgetKind: WidgetKind | GENERIC_WIDGET_KIND]: string; + }; +} + +export interface TranslatedCapabilityText { + primary: TranslatedString; + byline?: TranslatedString; +} + +export class CapabilityText { + private static simpleCaps: IStaticCapText = { + [MatrixCapabilities.AlwaysOnScreen]: { + [WidgetKind.Room]: _td("Remain on your screen when you leave this room (when running)"), + [GENERIC_WIDGET_KIND]: _td("Remain on your screen while running"), + }, + [MatrixCapabilities.StickerSending]: { + [WidgetKind.Room]: _td("Send stickers into this room"), + [GENERIC_WIDGET_KIND]: _td("Send stickers into your active room"), + }, + [ElementWidgetCapabilities.CanChangeViewedRoom]: { + [GENERIC_WIDGET_KIND]: _td("Change which room you're viewing"), + }, + }; + + private static stateSendRecvCaps: ISendRecvStaticCapText = { + [EventType.RoomTopic]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Change the topic of this room"), + [EventDirection.Receive]: _td("See when the topic changes in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Change the topic of your active room"), + [EventDirection.Receive]: _td("See when the topic changes in your active room"), + }, + }, + [EventType.RoomName]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Change the name of this room"), + [EventDirection.Receive]: _td("See when the name changes in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Change the name of your active room"), + [EventDirection.Receive]: _td("See when the name changes in your active room"), + }, + }, + [EventType.RoomAvatar]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Change the avatar of this room"), + [EventDirection.Receive]: _td("See when the avatar changes in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Change the avatar of your active room"), + [EventDirection.Receive]: _td("See when the avatar changes in your active room"), + }, + }, + }; + + private static nonStateSendRecvCaps: ISendRecvStaticCapText = { + [EventType.Sticker]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Send stickers to this room as you"), + [EventDirection.Receive]: _td("See when a sticker is posted in this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Send stickers to your active room as you"), + [EventDirection.Receive]: _td("See when anyone posts a sticker to your active room"), + }, + }, + }; + + private static bylineFor(eventCap: WidgetEventCapability): TranslatedString { + if (eventCap.isState) { + return !eventCap.keyStr + ? _t("with an empty state key") + : _t("with state key %(stateKey)s", {stateKey: eventCap.keyStr}); + } + return null; // room messages are handled specially + } + + public static for(capability: Capability, kind: WidgetKind): TranslatedCapabilityText { + // First see if we have a super simple line of text to provide back + if (CapabilityText.simpleCaps[capability]) { + const textForKind = CapabilityText.simpleCaps[capability]; + if (textForKind[kind]) return {primary: _t(textForKind[kind])}; + if (textForKind[GENERIC_WIDGET_KIND]) return {primary: _t(textForKind[GENERIC_WIDGET_KIND])}; + + // ... we'll fall through to the generic capability processing at the end of this + // function if we fail to locate a simple string and the capability isn't for an + // event. + } + + // We didn't have a super simple line of text, so try processing the capability as the + // more complex event send/receive permission type. + const [eventCap] = WidgetEventCapability.findEventCapabilities([capability]); + if (eventCap) { + // Special case room messages so they show up a bit cleaner to the user. Result is + // effectively "Send images" instead of "Send messages... of type images" if we were + // to handle the msgtype nuances in this function. + if (!eventCap.isState && eventCap.eventType === EventType.RoomMessage) { + return CapabilityText.forRoomMessageCap(eventCap, kind); + } + + // See if we have a static line of text to provide for the given event type and + // direction. The hope is that we do for common event types for friendlier copy. + const evSendRecv = eventCap.isState + ? CapabilityText.stateSendRecvCaps + : CapabilityText.nonStateSendRecvCaps; + if (evSendRecv[eventCap.eventType]) { + const textForKind = evSendRecv[eventCap.eventType]; + const textForDirection = textForKind[kind] || textForKind[GENERIC_WIDGET_KIND]; + if (textForDirection && textForDirection[eventCap.direction]) { + return { + primary: _t(textForDirection[eventCap.direction]), + byline: CapabilityText.bylineFor(eventCap), + }; + } + } + + // We don't have anything simple, so just return a generic string for the event cap + if (kind === WidgetKind.Room) { + if (eventCap.direction === EventDirection.Send) { + return { + primary: _t("Send %(eventType)s events as you in this room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } else { + return { + primary: _t("See %(eventType)s events posted to this room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } + } else { // assume generic + if (eventCap.direction === EventDirection.Send) { + return { + primary: _t("Send %(eventType)s events as you in your active room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } else { + return { + primary: _t("See %(eventType)s events posted to your active room", { + eventType: eventCap.eventType, + }, { + b: sub => {sub}, + }), + byline: CapabilityText.bylineFor(eventCap), + }; + } + } + } + + // We don't have enough context to render this capability specially, so we'll present it as-is + return { + primary: _t("The %(capability)s capability", {capability}, { + b: sub => {sub}, + }), + }; + } + + private static forRoomMessageCap(eventCap: WidgetEventCapability, kind: WidgetKind): TranslatedCapabilityText { + // First handle the case of "all messages" to make the switch later on a bit clearer + if (!eventCap.keyStr) { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send messages as you in this room") + : _t("Send messages as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See messages posted to this room") + : _t("See messages posted to your active room"), + }; + } + } + + // Now handle all the message types we care about. There are more message types available, however + // they are not as common so we don't bother rendering them. They'll fall into the generic case. + switch(eventCap.keyStr) { + case MsgType.Text: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send text messages as you in this room") + : _t("Send text messages as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See text messages posted to this room") + : _t("See text messages posted to your active room"), + }; + } + } + case MsgType.Emote: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send emotes as you in this room") + : _t("Send emotes as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See emotes posted to this room") + : _t("See emotes posted to your active room"), + }; + } + } + case MsgType.Image: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send images as you in this room") + : _t("Send images as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See images posted to this room") + : _t("See images posted to your active room"), + }; + } + } + case MsgType.Video: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send videos as you in this room") + : _t("Send videos as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See videos posted to this room") + : _t("See videos posted to your active room"), + }; + } + } + case MsgType.File: { + if (eventCap.direction === EventDirection.Send) { + return { + primary: kind === WidgetKind.Room + ? _t("Send general files as you in this room") + : _t("Send general files as you in your active room"), + }; + } else { + return { + primary: kind === WidgetKind.Room + ? _t("See general files posted to this room") + : _t("See general files posted to your active room"), + }; + } + } + default: { + let primary: TranslatedString; + if (eventCap.direction === EventDirection.Send) { + if (kind === WidgetKind.Room) { + primary = _t("Send %(msgtype)s messages as you in this room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } else { + primary = _t("Send %(msgtype)s messages as you in your active room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } + } else { + if (kind === WidgetKind.Room) { + primary = _t("See %(msgtype)s messages posted to this room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } else { + primary = _t("See %(msgtype)s messages posted to your active room", { + msgtype: eventCap.keyStr, + }, { + b: sub => {sub}, + }); + } + } + return {primary}; + } + } + } +} From 21663314ee1d58e5eab644aa2ae071d51620446d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 19 Nov 2020 11:24:17 -0700 Subject: [PATCH 07/13] Minor touchups to the dialog --- src/components/views/dialogs/ModalWidgetDialog.tsx | 3 ++- src/i18n/strings/en_EN.json | 2 +- src/stores/widgets/StopGapWidget.ts | 5 ++++- src/stores/widgets/StopGapWidgetDriver.ts | 8 ++++++-- src/widgets/CapabilityText.tsx | 2 +- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 652c04c0436..e7223745557 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -31,6 +31,7 @@ import { ModalButtonKind, Widget, WidgetApiFromWidgetAction, + WidgetKind, } from "matrix-widget-api"; import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; @@ -72,7 +73,7 @@ export default class ModalWidgetDialog extends React.PureComponent this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 99b6aacb26c..b6d17b1b1bb 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -17,8 +17,10 @@ import { Capability, ISendEventDetails, - MatrixCapabilities, Widget, + MatrixCapabilities, + Widget, WidgetDriver, + WidgetKind, } from "matrix-widget-api"; import { iterableDiff, iterableUnion } from "../../utils/iterables"; import { MatrixClientPeg } from "../../MatrixClientPeg"; @@ -31,7 +33,8 @@ import WidgetCapabilitiesPromptDialog, { getRememberedCapabilitiesForWidget } fr export class StopGapWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; - constructor(allowedCapabilities: Capability[], private forWidget: Widget) { + // TODO: Refactor widgetKind into the Widget class + constructor(allowedCapabilities: Capability[], private forWidget: Widget, private forWidgetKind: WidgetKind) { super(); // Always allow screenshots to be taken because it's a client-induced flow. The widget can't @@ -57,6 +60,7 @@ export class StopGapWidgetDriver extends WidgetDriver { { requestedCapabilities: missing, widget: this.forWidget, + widgetKind: this.forWidgetKind, }).finished; (result.approved || []).forEach(cap => allowedSoFar.add(cap)); } catch (e) { diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index cb407dc6869..817aae699c2 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.tsx @@ -50,7 +50,7 @@ export interface TranslatedCapabilityText { export class CapabilityText { private static simpleCaps: IStaticCapText = { [MatrixCapabilities.AlwaysOnScreen]: { - [WidgetKind.Room]: _td("Remain on your screen when you leave this room (when running)"), + [WidgetKind.Room]: _td("Remain on your screen when viewing another room, when running"), [GENERIC_WIDGET_KIND]: _td("Remain on your screen while running"), }, [MatrixCapabilities.StickerSending]: { From 0104164d51e91efc2fa60122fa3b3548df8fce02 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 19 Nov 2020 12:06:24 -0700 Subject: [PATCH 08/13] Approximate the design as proposed --- res/css/_common.scss | 9 +++++ .../_WidgetCapabilitiesPromptDialog.scss | 33 +++++++++++++++++-- .../WidgetCapabilitiesPromptDialog.tsx | 13 ++++---- .../views/elements/DialogButtons.js | 9 +++++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/res/css/_common.scss b/res/css/_common.scss index 0317e89d203..7ab88d6f02b 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -60,6 +60,10 @@ pre, code { color: $accent-color; } +.text-muted { + color: $muted-fg-color; +} + b { // On Firefox, the default weight for `` is `bolder` which results in no bold // effect since we only have specific weights of our fonts available. @@ -364,6 +368,11 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_buttons { margin-top: 20px; text-align: right; + + .mx_Dialog_buttons_additive { + // The consumer is responsible for positioning their elements. + float: left; + } } /* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied diff --git a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss index bd39bb89899..13a1e7a890a 100644 --- a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -16,26 +16,55 @@ limitations under the License. .mx_WidgetCapabilitiesPromptDialog { + .text-muted { + font-size: $font-12px; + } + .mx_Dialog_content { margin-bottom: 16px; } .mx_WidgetCapabilitiesPromptDialog_cap { - margin-top: 8px; + margin-top: 20px; + font-size: $font-15px; + line-height: $font-15px; .mx_WidgetCapabilitiesPromptDialog_byline { color: $muted-fg-color; margin-left: 26px; + font-size: $font-12px; + line-height: $font-12px; } } + .mx_Dialog_buttons { + margin-top: 40px; // double normal + } + .mx_SettingsFlag { - margin-top: 24px; + line-height: calc($font-14px + 7px + 7px); // 7px top & bottom padding + color: $muted-fg-color; + font-size: $font-12px; .mx_ToggleSwitch { display: inline-block; vertical-align: middle; margin-right: 8px; + + // downsize the switch + ball + width: $font-32px; + height: $font-15px; + + + &.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { + left: calc(100% - $font-15px); + } + + .mx_ToggleSwitch_ball { + width: $font-15px; + height: $font-15px; + border-radius: $font-15px; + } } .mx_SettingsFlag_label { diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx index 9e2481f5242..7784b733871 100644 --- a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx @@ -126,18 +126,19 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< >
- {_t("This widget would like to:")} +
{_t("This widget would like to:")}
{checkboxRows} - } />
diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js index 001292b6b74..3417485eb8e 100644 --- a/src/components/views/elements/DialogButtons.js +++ b/src/components/views/elements/DialogButtons.js @@ -54,6 +54,9 @@ export default class DialogButtons extends React.Component { // disables only the primary button primaryDisabled: PropTypes.bool, + + // something to stick next to the buttons, optionally + additive: PropTypes.element, }; static defaultProps = { @@ -85,8 +88,14 @@ export default class DialogButtons extends React.Component { ; } + let additive = null; + if (this.props.additive) { + additive =
{this.props.additive}
; + } + return (
+ { additive } { cancelButton } { this.props.children }