Merge pull request #5385 from matrix-org/travis/msc-send-widget-events

Add new widget API actions for changing rooms and sending/receiving events
This commit is contained in:
Travis Ralston 2020-11-20 09:17:18 -07:00 committed by GitHub
commit 5f47077a30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 835 additions and 15 deletions

View file

@ -79,7 +79,7 @@
"linkifyjs": "^2.1.9",
"lodash": "^4.17.19",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.8",
"matrix-widget-api": "^0.1.0-beta.9",
"minimist": "^1.2.5",
"pako": "^1.0.11",
"parse5": "^5.1.1",

View file

@ -60,6 +60,10 @@ pre, code {
color: $accent-color;
}
.text-muted {
color: $muted-fg-color;
}
b {
// On Firefox, the default weight for `<b>` 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

View file

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

View file

@ -0,0 +1,75 @@
/*
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 {
.text-muted {
font-size: $font-12px;
}
.mx_Dialog_content {
margin-bottom: 16px;
}
.mx_WidgetCapabilitiesPromptDialog_cap {
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 {
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 {
display: inline-block;
vertical-align: middle;
}
}
}

View file

@ -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<IProps, IStat
}
public componentDidMount() {
const driver = new StopGapWidgetDriver( []);
const driver = new StopGapWidgetDriver( [], this.widget, WidgetKind.Modal);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
this.setState({messaging});
}

View file

@ -0,0 +1,147 @@
/*
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 } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import {
Capability,
Widget,
WidgetEventCapability,
WidgetKind,
} from "matrix-widget-api";
import { objectShallowClone } from "../../../utils/objects";
import StyledCheckbox from "../elements/StyledCheckbox";
import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
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<Capability>;
widget: Widget;
widgetKind: WidgetKind; // TODO: Refactor into the Widget class
}
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<IProps, IState> {
private eventPermissionsMap = new Map<Capability, WidgetEventCapability>();
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 text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{text.byline}</span>
: null;
return (
<div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}>
<StyledCheckbox
checked={isChecked}
onChange={() => this.onToggle(cap)}
>{text.primary}</StyledCheckbox>
{byline}
</div>
);
});
return (
<BaseDialog
className="mx_WidgetCapabilitiesPromptDialog"
onFinished={this.props.onFinished}
title={_t("Approve widget permissions")}
>
<form onSubmit={this.onSubmit}>
<div className="mx_Dialog_content">
<div className="text-muted">{_t("This widget would like to:")}</div>
{checkboxRows}
<DialogButtons
primaryButton={_t("Approve")}
cancelButton={_t("Decline All")}
onPrimaryButtonClick={this.onSubmit}
onCancel={this.onReject}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("Remember my selection for this widget")} />}
/>
</div>
</form>
</BaseDialog>
);
}
}

View file

@ -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 {
</button>;
}
let additive = null;
if (this.props.additive) {
additive = <div className="mx_Dialog_buttons_additive">{this.props.additive}</div>;
}
return (
<div className="mx_Dialog_buttons">
{ additive }
{ cancelButton }
{ this.props.children }
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}

View file

@ -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 viewing another room, when running": "Remain on your screen when viewing another 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 <b>%(eventType)s</b> events as you in this room": "Send <b>%(eventType)s</b> events as you in this room",
"See <b>%(eventType)s</b> events posted to this room": "See <b>%(eventType)s</b> events posted to this room",
"Send <b>%(eventType)s</b> events as you in your active room": "Send <b>%(eventType)s</b> events as you in your active room",
"See <b>%(eventType)s</b> events posted to your active room": "See <b>%(eventType)s</b> events posted to your active room",
"The <b>%(capability)s</b> capability": "The <b>%(capability)s</b> 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 <b>%(msgtype)s</b> messages as you in this room": "Send <b>%(msgtype)s</b> messages as you in this room",
"Send <b>%(msgtype)s</b> messages as you in your active room": "Send <b>%(msgtype)s</b> messages as you in your active room",
"See <b>%(msgtype)s</b> messages posted to this room": "See <b>%(msgtype)s</b> messages posted to this room",
"See <b>%(msgtype)s</b> messages posted to your active room": "See <b>%(msgtype)s</b> 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",
@ -2125,9 +2181,13 @@
"Upload Error": "Upload Error",
"Verify other session": "Verify other session",
"Verification Request": "Verification Request",
"Approve widget permissions": "Approve widget permissions",
"This widget would like to:": "This widget would like to:",
"Approve": "Approve",
"Decline All": "Decline All",
"Remember my selection for this widget": "Remember my selection for this widget",
"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",

View file

@ -103,6 +103,8 @@ export interface IVariables {
type Tags = Record<string, (sub: string) => 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 <a>here</a> now to %(foo)s".
@ -121,7 +123,7 @@ type Tags = Record<string, (sub: string) => 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

View file

@ -14,8 +14,17 @@
* limitations under the License.
*/
import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
ClientReady = "im.vector.ready",
HangupCall = "im.vector.hangup",
OpenIntegrationManager = "integration_manager_open",
ViewRoom = "io.element.view_room",
}
export interface IViewRoomApiRequest extends IWidgetApiRequest {
data: {
room_id: string; // eslint-disable-line camelcase
};
}

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
export enum ElementWidgetCapabilities {
CanChangeViewedRoom = "io.element.view_room",
}

View file

@ -33,6 +33,8 @@ import {
WidgetApiToWidgetAction,
WidgetApiFromWidgetAction,
IModalWidgetOpenRequest,
IWidgetApiErrorResponseData,
WidgetKind,
} from "matrix-widget-api";
import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
import { EventEmitter } from "events";
@ -47,13 +49,15 @@ import { WidgetType } from "../../widgets/WidgetType";
import ActiveWidgetStore from "../ActiveWidgetStore";
import { objectShallowClone } from "../../utils/objects";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ElementWidgetActions } from "./ElementWidgetActions";
import { ElementWidgetActions, IViewRoomApiRequest } from "./ElementWidgetActions";
import Modal from "../../Modal";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import {ModalWidgetStore} from "../ModalWidgetStore";
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";
// TODO: Destroy all of this code
@ -148,6 +152,8 @@ export class StopGapWidget extends EventEmitter {
private messaging: ClientWidgetApi;
private mockWidget: ElementWidget;
private scalarToken: string;
private roomId?: string;
private kind: WidgetKind;
constructor(private appTileProps: IAppTileProps) {
super();
@ -160,6 +166,19 @@ export class StopGapWidget extends EventEmitter {
}
this.mockWidget = new ElementWidget(app);
this.roomId = appTileProps.room?.roomId;
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
}
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 {
@ -286,7 +305,8 @@ export class StopGapWidget extends EventEmitter {
public start(iframe: HTMLIFrameElement) {
if (this.started) return;
const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []);
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
const driver = new StopGapWidgetDriver( allowedCapabilities, this.mockWidget, this.kind);
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready"));
@ -298,6 +318,39 @@ 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<IViewRoomApiRequest>) => {
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, <IWidgetApiErrorResponseData>{
error: {message: "Room ID not supplied."},
});
}
// Check the widget's permission
if (!this.messaging.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) {
return this.messaging.transport.reply(ev.detail, <IWidgetApiErrorResponseData>{
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, <IWidgetApiRequestEmptyData>{});
});
// 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<IStickyActionRequest>) => {
@ -391,5 +444,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() !== this.eventListenerRoomId) return;
this.feedEvent(ev);
};
private onEventDecrypted = (ev: MatrixEvent) => {
if (ev.isDecryptionFailure()) return;
if (ev.getRoomId() !== this.eventListenerRoomId) 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);
});
}
}

View file

@ -14,17 +14,80 @@
* limitations under the License.
*/
import { Capability, WidgetDriver } from "matrix-widget-api";
import { iterableUnion } from "../../utils/iterables";
import {
Capability,
ISendEventDetails,
MatrixCapabilities,
Widget,
WidgetDriver,
WidgetKind,
} from "matrix-widget-api";
import { iterableDiff, iterableUnion } from "../../utils/iterables";
import { MatrixClientPeg } from "../../MatrixClientPeg";
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 allowedCapabilities: Set<Capability>;
// 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
// 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<Capability>): Promise<Set<Capability>> {
return new Set(iterableUnion(requested, this.allowedCapabilities));
// 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,
widgetKind: this.forWidgetKind,
}).finished;
(result.approved || []).forEach(cap => allowedSoFar.add(cap));
} catch (e) {
console.error("Non-fatal error getting capabilities: ", e);
}
}
return new Set(iterableUnion(allowedSoFar, requested));
}
public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise<ISendEventDetails> {
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; // eslint-disable-line camelcase
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};
}
}

View file

@ -14,8 +14,12 @@
* limitations under the License.
*/
import { arrayUnion } from "./arrays";
import { arrayDiff, arrayUnion } from "./arrays";
export function iterableUnion<T>(a: Iterable<T>, b: Iterable<T>): Iterable<T> {
return arrayUnion(Array.from(a), Array.from(b));
}
export function iterableDiff<T>(a: Iterable<T>, b: Iterable<T>): { added: Iterable<T>, removed: Iterable<T> } {
return arrayDiff(Array.from(a), Array.from(b));
}

View file

@ -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 viewing another 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]),
// no byline because we would have already represented the event properly
};
}
}
// 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 <b>%(eventType)s</b> events as you in this room", {
eventType: eventCap.eventType,
}, {
b: sub => <b>{sub}</b>,
}),
byline: CapabilityText.bylineFor(eventCap),
};
} else {
return {
primary: _t("See <b>%(eventType)s</b> events posted to this room", {
eventType: eventCap.eventType,
}, {
b: sub => <b>{sub}</b>,
}),
byline: CapabilityText.bylineFor(eventCap),
};
}
} else { // assume generic
if (eventCap.direction === EventDirection.Send) {
return {
primary: _t("Send <b>%(eventType)s</b> events as you in your active room", {
eventType: eventCap.eventType,
}, {
b: sub => <b>{sub}</b>,
}),
byline: CapabilityText.bylineFor(eventCap),
};
} else {
return {
primary: _t("See <b>%(eventType)s</b> events posted to your active room", {
eventType: eventCap.eventType,
}, {
b: sub => <b>{sub}</b>,
}),
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 <b>%(capability)s</b> capability", {capability}, {
b: sub => <b>{sub}</b>,
}),
};
}
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 <b>%(msgtype)s</b> messages as you in this room", {
msgtype: eventCap.keyStr,
}, {
b: sub => <b>{sub}</b>,
});
} else {
primary = _t("Send <b>%(msgtype)s</b> messages as you in your active room", {
msgtype: eventCap.keyStr,
}, {
b: sub => <b>{sub}</b>,
});
}
} else {
if (kind === WidgetKind.Room) {
primary = _t("See <b>%(msgtype)s</b> messages posted to this room", {
msgtype: eventCap.keyStr,
}, {
b: sub => <b>{sub}</b>,
});
} else {
primary = _t("See <b>%(msgtype)s</b> messages posted to your active room", {
msgtype: eventCap.keyStr,
}, {
b: sub => <b>{sub}</b>,
});
}
}
return {primary};
}
}
}
}

View file

@ -6532,10 +6532,10 @@ matrix-react-test-utils@^0.2.2:
resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853"
integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ==
matrix-widget-api@^0.1.0-beta.8:
version "0.1.0-beta.8"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.8.tgz#17e85c03c46353373890b869b1fd46162bdb0026"
integrity sha512-sWqyWs0RQqny/BimZUOxUd9BTJBzQmJlJ1i3lsSh1JBygV+aK5xQsONL97fc4i6/nwQPK72uCVDF+HwTtkpAbQ==
matrix-widget-api@^0.1.0-beta.9:
version "0.1.0-beta.9"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.9.tgz#83952132c1610e013acb3e695f923f971ddd5637"
integrity sha512-nXo4iaquSya6hYLXccX8o1K960ckSQ0YXIubRDha+YmB+L09F5a7bUPS5JN2tYANOMzyfFAzWVuFwjHv4+K+rg==
dependencies:
events "^3.2.0"