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',