From ea99eebb7bfbca43b6726742f8ae4ef7e5f4cf41 Mon Sep 17 00:00:00 2001
From: Zoe <foldleft@users.noreply.github.com>
Date: Tue, 17 Mar 2020 10:20:58 +0000
Subject: [PATCH 01/14] Report to the user when a key signature upload fails

---
 src/components/structures/MatrixChat.js       |  10 ++
 .../dialogs/KeySignatureUploadFailedDialog.js | 108 ++++++++++++++++++
 src/i18n/strings/en_EN.json                   |  10 ++
 3 files changed, 128 insertions(+)
 create mode 100644 src/components/views/dialogs/KeySignatureUploadFailedDialog.js

diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js
index f1a5a372be..e3a239663e 100644
--- a/src/components/structures/MatrixChat.js
+++ b/src/components/structures/MatrixChat.js
@@ -1495,6 +1495,16 @@ export default createReactClass({
             }
         });
 
+        cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => {
+            const KeySignatureUploadFailedDialog =
+                sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog');
+            Modal.createTrackedDialog(
+                'Failed to upload key signatures',
+                'Failed to upload key signatures',
+                KeySignatureUploadFailedDialog,
+                { failures, source, continuation });
+        });
+
         cli.on("crypto.verification.request", request => {
             const isFlagOn = SettingsStore.isFeatureEnabled("feature_cross_signing");
 
diff --git a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js
new file mode 100644
index 0000000000..a04c4a389f
--- /dev/null
+++ b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js
@@ -0,0 +1,108 @@
+/*
+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, {useState, useCallback, useRef} from 'react';
+import * as sdk from '../../../index';
+import { _t } from '../../../languageHandler';
+
+export default function KeySignatureUploadFailedDialog({
+        failures,
+        source,
+        continuation,
+        onFinished,
+    }) {
+    const RETRIES = 2;
+    const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
+    const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
+    const Spinner = sdk.getComponent('elements.Spinner');
+    const [retry, setRetry] = useState(RETRIES);
+    const [cancelled, setCancelled] = useState(false);
+    const [retrying, setRetrying] = useState(false);
+    const [success, setSuccess] = useState(false);
+    const onCancel = useRef(onFinished);
+
+    const causes = new Map([
+        ["_afterCrossSigningLocalKeyChange", _t("a new master key signature")],
+        ["checkOwnCrossSigningTrust", _t("a new cross-signing key signature")],
+        ["setDeviceVerification", _t("a device cross-signing signature")],
+    ]);
+    const defaultCause = _t("a key signature");
+
+    const onRetry = useCallback(async () => {
+        try {
+            setRetrying(true);
+            const cancel = new Promise((resolve, reject) => {
+                onCancel.current = reject;
+            }).finally(() => {
+                setCancelled(true);
+            });
+            await Promise.race([
+                continuation(),
+                cancel,
+            ]);
+            setSuccess(true);
+        } catch (e) {
+            setRetry(r => r-1);
+        } finally {
+            onCancel.current = onFinished;
+            setRetrying(false);
+        }
+    }, [continuation, onFinished]);
+
+    let body;
+    if (!success && !cancelled && continuation && retry > 0) {
+        const reason = causes.get(source) || defaultCause;
+
+        body = (<div>
+            <p>{_t("Riot encountered an error during upload of:")}</p>
+            <p>{reason}</p>
+            {retrying && <Spinner />}
+            <pre>{JSON.stringify(failures, null, 2)}</pre>
+            <DialogButtons
+                primaryButton='Retry'
+                hasCancel={true}
+                onPrimaryButtonClick={onRetry}
+                onCancel={onCancel.current}
+                primaryDisabled={retrying}
+            />
+        </div>);
+    } else {
+        body = (<div>
+            {success ?
+              <span>{_t("Upload completed")}</span> :
+              cancelled ?
+                <span>{_t("Cancelled signature upload")}</span> :
+                <span>{_t("Unabled to upload")}</span>}
+            <DialogButtons
+                primaryButton={_t("OK")}
+                hasCancel={false}
+                onPrimaryButtonClick={onFinished}
+            />
+        </div>);
+    }
+
+    return (
+        <BaseDialog
+            title={success ?
+                _t("Signature upload success") :
+                _t("Signature upload failed")}
+            fixedWidth={false}
+            onFinished={() => {}}
+        >
+            {body}
+        </BaseDialog>
+    );
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 5f3ead1490..971e6037e4 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1577,6 +1577,16 @@
     "Ignore request": "Ignore request",
     "Loading session info...": "Loading session info...",
     "Encryption key request": "Encryption key request",
+    "a new master key signature": "a new master key signature",
+    "a new cross-signing key signature": "a new cross-signing key signature",
+    "a device cross-signing signature": "a device cross-signing signature",
+    "a key signature": "a key signature",
+    "Riot encountered an error during upload of:": "Riot encountered an error during upload of:",
+    "Upload completed": "Upload completed",
+    "Cancelled signature upload": "Cancelled signature upload",
+    "Unabled to upload": "Unabled to upload",
+    "Signature upload success": "Signature upload success",
+    "Signature upload failed": "Signature upload failed",
     "You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.": "You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.",
     "If the other version of Riot is still open in another tab, please close it as using Riot on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "If the other version of Riot is still open in another tab, please close it as using Riot on the same host with both lazy loading enabled and disabled simultaneously will cause issues.",
     "Incompatible local cache": "Incompatible local cache",

From 22c8df9f2309842708ea9b567d73f73017a9feb5 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 18 Mar 2020 21:08:37 +0000
Subject: [PATCH 02/14] Update DM invite copy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/views/dialogs/InviteDialog.js | 9 +++++----
 src/i18n/strings/en_EN.json                  | 2 +-
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js
index d27a66165e..84ce69e174 100644
--- a/src/components/views/dialogs/InviteDialog.js
+++ b/src/components/views/dialogs/InviteDialog.js
@@ -1060,10 +1060,11 @@ export default class InviteDialog extends React.PureComponent {
 
             title = _t("Direct Messages");
             helpText = _t(
-                "If you can't find someone, ask them for their username, share your " +
-                "username (%(userId)s) or <a>profile link</a>.",
-                {userId},
-                {a: (sub) => <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{sub}</a>},
+                "Start a conversation with someone using their name, username (like <userId/>) or email address.",
+                {},
+                {userId: () => {
+                    return <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>;
+                }},
             );
             buttonText = _t("Go");
             goButtonFn = this._startDm;
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index f8c8ad0200..2f800f8d21 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1584,7 +1584,7 @@
     "Recent Conversations": "Recent Conversations",
     "Suggestions": "Suggestions",
     "Recently Direct Messaged": "Recently Direct Messaged",
-    "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.",
+    "Start a conversation with someone using their name, username (like <userId/>) or email address.": "Start a conversation with someone using their name, username (like <userId/>) or email address.",
     "Go": "Go",
     "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.",
     "You added a new session '%(displayName)s', which is requesting encryption keys.": "You added a new session '%(displayName)s', which is requesting encryption keys.",

From dd9ead0166181da4c7dd5471a84d7feb42dbb7e6 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 18 Mar 2020 21:09:52 +0000
Subject: [PATCH 03/14] Invite dialog: backspace on empty field remove
 right-most target

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/views/dialogs/InviteDialog.js | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js
index 84ce69e174..8cf3967ac5 100644
--- a/src/components/views/dialogs/InviteDialog.js
+++ b/src/components/views/dialogs/InviteDialog.js
@@ -35,6 +35,7 @@ import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
 import {inviteMultipleToRoom} from "../../../RoomInvite";
 import SettingsStore from '../../../settings/SettingsStore';
 import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
+import {Key} from "../../../Keyboard";
 
 export const KIND_DM = "dm";
 export const KIND_INVITE = "invite";
@@ -647,6 +648,14 @@ export default class InviteDialog extends React.PureComponent {
         this.props.onFinished();
     };
 
+    _onKeyDown = (e) => {
+        // when the field is empty and the user hits backspace remove the right-most target
+        if (!e.target.value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !e.ctrlKey && !e.shiftKey) {
+            e.preventDefault();
+            this._removeMember(this.state.targets[this.state.targets.length - 1]);
+        }
+    };
+
     _updateFilter = (e) => {
         const term = e.target.value;
         this.setState({filterText: term});
@@ -988,8 +997,8 @@ export default class InviteDialog extends React.PureComponent {
         ));
         const input = (
             <textarea
-                key={"input"}
                 rows={1}
+                onKeyDown={this._onKeyDown}
                 onChange={this._updateFilter}
                 value={this.state.filterText}
                 ref={this._editorRef}

From 963c0a7b3f0451c19110c8a1b25d23271a06700c Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 18 Mar 2020 21:16:41 +0000
Subject: [PATCH 04/14] Hide remove target buttons when busy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/views/dialogs/InviteDialog.js | 27 +++++++++++++-------
 1 file changed, 18 insertions(+), 9 deletions(-)

diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js
index 8cf3967ac5..ad5db69947 100644
--- a/src/components/views/dialogs/InviteDialog.js
+++ b/src/components/views/dialogs/InviteDialog.js
@@ -126,7 +126,7 @@ class ThreepidMember extends Member {
 class DMUserTile extends React.PureComponent {
     static propTypes = {
         member: PropTypes.object.isRequired, // Should be a Member (see interface above)
-        onRemove: PropTypes.func.isRequired, // takes 1 argument, the member being removed
+        onRemove: PropTypes.func, // takes 1 argument, the member being removed
     };
 
     _onRemove = (e) => {
@@ -157,18 +157,25 @@ class DMUserTile extends React.PureComponent {
                 width={avatarSize}
                 height={avatarSize} />;
 
-        return (
-            <span className='mx_InviteDialog_userTile'>
-                <span className='mx_InviteDialog_userTile_pill'>
-                    {avatar}
-                    <span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
-                </span>
+        let closeButton;
+        if (this.props.onRemove) {
+            closeButton = (
                 <AccessibleButton
                     className='mx_InviteDialog_userTile_remove'
                     onClick={this._onRemove}
                 >
                     <img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
                 </AccessibleButton>
+            );
+        }
+
+        return (
+            <span className='mx_InviteDialog_userTile'>
+                <span className='mx_InviteDialog_userTile_pill'>
+                    {avatar}
+                    <span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
+                </span>
+                { closeButton }
             </span>
         );
     }
@@ -650,7 +657,9 @@ export default class InviteDialog extends React.PureComponent {
 
     _onKeyDown = (e) => {
         // when the field is empty and the user hits backspace remove the right-most target
-        if (!e.target.value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !e.ctrlKey && !e.shiftKey) {
+        if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
+            !e.ctrlKey && !e.shiftKey && !e.metaKey
+        ) {
             e.preventDefault();
             this._removeMember(this.state.targets[this.state.targets.length - 1]);
         }
@@ -993,7 +1002,7 @@ export default class InviteDialog extends React.PureComponent {
 
     _renderEditor() {
         const targets = this.state.targets.map(t => (
-            <DMUserTile member={t} onRemove={this._removeMember} key={t.userId} />
+            <DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
         ));
         const input = (
             <textarea

From 9da57817d17c51d200e3a69a2ca96f303cce294a Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Wed, 18 Mar 2020 15:50:05 -0600
Subject: [PATCH 05/14] Use a local wrapper for Jitsi calls

Requires https://github.com/vector-im/riot-web/pull/12780
---
 docs/jitsi.md                            |  31 +++++
 src/CallHandler.js                       |  53 ++-------
 src/SdkConfig.ts                         |   7 ++
 src/components/views/elements/AppTile.js |   8 +-
 src/utils/WidgetUtils.js                 |  32 ++++++
 src/widgets/WidgetApi.ts                 | 140 +++++++++++++++++++++++
 6 files changed, 225 insertions(+), 46 deletions(-)
 create mode 100644 docs/jitsi.md
 create mode 100644 src/widgets/WidgetApi.ts

diff --git a/docs/jitsi.md b/docs/jitsi.md
new file mode 100644
index 0000000000..3f1b6c4377
--- /dev/null
+++ b/docs/jitsi.md
@@ -0,0 +1,31 @@
+# Jitsi Wrapper
+
+**Note**: these are developer docs. Please consult your client's documentation for
+instructions on setting up Jitsi.
+
+The react-sdk wraps all Jitsi call widgets in a local wrapper called `jitsi.html`
+which takes several parameters:
+
+*Query string*:
+* `widgetId`: The ID of the widget. This is needed for communication back to the 
+  react-sdk.
+* `parentUrl`: The URL of the parent window. This is also ideally needed for
+  communication back to the react-sdk.
+
+*Hash/fragment (formatted as a query string)*:
+* `conferenceDomain`: The domain to connect Jitsi Meet to.
+* `conferenceId`: The room or conference ID to connect Jitsi Meet to.
+* `isAudioOnly`: Boolean for whether this is a voice-only conference. May not
+  be present, should default to `false`.
+* `displayName`: The display name of the user viewing the widget. May not
+  be present or could be null.
+* `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May
+  not be present or could be null.
+* `userId`: The MXID of the user viewing the widget. May not be present or could
+  be null.
+
+The react-sdk will assume that `jitsi.html` is at the path of wherever it is currently
+being served. For example, `https://riot.im/develop/jitsi.html` or `vector://webapp/jitsi.html`.
+
+The `jitsi.html` wrapper can use the react-sdk's `WidgetApi` to communicate, making
+it easier to actually implement the feature.
diff --git a/src/CallHandler.js b/src/CallHandler.js
index 2988e90f40..4817523f0d 100644
--- a/src/CallHandler.js
+++ b/src/CallHandler.js
@@ -395,32 +395,6 @@ function _onAction(payload) {
 }
 
 async function _startCallApp(roomId, type) {
-    // check for a working integration manager. Technically we could put
-    // the state event in anyway, but the resulting widget would then not
-    // work for us. Better that the user knows before everyone else in the
-    // room sees it.
-    const managers = IntegrationManagers.sharedInstance();
-    let haveScalar = false;
-    if (managers.hasManager()) {
-        try {
-            const scalarClient = managers.getPrimaryManager().getScalarClient();
-            await scalarClient.connect();
-            haveScalar = scalarClient.hasCredentials();
-        } catch (e) {
-            // ignore
-        }
-    }
-
-    if (!haveScalar) {
-        const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-
-        Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, {
-            title: _t('Could not connect to the integration server'),
-            description: _t('A conference call could not be started because the integrations server is not available'),
-        });
-        return;
-    }
-
     dis.dispatch({
         action: 'appsDrawer',
         show: true,
@@ -460,27 +434,16 @@ async function _startCallApp(roomId, type) {
     // the event. It's just a random string to make the Jitsi URLs unique.
     const widgetSessionId = Math.random().toString(36).substring(2);
     const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId;
-    // NB. we can't just encodeURICompoent all of these because the $ signs need to be there
-    // (but currently the only thing that needs encoding is the confId)
-    const queryString = [
-        'confId='+encodeURIComponent(confId),
-        'isAudioConf='+(type === 'voice' ? 'true' : 'false'),
-        'displayName=$matrix_display_name',
-        'avatarUrl=$matrix_avatar_url',
-        'email=$matrix_user_id',
-    ].join('&');
+    const jitsiDomain = SdkConfig.get()['jitsi']['preferredDomain'];
 
-    let widgetUrl;
-    if (SdkConfig.get().integrations_jitsi_widget_url) {
-        // Try this config key. This probably isn't ideal as a way of discovering this
-        // URL, but this will at least allow the integration manager to not be hardcoded.
-        widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString;
-    } else {
-        const apiUrl = IntegrationManagers.sharedInstance().getPrimaryManager().apiUrl;
-        widgetUrl = apiUrl + '/widgets/jitsi.html?' + queryString;
-    }
+    const widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
 
-    const widgetData = { widgetSessionId };
+    const widgetData = {
+        widgetSessionId, // TODO: Remove this eventually
+        conferenceId: confId,
+        isAudioOnly: type === 'voice',
+        domain: jitsiDomain,
+    };
 
     const widgetId = (
         'jitsi_' +
diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts
index 8177a6c5b8..34f3402334 100644
--- a/src/SdkConfig.ts
+++ b/src/SdkConfig.ts
@@ -26,6 +26,13 @@ export const DEFAULTS: ConfigOptions = {
     integrations_rest_url: "https://scalar.vector.im/api",
     // Where to send bug reports. If not specified, bugs cannot be sent.
     bug_report_endpoint_url: null,
+    // Jitsi conference options
+    jitsi: {
+        // Default conference domain
+        preferredDomain: "jitsi.riot.im",
+        // Default Jitsi Meet API location
+        externalApiUrl: "https://jitsi.riot.im/libs/external_api.min.js",
+    },
 };
 
 export default class SdkConfig {
diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js
index 20d98f5e23..a26478c461 100644
--- a/src/components/views/elements/AppTile.js
+++ b/src/components/views/elements/AppTile.js
@@ -520,7 +520,13 @@ export default class AppTile extends React.Component {
             parsedWidgetUrl.query.react_perf = true;
         }
         let safeWidgetUrl = '';
-        if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
+        if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol) || (
+            // Check if the widget URL is a Jitsi widget in Electron
+            parsedWidgetUrl.protocol === 'vector:'
+            && parsedWidgetUrl.host === 'vector'
+            && parsedWidgetUrl.pathname === '/webapp/jitsi.html'
+            && this.props.type === 'jitsi'
+        )) {
             safeWidgetUrl = url.format(parsedWidgetUrl);
         }
         return safeWidgetUrl;
diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js
index c09cd8a858..fa6bd664a2 100644
--- a/src/utils/WidgetUtils.js
+++ b/src/utils/WidgetUtils.js
@@ -430,6 +430,11 @@ export default class WidgetUtils {
             app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
         }
 
+        if (app.type === 'jitsi') {
+            console.log("Replacing Jitsi widget URL with local wrapper");
+            app.url = WidgetUtils.getLocalJitsiWrapperUrl(true);
+        }
+
         app.url = encodeUri(app.url, params);
 
         return app;
@@ -468,4 +473,31 @@ export default class WidgetUtils {
 
         return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
     }
+
+    static getLocalJitsiWrapperUrl(forLocalRender = false) {
+        // NB. we can't just encodeURIComponent all of these because the $ signs need to be there
+        const queryString = [
+            'conferenceDomain=$domain',
+            'conferenceId=$conferenceId',
+            'isAudioOnly=$isAudioOnly',
+            'displayName=$matrix_display_name',
+            'avatarUrl=$matrix_avatar_url',
+            'userId=$matrix_user_id',
+        ].join('&');
+
+        let currentUrl = window.location.href.split('#')[0];
+        if (!currentUrl.startsWith("https://") && !forLocalRender) {
+            // Use an external wrapper if we're not locally rendering the widget. This is usually
+            // the URL that will end up in the widget event, so we want to make sure it's relatively
+            // safe to send.
+            // We'll end up using a local render URL when we see a Jitsi widget anyways, so this is
+            // really just for backwards compatibility and to appease the spec.
+            currentUrl = "https://riot.im/app"
+        }
+        if (!currentUrl.endsWith('/')) {
+            currentUrl = `${currentUrl}/`;
+        }
+
+        return currentUrl + "jitsi.html#" + queryString;
+    }
 }
diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts
new file mode 100644
index 0000000000..c19e34ae43
--- /dev/null
+++ b/src/widgets/WidgetApi.ts
@@ -0,0 +1,140 @@
+/*
+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.
+*/
+
+// Dev note: This is largely inspired by Dimension. Used with permission.
+// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts
+
+import { randomString } from "matrix-js-sdk/src/randomstring";
+
+export enum Capability {
+    Screenshot = "m.capability.screenshot",
+    Sticker = "m.sticker",
+    AlwaysOnScreen = "m.always_on_screen",
+}
+
+export enum KnownWidgetActions {
+    GetSupportedApiVersions = "supported_api_versions",
+    TakeScreenshot = "screenshot",
+    GetCapabilities = "capabilities",
+    SendEvent = "send_event",
+    UpdateVisibility = "visibility",
+    ReceiveOpenIDCredentials = "openid_credentials",
+    SetAlwaysOnScreen = "set_always_on_screen",
+}
+export type WidgetAction = KnownWidgetActions | string;
+
+export enum WidgetApiType {
+    ToWidget = "toWidget",
+    FromWidget = "fromWidget",
+}
+
+export interface WidgetRequest {
+    api: WidgetApiType;
+    widgetId: string;
+    requestId: string;
+    data: any;
+    action: WidgetAction;
+}
+
+export interface ToWidgetRequest extends WidgetRequest {
+    api: WidgetApiType.ToWidget;
+}
+
+export interface FromWidgetRequest extends WidgetRequest {
+    api: WidgetApiType.FromWidget;
+    response: any;
+}
+
+/**
+ * Handles Riot <--> Widget interactions for embedded/standalone widgets.
+ */
+export class WidgetApi {
+    private origin: string;
+    private inFlightRequests: {[requestId: string]: (reply: FromWidgetRequest) => void} = {};
+    private readyPromise: Promise<any>;
+    private readyPromiseResolve: () => void;
+
+    constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
+        this.origin = new URL(currentUrl).origin;
+
+        this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
+
+        window.addEventListener("message", event => {
+            if (event.origin !== this.origin) return; // ignore: invalid origin
+            if (!event.data) return; // invalid schema
+            if (event.data.widgetId !== this.widgetId) return; // not for us
+
+            const payload = <WidgetRequest>event.data;
+            if (payload.api === WidgetApiType.ToWidget && payload.action) {
+                console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`);
+
+                if (payload.action === KnownWidgetActions.GetCapabilities) {
+                    this.onCapabilitiesRequest(<ToWidgetRequest>payload);
+                    this.readyPromiseResolve();
+                } else {
+                    console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
+                }
+            } else if (payload.api === WidgetApiType.FromWidget && this.inFlightRequests[payload.requestId]) {
+                console.log(`[WidgetAPI] Got reply: ${JSON.stringify(payload)}`);
+                const handler = this.inFlightRequests[payload.requestId];
+                delete this.inFlightRequests[payload.requestId];
+                handler(<FromWidgetRequest>payload);
+            } else {
+                console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`);
+            }
+        });
+    }
+
+    public waitReady(): Promise<any> {
+        return this.readyPromise;
+    }
+
+    private replyToRequest(payload: ToWidgetRequest, reply: any) {
+        if (!window.parent) return;
+
+        const request = JSON.parse(JSON.stringify(payload));
+        request.response = reply;
+
+        window.parent.postMessage(request, this.origin);
+    }
+
+    private onCapabilitiesRequest(payload: ToWidgetRequest) {
+        return this.replyToRequest(payload, {capabilities: this.requestedCapabilities});
+    }
+
+    public callAction(action: WidgetAction, payload: any, callback: (reply: FromWidgetRequest) => void) {
+        if (!window.parent) return;
+
+        const request: FromWidgetRequest = {
+            api: WidgetApiType.FromWidget,
+            widgetId: this.widgetId,
+            action: action,
+            requestId: randomString(160),
+            data: payload,
+            response: {}, // Not used at this layer - it's used when the client responds
+        };
+        this.inFlightRequests[request.requestId] = callback;
+
+        console.log(`[WidgetAPI] Sending request: `, request);
+        window.parent.postMessage(request, "*");
+    }
+
+    public setAlwaysOnScreen(onScreen: boolean): Promise<any> {
+        return new Promise<any>(resolve => {
+            this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, resolve);
+        });
+    }
+}

From 2a68479f5a747511b73d92a06a7a6863ec22fa5b Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Wed, 18 Mar 2020 15:53:05 -0600
Subject: [PATCH 06/14] Remove unused strings

---
 src/i18n/strings/en_EN.json | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index f8c8ad0200..528a4d11a5 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -45,8 +45,6 @@
     "VoIP is unsupported": "VoIP is unsupported",
     "You cannot place VoIP calls in this browser.": "You cannot place VoIP calls in this browser.",
     "You cannot place a call with yourself.": "You cannot place a call with yourself.",
-    "Could not connect to the integration server": "Could not connect to the integration server",
-    "A conference call could not be started because the integrations server is not available": "A conference call could not be started because the integrations server is not available",
     "Call in Progress": "Call in Progress",
     "A call is currently being placed!": "A call is currently being placed!",
     "A call is already in progress!": "A call is already in progress!",

From 8519a83b6139df47cef8df4b61df02d0605fb361 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Wed, 18 Mar 2020 15:58:57 -0600
Subject: [PATCH 07/14] Appease the linter

---
 src/CallHandler.js       | 1 -
 src/utils/WidgetUtils.js | 2 +-
 2 files changed, 1 insertion(+), 2 deletions(-)

diff --git a/src/CallHandler.js b/src/CallHandler.js
index 4817523f0d..7ec4e7d8bb 100644
--- a/src/CallHandler.js
+++ b/src/CallHandler.js
@@ -64,7 +64,6 @@ import SdkConfig from './SdkConfig';
 import { showUnknownDeviceDialogForCalls } from './cryptodevices';
 import WidgetUtils from './utils/WidgetUtils';
 import WidgetEchoStore from './stores/WidgetEchoStore';
-import {IntegrationManagers} from "./integrations/IntegrationManagers";
 import SettingsStore, { SettingLevel } from './settings/SettingsStore';
 
 global.mxCalls = {
diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js
index fa6bd664a2..e68ec8483c 100644
--- a/src/utils/WidgetUtils.js
+++ b/src/utils/WidgetUtils.js
@@ -492,7 +492,7 @@ export default class WidgetUtils {
             // safe to send.
             // We'll end up using a local render URL when we see a Jitsi widget anyways, so this is
             // really just for backwards compatibility and to appease the spec.
-            currentUrl = "https://riot.im/app"
+            currentUrl = "https://riot.im/app";
         }
         if (!currentUrl.endsWith('/')) {
             currentUrl = `${currentUrl}/`;

From 3bb1422f93a188e2de7c09055bac2964d799ac60 Mon Sep 17 00:00:00 2001
From: Hubert Chathi <hubert@uhoreg.ca>
Date: Wed, 18 Mar 2020 19:23:36 -0400
Subject: [PATCH 08/14] pre-send megolm keys when possible when a user starts
 typing

---
 src/components/views/rooms/SendMessageComposer.js | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js
index 63e58bf738..31085ad6db 100644
--- a/src/components/views/rooms/SendMessageComposer.js
+++ b/src/components/views/rooms/SendMessageComposer.js
@@ -42,6 +42,8 @@ import {_t, _td} from '../../../languageHandler';
 import ContentMessages from '../../../ContentMessages';
 import {Key} from "../../../Keyboard";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import {MatrixClientPeg} from "../../../MatrixClientPeg";
+import RateLimitedFunc from '../../../ratelimitedfunc';
 
 function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
     const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@@ -102,6 +104,12 @@ export default class SendMessageComposer extends React.Component {
         this.model = null;
         this._editorRef = null;
         this.currentlyComposedEditorState = null;
+        const cli = MatrixClientPeg.get();
+        if (cli.isCryptoEnabled() && cli.isRoomEncrypted(this.props.room.roomId)) {
+            this._prepareToEncrypt = new RateLimitedFunc(() => {
+                cli.prepareToEncrypt(this.props.room);
+            }, 60000);
+        }
     }
 
     _setEditorRef = ref => {
@@ -121,6 +129,8 @@ export default class SendMessageComposer extends React.Component {
             this.onVerticalArrow(event, true);
         } else if (event.key === Key.ARROW_DOWN) {
             this.onVerticalArrow(event, false);
+        } else if (this._prepareToEncrypt) {
+            this._prepareToEncrypt();
         }
     }
 

From 1dc30beb95aa6990c48d7bad7658d8ebda62c3ea Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Thu, 19 Mar 2020 09:58:04 +0000
Subject: [PATCH 09/14] disable textarea when busy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/views/dialogs/InviteDialog.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js
index ad5db69947..88f7c8d404 100644
--- a/src/components/views/dialogs/InviteDialog.js
+++ b/src/components/views/dialogs/InviteDialog.js
@@ -1013,6 +1013,7 @@ export default class InviteDialog extends React.PureComponent {
                 ref={this._editorRef}
                 onPaste={this._onPaste}
                 autoFocus={true}
+                disabled={this.state.busy}
             />
         );
         return (

From fa6a937896b0994c3f6b8370c6f1cee8e039d8ce Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Thu, 19 Mar 2020 09:58:49 +0000
Subject: [PATCH 10/14] don't block onFinished as there are other ways to close
 modal

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/views/dialogs/InviteDialog.js | 11 ++---------
 1 file changed, 2 insertions(+), 9 deletions(-)

diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js
index 88f7c8d404..f0d5443cac 100644
--- a/src/components/views/dialogs/InviteDialog.js
+++ b/src/components/views/dialogs/InviteDialog.js
@@ -648,13 +648,6 @@ export default class InviteDialog extends React.PureComponent {
         });
     };
 
-    _cancel = () => {
-        // We do not want the user to close the dialog while an action is in progress
-        if (this.state.busy) return;
-
-        this.props.onFinished();
-    };
-
     _onKeyDown = (e) => {
         // when the field is empty and the user hits backspace remove the right-most target
         if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
@@ -907,7 +900,7 @@ export default class InviteDialog extends React.PureComponent {
     _onManageSettingsClick = (e) => {
         e.preventDefault();
         dis.dispatch({ action: 'view_user_settings' });
-        this._cancel();
+        this.props.onFinished();
     };
 
     _renderSection(kind: "recents"|"suggestions") {
@@ -1107,7 +1100,7 @@ export default class InviteDialog extends React.PureComponent {
             <BaseDialog
                 className='mx_InviteDialog'
                 hasCancel={true}
-                onFinished={this._cancel}
+                onFinished={this.props.onFinished}
                 title={title}
             >
                 <div className='mx_InviteDialog_content'>

From fb462585280a1f4dff9b0a3b64154b068e52791c Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Thu, 19 Mar 2020 13:57:36 +0000
Subject: [PATCH 11/14] we don't do mx_fadable anymore so get rid of broken RLS
 disabling

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 src/components/structures/GroupView.js       | 4 ----
 src/components/views/rooms/ForwardMessage.js | 2 --
 src/stores/RightPanelStore.js                | 9 +--------
 3 files changed, 1 insertion(+), 14 deletions(-)

diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js
index cadc511fc3..0d10dd6d8f 100644
--- a/src/components/structures/GroupView.js
+++ b/src/components/structures/GroupView.js
@@ -555,10 +555,6 @@ export default createReactClass({
                         GROUP_JOINPOLICY_INVITE,
             },
         });
-        dis.dispatch({
-            action: 'panel_disable',
-            sideDisabled: true,
-        });
     },
 
     _onShareClick: function() {
diff --git a/src/components/views/rooms/ForwardMessage.js b/src/components/views/rooms/ForwardMessage.js
index c7c954dc9f..78466f7cb5 100644
--- a/src/components/views/rooms/ForwardMessage.js
+++ b/src/components/views/rooms/ForwardMessage.js
@@ -33,7 +33,6 @@ export default createReactClass({
     componentWillMount: function() {
         dis.dispatch({
             action: 'panel_disable',
-            rightDisabled: true,
             middleDisabled: true,
         });
     },
@@ -45,7 +44,6 @@ export default createReactClass({
     componentWillUnmount: function() {
         dis.dispatch({
             action: 'panel_disable',
-            sideDisabled: false,
             middleDisabled: false,
         });
         document.removeEventListener('keydown', this._onKeyDown);
diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js
index 1b3cb3d64b..ccdeb006f4 100644
--- a/src/stores/RightPanelStore.js
+++ b/src/stores/RightPanelStore.js
@@ -42,8 +42,6 @@ const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("G
 export default class RightPanelStore extends Store {
     static _instance;
 
-    _inhibitUpdates = false;
-
     constructor() {
         super(dis);
 
@@ -116,11 +114,6 @@ export default class RightPanelStore extends Store {
     }
 
     __onDispatch(payload) {
-        if (payload.action === 'panel_disable') {
-            this._inhibitUpdates = payload.rightDisabled || payload.sideDisabled || false;
-            return;
-        }
-
         if (payload.action === 'view_room' || payload.action === 'view_group') {
             // Reset to the member list if we're viewing member info
             const memberInfoPhases = [
@@ -138,7 +131,7 @@ export default class RightPanelStore extends Store {
             }
         }
 
-        if (payload.action !== 'set_right_panel_phase' || this._inhibitUpdates) return;
+        if (payload.action !== 'set_right_panel_phase') return;
 
         const targetPhase = payload.phase;
         if (!RIGHT_PANEL_PHASES[targetPhase]) {

From f14257926ec4839d4bd7c31f8dae7ecd326278ea Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Thu, 19 Mar 2020 15:24:28 +0000
Subject: [PATCH 12/14] RoomPreviewBar word-break the sender name too

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
 res/css/views/rooms/_RoomPreviewBar.scss | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss
index b3f6a12103..981cf06c69 100644
--- a/res/css/views/rooms/_RoomPreviewBar.scss
+++ b/res/css/views/rooms/_RoomPreviewBar.scss
@@ -25,9 +25,6 @@ limitations under the License.
     h3 {
         font-size: 18px;
         font-weight: 600;
-        // break-word, with fallback to break-all, which is wider supported
-        word-break: break-all;
-        word-break: break-word;
 
         &.mx_RoomPreviewBar_spinnerTitle {
             display: flex;
@@ -36,6 +33,13 @@ limitations under the License.
         }
     }
 
+    h3,
+    .mx_RoomPreviewBar_message p {
+        // break-word, with fallback to break-all, which is wider supported
+        word-break: break-all;
+        word-break: break-word;
+    }
+
     .mx_Spinner {
         width: auto;
         height: auto;

From af97ec57d064ca47da5ec021ee2e1c1c1072a0aa Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Thu, 19 Mar 2020 11:45:17 -0600
Subject: [PATCH 13/14] Apply suggestions from code review

Co-Authored-By: J. Ryan Stinnett <jryans@gmail.com>
---
 docs/jitsi.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/docs/jitsi.md b/docs/jitsi.md
index 3f1b6c4377..779ef79d3a 100644
--- a/docs/jitsi.md
+++ b/docs/jitsi.md
@@ -1,6 +1,6 @@
 # Jitsi Wrapper
 
-**Note**: these are developer docs. Please consult your client's documentation for
+**Note**: These are developer docs. Please consult your client's documentation for
 instructions on setting up Jitsi.
 
 The react-sdk wraps all Jitsi call widgets in a local wrapper called `jitsi.html`
@@ -9,7 +9,7 @@ which takes several parameters:
 *Query string*:
 * `widgetId`: The ID of the widget. This is needed for communication back to the 
   react-sdk.
-* `parentUrl`: The URL of the parent window. This is also ideally needed for
+* `parentUrl`: The URL of the parent window. This is also needed for
   communication back to the react-sdk.
 
 *Hash/fragment (formatted as a query string)*:

From 92251316a3bf90c19b0097c7235e818884f7c09a Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Thu, 19 Mar 2020 11:51:01 -0600
Subject: [PATCH 14/14] Switch to named object

---
 src/utils/WidgetUtils.js | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js
index e68ec8483c..7d6bf5e90d 100644
--- a/src/utils/WidgetUtils.js
+++ b/src/utils/WidgetUtils.js
@@ -432,7 +432,7 @@ export default class WidgetUtils {
 
         if (app.type === 'jitsi') {
             console.log("Replacing Jitsi widget URL with local wrapper");
-            app.url = WidgetUtils.getLocalJitsiWrapperUrl(true);
+            app.url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true});
         }
 
         app.url = encodeUri(app.url, params);
@@ -474,7 +474,7 @@ export default class WidgetUtils {
         return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
     }
 
-    static getLocalJitsiWrapperUrl(forLocalRender = false) {
+    static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean}) {
         // NB. we can't just encodeURIComponent all of these because the $ signs need to be there
         const queryString = [
             'conferenceDomain=$domain',
@@ -486,7 +486,7 @@ export default class WidgetUtils {
         ].join('&');
 
         let currentUrl = window.location.href.split('#')[0];
-        if (!currentUrl.startsWith("https://") && !forLocalRender) {
+        if (!currentUrl.startsWith("https://") && !opts.forLocalRender) {
             // Use an external wrapper if we're not locally rendering the widget. This is usually
             // the URL that will end up in the widget event, so we want to make sure it's relatively
             // safe to send.