diff --git a/res/css/_components.scss b/res/css/_components.scss index 002f95119d..2df4c98809 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 0000000000..bd39bb8989 --- /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 0000000000..7e332e6e9d --- /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 e6ba79295e..d17da7be15 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 0921b65137..b61f57d4b3 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 73399a5086..aed07ea32b 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 e2dbf3568e..99b6aacb26 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 56e0bca1b7..7883b2257a 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)); +}