From e1eb16ce462e70045dc42fc5ffe45d434a6690eb Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 18 Mar 2020 15:47:56 -0600 Subject: [PATCH] Use a local widget wrapper for Jitsi calls Effectively fixes https://github.com/vector-im/riot-web/issues/11074 Effectively fixes https://github.com/vector-im/riot-web/issues/7112 Fixes https://github.com/vector-im/riot-web/issues/6930 Fixes Jitsi widgets not working for guests (https://github.com/vector-im/riot-web/issues/8933) Fixes https://github.com/vector-im/riot-web/issues/5048 Previously we were relying on an integration manager to be defined, functional, and alive in order to join Jitsi calls. This commit changes this so we aren't reliant on an integration manager for Jitsi calls at all, and gives people the option of choosing a Jitsi server via the config.json. This side is just the wrapper/shell: the logic is mostly in the react-sdk (to be linked via PRs). This layer simply has an HTML file exported that can be used to render a Jitsi widget, and the react-sdk constructs a URL to access it locally. This is similar to how the mobile apps handle Jitsi widgets: instead of iframing the widget URL directly into the app, they pull apart the widget information and natively render it. We're effectively doing the same here by parsing the widget options and using our local wrapper instead of whatever happens to be defined in the widget state event. Integration managers should still continue to offer a widget URL for Jitsi widgets as this is what the spec requires. A large part of this is based upon Dimension's handling of Jitsi and widgets in general: a license has been granted to allow Riot (and therefore the react-sdk) to use the code and be inspired by it. --- config.sample.json | 5 +- docs/config.md | 7 ++ src/vector/app.js | 42 +++--------- src/vector/initial-load.ts | 58 ++++++++++++++++ src/vector/jitsi/index.html | 19 ++++++ src/vector/jitsi/index.scss | 75 +++++++++++++++++++++ src/vector/jitsi/index.ts | 127 ++++++++++++++++++++++++++++++++++++ webpack.config.js | 11 +++- 8 files changed, 308 insertions(+), 36 deletions(-) create mode 100644 src/vector/initial-load.ts create mode 100644 src/vector/jitsi/index.html create mode 100644 src/vector/jitsi/index.scss create mode 100644 src/vector/jitsi/index.ts diff --git a/config.sample.json b/config.sample.json index 69dc396884..b8dc2fbb4c 100644 --- a/config.sample.json +++ b/config.sample.json @@ -22,7 +22,6 @@ "https://scalar-staging.vector.im/api", "https://scalar-staging.riot.im/scalar/api" ], - "integrations_jitsi_widget_url": "https://scalar.vector.im/api/widgets/jitsi.html", "bug_report_endpoint_url": "https://riot.im/bugreports/submit", "defaultCountryCode": "GB", "showLabsSettings": false, @@ -52,5 +51,9 @@ }, "settingDefaults": { "breadcrumbs": true + }, + "jitsi": { + "preferredDomain": "jitsi.riot.im", + "externalApiUrl": "https://jitsi.riot.im/libs/external_api.min.js" } } diff --git a/docs/config.md b/docs/config.md index d11d8638cd..40e71361bb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -84,6 +84,13 @@ For a good example, see https://riot.im/develop/config.json. By default, this is "https://matrix.to" to generate matrix.to (spec) permalinks. Set this to your Riot instance URL if you run an unfederated server (eg: "https://riot.example.org"). +1. `jitsi`: Used to change the default conference options. + 1. `preferredDomain`: The domain name of the preferred Jitsi instance. Defaults + to `jitsi.riot.im`. This is used whenever a user clicks on the voice/video + call buttons - integration managers may use a different domain. + 1. `externalApiUrl`: The URL to the Jitsi Meet API script. This is required + for showing any Jitsi widgets, no matter the source. Defaults to + `https://jitsi.riot.im/libs/external_api.min.js`. Note that `index.html` also has an og:image meta tag that is set to an image hosted on riot.im. This is the image used if links to your copy of Riot diff --git a/src/vector/app.js b/src/vector/app.js index da46dffd76..6a1635dd50 100644 --- a/src/vector/app.js +++ b/src/vector/app.js @@ -39,9 +39,6 @@ import url from 'url'; import {parseQs, parseQsFromFragment} from './url_utils'; -import ElectronPlatform from './platform/ElectronPlatform'; -import WebPlatform from './platform/WebPlatform'; - import {MatrixClientPeg} from 'matrix-react-sdk/src/MatrixClientPeg'; import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; import SdkConfig from "matrix-react-sdk/src/SdkConfig"; @@ -50,6 +47,7 @@ import {setTheme} from "matrix-react-sdk/src/theme"; import Olm from 'olm'; import CallHandler from 'matrix-react-sdk/src/CallHandler'; +import {loadConfig, preparePlatform} from "./initial-load"; let lastLocationHashSet = null; @@ -191,35 +189,11 @@ export async function loadApp() { await loadOlm(); // set the platform for react sdk - if (window.ipcRenderer) { - console.log("Using Electron platform"); - const plaf = new ElectronPlatform(); - PlatformPeg.set(plaf); - } else { - console.log("Using Web platform"); - PlatformPeg.set(new WebPlatform()); - } - + preparePlatform(); const platform = PlatformPeg.get(); - let configJson; - let configError; - let configSyntaxError = false; - try { - configJson = await platform.getConfig(); - } catch (e) { - configError = e; - - if (e && e.err && e.err instanceof SyntaxError) { - console.error("SyntaxError loading config:", e); - configSyntaxError = true; - configJson = {}; // to prevent errors between here and loading CSS for the error box - } - } - - // XXX: We call this twice, once here and once in MatrixChat as a prop. We call it here to ensure - // granular settings are loaded correctly and to avoid duplicating the override logic for the theme. - SdkConfig.put(configJson); + // Load the config from the platform + const configInfo = await loadConfig(); // Load language after loading config.json so that settingsDefaults.language can be applied await loadLanguage(); @@ -248,7 +222,7 @@ export async function loadApp() { await setTheme(); // Now that we've loaded the theme (CSS), display the config syntax error if needed. - if (configSyntaxError) { + if (configInfo.configSyntaxError) { const errorMessage = (

@@ -260,7 +234,7 @@ export async function loadApp() {

{_t( "The message from the parser is: %(message)s", - {message: configError.err.message || _t("Invalid JSON")}, + {message: configInfo.configError.err.message || _t("Invalid JSON")}, )}

@@ -280,7 +254,7 @@ export async function loadApp() { const urlWithoutQuery = window.location.protocol + '//' + window.location.host + window.location.pathname; console.log("Vector starting at " + urlWithoutQuery); - if (configError) { + if (configInfo.configError) { window.matrixChat = ReactDOM.render(
Unable to load config file: please refresh the page to try again.
, document.getElementById('matrixchat')); @@ -298,7 +272,7 @@ export async function loadApp() { config={newConfig} realQueryParams={params} startingFragmentQueryParams={fragparts.params} - enableGuest={!configJson.disable_guests} + enableGuest={!SdkConfig.get().disable_guests} onTokenLoginCompleted={onTokenLoginCompleted} initialScreenAfterLogin={getScreenFromLocation(window.location)} defaultDeviceDisplayName={platform.getDefaultDeviceDisplayName()} diff --git a/src/vector/initial-load.ts b/src/vector/initial-load.ts new file mode 100644 index 0000000000..7bd73c5a4d --- /dev/null +++ b/src/vector/initial-load.ts @@ -0,0 +1,58 @@ +/* +Copyright 2020 New Vector Ltd + +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 ElectronPlatform from './platform/ElectronPlatform'; +import WebPlatform from './platform/WebPlatform'; +import PlatformPeg from 'matrix-react-sdk/src/PlatformPeg'; +import SdkConfig from "matrix-react-sdk/src/SdkConfig"; + +export function preparePlatform() { + if ((window).ipcRenderer) { + console.log("Using Electron platform"); + const plaf = new ElectronPlatform(); + PlatformPeg.set(plaf); + } else { + console.log("Using Web platform"); + PlatformPeg.set(new WebPlatform()); + } +} + +export async function loadConfig(): Promise<{configError?: Error, configSyntaxError: boolean}> { + const platform = PlatformPeg.get(); + + let configJson; + let configError; + let configSyntaxError = false; + try { + configJson = await platform.getConfig(); + } catch (e) { + configError = e; + + if (e && e.err && e.err instanceof SyntaxError) { + console.error("SyntaxError loading config:", e); + configSyntaxError = true; + configJson = {}; // to prevent errors between here and loading CSS for the error box + } + } + + // XXX: We call this twice, once here and once in MatrixChat as a prop. We call it here to ensure + // granular settings are loaded correctly and to avoid duplicating the override logic for the theme. + // + // Note: this isn't called twice for some wrappers, like the Jitsi wrapper. + SdkConfig.put(configJson); + + return {configError, configSyntaxError}; +} diff --git a/src/vector/jitsi/index.html b/src/vector/jitsi/index.html new file mode 100644 index 0000000000..99e7b831ee --- /dev/null +++ b/src/vector/jitsi/index.html @@ -0,0 +1,19 @@ + + + + + Jitsi Widget + + +
+
+
+
+ +

Jitsi Video Conference

+ +
+
+
+ + diff --git a/src/vector/jitsi/index.scss b/src/vector/jitsi/index.scss new file mode 100644 index 0000000000..c8c6b3e168 --- /dev/null +++ b/src/vector/jitsi/index.scss @@ -0,0 +1,75 @@ +/* +Copyright 2020 New Vector Ltd. + +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. +*/ + +// TODO: Match the user's theme instead of hardcoding a bunch of colors + +@font-face { + font-family: 'Nunito'; + font-style: normal; + font-weight: 400; + src: url('~matrix-react-sdk/res/fonts/Nunito/Nunito-Regular.ttf') format('truetype'); +} + +body { + font-family: Nunito, Arial, Helvetica, sans-serif; + background-color: #181b21; + color: #edf3ff; +} + +body, html { + padding: 0; + margin: 0; +} + +#jitsiContainer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +#joinButtonContainer { + display: table; + position: absolute; + height: 100%; + width: 100%; +} + +.join-conference-boat { + display: table-cell; + vertical-align: middle; +} + +.join-conference-prompt { + margin-left: auto; + margin-right: auto; + width: 90%; + text-align: center; +} + +#joinButton { + // A mix of AccessibleButton styles + cursor: pointer; + padding: 7px 18px; + text-align: center; + border-radius: 4px; + display: inline-block; + font-size: 14px; + color: #ffffff; + background-color: #03b381; + border: 0; +} diff --git a/src/vector/jitsi/index.ts b/src/vector/jitsi/index.ts new file mode 100644 index 0000000000..b6d3f26851 --- /dev/null +++ b/src/vector/jitsi/index.ts @@ -0,0 +1,127 @@ +/* +Copyright 2020 New Vector Ltd. + +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. +*/ + +// We have to trick webpack into loading our CSS for us. +require("./index.scss"); + +import * as qs from 'querystring'; +import { Capability, WidgetApi } from "matrix-react-sdk/src/widgets/WidgetApi"; +import SdkConfig from "matrix-react-sdk/src/SdkConfig"; +import { loadConfig, preparePlatform } from "../initial-load"; + +// Dev note: we use raw JS without many dependencies to reduce bundle size. +// We do not need all of React to render a Jitsi conference. + +declare var JitsiMeetExternalAPI: any; + +let inConference = false; + +// Jitsi params +let jitsiDomain: string; +let conferenceId: string; +let displayName: string; +let avatarUrl: string; +let userId: string; + +let widgetApi: WidgetApi; + +(async function () { + try { + // The widget's options are encoded into the fragment to avoid leaking info to the server. The widget + // spec on the other hand requires the widgetId and parentUrl to show up in the regular query string. + const widgetQuery = qs.parse(window.location.hash.substring(1)); + const query = Object.assign({}, qs.parse(window.location.search.substring(1)), widgetQuery); + const qsParam = (name: string, optional = false): string => { + if (!optional && (!query[name] || typeof (query[name]) !== 'string')) { + throw new Error(`Expected singular ${name} in query string`); + } + return query[name]; + }; + + // Set this up as early as possible because Riot will be hitting it almost immediately. + widgetApi = new WidgetApi(qsParam('parentUrl'), qsParam('widgetId'), [ + Capability.AlwaysOnScreen, + ]); + + widgetApi.waitReady().then(async () => { + // Start off by ensuring we're not stuck on screen + await widgetApi.setAlwaysOnScreen(false); + }); + + // Bootstrap ourselves for loading the script and such + preparePlatform(); + await loadConfig(); + + // Populate the Jitsi params now + jitsiDomain = qsParam('conferenceDomain', true) || SdkConfig.get()['jitsi']['preferredDomain']; + conferenceId = qsParam('conferenceId'); + displayName = qsParam('displayName', true); + avatarUrl = qsParam('avatarUrl', true); // http not mxc + userId = qsParam('userId'); + + // Get the Jitsi Meet API loaded up as fast as possible, but ensure that the widget's postMessage + // receiver (WidgetApi) is up and running first. + const scriptTag = document.createElement("script"); + scriptTag.src = SdkConfig.get()['jitsi']['externalApiUrl']; + document.body.appendChild(scriptTag); + + // TODO: register widgetApi listeners for PTT controls + + document.getElementById("joinButton").onclick = () => joinConference(); + } catch (e) { + console.error("Error setting up Jitsi widget", e); + document.getElementById("jitsiContainer").innerText = "Failed to load Jitsi widget"; + switchVisibleContainers(); + } +})(); + +function switchVisibleContainers() { + inConference = !inConference; + document.getElementById("jitsiContainer").style.visibility = inConference ? 'unset' : 'hidden'; + document.getElementById("joinButtonContainer").style.visibility = inConference ? 'hidden' : 'unset'; +} + +function joinConference() { // event handler bound in HTML + switchVisibleContainers(); + + // noinspection JSIgnoredPromiseFromCall + widgetApi.setAlwaysOnScreen(true); // ignored promise because we don't care if it works + + const meetApi = new JitsiMeetExternalAPI(jitsiDomain, { + width: "100%", + height: "100%", + parentNode: document.querySelector("#jitsiContainer"), + roomName: conferenceId, + interfaceConfigOverwrite: { + SHOW_JITSI_WATERMARK: false, + SHOW_WATERMARK_FOR_GUESTS: false, + MAIN_TOOLBAR_BUTTONS: [], + VIDEO_LAYOUT_FIT: "height", + }, + }); + if (displayName) meetApi.executeCommand("displayName", displayName); + if (avatarUrl) meetApi.executeCommand("avatarUrl", avatarUrl); + if (userId) meetApi.executeCommand("email", userId); + + meetApi.on("readyToClose", () => { + switchVisibleContainers(); + + // noinspection JSIgnoredPromiseFromCall + widgetApi.setAlwaysOnScreen(false); // ignored promise because we don't care if it works + + document.getElementById("jitsiContainer").innerHTML = ""; + }); +} diff --git a/webpack.config.js b/webpack.config.js index fbee94766d..f54655b5bd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -34,6 +34,7 @@ module.exports = (env, argv) => { "bundle": "./src/vector/index.js", "indexeddb-worker": "./src/vector/indexeddb-worker.js", "mobileguide": "./src/vector/mobile_guide/index.js", + "jitsi": "./src/vector/jitsi/index.ts", "usercontent": "./node_modules/matrix-react-sdk/src/usercontent/index.js", // CSS themes @@ -303,13 +304,21 @@ module.exports = (env, argv) => { // HtmlWebpackPlugin will screw up our formatting like the names // of the themes and which chunks we actually care about. inject: false, - excludeChunks: ['mobileguide', 'usercontent'], + excludeChunks: ['mobileguide', 'usercontent', 'jitsi'], minify: argv.mode === 'production', vars: { og_image_url: og_image_url, }, }), + // This is the jitsi widget wrapper (embedded, so isolated stack) + new HtmlWebpackPlugin({ + template: './src/vector/jitsi/index.html', + filename: 'jitsi.html', + minify: argv.mode === 'production', + chunks: ['jitsi'], + }), + // This is the mobile guide's entry point (separate for faster mobile loading) new HtmlWebpackPlugin({ template: './src/vector/mobile_guide/index.html',