Use a local wrapper for Jitsi calls
Requires https://github.com/vector-im/riot-web/pull/12780
This commit is contained in:
parent
ce90cbe35e
commit
9da57817d1
6 changed files with 225 additions and 46 deletions
31
docs/jitsi.md
Normal file
31
docs/jitsi.md
Normal file
|
@ -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.
|
|
@ -395,32 +395,6 @@ function _onAction(payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _startCallApp(roomId, type) {
|
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({
|
dis.dispatch({
|
||||||
action: 'appsDrawer',
|
action: 'appsDrawer',
|
||||||
show: true,
|
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.
|
// the event. It's just a random string to make the Jitsi URLs unique.
|
||||||
const widgetSessionId = Math.random().toString(36).substring(2);
|
const widgetSessionId = Math.random().toString(36).substring(2);
|
||||||
const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId;
|
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
|
const jitsiDomain = SdkConfig.get()['jitsi']['preferredDomain'];
|
||||||
// (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('&');
|
|
||||||
|
|
||||||
let widgetUrl;
|
const widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
|
||||||
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 widgetData = { widgetSessionId };
|
const widgetData = {
|
||||||
|
widgetSessionId, // TODO: Remove this eventually
|
||||||
|
conferenceId: confId,
|
||||||
|
isAudioOnly: type === 'voice',
|
||||||
|
domain: jitsiDomain,
|
||||||
|
};
|
||||||
|
|
||||||
const widgetId = (
|
const widgetId = (
|
||||||
'jitsi_' +
|
'jitsi_' +
|
||||||
|
|
|
@ -26,6 +26,13 @@ export const DEFAULTS: ConfigOptions = {
|
||||||
integrations_rest_url: "https://scalar.vector.im/api",
|
integrations_rest_url: "https://scalar.vector.im/api",
|
||||||
// Where to send bug reports. If not specified, bugs cannot be sent.
|
// Where to send bug reports. If not specified, bugs cannot be sent.
|
||||||
bug_report_endpoint_url: null,
|
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 {
|
export default class SdkConfig {
|
||||||
|
|
|
@ -520,7 +520,13 @@ export default class AppTile extends React.Component {
|
||||||
parsedWidgetUrl.query.react_perf = true;
|
parsedWidgetUrl.query.react_perf = true;
|
||||||
}
|
}
|
||||||
let safeWidgetUrl = '';
|
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);
|
safeWidgetUrl = url.format(parsedWidgetUrl);
|
||||||
}
|
}
|
||||||
return safeWidgetUrl;
|
return safeWidgetUrl;
|
||||||
|
|
|
@ -430,6 +430,11 @@ export default class WidgetUtils {
|
||||||
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
|
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);
|
app.url = encodeUri(app.url, params);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
@ -468,4 +473,31 @@ export default class WidgetUtils {
|
||||||
|
|
||||||
return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
140
src/widgets/WidgetApi.ts
Normal file
140
src/widgets/WidgetApi.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue