diff --git a/docs/jitsi.md b/docs/jitsi.md new file mode 100644 index 0000000000..779ef79d3a --- /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 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..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 = { @@ -395,32 +394,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 +433,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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 99dc1809ab..c3befa2298 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!", diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index c09cd8a858..7d6bf5e90d 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({forLocalRender: true}); + } + app.url = encodeUri(app.url, params); return app; @@ -468,4 +473,31 @@ export default class WidgetUtils { return encodeURIComponent(`${widgetLocation}::${widgetUrl}`); } + + 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', + '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://") && !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. + // 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; + private readyPromiseResolve: () => void; + + constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) { + this.origin = new URL(currentUrl).origin; + + this.readyPromise = new Promise(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 = event.data; + if (payload.api === WidgetApiType.ToWidget && payload.action) { + console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`); + + if (payload.action === KnownWidgetActions.GetCapabilities) { + this.onCapabilitiesRequest(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(payload); + } else { + console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`); + } + }); + } + + public waitReady(): Promise { + 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 { + return new Promise(resolve => { + this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, resolve); + }); + } +}