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) {
|
||||
// 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_' +
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
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