Merge branches 'develop' and 't3chguy/shortcuts1' of github.com:matrix-org/matrix-react-sdk into t3chguy/shortcuts1

This commit is contained in:
Michael Telatynski 2020-03-19 19:18:20 +00:00
commit 7d28137cfd
12 changed files with 404 additions and 73 deletions

31
docs/jitsi.md Normal file
View 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 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.

View file

@ -25,9 +25,6 @@ limitations under the License.
h3 {
font-size: 18px;
font-weight: 600;
// break-word, with fallback to break-all, which is wider supported
word-break: break-all;
word-break: break-word;
&.mx_RoomPreviewBar_spinnerTitle {
display: flex;
@ -36,6 +33,13 @@ limitations under the License.
}
}
h3,
.mx_RoomPreviewBar_message p {
// break-word, with fallback to break-all, which is wider supported
word-break: break-all;
word-break: break-word;
}
.mx_Spinner {
width: auto;
height: auto;

View file

@ -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_' +

View file

@ -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 {

View file

@ -1494,6 +1494,16 @@ export default createReactClass({
}
});
cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => {
const KeySignatureUploadFailedDialog =
sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog');
Modal.createTrackedDialog(
'Failed to upload key signatures',
'Failed to upload key signatures',
KeySignatureUploadFailedDialog,
{ failures, source, continuation });
});
cli.on("crypto.verification.request", request => {
const isFlagOn = SettingsStore.isFeatureEnabled("feature_cross_signing");

View file

@ -35,6 +35,7 @@ import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite";
import SettingsStore from '../../../settings/SettingsStore';
import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
import {Key} from "../../../Keyboard";
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
@ -125,7 +126,7 @@ class ThreepidMember extends Member {
class DMUserTile extends React.PureComponent {
static propTypes = {
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
onRemove: PropTypes.func.isRequired, // takes 1 argument, the member being removed
onRemove: PropTypes.func, // takes 1 argument, the member being removed
};
_onRemove = (e) => {
@ -156,18 +157,25 @@ class DMUserTile extends React.PureComponent {
width={avatarSize}
height={avatarSize} />;
return (
<span className='mx_InviteDialog_userTile'>
<span className='mx_InviteDialog_userTile_pill'>
{avatar}
<span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
let closeButton;
if (this.props.onRemove) {
closeButton = (
<AccessibleButton
className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
</AccessibleButton>
);
}
return (
<span className='mx_InviteDialog_userTile'>
<span className='mx_InviteDialog_userTile_pill'>
{avatar}
<span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
{ closeButton }
</span>
);
}
@ -640,11 +648,14 @@ export default class InviteDialog extends React.PureComponent {
});
};
_cancel = () => {
// We do not want the user to close the dialog while an action is in progress
if (this.state.busy) return;
this.props.onFinished();
_onKeyDown = (e) => {
// when the field is empty and the user hits backspace remove the right-most target
if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
!e.ctrlKey && !e.shiftKey && !e.metaKey
) {
e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]);
}
};
_updateFilter = (e) => {
@ -889,7 +900,7 @@ export default class InviteDialog extends React.PureComponent {
_onManageSettingsClick = (e) => {
e.preventDefault();
dis.dispatch({ action: 'view_user_settings' });
this._cancel();
this.props.onFinished();
};
_renderSection(kind: "recents"|"suggestions") {
@ -984,17 +995,18 @@ export default class InviteDialog extends React.PureComponent {
_renderEditor() {
const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={this._removeMember} key={t.userId} />
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
));
const input = (
<textarea
key={"input"}
rows={1}
onKeyDown={this._onKeyDown}
onChange={this._updateFilter}
value={this.state.filterText}
ref={this._editorRef}
onPaste={this._onPaste}
autoFocus={true}
disabled={this.state.busy}
/>
);
return (
@ -1060,10 +1072,11 @@ export default class InviteDialog extends React.PureComponent {
title = _t("Direct Messages");
helpText = _t(
"If you can't find someone, ask them for their username, share your " +
"username (%(userId)s) or <a>profile link</a>.",
{userId},
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{sub}</a>},
"Start a conversation with someone using their name, username (like <userId/>) or email address.",
{},
{userId: () => {
return <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>;
}},
);
buttonText = _t("Go");
goButtonFn = this._startDm;
@ -1087,7 +1100,7 @@ export default class InviteDialog extends React.PureComponent {
<BaseDialog
className='mx_InviteDialog'
hasCancel={true}
onFinished={this._cancel}
onFinished={this.props.onFinished}
title={title}
>
<div className='mx_InviteDialog_content'>

View file

@ -0,0 +1,108 @@
/*
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.
*/
import React, {useState, useCallback, useRef} from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default function KeySignatureUploadFailedDialog({
failures,
source,
continuation,
onFinished,
}) {
const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('elements.Spinner');
const [retry, setRetry] = useState(RETRIES);
const [cancelled, setCancelled] = useState(false);
const [retrying, setRetrying] = useState(false);
const [success, setSuccess] = useState(false);
const onCancel = useRef(onFinished);
const causes = new Map([
["_afterCrossSigningLocalKeyChange", _t("a new master key signature")],
["checkOwnCrossSigningTrust", _t("a new cross-signing key signature")],
["setDeviceVerification", _t("a device cross-signing signature")],
]);
const defaultCause = _t("a key signature");
const onRetry = useCallback(async () => {
try {
setRetrying(true);
const cancel = new Promise((resolve, reject) => {
onCancel.current = reject;
}).finally(() => {
setCancelled(true);
});
await Promise.race([
continuation(),
cancel,
]);
setSuccess(true);
} catch (e) {
setRetry(r => r-1);
} finally {
onCancel.current = onFinished;
setRetrying(false);
}
}, [continuation, onFinished]);
let body;
if (!success && !cancelled && continuation && retry > 0) {
const reason = causes.get(source) || defaultCause;
body = (<div>
<p>{_t("Riot encountered an error during upload of:")}</p>
<p>{reason}</p>
{retrying && <Spinner />}
<pre>{JSON.stringify(failures, null, 2)}</pre>
<DialogButtons
primaryButton='Retry'
hasCancel={true}
onPrimaryButtonClick={onRetry}
onCancel={onCancel.current}
primaryDisabled={retrying}
/>
</div>);
} else {
body = (<div>
{success ?
<span>{_t("Upload completed")}</span> :
cancelled ?
<span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unabled to upload")}</span>}
<DialogButtons
primaryButton={_t("OK")}
hasCancel={false}
onPrimaryButtonClick={onFinished}
/>
</div>);
}
return (
<BaseDialog
title={success ?
_t("Signature upload success") :
_t("Signature upload failed")}
fixedWidth={false}
onFinished={() => {}}
>
{body}
</BaseDialog>
);
}

View file

@ -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;

View file

@ -42,6 +42,8 @@ import {_t, _td} from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RateLimitedFunc from '../../../ratelimitedfunc';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -102,6 +104,12 @@ export default class SendMessageComposer extends React.Component {
this.model = null;
this._editorRef = null;
this.currentlyComposedEditorState = null;
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled() && cli.isRoomEncrypted(this.props.room.roomId)) {
this._prepareToEncrypt = new RateLimitedFunc(() => {
cli.prepareToEncrypt(this.props.room);
}, 60000);
}
}
_setEditorRef = ref => {
@ -121,6 +129,8 @@ export default class SendMessageComposer extends React.Component {
this.onVerticalArrow(event, true);
} else if (event.key === Key.ARROW_DOWN) {
this.onVerticalArrow(event, false);
} else if (this._prepareToEncrypt) {
this._prepareToEncrypt();
}
}

View file

@ -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!",
@ -1585,7 +1583,7 @@
"Recent Conversations": "Recent Conversations",
"Suggestions": "Suggestions",
"Recently Direct Messaged": "Recently Direct Messaged",
"If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.": "If you can't find someone, ask them for their username, share your username (%(userId)s) or <a>profile link</a>.",
"Start a conversation with someone using their name, username (like <userId/>) or email address.": "Start a conversation with someone using their name, username (like <userId/>) or email address.",
"Go": "Go",
"If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.": "If you can't find someone, ask them for their username (e.g. @user:server.com) or <a>share this room</a>.",
"You added a new session '%(displayName)s', which is requesting encryption keys.": "You added a new session '%(displayName)s', which is requesting encryption keys.",
@ -1595,6 +1593,16 @@
"Ignore request": "Ignore request",
"Loading session info...": "Loading session info...",
"Encryption key request": "Encryption key request",
"a new master key signature": "a new master key signature",
"a new cross-signing key signature": "a new cross-signing key signature",
"a device cross-signing signature": "a device cross-signing signature",
"a key signature": "a key signature",
"Riot encountered an error during upload of:": "Riot encountered an error during upload of:",
"Upload completed": "Upload completed",
"Cancelled signature upload": "Cancelled signature upload",
"Unabled to upload": "Unabled to upload",
"Signature upload success": "Signature upload success",
"Signature upload failed": "Signature upload failed",
"You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.": "You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.",
"If the other version of Riot is still open in another tab, please close it as using Riot on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "If the other version of Riot is still open in another tab, please close it as using Riot on the same host with both lazy loading enabled and disabled simultaneously will cause issues.",
"Incompatible local cache": "Incompatible local cache",

View file

@ -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;
}
}

140
src/widgets/WidgetApi.ts Normal file
View 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);
});
}
}