Merge remote-tracking branch 'upstream/develop' into compact-reply-rendering

This commit is contained in:
Tulir Asokan 2020-03-05 13:19:15 +02:00
commit a8c5574bc8
792 changed files with 44313 additions and 29058 deletions

View file

@ -16,8 +16,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import sdk from './index';
import {MatrixClientPeg} from './MatrixClientPeg';
import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
import IdentityAuthClient from './IdentityAuthClient';
@ -236,6 +236,8 @@ export default class AddThreepid {
*/
async haveMsisdnToken(msisdnToken) {
const authClient = new IdentityAuthClient();
const supportsSeparateAddAndBind =
await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind();
let result;
if (this.submitUrl) {
@ -245,19 +247,21 @@ export default class AddThreepid {
this.clientSecret,
msisdnToken,
);
} else {
} else if (this.bind || !supportsSeparateAddAndBind) {
result = await MatrixClientPeg.get().submitMsisdnToken(
this.sessionId,
this.clientSecret,
msisdnToken,
await authClient.getAccessToken(),
);
} else {
throw new Error("The add / bind with MSISDN flow is misconfigured");
}
if (result.errcode) {
throw result;
}
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
if (supportsSeparateAddAndBind) {
if (this.bind) {
await MatrixClientPeg.get().bindThreePid({
sid: this.sessionId,

View file

@ -1,24 +1,27 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
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
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
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.
*/
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 from 'react';
import { getCurrentLanguage, _t, _td } from './languageHandler';
import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig';
import Modal from './Modal';
import sdk from './index';
import * as sdk from './index';
const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password|home|directory)/;
const hashVarRegex = /#\/(group|room|user)\/.*$/;
@ -54,6 +57,8 @@ function getRedactedUrl() {
}
const customVariables = {
// The Matomo installation at https://matomo.riot.im is currently configured
// with a limit of 10 custom variables.
'App Platform': {
id: 1,
expl: _td('The platform you\'re on'),
@ -61,7 +66,7 @@ const customVariables = {
},
'App Version': {
id: 2,
expl: _td('The version of Riot.im'),
expl: _td('The version of Riot'),
example: '15.0.0',
},
'User Type': {
@ -84,20 +89,25 @@ const customVariables = {
expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'),
example: 'off',
},
'Breadcrumbs': {
id: 9,
expl: _td("Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)"),
example: 'disabled',
},
'Homeserver URL': {
id: 7,
expl: _td('Your homeserver\'s URL'),
example: 'https://matrix.org',
},
'Identity Server URL': {
'Touch Input': {
id: 8,
expl: _td('Your identity server\'s URL'),
example: 'https://vector.im',
expl: _td("Whether you're using Riot on a device where touch is the primary input mechanism"),
example: 'false',
},
'Breadcrumbs': {
id: 9,
expl: _td("Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)"),
example: 'disabled',
},
'Installed PWA': {
id: 10,
expl: _td("Whether you're using Riot as an installed Progressive Web App"),
example: 'false',
},
};
@ -106,61 +116,80 @@ function whitelistRedact(whitelist, str) {
return '<redacted>';
}
const UID_KEY = "mx_Riot_Analytics_uid";
const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() {
try {
let data = localStorage.getItem(UID_KEY);
if (!data) {
localStorage.setItem(UID_KEY, data = [...Array(16)].map(() => Math.random().toString(16)[2]).join(''));
}
return data;
} catch (e) {
console.error("Analytics error: ", e);
return "";
}
}
const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
class Analytics {
constructor() {
this._paq = null;
this.disabled = true;
this.baseUrl = null;
this.siteId = null;
this.visitVariables = {};
this.firstPage = true;
this._heartbeatIntervalID = null;
this.creationTs = localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs) {
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
}
this.lastVisitTs = localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage.getItem(VISIT_COUNT_KEY) || 0;
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
}
get disabled() {
return !this.baseUrl;
}
/**
* Enable Analytics if initialized but disabled
* otherwise try and initalize, no-op if piwik config missing
*/
enable() {
if (this._paq || this._init()) {
this.disabled = false;
}
}
async enable() {
if (!this.disabled) return;
/**
* Disable Analytics calls, will not fully unload Piwik until a refresh,
* but this is second best, Piwik should not pull anything implicitly.
*/
disable() {
this.trackEvent('Analytics', 'opt-out');
// disableHeartBeatTimer is undocumented but exists in the piwik code
// the _paq.push method will result in an error being printed in the console
// if an unknown method signature is passed
this._paq.push(['disableHeartBeatTimer']);
this.disabled = true;
}
_init() {
const config = SdkConfig.get();
if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return;
const url = config.piwik.url;
const siteId = config.piwik.siteId;
const self = this;
window._paq = this._paq = window._paq || [];
this._paq.push(['setTrackerUrl', url+'piwik.php']);
this._paq.push(['setSiteId', siteId]);
this._paq.push(['trackAllContentImpressions']);
this._paq.push(['discardHashTag', false]);
this._paq.push(['enableHeartBeatTimer']);
// this._paq.push(['enableLinkTracking', true]);
this.baseUrl = new URL("piwik.php", config.piwik.url);
// set constants
this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
this.baseUrl.searchParams.set("apiv", 1); // API version to use
this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF
// set user parameters
this.baseUrl.searchParams.set("_id", getUid()); // uuid
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
if (this.lastVisitTs) {
this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
}
const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName());
platform.getAppVersion().then((version) => {
this._setVisitVariable('App Version', version);
}).catch(() => {
try {
this._setVisitVariable('App Version', await platform.getAppVersion());
} catch (e) {
this._setVisitVariable('App Version', 'unknown');
});
}
this._setVisitVariable('Chosen Language', getCurrentLanguage());
@ -168,20 +197,77 @@ class Analytics {
this._setVisitVariable('Instance', window.location.pathname);
}
(function() {
const g = document.createElement('script');
const s = document.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=url+'piwik.js';
let installedPWA = "unknown";
try {
// Known to work at least for desktop Chrome
installedPWA = window.matchMedia('(display-mode: standalone)').matches;
} catch (e) { }
this._setVisitVariable('Installed PWA', installedPWA);
g.onload = function() {
console.log('Initialised anonymous analytics');
self._paq = window._paq;
};
let touchInput = "unknown";
try {
// MDN claims broad support across browsers
touchInput = window.matchMedia('(pointer: coarse)').matches;
} catch (e) { }
this._setVisitVariable('Touch Input', touchInput);
s.parentNode.insertBefore(g, s);
})();
// start heartbeat
this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
}
return true;
/**
* Disable Analytics, stop the heartbeat and clear identifiers from localStorage
*/
disable() {
if (this.disabled) return;
this.trackEvent('Analytics', 'opt-out');
window.clearInterval(this._heartbeatIntervalID);
this.baseUrl = null;
this.visitVariables = {};
localStorage.removeItem(UID_KEY);
localStorage.removeItem(CREATION_TS_KEY);
localStorage.removeItem(VISIT_COUNT_KEY);
localStorage.removeItem(LAST_VISIT_TS_KEY);
}
async _track(data) {
if (this.disabled) return;
const now = new Date();
const params = {
...data,
url: getRedactedUrl(),
_cvar: JSON.stringify(this.visitVariables), // user custom vars
res: `${window.screen.width}x${window.screen.height}`, // resolution as WWWWxHHHH
rand: String(Math.random()).slice(2, 8), // random nonce to cache-bust
h: now.getHours(),
m: now.getMinutes(),
s: now.getSeconds(),
};
const url = new URL(this.baseUrl);
for (const key in params) {
url.searchParams.set(key, params[key]);
}
try {
await window.fetch(url, {
method: "GET",
mode: "no-cors",
cache: "no-cache",
redirect: "follow",
});
} catch (e) {
console.error("Analytics error: ", e);
}
}
ping() {
this._track({
ping: 1,
});
localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts
}
trackPageChange(generationTimeMs) {
@ -193,31 +279,29 @@ class Analytics {
return;
}
if (typeof generationTimeMs === 'number') {
this._paq.push(['setGenerationTimeMs', generationTimeMs]);
} else {
if (typeof generationTimeMs !== 'number') {
console.warn('Analytics.trackPageChange: expected generationTimeMs to be a number');
// But continue anyway because we still want to track the change
}
this._paq.push(['setCustomUrl', getRedactedUrl()]);
this._paq.push(['trackPageView']);
this._track({
gt_ms: generationTimeMs,
});
}
trackEvent(category, action, name, value) {
if (this.disabled) return;
this._paq.push(['setCustomUrl', getRedactedUrl()]);
this._paq.push(['trackEvent', category, action, name, value]);
}
logout() {
if (this.disabled) return;
this._paq.push(['deleteCookies']);
this._track({
e_c: category,
e_a: action,
e_n: name,
e_v: value,
});
}
_setVisitVariable(key, value) {
if (this.disabled) return;
this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']);
this.visitVariables[customVariables[key].id] = [key, value];
}
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
@ -227,16 +311,9 @@ class Analytics {
if (!config.piwik) return;
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
const whitelistedISUrls = config.piwik.whitelistedISUrls || [];
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl));
}
setRichtextMode(state) {
if (this.disabled) return;
this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off');
}
setBreadcrumbs(state) {
@ -244,13 +321,11 @@ class Analytics {
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
}
showDetailsModal() {
showDetailsModal = () => {
let rows = [];
if (window.Piwik) {
const Tracker = window.Piwik.getAsyncTracker();
rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean);
if (!this.disabled) {
rows = Object.values(this.visitVariables);
} else {
// Piwik may not have been enabled, so show example values
rows = Object.keys(customVariables).map(
(k) => [
k,
@ -271,7 +346,7 @@ class Analytics {
},
),
},
{ expl: _td('Your User Agent'), value: navigator.userAgent },
{ expl: _td('Your user agent'), value: navigator.userAgent },
{ expl: _td('Your device resolution'), value: resolution },
];
@ -280,7 +355,7 @@ class Analytics {
title: _t('Analytics'),
description: <div className="mx_AnalyticsModal">
<div>
{ _t('The information being sent to us to help make Riot.im better includes:') }
{ _t('The information being sent to us to help make Riot better includes:') }
</div>
<table>
{ rows.map((row) => <tr key={row[0]}>
@ -300,10 +375,10 @@ class Analytics {
</div>
</div>,
});
}
};
}
if (!global.mxAnalytics) {
global.mxAnalytics = new Analytics();
}
module.exports = global.mxAnalytics;
export default global.mxAnalytics;

92
src/AsyncWrapper.js Normal file
View file

@ -0,0 +1,92 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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 createReactClass from 'create-react-class';
import * as sdk from './index';
import PropTypes from 'prop-types';
import { _t } from './languageHandler';
/**
* Wrap an asynchronous loader function with a react component which shows a
* spinner until the real component loads.
*/
export default createReactClass({
propTypes: {
/** A promise which resolves with the real component
*/
prom: PropTypes.object.isRequired,
},
getInitialState: function() {
return {
component: null,
error: null,
};
},
componentWillMount: function() {
this._unmounted = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Starting load of AsyncWrapper for modal');
this.props.prom.then((result) => {
if (this._unmounted) {
return;
}
// Take the 'default' member if it's there, then we support
// passing in just an import()ed module, since ES6 async import
// always returns a module *namespace*.
const component = result.default ? result.default : result;
this.setState({component});
}).catch((e) => {
console.warn('AsyncWrapper promise failed', e);
this.setState({error: e});
});
},
componentWillUnmount: function() {
this._unmounted = true;
},
_onWrapperCancelClick: function() {
this.props.onFinished(false);
},
render: function() {
if (this.state.component) {
const Component = this.state.component;
return <Component {...this.props} />;
} else if (this.state.error) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <BaseDialog onFinished={this.props.onFinished}
title={_t("Error")}
>
{_t("Unable to load! Check your network connectivity and try again.")}
<DialogButtons primaryButton={_t("Dismiss")}
onPrimaryButtonClick={this._onWrapperCancelClick}
hasCancel={false}
/>
</BaseDialog>;
} else {
// show a spinner until the component is loaded.
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});

View file

@ -15,13 +15,14 @@ limitations under the License.
*/
'use strict';
import {ContentRepo} from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
module.exports = {
avatarUrlForMember: function(member, width, height, resizeMethod) {
let url = member.getAvatarUrl(
export function avatarUrlForMember(member, width, height, resizeMethod) {
let url;
if (member && member.getAvatarUrl) {
url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
@ -29,106 +30,108 @@ module.exports = {
false,
false,
);
if (!url) {
// member can be null here currently since on invites, the JS SDK
// does not have enough info to build a RoomMember object for
// the inviter.
url = this.defaultAvatarUrlForString(member ? member.userId : '');
}
if (!url) {
// member can be null here currently since on invites, the JS SDK
// does not have enough info to build a RoomMember object for
// the inviter.
url = defaultAvatarUrlForString(member ? member.userId : '');
}
return url;
}
export function avatarUrlForUser(user, width, height, resizeMethod) {
const url = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
if (!url || url.length === 0) {
return null;
}
return url;
}
export function defaultAvatarUrlForString(s) {
const images = ['03b381', '368bd6', 'ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
return require('../res/img/' + images[total % images.length] + '.png');
}
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
* @param {string} name
* @return {string} the first letter
*/
export function getInitialLetter(name) {
if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied");
return undefined;
}
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
return url;
},
}
avatarUrlForUser: function(user, width, height, resizeMethod) {
const url = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod,
);
if (!url || url.length === 0) {
return null;
}
return url;
},
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
}
defaultAvatarUrlForString: function(s) {
const images = ['03b381', '368bd6', 'ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
return require('../res/img/' + images[total % images.length] + '.png');
},
export function avatarUrlForRoom(room, width, height, resizeMethod) {
if (!room) return null; // null-guard
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
* @param {string} name
* @return {string} the first letter
*/
getInitialLetter(name) {
if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied");
return undefined;
}
if (name.length < 1) {
return undefined;
}
const explicitRoomAvatar = room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
},
avatarUrlForRoom(room, width, height, resizeMethod) {
const explicitRoomAvatar = room.getAvatarUrl(
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
}
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
}
return null;
},
};
}
return null;
}

View file

@ -4,6 +4,7 @@
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
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.
@ -18,7 +19,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClient} from "matrix-js-sdk";
import dis from './dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
/**
* Base class for classes that provide platform-specific functionality
@ -151,4 +154,38 @@ export default class BasePlatform {
async setMinimizeToTrayEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
/**
* Get our platform specific EventIndexManager.
*
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
* can be null if the platform doesn't support event indexing.
*/
getEventIndexingManager(): BaseEventIndexManager | null {
return null;
}
setLanguage(preferredLangs: string[]) {}
getSSOCallbackUrl(hsUrl: string, isUrl: string): URL {
const url = new URL(window.location.href);
// XXX: at this point, the fragment will always be #/login, which is no
// use to anyone. Ideally, we would get the intended fragment from
// MatrixChat.screenAfterLogin so that you could follow #/room links etc
// through an SSO login.
url.hash = "";
url.searchParams.set("homeserver", hsUrl);
url.searchParams.set("identityServer", isUrl);
return url;
}
/**
* Begin Single Sign On flows.
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso"|"cas") {
const callbackUrl = this.getSSOCallbackUrl(mxClient.getHomeserverUrl(), mxClient.getIdentityServerUrl());
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO
}
}

View file

@ -53,10 +53,10 @@ limitations under the License.
* }
*/
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import Modal from './Modal';
import sdk from './index';
import * as sdk from './index';
import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk';
import dis from './dispatcher';
@ -80,13 +80,26 @@ function play(audioId) {
// which listens?
const audio = document.getElementById(audioId);
if (audio) {
const playAudio = async () => {
try {
// This still causes the chrome debugger to break on promise rejection if
// the promise is rejected, even though we're catching the exception.
await audio.play();
} catch (e) {
// This is usually because the user hasn't interacted with the document,
// or chrome doesn't think so and is denying the request. Not sure what
// we can really do here...
// https://github.com/vector-im/riot-web/issues/7657
console.log("Unable to play audio clip", e);
}
};
if (audioPromises[audioId]) {
audioPromises[audioId] = audioPromises[audioId].then(()=>{
audio.load();
return audio.play();
return playAudio();
});
} else {
audioPromises[audioId] = audio.play();
audioPromises[audioId] = playAudio();
}
}
}
@ -126,7 +139,7 @@ function _setCallListeners(call) {
Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
title: _t('Call Failed'),
description: _t(
"There are unknown devices in this room: "+
"There are unknown sessions in this room: "+
"if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call.",
),
@ -205,7 +218,7 @@ function _setCallListeners(call) {
function _setCallState(call, roomId, status) {
console.log(
"Call state in %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-"),
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
);
calls[roomId] = call;
@ -289,7 +302,7 @@ function _onAction(payload) {
switch (payload.action) {
case 'place_call':
{
if (module.exports.getAnyActiveCall()) {
if (callHandler.getAnyActiveCall()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Existing Call'),
@ -322,7 +335,7 @@ function _onAction(payload) {
});
return;
} else if (members.length === 2) {
console.log("Place %s call in %s", payload.type, payload.room_id);
console.info("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call);
} else { // > 2
@ -337,12 +350,12 @@ function _onAction(payload) {
}
break;
case 'place_conference_call':
console.log("Place conference call in %s", payload.room_id);
console.info("Place conference call in %s", payload.room_id);
_startCallApp(payload.room_id, payload.type);
break;
case 'incoming_call':
{
if (module.exports.getAnyActiveCall()) {
if (callHandler.getAnyActiveCall()) {
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
// we avoid rejecting with "busy" in case the user wants to answer it on a different device.
// in future we could signal a "local busy" as a warning to the caller.
@ -382,12 +395,12 @@ function _onAction(payload) {
}
async function _startCallApp(roomId, type) {
// check for a working integrations manager. Technically we could put
// 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 = true;
let haveScalar = false;
if (managers.hasManager()) {
try {
const scalarClient = managers.getPrimaryManager().getScalarClient();
@ -396,8 +409,6 @@ async function _startCallApp(roomId, type) {
} catch (e) {
// ignore
}
} else {
haveScalar = false;
}
if (!haveScalar) {
@ -497,11 +508,22 @@ async function _startCallApp(roomId, type) {
// with the dispatcher once
if (!global.mxCallHandler) {
dis.register(_onAction);
// add empty handlers for media actions, otherwise the media keys
// end up causing the audio elements with our ring/ringback etc
// audio clips in to play.
if (navigator.mediaSession) {
navigator.mediaSession.setActionHandler('play', function() {});
navigator.mediaSession.setActionHandler('pause', function() {});
navigator.mediaSession.setActionHandler('seekbackward', function() {});
navigator.mediaSession.setActionHandler('seekforward', function() {});
navigator.mediaSession.setActionHandler('previoustrack', function() {});
navigator.mediaSession.setActionHandler('nexttrack', function() {});
}
}
const callHandler = {
getCallForRoom: function(roomId) {
let call = module.exports.getCall(roomId);
let call = callHandler.getCall(roomId);
if (call) return call;
if (ConferenceHandler) {
@ -561,4 +583,4 @@ if (global.mxCallHandler === undefined) {
global.mxCallHandler = callHandler;
}
module.exports = global.mxCallHandler;
export default global.mxCallHandler;

View file

@ -17,11 +17,10 @@ limitations under the License.
'use strict';
import Promise from 'bluebird';
import extend from './extend';
import dis from './dispatcher';
import MatrixClientPeg from './MatrixClientPeg';
import sdk from './index';
import {MatrixClientPeg} from './MatrixClientPeg';
import * as sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import RoomViewStore from './stores/RoomViewStore';
@ -59,40 +58,38 @@ export class UploadCanceledError extends Error {}
* and a thumbnail key.
*/
function createThumbnail(element, inputWidth, inputHeight, mimeType) {
const deferred = Promise.defer();
return new Promise((resolve) => {
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
}
if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
}
let targetWidth = inputWidth;
let targetHeight = inputHeight;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
}
if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
}
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
canvas.toBlob(function(thumbnail) {
deferred.resolve({
info: {
thumbnail_info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
canvas.toBlob(function(thumbnail) {
resolve({
info: {
thumbnail_info: {
w: targetWidth,
h: targetHeight,
mimetype: thumbnail.type,
size: thumbnail.size,
},
w: inputWidth,
h: inputHeight,
},
w: inputWidth,
h: inputHeight,
},
thumbnail: thumbnail,
});
}, mimeType);
return deferred.promise;
thumbnail: thumbnail,
});
}, mimeType);
});
}
/**
@ -179,30 +176,29 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
* @return {Promise} A promise that resolves with the video image element.
*/
function loadVideoElement(videoFile) {
const deferred = Promise.defer();
return new Promise((resolve, reject) => {
// Load the file into an html element
const video = document.createElement("video");
// Load the file into an html element
const video = document.createElement("video");
const reader = new FileReader();
const reader = new FileReader();
reader.onload = function(e) {
video.src = e.target.result;
reader.onload = function(e) {
video.src = e.target.result;
// Once ready, returns its size
// Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = function() {
deferred.resolve(video);
// Once ready, returns its size
// Wait until we have enough data to thumbnail the first frame.
video.onloadeddata = function() {
resolve(video);
};
video.onerror = function(e) {
reject(e);
};
};
video.onerror = function(e) {
deferred.reject(e);
reader.onerror = function(e) {
reject(e);
};
};
reader.onerror = function(e) {
deferred.reject(e);
};
reader.readAsDataURL(videoFile);
return deferred.promise;
reader.readAsDataURL(videoFile);
});
}
/**
@ -236,16 +232,16 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
* is read.
*/
function readFileAsArrayBuffer(file) {
const deferred = Promise.defer();
const reader = new FileReader();
reader.onload = function(e) {
deferred.resolve(e.target.result);
};
reader.onerror = function(e) {
deferred.reject(e);
};
reader.readAsArrayBuffer(file);
return deferred.promise;
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
resolve(e.target.result);
};
reader.onerror = function(e) {
reject(e);
};
reader.readAsArrayBuffer(file);
});
}
/**
@ -426,6 +422,9 @@ export default class ContentMessages {
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
let uploadAll = false;
// Promise to complete before sending next file into room, used for synchronisation of file-sending
// to match the order the files were specified in
let promBefore = Promise.resolve();
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
if (!uploadAll) {
@ -444,11 +443,11 @@ export default class ContentMessages {
});
if (!shouldContinue) break;
}
this._sendContentToRoom(file, roomId, matrixClient);
promBefore = this._sendContentToRoom(file, roomId, matrixClient, promBefore);
}
}
_sendContentToRoom(file, roomId, matrixClient) {
_sendContentToRoom(file, roomId, matrixClient, promBefore) {
const content = {
body: file.name || 'Attachment',
info: {
@ -461,33 +460,34 @@ export default class ContentMessages {
content.info.mimetype = file.type;
}
const def = Promise.defer();
if (file.type.indexOf('image/') == 0) {
content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{
extend(content.info, imageInfo);
def.resolve();
}, (error)=>{
console.error(error);
const prom = new Promise((resolve) => {
if (file.type.indexOf('image/') == 0) {
content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then((imageInfo)=>{
extend(content.info, imageInfo);
resolve();
}, (error)=>{
console.error(error);
content.msgtype = 'm.file';
resolve();
});
} else if (file.type.indexOf('audio/') == 0) {
content.msgtype = 'm.audio';
resolve();
} else if (file.type.indexOf('video/') == 0) {
content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{
extend(content.info, videoInfo);
resolve();
}, (error)=>{
content.msgtype = 'm.file';
resolve();
});
} else {
content.msgtype = 'm.file';
def.resolve();
});
} else if (file.type.indexOf('audio/') == 0) {
content.msgtype = 'm.audio';
def.resolve();
} else if (file.type.indexOf('video/') == 0) {
content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then((videoInfo)=>{
extend(content.info, videoInfo);
def.resolve();
}, (error)=>{
content.msgtype = 'm.file';
def.resolve();
});
} else {
content.msgtype = 'm.file';
def.resolve();
}
resolve();
}
});
const upload = {
fileName: file.name || 'Attachment',
@ -509,7 +509,7 @@ export default class ContentMessages {
dis.dispatch({action: 'upload_progress', upload: upload});
}
return def.promise.then(function() {
return prom.then(function() {
// XXX: upload.promise must be the promise that
// is returned by uploadFile as it has an abort()
// method hacked onto it.
@ -520,7 +520,10 @@ export default class ContentMessages {
content.file = result.file;
content.url = result.url;
});
}).then(function(url) {
}).then((url) => {
// Await previous message being sent into the room
return promBefore;
}).then(function() {
return matrixClient.sendMessage(roomId, content);
}, function(err) {
error = err;

201
src/CrossSigningManager.js Normal file
View file

@ -0,0 +1,201 @@
/*
Copyright 2019 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 Modal from './Modal';
import * as sdk from './index';
import {MatrixClientPeg} from './MatrixClientPeg';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler';
import SettingsStore from './settings/SettingsStore';
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
// during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the
// operation ends.
let secretStorageKeys = {};
let secretStorageBeingAccessed = false;
function isCachingAllowed() {
return (
secretStorageBeingAccessed ||
SettingsStore.getValue("keepSecretStoragePassphraseForSession")
);
}
export class AccessCancelledError extends Error {
constructor() {
super("Secret storage access canceled");
}
}
async function confirmToDismiss(name) {
let description;
if (name === "m.cross_signing.user_signing") {
description = _t("If you cancel now, you won't complete verifying the other user.");
} else if (name === "m.cross_signing.self_signing") {
description = _t("If you cancel now, you won't complete verifying your other session.");
} else {
description = _t("If you cancel now, you won't complete your secret storage operation.");
}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"),
description,
danger: true,
cancelButton: _t("Enter passphrase"),
button: _t("Cancel"),
}).finished;
return sure;
}
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) {
throw new Error("Multiple storage key requests not implemented");
}
const [name, info] = keyInfoEntries[0];
// Check the in-memory cache
if (isCachingAllowed() && secretStorageKeys[name]) {
return [name, secretStorageKeys[name]];
}
const inputToKey = async ({ passphrase, recoveryKey }) => {
if (passphrase) {
return deriveKey(
passphrase,
info.passphrase.salt,
info.passphrase.iterations,
);
} else {
return decodeRecoveryKey(recoveryKey);
}
};
const AccessSecretStorageDialog =
sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog");
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
AccessSecretStorageDialog,
/* props= */
{
keyInfo: info,
checkPrivateKey: async (input) => {
const key = await inputToKey(input);
return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey);
},
},
/* className= */ null,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason) => {
if (reason === "backgroundClick") {
return confirmToDismiss(ssssItemName);
}
return true;
},
},
);
const [input] = await finished;
if (!input) {
throw new AccessCancelledError();
}
const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session
if (isCachingAllowed()) {
secretStorageKeys[name] = key;
}
return [name, key];
}
export const crossSigningCallbacks = {
getSecretStorageKey,
};
/**
* This helper should be used whenever you need to access secret storage. It
* ensures that secret storage (and also cross-signing since they each depend on
* each other in a cycle of sorts) have been bootstrapped before running the
* provided function.
*
* Bootstrapping secret storage may take one of these paths:
* 1. Create secret storage from a passphrase and store cross-signing keys
* in secret storage.
* 2. Access existing secret storage by requesting passphrase and accessing
* cross-signing keys as needed.
* 3. All keys are loaded and there's nothing to do.
*
* Additionally, the secret storage keys are cached during the scope of this function
* to ensure the user is prompted only once for their secret storage
* passphrase. The cache is then cleared once the provided function completes.
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param {bool} [force] Reset secret storage even if it's already set up
*/
export async function accessSecretStorage(func = async () => { }, force = false) {
const cli = MatrixClientPeg.get();
secretStorageBeingAccessed = true;
try {
if (!await cli.hasSecretStorageKey() || force) {
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"),
{
force,
},
null, /* priority = */ false, /* static = */ true,
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Secret storage creation canceled");
}
} else {
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: async (makeRequest) => {
const { finished } = Modal.createTrackedDialog(
'Cross-signing keys dialog', '', InteractiveAuthDialog,
{
title: _t("Setting up keys"),
matrixClient: MatrixClientPeg.get(),
makeRequest,
},
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
});
}
// `return await` needed here to ensure `finally` block runs after the
// inner operation completes.
return await func();
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;
if (!isCachingAllowed()) {
secretStorageKeys = {};
}
}
}

178
src/DeviceListener.js Normal file
View file

@ -0,0 +1,178 @@
/*
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 { MatrixClientPeg } from './MatrixClientPeg';
import SettingsStore from './settings/SettingsStore';
import * as sdk from './index';
import { _t } from './languageHandler';
import ToastStore from './stores/ToastStore';
function toastKey(deviceId) {
return 'unverified_session_' + deviceId;
}
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
const THIS_DEVICE_TOAST_KEY = 'setupencryption';
export default class DeviceListener {
static sharedInstance() {
if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener();
return global.mx_DeviceListener;
}
constructor() {
// set of device IDs we're currently showing toasts for
this._activeNagToasts = new Set();
// device IDs for which the user has dismissed the verify toast ('Later')
this._dismissed = new Set();
// has the user dismissed any of the various nag toasts to setup encryption on this device?
this._dismissedThisDeviceToast = false;
// cache of the key backup info
this._keyBackupInfo = null;
this._keyBackupFetchedAt = null;
}
start() {
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
this._recheck();
}
stop() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
}
this._dismissed.clear();
}
dismissVerification(deviceId) {
this._dismissed.add(deviceId);
this._recheck();
}
dismissEncryptionSetup() {
this._dismissedThisDeviceToast = true;
this._recheck();
}
_onDevicesUpdated = (users) => {
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
this._recheck();
}
_onDeviceVerificationChanged = (userId) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
}
_onUserTrustStatusChanged = (userId, trustLevel) => {
if (userId !== MatrixClientPeg.get().getUserId()) return;
this._recheck();
}
// The server doesn't tell us when key backup is set up, so we poll
// & cache the result
async _getKeyBackupInfo() {
const now = (new Date()).getTime();
if (!this._keyBackupInfo || this._keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
this._keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this._keyBackupFetchedAt = now;
}
return this._keyBackupInfo;
}
async _recheck() {
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return;
const cli = MatrixClientPeg.get();
if (!cli.isCryptoEnabled()) return;
if (!cli.getCrossSigningId()) {
if (this._dismissedThisDeviceToast) {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
return;
}
// cross signing isn't enabled - nag to enable it
// There are 3 different toasts for:
if (cli.getStoredCrossSigningForUser(cli.getUserId())) {
// Cross-signing on account but this device doesn't trust the master key (verify this session)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Verify this session"),
icon: "verification_warning",
props: {kind: 'verify_this_session'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
const backupInfo = await this._getKeyBackupInfo();
if (backupInfo) {
// No cross-signing on account but key backup available (upgrade encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Encryption upgrade available"),
icon: "verification_warning",
props: {kind: 'upgrade_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
} else {
// No cross-signing or key backup on account (set up encryption)
ToastStore.sharedInstance().addOrReplaceToast({
key: THIS_DEVICE_TOAST_KEY,
title: _t("Set up encryption"),
icon: "verification_warning",
props: {kind: 'set_up_encryption'},
component: sdk.getComponent("toasts.SetupEncryptionToast"),
});
}
}
return;
} else {
ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY);
}
const newActiveToasts = new Set();
const devices = await cli.getStoredDevicesForUser(cli.getUserId());
for (const device of devices) {
if (device.deviceId == cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
ToastStore.sharedInstance().dismissToast(toastKey(device.deviceId));
} else {
this._activeNagToasts.add(device.deviceId);
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey(device.deviceId),
title: _t("Unverified session"),
icon: "verification_warning",
props: { device },
component: sdk.getComponent("toasts.UnverifiedSessionToast"),
});
newActiveToasts.add(device.deviceId);
}
}
// clear any other outstanding toasts (eg. logged out devices)
for (const deviceId of this._activeNagToasts) {
if (!newActiveToasts.has(deviceId)) ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
}
this._activeNagToasts = newActiveToasts;
}
}

View file

@ -1,140 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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 sdk from './index';
function isMatch(query, name, uid) {
query = query.toLowerCase();
name = name.toLowerCase();
uid = uid.toLowerCase();
// direct prefix matches
if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) {
return true;
}
// strip @ on uid and try matching again
if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) {
return true;
}
// split spaces in name and try matching constituent parts
const parts = name.split(" ");
for (let i = 0; i < parts.length; i++) {
if (parts[i].indexOf(query) === 0) {
return true;
}
}
return false;
}
/*
* Converts various data models to Entity objects.
*
* Entity objects provide an interface for UI components to use to display
* members in a data-agnostic way. This means they don't need to care if the
* underlying data model is a RoomMember, User or 3PID data structure, it just
* cares about rendering.
*/
class Entity {
constructor(model) {
this.model = model;
}
getJsx() {
return null;
}
matches(queryString) {
return false;
}
}
class MemberEntity extends Entity {
getJsx() {
const MemberTile = sdk.getComponent("rooms.MemberTile");
return (
<MemberTile key={this.model.userId} member={this.model} />
);
}
matches(queryString) {
return isMatch(queryString, this.model.name, this.model.userId);
}
}
class UserEntity extends Entity {
constructor(model, showInviteButton, inviteFn) {
super(model);
this.showInviteButton = Boolean(showInviteButton);
this.inviteFn = inviteFn;
this.onClick = this.onClick.bind(this);
}
onClick() {
if (this.inviteFn) {
this.inviteFn(this.model.userId);
}
}
getJsx() {
const UserTile = sdk.getComponent("rooms.UserTile");
return (
<UserTile key={this.model.userId} user={this.model}
showInviteButton={this.showInviteButton} onClick={this.onClick} />
);
}
matches(queryString) {
const name = this.model.displayName || this.model.userId;
return isMatch(queryString, name, this.model.userId);
}
}
module.exports = {
newEntity: function(jsx, matchFn) {
const entity = new Entity();
entity.getJsx = function() {
return jsx;
};
entity.matches = matchFn;
return entity;
},
/**
* @param {RoomMember[]} members
* @return {Entity[]}
*/
fromRoomMembers: function(members) {
return members.map(function(m) {
return new MemberEntity(m);
});
},
/**
* @param {User[]} users
* @param {boolean} showInviteButton
* @param {Function} inviteFn Called with the user ID.
* @return {Entity[]}
*/
fromUsers: function(users, showInviteButton, inviteFn) {
return users.map(function(u) {
return new UserEntity(u, showInviteButton, inviteFn);
});
},
};

View file

@ -20,7 +20,7 @@ import URL from 'url';
import dis from './dispatcher';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import MatrixClientPeg from "./MatrixClientPeg";
import {MatrixClientPeg} from "./MatrixClientPeg";
import RoomViewStore from "./stores/RoomViewStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore from "./settings/SettingsStore";

View file

@ -16,11 +16,12 @@ limitations under the License.
import React from 'react';
import Modal from './Modal';
import sdk from './';
import * as sdk from './';
import MultiInviter from './utils/MultiInviter';
import { _t } from './languageHandler';
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import GroupStore from './stores/GroupStore';
import {allSettled} from "./utils/promise";
export function showGroupInviteDialog(groupId) {
return new Promise((resolve, reject) => {
@ -118,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) {
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const matrixClient = MatrixClientPeg.get();
const errorList = [];
return Promise.all(addrs.map((addr) => {
return allSettled(addrs.map((addr) => {
return GroupStore
.addRoomToGroup(groupId, addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); })
@ -138,7 +139,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
groups.push(groupId);
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, '');
}
}).reflect();
});
})).then(() => {
if (errorList.length === 0) {
return;

View file

@ -23,18 +23,17 @@ import ReplyThread from "./components/views/elements/ReplyThread";
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import highlight from 'highlight.js';
import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames';
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import url from 'url';
import EMOJIBASE from 'emojibase-data/en/compact.json';
import EMOJIBASE_REGEX from 'emojibase-regex';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
linkifyMatrix(linkify);
@ -53,14 +52,11 @@ const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g");
const WHITESPACE_REGEX = new RegExp("\\s", "g");
const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const SINGLE_EMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
const VARIATION_SELECTOR = String.fromCharCode(0xFE0F);
/*
* Return true if the given string contains emoji
* Uses a much, much simpler regex than emojibase's so will give false
@ -79,10 +75,7 @@ function mightContainEmoji(str) {
* @return {String} The shortcode (such as :thumbup:)
*/
export function unicodeToShortcode(char) {
// Check against both the char and the char with an empty variation selector appended because that's how
// emoji-base stores its base emojis which have variations. https://github.com/vector-im/riot-web/issues/9785
const emptyVariation = char + VARIATION_SELECTOR;
const data = EMOJIBASE.find(e => e.unicode === char || e.unicode === emptyVariation);
const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
}
@ -94,7 +87,7 @@ export function unicodeToShortcode(char) {
*/
export function shortcodeToUnicode(shortcode) {
shortcode = shortcode.slice(1, shortcode.length - 1);
const data = EMOJIBASE.find(e => e.shortcodes && e.shortcodes.includes(shortcode));
const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null;
}
@ -166,7 +159,7 @@ const transformTags = { // custom to matrix
delete attribs.target;
}
}
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
'img': function(tagName, attribs) {
@ -383,6 +376,7 @@ class TextHighlighter extends BaseHighlighter {
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
* opts.returnString: return an HTML string rather than JSX elements
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/
export function bodyToHtml(content, highlights, opts={}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
@ -465,18 +459,19 @@ export function bodyToHtml(content, highlights, opts={}) {
});
return isDisplayedWithHtml ?
<span key="body" className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
<span key="body" className={className} dir="auto">{ strippedBody }</span>;
<span key="body" ref={opts.ref} className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
<span key="body" ref={opts.ref} className={className} dir="auto">{ strippedBody }</span>;
}
/**
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
*
* @param {string} str
* @returns {string}
* @param {string} str string to linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/
export function linkifyString(str) {
return _linkifyString(str);
export function linkifyString(str, options = linkifyMatrix.options) {
return _linkifyString(str, options);
}
/**
@ -494,10 +489,11 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* Linkify the given string and sanitize the HTML afterwards.
*
* @param {string} dirtyHtml The HTML string to sanitize and linkify
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml) {
return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams);
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
/**

View file

@ -16,8 +16,19 @@ limitations under the License.
import { createClient, SERVICE_TYPES } from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import Modal from './Modal';
import * as sdk from './index';
import { _t } from './languageHandler';
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
import {
doesAccountDataHaveIdentityServer,
doesIdentityServerHaveTerms,
useDefaultIdentityServer,
} from './utils/IdentityServerUtils';
import { abbreviateUrl } from './utils/UrlUtils';
export class AbortedIdentityActionError extends Error {}
export default class IdentityAuthClient {
/**
@ -89,7 +100,10 @@ export default class IdentityAuthClient {
try {
await this._checkToken(token);
} catch (e) {
if (e instanceof TermsNotSignedError) {
if (
e instanceof TermsNotSignedError ||
e instanceof AbortedIdentityActionError
) {
// Retrying won't help this
throw e;
}
@ -106,6 +120,8 @@ export default class IdentityAuthClient {
}
async _checkToken(token) {
const identityServerUrl = this._matrixClient.getIdentityServerUrl();
try {
await this._matrixClient.getIdentityAccount(token);
} catch (e) {
@ -113,7 +129,7 @@ export default class IdentityAuthClient {
console.log("Identity Server requires new terms to be agreed to");
await startTermsFlow([new Service(
SERVICE_TYPES.IS,
this._matrixClient.getIdentityServerUrl(),
identityServerUrl,
token,
)]);
return;
@ -121,6 +137,42 @@ export default class IdentityAuthClient {
throw e;
}
if (
!this.tempClient &&
!doesAccountDataHaveIdentityServer() &&
!await doesIdentityServerHaveTerms(identityServerUrl)
) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '',
QuestionDialog, {
title: _t("Identity server has no terms of service"),
description: (
<div>
<p>{_t(
"This action requires accessing the default identity server " +
"<server /> to validate an email address or phone number, " +
"but the server does not have any terms of service.", {},
{
server: () => <b>{abbreviateUrl(identityServerUrl)}</b>,
},
)}</p>
<p>{_t(
"Only continue if you trust the owner of the server.",
)}</p>
</div>
),
button: _t("Trust"),
});
const [confirmed] = await finished;
if (confirmed) {
useDefaultIdentityServer();
} else {
throw new AbortedIdentityActionError(
"User aborted identity server action without terms",
);
}
}
// We should ensure the token in `localStorage` is cleared
// appropriately. We already clear storage on sign out, but we'll need
// additional clearing when changing ISes in settings as part of future
@ -131,8 +183,10 @@ export default class IdentityAuthClient {
async registerForToken(check=true) {
try {
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
const { access_token: identityAccessToken } =
// XXX: The spec is `token`, but we used `access_token` for a Sydent release.
const { access_token: accessToken, token } =
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken);
const identityAccessToken = token ? token : accessToken;
if (check) await this._checkToken(identityAccessToken);
return identityAccessToken;
} catch (e) {

View file

@ -16,41 +16,38 @@ limitations under the License.
'use strict';
module.exports = {
/**
* Returns the actual height that an image of dimensions (fullWidth, fullHeight)
* will occupy if resized to fit inside a thumbnail bounding box of size
* (thumbWidth, thumbHeight).
*
* If the aspect ratio of the source image is taller than the aspect ratio of
* the thumbnail bounding box, then we return the thumbHeight parameter unchanged.
* Otherwise we return the thumbHeight parameter scaled down appropriately to
* reflect the actual height the scaled thumbnail occupies.
*
* This is very useful for calculating how much height a thumbnail will actually
* consume in the timeline, when performing scroll offset calcuations
* (e.g. scroll locking)
*/
thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) {
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
return undefined;
}
if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
// no scaling needs to be applied
return fullHeight;
}
const widthMulti = thumbWidth / fullWidth;
const heightMulti = thumbHeight / fullHeight;
if (widthMulti < heightMulti) {
// width is the dominant dimension so scaling will be fixed on that
return Math.floor(widthMulti * fullHeight);
} else {
// height is the dominant dimension so scaling will be fixed on that
return Math.floor(heightMulti * fullHeight);
}
},
};
/**
* Returns the actual height that an image of dimensions (fullWidth, fullHeight)
* will occupy if resized to fit inside a thumbnail bounding box of size
* (thumbWidth, thumbHeight).
*
* If the aspect ratio of the source image is taller than the aspect ratio of
* the thumbnail bounding box, then we return the thumbHeight parameter unchanged.
* Otherwise we return the thumbHeight parameter scaled down appropriately to
* reflect the actual height the scaled thumbnail occupies.
*
* This is very useful for calculating how much height a thumbnail will actually
* consume in the timeline, when performing scroll offset calcuations
* (e.g. scroll locking)
*/
export function thumbHeight(fullWidth, fullHeight, thumbWidth, thumbHeight) {
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
return undefined;
}
if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
// no scaling needs to be applied
return fullHeight;
}
const widthMulti = thumbWidth / fullWidth;
const heightMulti = thumbHeight / fullHeight;
if (widthMulti < heightMulti) {
// width is the dominant dimension so scaling will be fixed on that
return Math.floor(widthMulti * fullHeight);
} else {
// height is the dominant dimension so scaling will be fixed on that
return Math.floor(heightMulti * fullHeight);
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
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.
@ -14,9 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import sdk from './index';
import * as sdk from './index';
import Modal from './Modal';
import SettingsStore from './settings/SettingsStore';
// TODO: We can remove this once cross-signing is the only way.
// https://github.com/vector-im/riot-web/issues/11908
export default class KeyRequestHandler {
constructor(matrixClient) {
this._matrixClient = matrixClient;
@ -30,6 +34,11 @@ export default class KeyRequestHandler {
}
handleKeyRequest(keyRequest) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
const userId = keyRequest.userId;
const deviceId = keyRequest.deviceId;
const requestId = keyRequest.requestId;
@ -60,6 +69,11 @@ export default class KeyRequestHandler {
}
handleKeyRequestCancellation(cancellation) {
// Ignore own device key requests if cross-signing lab enabled
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
return;
}
// see if we can find the request in the queue
const userId = cancellation.userId;
const deviceId = cancellation.deviceId;
@ -111,6 +125,12 @@ export default class KeyRequestHandler {
this._currentUser = null;
this._currentDevice = null;
if (!this._pendingKeyRequests[userId] || !this._pendingKeyRequests[userId][deviceId]) {
// request was removed in the time the dialog was displayed
this._processNextRequest();
return;
}
if (r) {
for (const req of this._pendingKeyRequests[userId][deviceId]) {
req.share();

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2019 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.
@ -15,52 +16,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/* a selection of key codes, as used in KeyboardEvent.keyCode */
export const KeyCode = {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
SHIFT: 16,
ESCAPE: 27,
SPACE: 32,
PAGE_UP: 33,
PAGE_DOWN: 34,
END: 35,
HOME: 36,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
DELETE: 46,
KEY_A: 65,
KEY_B: 66,
KEY_C: 67,
KEY_D: 68,
KEY_E: 69,
KEY_F: 70,
KEY_G: 71,
KEY_H: 72,
KEY_I: 73,
KEY_J: 74,
KEY_K: 75,
KEY_L: 76,
KEY_M: 77,
KEY_N: 78,
KEY_O: 79,
KEY_P: 80,
KEY_Q: 81,
KEY_R: 82,
KEY_S: 83,
KEY_T: 84,
KEY_U: 85,
KEY_V: 86,
KEY_W: 87,
KEY_X: 88,
KEY_Y: 89,
KEY_Z: 90,
KEY_BACKTICK: 223, // DO NOT USE THIS: browsers disagree on backtick 192 vs 223
};
export const Key = {
HOME: "Home",
END: "End",
@ -69,6 +24,8 @@ export const Key = {
BACKSPACE: "Backspace",
ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
TAB: "Tab",
ESCAPE: "Escape",
ENTER: "Enter",
@ -76,14 +33,37 @@ export const Key = {
CONTROL: "Control",
META: "Meta",
SHIFT: "Shift",
CONTEXT_MENU: "ContextMenu",
COMMA: ",",
LESS_THAN: "<",
GREATER_THAN: ">",
BACKTICK: "`",
SPACE: " ",
A: "a",
B: "b",
C: "c",
D: "d",
E: "e",
F: "f",
G: "g",
H: "h",
I: "i",
J: "j",
K: "k",
L: "l",
M: "m",
N: "n",
O: "o",
P: "p",
Q: "q",
R: "r",
S: "s",
T: "t",
U: "u",
V: "v",
W: "w",
X: "x",
Y: "y",
Z: "z",
};

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019, 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.
@ -16,10 +17,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from 'bluebird';
import Matrix from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import EventIndexPeg from './indexing/EventIndexPeg';
import createMatrixClient from './utils/createMatrixClient';
import Analytics from './Analytics';
import Notifier from './Notifier';
@ -28,14 +29,17 @@ import Presence from './Presence';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import Modal from './Modal';
import sdk from './index';
import * as sdk from './index';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import PlatformPeg from "./PlatformPeg";
import { sendLoginRequest } from "./Login";
import * as StorageManager from './utils/StorageManager';
import SettingsStore from "./settings/SettingsStore";
import TypingStore from "./stores/TypingStore";
import ToastStore from "./stores/ToastStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {Mjolnir} from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener";
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -312,18 +316,14 @@ async function _restoreFromLocalStorage(opts) {
function _handleLoadSessionFailure(e) {
console.error("Unable to load session", e);
const def = Promise.defer();
const SessionRestoreErrorDialog =
sdk.getComponent('views.dialogs.SessionRestoreErrorDialog');
Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
error: e.message,
onFinished: (success) => {
def.resolve(success);
},
});
return def.promise.then((success) => {
return modal.finished.then(([success]) => {
if (success) {
// user clicked continue.
_clearStorage();
@ -378,7 +378,7 @@ export function hydrateSession(credentials) {
const overwrite = credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId;
if (overwrite) {
console.warn("Clearing all data: Old session belongs to a different user/device");
console.warn("Clearing all data: Old session belongs to a different user/session");
}
return _doSetLoggedIn(credentials, overwrite);
@ -435,7 +435,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
}
}
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
if (localStorage) {
try {
@ -528,7 +528,7 @@ export function logout() {
console.log("Failed to call logout API: token will not be invalidated");
onLoggedOut();
},
).done();
);
}
export function softLogout() {
@ -578,6 +578,7 @@ async function startMatrixClient(startSyncing=true) {
Notifier.start();
UserActivity.sharedInstance().start();
TypingStore.sharedInstance().reset(); // just in case
ToastStore.sharedInstance().reset();
if (!SettingsStore.getValue("lowBandwidth")) {
Presence.start();
}
@ -585,13 +586,25 @@ async function startMatrixClient(startSyncing=true) {
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.start();
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
// the thing just wastes CPU cycles, but should result in no actual functionality
// being exposed to the user.
Mjolnir.sharedInstance().start();
if (startSyncing) {
// The client might want to populate some views with events from the
// index (e.g. the FilePanel), therefore initialize the event index
// before the client.
await EventIndexPeg.init();
await MatrixClientPeg.start();
} else {
console.warn("Caller requested only auxiliary services be started");
await MatrixClientPeg.assign();
}
// This needs to be started after crypto is set up
DeviceListener.sharedInstance().start();
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
dis.dispatch({action: 'client_started'});
@ -605,21 +618,21 @@ async function startMatrixClient(startSyncing=true) {
* Stops a running client and all related services, and clears persistent
* storage. Used after a session has been logged out.
*/
export function onLoggedOut() {
export async function onLoggedOut() {
_isLoggingOut = false;
// Ensure that we dispatch a view change **before** stopping the client so
// so that React components unmount first. This avoids React soft crashes
// that can occur when components try to use a null client.
dis.dispatch({action: 'on_logged_out'});
dis.dispatch({action: 'on_logged_out'}, true);
stopMatrixClient();
_clearStorage().done();
await _clearStorage();
}
/**
* @returns {Promise} promise which resolves once the stores have been cleared
*/
function _clearStorage() {
Analytics.logout();
async function _clearStorage() {
Analytics.disable();
if (window.localStorage) {
window.localStorage.clear();
@ -630,7 +643,9 @@ function _clearStorage() {
// we'll never make any requests, so can pass a bogus HS URL
baseUrl: "",
});
return cli.clearStores();
await EventIndexPeg.deleteEventIndex();
await cli.clearStores();
}
/**
@ -645,7 +660,10 @@ export function stopMatrixClient(unsetClient=true) {
Presence.stop();
ActiveWidgetStore.stop();
IntegrationManagers.sharedInstance().stopWatching();
Mjolnir.sharedInstance().stop();
DeviceListener.sharedInstance().stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop();
EventIndexPeg.stop();
const cli = MatrixClientPeg.get();
if (cli) {
cli.stopClient();
@ -653,6 +671,7 @@ export function stopMatrixClient(unsetClient=true) {
if (unsetClient) {
MatrixClientPeg.unset();
EventIndexPeg.unset();
}
}
}

View file

@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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.
@ -19,8 +20,6 @@ limitations under the License.
import Matrix from "matrix-js-sdk";
import url from 'url';
export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
this._hsUrl = hsUrl;
@ -29,6 +28,7 @@ export default class Login {
this._currentFlowIndex = 0;
this._flows = [];
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
this._tempClient = null; // memoize
}
getHomeserverUrl() {
@ -40,10 +40,12 @@ export default class Login {
}
setHomeserverUrl(hsUrl) {
this._tempClient = null; // clear memoization
this._hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._tempClient = null; // clear memoization
this._isUrl = isUrl;
}
@ -52,8 +54,9 @@ export default class Login {
* requests.
* @returns {MatrixClient}
*/
_createTemporaryClient() {
return Matrix.createClient({
createTemporaryClient() {
if (this._tempClient) return this._tempClient; // use memoization
return this._tempClient = Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
});
@ -61,7 +64,7 @@ export default class Login {
getFlows() {
const self = this;
const client = this._createTemporaryClient();
const client = this.createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
@ -139,21 +142,6 @@ export default class Login {
throw error;
});
}
getSsoLoginUrl(loginType) {
const client = this._createTemporaryClient();
const parsedUrl = url.parse(window.location.href, true);
// XXX: at this point, the fragment will always be #/login, which is no
// use to anyone. Ideally, we would get the intended fragment from
// MatrixChat.screenAfterLogin so that you could follow #/room links etc
// through an SSO login.
parsedUrl.hash = "";
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
return client.getSsoLoginUrl(url.format(parsedUrl), loginType);
}
}

View file

@ -91,7 +91,7 @@ export default class Markdown {
return true;
}
toHTML() {
toHTML({ externalLinks = false } = {}) {
const renderer = new commonmark.HtmlRenderer({
safe: false,
@ -125,6 +125,24 @@ export default class Markdown {
}
};
renderer.link = function(node, entering) {
const attrs = this.attrs(node);
if (entering) {
attrs.push(['href', this.esc(node.destination)]);
if (node.title) {
attrs.push(['title', this.esc(node.title)]);
}
// Modified link behaviour to treat them all as external and
// thus opening in a new tab.
if (externalLinks) {
attrs.push(['target', '_blank']);
attrs.push(['rel', 'noreferrer noopener']);
}
this.tag('a', attrs);
} else {
this.tag('/a');
}
};
renderer.html_inline = html_if_tag_allowed;

View file

@ -1,7 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd.
Copyright 2017 New Vector Ltd
Copyright 2017, 2018, 2019 New Vector Ltd
Copyright 2019, 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.
@ -18,18 +19,20 @@ limitations under the License.
import {MatrixClient, MemoryStore} from 'matrix-js-sdk';
import utils from 'matrix-js-sdk/lib/utils';
import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline';
import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set';
import sdk from './index';
import * as utils from 'matrix-js-sdk/src/utils';
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
import {EventTimelineSet} from 'matrix-js-sdk/src/models/event-timeline-set';
import * as sdk from './index';
import createMatrixClient from './utils/createMatrixClient';
import SettingsStore from './settings/SettingsStore';
import MatrixActionCreators from './actions/MatrixActionCreators';
import Modal from './Modal';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
import * as StorageManager from './utils/StorageManager';
import IdentityAuthClient from './IdentityAuthClient';
import { crossSigningCallbacks } from './CrossSigningManager';
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
interface MatrixClientCreds {
homeserverUrl: string,
@ -46,7 +49,7 @@ interface MatrixClientCreds {
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class MatrixClientPeg {
class _MatrixClientPeg {
constructor() {
this.matrixClient = null;
this._justRegisteredUserId = null;
@ -215,11 +218,21 @@ class MatrixClientPeg {
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
verificationMethods: [verificationMethods.SAS],
verificationMethods: [
verificationMethods.SAS,
SHOW_QR_CODE_METHOD,
verificationMethods.RECIPROCATE_QR_CODE,
],
unstableClientRelationAggregation: true,
identityServer: new IdentityAuthClient(),
};
opts.cryptoCallbacks = {};
// These are always installed regardless of the labs flag so that
// cross-signing features can toggle on without reloading and also be
// accessed immediately after login.
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks);
this.matrixClient = createMatrixClient(opts);
// we're going to add eventlisteners for each matrix event tile, so the
@ -238,6 +251,7 @@ class MatrixClientPeg {
}
if (!global.mxMatrixClientPeg) {
global.mxMatrixClientPeg = new MatrixClientPeg();
global.mxMatrixClientPeg = new _MatrixClientPeg();
}
export default global.mxMatrixClientPeg;
export const MatrixClientPeg = global.mxMatrixClientPeg;

View file

@ -17,87 +17,14 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import Analytics from './Analytics';
import sdk from './index';
import dis from './dispatcher';
import { _t } from './languageHandler';
import Promise from "bluebird";
import {defer} from './utils/promise';
import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
/**
* Wrap an asynchronous loader function with a react component which shows a
* spinner until the real component loads.
*/
const AsyncWrapper = createReactClass({
propTypes: {
/** A promise which resolves with the real component
*/
prom: PropTypes.object.isRequired,
},
getInitialState: function() {
return {
component: null,
error: null,
};
},
componentWillMount: function() {
this._unmounted = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Starting load of AsyncWrapper for modal');
this.props.prom.then((result) => {
if (this._unmounted) {
return;
}
// Take the 'default' member if it's there, then we support
// passing in just an import()ed module, since ES6 async import
// always returns a module *namespace*.
const component = result.default ? result.default : result;
this.setState({component});
}).catch((e) => {
console.warn('AsyncWrapper promise failed', e);
this.setState({error: e});
});
},
componentWillUnmount: function() {
this._unmounted = true;
},
_onWrapperCancelClick: function() {
this.props.onFinished(false);
},
render: function() {
if (this.state.component) {
const Component = this.state.component;
return <Component {...this.props} />;
} else if (this.state.error) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <BaseDialog onFinished={this.props.onFinished}
title={_t("Error")}
>
{_t("Unable to load! Check your network connectivity and try again.")}
<DialogButtons primaryButton={_t("Dismiss")}
onPrimaryButtonClick={this._onWrapperCancelClick}
hasCancel={false}
/>
</BaseDialog>;
} else {
// show a spinner until the component is loaded.
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});
class ModalManager {
constructor() {
this._counter = 0;
@ -120,7 +47,7 @@ class ModalManager {
} */
];
this.closeAll = this.closeAll.bind(this);
this.onBackgroundClick = this.onBackgroundClick.bind(this);
}
hasDialogs() {
@ -179,7 +106,7 @@ class ModalManager {
return this.appendDialogAsync(...rest);
}
_buildModal(prom, props, className) {
_buildModal(prom, props, className, options) {
const modal = {};
// never call this from onFinished() otherwise it will loop
@ -197,13 +124,27 @@ class ModalManager {
);
modal.onFinished = props ? props.onFinished : null;
modal.className = className;
modal.onBeforeClose = options.onBeforeClose;
modal.beforeClosePromise = null;
modal.close = closeDialog;
modal.closeReason = null;
return {modal, closeDialog, onFinishedProm};
}
_getCloseFn(modal, props) {
const deferred = Promise.defer();
return [(...args) => {
const deferred = defer();
return [async (...args) => {
if (modal.beforeClosePromise) {
await modal.beforeClosePromise;
} else if (modal.onBeforeClose) {
modal.beforeClosePromise = modal.onBeforeClose(modal.closeReason);
const shouldClose = await modal.beforeClosePromise;
modal.beforeClosePromise = null;
if (!shouldClose) {
return;
}
}
deferred.resolve(args);
if (props && props.onFinished) props.onFinished.apply(null, args);
const i = this._modals.indexOf(modal);
@ -229,6 +170,12 @@ class ModalManager {
}, deferred.promise];
}
/**
* @callback onBeforeClose
* @param {string?} reason either "backgroundClick" or null
* @return {Promise<bool>} whether the dialog should close
*/
/**
* Open a modal view.
*
@ -256,11 +203,12 @@ class ModalManager {
* also be removed from the stack. This is not compatible
* with being a priority modal. Only one modal can be
* static at a time.
* @param {Object} options? extra options for the dialog
* @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog
* @returns {object} Object with 'close' parameter being a function that will close the dialog
*/
createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) {
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className);
createDialogAsync(prom, props, className, isPriorityModal, isStaticModal, options = {}) {
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, options);
if (isPriorityModal) {
// XXX: This is destructive
this._priorityModal = modal;
@ -279,7 +227,7 @@ class ModalManager {
}
appendDialogAsync(prom, props, className) {
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className);
const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, {});
this._modals.push(modal);
this._reRender();
@ -289,24 +237,22 @@ class ModalManager {
};
}
closeAll() {
const modalsToClose = [...this._modals, this._priorityModal];
this._modals = [];
this._priorityModal = null;
if (this._staticModal && modalsToClose.length === 0) {
modalsToClose.push(this._staticModal);
this._staticModal = null;
onBackgroundClick() {
const modal = this._getCurrentModal();
if (!modal) {
return;
}
// we want to pass a reason to the onBeforeClose
// callback, but close is currently defined to
// pass all number of arguments to the onFinished callback
// so, pass the reason to close through a member variable
modal.closeReason = "backgroundClick";
modal.close();
modal.closeReason = null;
}
for (let i = 0; i < modalsToClose.length; i++) {
const m = modalsToClose[i];
if (m && m.onFinished) {
m.onFinished(false);
}
}
this._reRender();
_getCurrentModal() {
return this._priorityModal ? this._priorityModal : (this._modals[0] || this._staticModal);
}
_reRender() {
@ -337,7 +283,7 @@ class ModalManager {
<div className="mx_Dialog">
{ this._staticModal.elem }
</div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.closeAll}></div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.onBackgroundClick}></div>
</div>
);
@ -347,8 +293,8 @@ class ModalManager {
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
}
const modal = this._priorityModal ? this._priorityModal : this._modals[0];
if (modal) {
const modal = this._getCurrentModal();
if (modal !== this._staticModal) {
const classes = "mx_Dialog_wrapper "
+ (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '')
+ (modal.className ? modal.className : '');
@ -358,7 +304,7 @@ class ModalManager {
<div className="mx_Dialog">
{modal.elem}
</div>
<div className="mx_Dialog_background" onClick={this.closeAll}></div>
<div className="mx_Dialog_background" onClick={this.onBackgroundClick}></div>
</div>
);

View file

@ -16,13 +16,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import TextForEvent from './TextForEvent';
import * as TextForEvent from './TextForEvent';
import Analytics from './Analytics';
import Avatar from './Avatar';
import * as Avatar from './Avatar';
import dis from './dispatcher';
import sdk from './index';
import * as sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
@ -146,17 +146,19 @@ const Notifier = {
}
document.body.appendChild(audioElement);
}
audioElement.play();
await audioElement.play();
} catch (ex) {
console.warn("Caught error when trying to fetch room notification sound:", ex);
}
},
start: function() {
this.boundOnEvent = this.onEvent.bind(this);
this.boundOnSyncStateChange = this.onSyncStateChange.bind(this);
this.boundOnRoomReceipt = this.onRoomReceipt.bind(this);
this.boundOnEventDecrypted = this.onEventDecrypted.bind(this);
// do not re-bind in the case of repeated call
this.boundOnEvent = this.boundOnEvent || this.onEvent.bind(this);
this.boundOnSyncStateChange = this.boundOnSyncStateChange || this.onSyncStateChange.bind(this);
this.boundOnRoomReceipt = this.boundOnRoomReceipt || this.onRoomReceipt.bind(this);
this.boundOnEventDecrypted = this.boundOnEventDecrypted || this.onEventDecrypted.bind(this);
MatrixClientPeg.get().on('event', this.boundOnEvent);
MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt);
MatrixClientPeg.get().on('Event.decrypted', this.boundOnEventDecrypted);
@ -166,7 +168,7 @@ const Notifier = {
},
stop: function() {
if (MatrixClientPeg.get() && this.boundOnRoomTimeline) {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('Event', this.boundOnEvent);
MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt);
MatrixClientPeg.get().removeListener('Event.decrypted', this.boundOnEventDecrypted);
@ -198,7 +200,7 @@ const Notifier = {
if (enable) {
// Attempt to get permission from user
plaf.requestNotificationPermission().done((result) => {
plaf.requestNotificationPermission().then((result) => {
if (result !== 'granted') {
// The permission request was dismissed or denied
// TODO: Support alternative branding in messaging
@ -364,4 +366,4 @@ if (!global.mxNotifier) {
global.mxNotifier = Notifier;
}
module.exports = global.mxNotifier;
export default global.mxNotifier;

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
@ -22,7 +23,7 @@ limitations under the License.
* @return {Object[]} An array of objects with the form:
* { key: $KEY, val: $VALUE, place: "add|del" }
*/
module.exports.getKeyValueArrayDiffs = function(before, after) {
export function getKeyValueArrayDiffs(before, after) {
const results = [];
const delta = {};
Object.keys(before).forEach(function(beforeKey) {
@ -76,7 +77,7 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
});
return results;
};
}
/**
* Shallow-compare two objects for equality: each key and value must be identical
@ -84,7 +85,7 @@ module.exports.getKeyValueArrayDiffs = function(before, after) {
* @param {Object} objB Second object to compare against the first
* @return {boolean} whether the two objects have same key=values
*/
module.exports.shallowEqual = function(objA, objB) {
export function shallowEqual(objA, objB) {
if (objA === objB) {
return true;
}
@ -109,4 +110,4 @@ module.exports.shallowEqual = function(objA, objB) {
}
return true;
};
}

View file

@ -25,7 +25,7 @@ import { _t } from './languageHandler';
* the client owns the given email address, which is then passed to the password
* API on the homeserver in question with the new password.
*/
class PasswordReset {
export default class PasswordReset {
/**
* Configure the endpoints for password resetting.
* @param {string} homeserverUrl The URL to the HS which has the account to reset.
@ -101,4 +101,3 @@ class PasswordReset {
}
}
module.exports = PasswordReset;

View file

@ -47,4 +47,4 @@ class PlatformPeg {
if (!global.mxPlatformPeg) {
global.mxPlatformPeg = new PlatformPeg();
}
module.exports = global.mxPlatformPeg;
export default global.mxPlatformPeg;

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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.
@ -15,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from "./MatrixClientPeg";
import {MatrixClientPeg} from "./MatrixClientPeg";
import dis from "./dispatcher";
import Timer from './utils/Timer';
@ -96,7 +97,7 @@ class Presence {
try {
await MatrixClientPeg.get().setPresence(this.state);
console.log("Presence: %s", newState);
console.info("Presence: %s", newState);
} catch (err) {
console.error("Failed to set presence: %s", err);
this.state = oldState;
@ -104,4 +105,4 @@ class Presence {
}
}
module.exports = new Presence();
export default new Presence();

View file

@ -21,10 +21,10 @@ limitations under the License.
*/
import dis from './dispatcher';
import sdk from './index';
import * as sdk from './index';
import Modal from './Modal';
import { _t } from './languageHandler';
// import MatrixClientPeg from './MatrixClientPeg';
// import {MatrixClientPeg} from './MatrixClientPeg';
// Regex for what a "safe" or "Matrix-looking" localpart would be.
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -39,6 +39,8 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
* If true, goes to the home page if the user cancels the action
* @param {bool} options.go_welcome_on_cancel
* If true, goes to the welcome page if the user cancels the action
* @param {bool} options.screen_after
* If present the screen to redirect to after a successful login or register.
*/
export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {};
@ -66,13 +68,21 @@ export async function startAnyRegistrationFlow(options) {
// });
//} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
title: _t("Registration Required"),
description: _t("You need to register to do this. Would you like to register now?"),
button: _t("Register"),
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
hasCancelButton: true,
quitOnly: true,
title: _t("Sign In or Create Account"),
description: _t("Use your account or create a new one to continue."),
button: _t("Create Account"),
extraButtons: [
<button key="start_login" onClick={() => {
modal.close();
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
}}>{ _t('Sign In') }</button>,
],
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({action: 'start_registration'});
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
} else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'});
} else if (options.go_welcome_on_cancel) {
@ -101,4 +111,3 @@ export async function startAnyRegistrationFlow(options) {
// }
// throw new Error("Register request succeeded when it should have returned 401!");
// }

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
@ -14,28 +15,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher';
import { EventStatus } from 'matrix-js-sdk';
module.exports = {
resendUnsentEvents: function(room) {
export default class Resend {
static resendUnsentEvents(room) {
room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
module.exports.resend(event);
Resend.resend(event);
});
},
cancelUnsentEvents: function(room) {
}
static cancelUnsentEvents(room) {
room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
module.exports.removeFromQueue(event);
Resend.removeFromQueue(event);
});
},
resend: function(event) {
}
static resend(event) {
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent(event, room).done(function(res) {
MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
dis.dispatch({
action: 'message_sent',
event: event,
@ -43,15 +46,16 @@ module.exports = {
}, function(err) {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')');
console.log('Resend got send failure: ' + err.name + '(' + err + ')');
dis.dispatch({
action: 'message_send_failed',
event: event,
});
});
},
removeFromQueue: function(event) {
}
static removeFromQueue(event) {
MatrixClientPeg.get().cancelPendingEvent(event);
},
};
}
}

View file

@ -28,8 +28,8 @@ export function levelRoleMap(usersDefault) {
export function textualPowerLevel(level, usersDefault) {
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`);
return LEVEL_ROLE_MAP[level];
} else {
return level;
return _t("Custom (%(level)s)", {level});
}
}

35
src/RoomAliasCache.js Normal file
View file

@ -0,0 +1,35 @@
/*
Copyright 2019 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.
*/
/**
* This is meant to be a cache of room alias to room ID so that moving between
* rooms happens smoothly (for example using browser back / forward buttons).
*
* For the moment, it's in memory only and so only applies for the current
* session for simplicity, but could be extended further in the future.
*
* A similar thing could also be achieved via `pushState` with a state object,
* but keeping it separate like this seems easier in case we do want to extend.
*/
const aliasToIDMap = new Map();
export function storeRoomAliasInCache(alias, id) {
aliasToIDMap.set(alias, id);
}
export function getCachedRoomIDForAlias(alias) {
return aliasToIDMap.get(alias);
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
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.
@ -16,15 +17,12 @@ limitations under the License.
*/
import React from 'react';
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import MultiInviter from './utils/MultiInviter';
import Modal from './Modal';
import { getAddressType } from './UserAddress';
import createRoom from './createRoom';
import sdk from './';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import * as sdk from './';
import { _t } from './languageHandler';
import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
/**
* Invites multiple addresses to a room
@ -35,50 +33,27 @@ import { _t } from './languageHandler';
* @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @returns {Promise} Promise
*/
function inviteMultipleToRoom(roomId, addrs) {
export function inviteMultipleToRoom(roomId, addrs) {
const inviter = new MultiInviter(roomId);
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter}));
}
export function showStartChatInviteDialog() {
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, {
title: _t('Start a chat'),
description: _t("Who would you like to communicate with?"),
placeholder: (validAddressTypes) => {
// The set of valid address type can be mutated inside the dialog
// when you first have no IS but agree to use one in the dialog.
if (validAddressTypes.includes('email')) {
return _t("Email, name or Matrix ID");
}
return _t("Name or Matrix ID");
},
validAddressTypes: ['mx-user-id', 'email'],
button: _t("Start Chat"),
onFinished: _onStartDmFinished,
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
// This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog(
'Start DM', '', InviteDialog, {kind: KIND_DM},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showRoomInviteDialog(roomId) {
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, {
title: _t('Invite new room members'),
button: _t('Send Invites'),
placeholder: (validAddressTypes) => {
// The set of valid address type can be mutated inside the dialog
// when you first have no IS but agree to use one in the dialog.
if (validAddressTypes.includes('email')) {
return _t("Email, name or Matrix ID");
}
return _t("Name or Matrix ID");
},
validAddressTypes: ['mx-user-id', 'email'],
onFinished: (shouldInvite, addrs) => {
_onRoomInviteFinished(roomId, shouldInvite, addrs);
},
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
// This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog(
'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
/**
@ -99,67 +74,8 @@ export function isValid3pidInvite(event) {
return true;
}
// TODO: Immutable DMs replaces this
function _onStartDmFinished(shouldInvite, addrs) {
if (!shouldInvite) return;
const addrTexts = addrs.map((addr) => addr.address);
if (_isDmChat(addrTexts)) {
const rooms = _getDirectMessageRooms(addrTexts[0]);
if (rooms.length > 0) {
// A Direct Message room already exists for this user, so reuse it
dis.dispatch({
action: 'view_room',
room_id: rooms[0],
should_peek: false,
joining: false,
});
} else {
// Start a new DM chat
createRoom({dmUserId: addrTexts[0]}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, {
title: _t("Failed to start chat"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
} else if (addrTexts.length === 1) {
// Start a new DM chat
createRoom({dmUserId: addrTexts[0]}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to start chat', '', ErrorDialog, {
title: _t("Failed to start chat"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
} else {
// Start multi user chat
let room;
createRoom().then((roomId) => {
room = MatrixClientPeg.get().getRoom(roomId);
return inviteMultipleToRoom(roomId, addrTexts);
}).then((result) => {
return _showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => {
console.error(err.stack);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t("Failed to invite"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
}
function _onRoomInviteFinished(roomId, shouldInvite, addrs) {
if (!shouldInvite) return;
const addrTexts = addrs.map((addr) => addr.address);
// Invite new users to a room
inviteMultipleToRoom(roomId, addrTexts).then((result) => {
export function inviteUsersToRoom(roomId, userIds) {
return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId);
return _showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => {
@ -172,15 +88,6 @@ function _onRoomInviteFinished(roomId, shouldInvite, addrs) {
});
}
// TODO: Immutable DMs replaces this
function _isDmChat(addrTexts) {
if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') {
return true;
} else {
return false;
}
}
function _showAnyInviteErrors(addrs, room, inviter) {
// Show user any errors
const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error');
@ -203,26 +110,16 @@ function _showAnyInviteErrors(addrs, room, inviter) {
}
if (errorList.length > 0) {
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
const description = <div>{errorList.map(e => <div key={e}>{e}</div>)}</div>;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, {
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
description: errorList.join(<br />),
description,
});
}
}
return addrs;
}
function _getDirectMessageRooms(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
const rooms = dmRooms.filter((dmRoom) => {
const room = MatrixClientPeg.get().getRoom(dmRoom);
if (room) {
return room.getMyMembership() === 'join';
}
});
return rooms;
}

View file

@ -24,12 +24,8 @@ function tsOfNewestEvent(room) {
}
}
function mostRecentActivityFirst(roomList) {
export function mostRecentActivityFirst(roomList) {
return roomList.sort(function(a, b) {
return tsOfNewestEvent(b) - tsOfNewestEvent(a);
});
}
module.exports = {
mostRecentActivityFirst,
};

View file

@ -15,9 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import Promise from 'bluebird';
import {MatrixClientPeg} from './MatrixClientPeg';
import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor';
export const ALL_MESSAGES_LOUD = 'all_messages_loud';
export const ALL_MESSAGES = 'all_messages';

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import Promise from 'bluebird';
import {MatrixClientPeg} from './MatrixClientPeg';
/**
* Given a room object, return the alias we should use for it,
@ -24,7 +23,7 @@ import Promise from 'bluebird';
* of aliases. Otherwise return null;
*/
export function getDisplayAliasForRoom(room) {
return room.getCanonicalAlias() || room.getAliases()[0];
return room.getCanonicalAlias() || room.getAltAliases()[0];
}
/**

View file

@ -16,15 +16,13 @@ limitations under the License.
*/
import url from 'url';
import Promise from 'bluebird';
import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
const request = require('browser-request');
const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require('./MatrixClientPeg');
import {MatrixClientPeg} from "./MatrixClientPeg";
import request from "browser-request";
import * as Matrix from 'matrix-js-sdk';
import SdkConfig from "./SdkConfig";
// The version of the integration manager API we're intending to work with
const imApiVersion = "1.1";

View file

@ -232,7 +232,7 @@ Example:
}
*/
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk';
import dis from './dispatcher';
import WidgetUtils from './utils/WidgetUtils';
@ -279,7 +279,7 @@ function inviteUser(event, roomId, userId) {
}
}
client.invite(roomId, userId).done(function() {
client.invite(roomId, userId).then(function() {
sendResponse(event, {
success: true,
});
@ -398,7 +398,7 @@ function setPlumbingState(event, roomId, status) {
sendError(event, _t('You need to be logged in.'));
return;
}
client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).done(() => {
client.sendStateEvent(roomId, "m.room.plumbing", { status: status }).then(() => {
sendResponse(event, {
success: true,
});
@ -414,7 +414,7 @@ function setBotOptions(event, roomId, userId) {
sendError(event, _t('You need to be logged in.'));
return;
}
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => {
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).then(() => {
sendResponse(event, {
success: true,
});
@ -444,7 +444,7 @@ function setBotPower(event, roomId, userId, level) {
},
);
client.setPowerLevel(roomId, userId, level, powerEvent).done(() => {
client.setPowerLevel(roomId, userId, level, powerEvent).then(() => {
sendResponse(event, {
success: true,
});
@ -658,30 +658,29 @@ const onMessage = function(event) {
let listenerCount = 0;
let openManagerUrl = null;
module.exports = {
startListening: function() {
if (listenerCount === 0) {
window.addEventListener("message", onMessage, false);
}
listenerCount += 1;
},
stopListening: function() {
listenerCount -= 1;
if (listenerCount === 0) {
window.removeEventListener("message", onMessage);
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"ScalarMessaging: mismatched startListening / stopListening detected." +
" Negative count",
);
console.error(e);
}
},
export function startListening() {
if (listenerCount === 0) {
window.addEventListener("message", onMessage, false);
}
listenerCount += 1;
}
setOpenManagerUrl: function(url) {
openManagerUrl = url;
},
};
export function stopListening() {
listenerCount -= 1;
if (listenerCount === 0) {
window.removeEventListener("message", onMessage);
}
if (listenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"ScalarMessaging: mismatched startListening / stopListening detected." +
" Negative count",
);
console.error(e);
}
}
export function setOpenManagerUrl(url) {
openManagerUrl = url;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
@ -14,7 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export const DEFAULTS = {
export interface ConfigOptions {
[key: string]: any;
}
export const DEFAULTS: ConfigOptions = {
// URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server
@ -23,30 +28,37 @@ export const DEFAULTS = {
bug_report_endpoint_url: null,
};
class SdkConfig {
static get() {
return global.mxReactSdkConfig || {};
export default class SdkConfig {
private static instance: ConfigOptions;
private static setInstance(i: ConfigOptions) {
SdkConfig.instance = i;
// For debugging purposes
(<any>window).mxReactSdkConfig = i;
}
static put(cfg) {
static get() {
return SdkConfig.instance || {};
}
static put(cfg: ConfigOptions) {
const defaultKeys = Object.keys(DEFAULTS);
for (let i = 0; i < defaultKeys.length; ++i) {
if (cfg[defaultKeys[i]] === undefined) {
cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]];
}
}
global.mxReactSdkConfig = cfg;
SdkConfig.setInstance(cfg);
}
static unset() {
global.mxReactSdkConfig = undefined;
SdkConfig.setInstance({});
}
static add(cfg) {
static add(cfg: ConfigOptions) {
const liveConfig = SdkConfig.get();
const newConfig = Object.assign({}, liveConfig, cfg);
SdkConfig.put(newConfig);
}
}
module.exports = SdkConfig;

138
src/Searching.js Normal file
View file

@ -0,0 +1,138 @@
/*
Copyright 2019 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 EventIndexPeg from "./indexing/EventIndexPeg";
import {MatrixClientPeg} from "./MatrixClientPeg";
function serverSideSearch(term, roomId = undefined) {
let filter;
if (roomId !== undefined) {
// XXX: it's unintuitive that the filter for searching doesn't have
// the same shape as the v2 filter API :(
filter = {
rooms: [roomId],
};
}
const searchPromise = MatrixClientPeg.get().searchRoomEvents({
filter,
term,
});
return searchPromise;
}
async function combinedSearch(searchTerm) {
// Create two promises, one for the local search, one for the
// server-side search.
const serverSidePromise = serverSideSearch(searchTerm);
const localPromise = localSearch(searchTerm);
// Wait for both promises to resolve.
await Promise.all([serverSidePromise, localPromise]);
// Get both search results.
const localResult = await localPromise;
const serverSideResult = await serverSidePromise;
// Combine the search results into one result.
const result = {};
// Our localResult and serverSideResult are both ordered by
// recency separately, when we combine them the order might not
// be the right one so we need to sort them.
const compare = (a, b) => {
const aEvent = a.context.getEvent().event;
const bEvent = b.context.getEvent().event;
if (aEvent.origin_server_ts >
bEvent.origin_server_ts) return -1;
if (aEvent.origin_server_ts <
bEvent.origin_server_ts) return 1;
return 0;
};
result.count = localResult.count + serverSideResult.count;
result.results = localResult.results.concat(
serverSideResult.results).sort(compare);
result.highlights = localResult.highlights.concat(
serverSideResult.highlights);
return result;
}
async function localSearch(searchTerm, roomId = undefined) {
const searchArgs = {
search_term: searchTerm,
before_limit: 1,
after_limit: 1,
order_by_recency: true,
room_id: undefined,
};
if (roomId !== undefined) {
searchArgs.room_id = roomId;
}
const eventIndex = EventIndexPeg.get();
const localResult = await eventIndex.search(searchArgs);
const response = {
search_categories: {
room_events: localResult,
},
};
const emptyResult = {
results: [],
highlights: [],
};
const result = MatrixClientPeg.get()._processRoomEventsSearch(
emptyResult, response);
return result;
}
function eventIndexSearch(term, roomId = undefined) {
let searchPromise;
if (roomId !== undefined) {
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
// The search is for a single encrypted room, use our local
// search method.
searchPromise = localSearch(term, roomId);
} else {
// The search is for a single non-encrypted room, use the
// server-side search.
searchPromise = serverSideSearch(term, roomId);
}
} else {
// Search across all rooms, combine a server side search and a
// local search.
searchPromise = combinedSearch(term);
}
return searchPromise;
}
export default function eventSearch(term, roomId = undefined) {
const eventIndex = EventIndexPeg.get();
if (eventIndex === null) return serverSideSearch(term, roomId);
else return eventIndexSearch(term, roomId);
}

View file

@ -20,6 +20,7 @@ class Skinner {
}
getComponent(name) {
if (!name) throw new Error(`Invalid component name: ${name}`);
if (this.components === null) {
throw new Error(
"Attempted to get a component before a skin has been loaded."+
@ -28,21 +29,31 @@ class Skinner {
" b) A component has called getComponent at the root level",
);
}
let comp = this.components[name];
// XXX: Temporarily also try 'views.' as we're currently
// leaving the 'views.' off views.
if (!comp) {
comp = this.components['views.'+name];
}
const doLookup = (components) => {
if (!components) return null;
let comp = components[name];
// XXX: Temporarily also try 'views.' as we're currently
// leaving the 'views.' off views.
if (!comp) {
comp = components['views.' + name];
}
return comp;
};
// Check the skin first
const comp = doLookup(this.components);
// Just return nothing instead of erroring - the consumer should be smart enough to
// handle this at this point.
if (!comp) {
throw new Error("No such component: "+name);
return null;
}
// components have to be functions.
const validType = typeof comp === 'function';
if (!validType) {
throw new Error(`Not a valid component: ${name}.`);
throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`);
}
return comp;
}
@ -59,6 +70,13 @@ class Skinner {
const comp = skinObject.components[compKeys[i]];
this.addComponent(compKeys[i], comp);
}
// Now that we have a skin, load our components too
const idx = require("./component-index");
if (!idx || !idx.components) throw new Error("Invalid react-sdk component index");
for (const c in idx.components) {
if (!this.components[c]) this.components[c] = idx.components[c];
}
}
addComponent(name, comp) {
@ -90,5 +108,5 @@ class Skinner {
if (global.mxSkinner === undefined) {
global.mxSkinner = new Skinner();
}
module.exports = global.mxSkinner;
export default global.mxSkinner;

View file

@ -18,9 +18,9 @@ limitations under the License.
import React from 'react';
import MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher';
import sdk from './index';
import * as sdk from './index';
import {_t, _td} from './languageHandler';
import Modal from './Modal';
import MultiInviter from './utils/MultiInviter';
@ -28,11 +28,11 @@ import { linkifyAndSanitizeHtml } from './HtmlUtils';
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import WidgetUtils from "./utils/WidgetUtils";
import {textToHtmlRainbow} from "./utils/colour";
import Promise from "bluebird";
import { getAddressType } from './UserAddress';
import { abbreviateUrl } from './utils/UrlUtils';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks";
import {inviteUsersToRoom} from "./RoomInvite";
const singleMxcUpload = async () => {
return new Promise((resolve) => {
@ -81,6 +81,8 @@ class Command {
}
run(roomId, args) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) return;
return this.runFn.bind(this)(roomId, args);
}
@ -155,70 +157,58 @@ export const CommandMap = {
return reject(_t("You do not have the required permissions to use this command."));
}
const RoomUpgradeWarningDialog = sdk.getComponent("dialogs.RoomUpgradeWarningDialog");
const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
QuestionDialog, {
title: _t('Room upgrade confirmation'),
description: (
<div>
<p>{_t("Upgrading a room can be destructive and isn't always necessary.")}</p>
<p>
{_t(
"Room upgrades are usually recommended when a room version is considered " +
"<i>unstable</i>. Unstable room versions might have bugs, missing features, or " +
"security vulnerabilities.",
{}, {
"i": (sub) => <i>{sub}</i>,
},
)}
</p>
<p>
{_t(
"Room upgrades usually only affect <i>server-side</i> processing of the " +
"room. If you're having problems with your Riot client, please file an issue " +
"with <issueLink />.",
{}, {
"i": (sub) => <i>{sub}</i>,
"issueLink": () => {
return <a href="https://github.com/vector-im/riot-web/issues/new/choose"
target="_blank" rel="noopener">
https://github.com/vector-im/riot-web/issues/new/choose
</a>;
},
},
)}
</p>
<p>
{_t(
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room " +
"members to the new version of the room.</i> We'll post a link to the new room " +
"in the old version of the room - room members will have to click this link to " +
"join the new room.",
{}, {
"b": (sub) => <b>{sub}</b>,
"i": (sub) => <i>{sub}</i>,
},
)}
</p>
<p>
{_t(
"Please confirm that you'd like to go forward with upgrading this room " +
"from <oldVersion /> to <newVersion />.",
{},
{
oldVersion: () => <code>{room ? room.getVersion() : "1"}</code>,
newVersion: () => <code>{args}</code>,
},
)}
</p>
</div>
),
button: _t("Upgrade"),
});
RoomUpgradeWarningDialog, {roomId: roomId, targetVersion: args}, /*className=*/null,
/*isPriority=*/false, /*isStatic=*/true);
return success(finished.then(([confirm]) => {
if (!confirm) return;
return success(finished.then(async ([resp]) => {
if (!resp.continue) return;
return cli.upgradeRoom(roomId, args);
let checkForUpgradeFn;
try {
const upgradePromise = cli.upgradeRoom(roomId, args);
// We have to wait for the js-sdk to give us the room back so
// we can more effectively abuse the MultiInviter behaviour
// which heavily relies on the Room object being available.
if (resp.invite) {
checkForUpgradeFn = async (newRoom) => {
// The upgradePromise should be done by the time we await it here.
const {replacement_room: newRoomId} = await upgradePromise;
if (newRoom.roomId !== newRoomId) return;
const toInvite = [
...room.getMembersWithMembership("join"),
...room.getMembersWithMembership("invite"),
].map(m => m.userId).filter(m => m !== cli.getUserId());
if (toInvite.length > 0) {
// Errors are handled internally to this function
await inviteUsersToRoom(newRoomId, toInvite);
}
cli.removeListener('Room', checkForUpgradeFn);
};
cli.on('Room', checkForUpgradeFn);
}
// We have to await after so that the checkForUpgradesFn has a proper reference
// to the new room's ID.
await upgradePromise;
} catch (e) {
console.error(e);
if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
title: _t('Error upgrading room'),
description: _t(
'Double check that your server supports the room version chosen and try again.'),
});
}
}));
}
return reject(this.getUsage());
@ -781,7 +771,7 @@ export const CommandMap = {
verify: new Command({
name: 'verify',
args: '<user-id> <device-id> <device-signing-key>',
description: _td('Verifies a user, device, and pubkey tuple'),
description: _td('Verifies a user, session, and pubkey tuple'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
@ -792,54 +782,52 @@ export const CommandMap = {
const deviceId = matches[2];
const fingerprint = matches[3];
return success(
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => {
if (!device) {
throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`);
}
return success((async () => {
const device = await cli.getStoredDevice(userId, deviceId);
if (!device) {
throw new Error(_t('Unknown (user, session) pair:') + ` (${userId}, ${deviceId})`);
}
const deviceTrust = await cli.checkDeviceTrust(userId, deviceId);
if (device.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw new Error(_t('Device already verified!'));
} else {
throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!'));
}
if (deviceTrust.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw new Error(_t('Session already verified!'));
} else {
throw new Error(_t('WARNING: Session already verified, but keys do NOT MATCH!'));
}
}
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new Error(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
'"%(fingerprint)s". This could mean your communications are being intercepted!',
{
fprint,
userId,
deviceId,
fingerprint,
}));
}
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new Error(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
'"%(fingerprint)s". This could mean your communications are being intercepted!',
{
fprint,
userId,
deviceId,
fingerprint,
}));
}
return cli.setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
title: _t('Verified key'),
description: <div>
<p>
{
_t('The signing key you provided matches the signing key you received ' +
'from %(userId)s\'s device %(deviceId)s. Device marked as verified.',
{userId, deviceId})
}
</p>
</div>,
});
}),
);
await cli.setDeviceVerified(userId, deviceId, true);
// Tell the user we verified everything
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
title: _t('Verified key'),
description: <div>
<p>
{
_t('The signing key you provided matches the signing key you received ' +
'from %(userId)s\'s session %(deviceId)s. Session marked as verified.',
{userId, deviceId})
}
</p>
</div>,
});
})());
}
}
return reject(this.getUsage());
@ -905,6 +893,26 @@ export const CommandMap = {
},
category: CommandCategories.advanced,
}),
whois: new Command({
name: "whois",
description: _td("Displays information about a user"),
args: '<user-id>',
runFn: function(roomId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage());
}
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
dis.dispatch({
action: 'view_user',
member: member || {userId},
});
return success();
},
category: CommandCategories.advanced,
}),
};
/* eslint-enable babel/no-invalid-this */
@ -919,25 +927,25 @@ const aliases = {
/**
* Process the given text for /commands and perform them.
* Process the given text for /commands and return a bound method to perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
* @return {Object|null} An object with the property 'error' if there was an error
* @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function processCommandInput(roomId, input) {
export function getCommand(roomId, input) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/') return null; // not a command
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
let cmd;
let args;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[3];
args = bits[2];
} else {
cmd = input;
}
@ -946,11 +954,6 @@ export function processCommandInput(roomId, input) {
cmd = aliases[cmd];
}
if (CommandMap[cmd]) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!CommandMap[cmd].runFn) return null;
return CommandMap[cmd].run(roomId, args);
} else {
return reject(_t('Unrecognised command:') + ' ' + input);
return () => CommandMap[cmd].run(roomId, args);
}
}

View file

@ -1,86 +0,0 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
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 {Value} from 'slate';
import _clamp from 'lodash/clamp';
type MessageFormat = 'rich' | 'markdown';
class HistoryItem {
// We store history items in their native format to ensure history is accurate
// and then convert them if our RTE has subsequently changed format.
value: Value;
format: MessageFormat = 'rich';
constructor(value: ?Value, format: ?MessageFormat) {
this.value = value;
this.format = format;
}
static fromJSON(obj: Object): HistoryItem {
return new HistoryItem(
Value.fromJSON(obj.value),
obj.format,
);
}
toJSON(): Object {
return {
value: this.value.toJSON(),
format: this.format,
};
}
}
export default class SlateComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
try {
this.history.push(
HistoryItem.fromJSON(JSON.parse(item)),
);
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
}
this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
}
save(value: Value, format: MessageFormat) {
const item = new HistoryItem(value, format);
this.history.push(item);
this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
}
getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

View file

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from 'bluebird';
import classNames from 'classnames';
import MatrixClientPeg from './MatrixClientPeg';
import sdk from './';
import {MatrixClientPeg} from './MatrixClientPeg';
import * as sdk from './';
import Modal from './Modal';
export class TermsNotSignedError extends Error {}

View file

@ -13,12 +13,13 @@ 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 MatrixClientPeg from './MatrixClientPeg';
import {MatrixClientPeg} from './MatrixClientPeg';
import CallHandler from './CallHandler';
import { _t } from './languageHandler';
import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
@ -274,6 +275,8 @@ function textForRoomAliasesEvent(ev) {
// This feels a bit overkill though, and it's not clear the i18n really needs it
// so instead it's landing as a simple textual event.
const maxShown = 3;
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAliases = ev.getPrevContent().aliases || [];
const newAliases = ev.getContent().aliases || [];
@ -286,18 +289,40 @@ function textForRoomAliasesEvent(ev) {
}
if (addedAliases.length && !removedAliases.length) {
if (addedAliases.length > maxShown) {
return _t("%(senderName)s added %(addedAddresses)s and %(count)s other addresses to this room", {
senderName: senderName,
count: addedAliases.length - maxShown,
addedAddresses: addedAliases.slice(0, maxShown).join(', '),
});
}
return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', {
senderName: senderName,
count: addedAliases.length,
addedAddresses: addedAliases.join(', '),
});
} else if (!addedAliases.length && removedAliases.length) {
if (removedAliases.length > maxShown) {
return _t("%(senderName)s removed %(removedAddresses)s and %(count)s other addresses from this room", {
senderName: senderName,
count: removedAliases.length - maxShown,
removedAddresses: removedAliases.slice(0, maxShown).join(', '),
});
}
return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
count: removedAliases.length,
removedAddresses: removedAliases.join(', '),
});
} else {
const combined = addedAliases.length + removedAliases.length;
if (combined > maxShown) {
return _t("%(senderName)s removed %(countRemoved)s and added %(countAdded)s addresses to this room", {
senderName: senderName,
countAdded: addedAliases.length,
countRemoved: removedAliases.length,
});
}
return _t(
'%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
@ -358,13 +383,25 @@ function textForCallHangupEvent(event) {
function textForCallInviteEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
let callType = "voice";
let isVoice = true;
if (event.getContent().offer && event.getContent().offer.sdp &&
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
callType = "video";
isVoice = false;
}
const isSupported = MatrixClientPeg.get().supportsVoip();
// This ladder could be reduced down to a couple string variables, however other languages
// can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) {
return _t("%(senderName)s placed a voice call.", {senderName});
} else if (isVoice && !isSupported) {
return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName});
} else if (!isVoice && isSupported) {
return _t("%(senderName)s placed a video call.", {senderName});
} else if (!isVoice && !isSupported) {
return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName});
}
const supported = MatrixClientPeg.get().supportsVoip() ? "" : _t('(not supported by this browser)');
return _t('%(senderName)s placed a %(callType)s call.', {senderName, callType}) + ' ' + supported;
}
function textForThreePidInviteEvent(event) {
@ -405,14 +442,6 @@ function textForHistoryVisibilityEvent(event) {
}
}
function textForEncryptionEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
return _t('%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).', {
senderName,
algorithm: event.getContent().algorithm,
});
}
// Currently will only display a change if a user's power level is changed
function textForPowerEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
@ -460,7 +489,7 @@ function textForPowerEvent(event) {
}
function textForPinnedEvent(event) {
const senderName = event.getSender();
const senderName = event.sender ? event.sender.name : event.getSender();
return _t("%(senderName)s changed the pinned messages for the room.", {senderName});
}
@ -494,6 +523,87 @@ function textForWidgetEvent(event) {
}
}
function textForMjolnirEvent(event) {
const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent();
const {entity, recommendation, reason} = event.getContent();
// Rule removed
if (!entity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning users matching %(glob)s",
{senderName, glob: prevEntity});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning rooms matching %(glob)s",
{senderName, glob: prevEntity});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning servers matching %(glob)s",
{senderName, glob: prevEntity});
}
// Unknown type. We'll say something, but we shouldn't end up here.
return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity});
}
// Invalid rule
if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName});
// Rule updated
if (entity === prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// New rule
if (!prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason});
}
// else the entity !== prevEntity - count as a removal & add
if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason});
}
// Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " +
"for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason});
}
const handlers = {
'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent,
@ -509,7 +619,6 @@ const stateHandlers = {
'm.room.member': textForMemberEvent,
'm.room.third_party_invite': textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.encryption': textForEncryptionEvent,
'm.room.power_levels': textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent,
'm.room.server_acl': textForServerACLEvent,
@ -521,10 +630,13 @@ const stateHandlers = {
'im.vector.modular.widgets': textForWidgetEvent,
};
module.exports = {
textForEvent: function(ev) {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
if (handler) return handler(ev);
return '';
},
};
// Add all the Mjolnir stuff to the renderer
for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent;
}
export function textForEvent(ev) {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
if (handler) return handler(ev);
return '';
}

View file

@ -143,10 +143,14 @@ class Tinter {
* over time then the best bet is to register a single callback for the
* entire set.
*
* To ensure the tintable work happens at least once, it is also called as
* part of registration.
*
* @param {Function} tintable Function to call when the tint changes.
*/
registerTintable(tintable) {
this.tintables.push(tintable);
tintable();
}
getKeyRgb() {

View file

@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from "bluebird";
// const OUTBOUND_API_NAME = 'toWidget';
// Initiate requests using the "toWidget" postMessage API and handle responses

View file

@ -14,78 +14,78 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const MatrixClientPeg = require('./MatrixClientPeg');
import {MatrixClientPeg} from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
const sdk = require('./index');
import * as sdk from "./index";
import {haveTileForEvent} from "./components/views/rooms/EventTile";
module.exports = {
/**
* Returns true iff this event arriving in a room should affect the room's
* count of unread messages
*/
eventTriggersUnreadCount: function(ev) {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
return false;
} else if (ev.getType() == 'm.room.member') {
return false;
} else if (ev.getType() == 'm.room.third_party_invite') {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false;
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
return false;
} else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') {
/**
* Returns true iff this event arriving in a room should affect the room's
* count of unread messages
*/
export function eventTriggersUnreadCount(ev) {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
return false;
} else if (ev.getType() == 'm.room.member') {
return false;
} else if (ev.getType() == 'm.room.third_party_invite') {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false;
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
return false;
} else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') {
return false;
} else if (ev.getType() == 'm.room.server_acl') {
return false;
}
return haveTileForEvent(ev);
}
export function doesRoomHaveUnreadMessages(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
// despite the name of the method :((
const readUpToId = room.getEventReadUpTo(myUserId);
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/riot-web/issues/3263
// https://github.com/vector-im/riot-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/riot-web/issues/3363
if (room.timeline.length &&
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
return false;
}
// this just looks at whatever history we have, which if we've only just started
// up probably won't be very much, so if the last couple of events are ones that
// don't count, we don't know if there are any events that do count between where
// we have and the read receipt. We could fetch more history to try & find out,
// but currently we just guess.
// Loop through messages, starting with the most recent...
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
if (ev.getId() == readUpToId) {
// If we've read up to this event, there's nothing more recent
// that counts and we can stop looking because the user's read
// this and everything before.
return false;
} else if (!shouldHideEvent(ev) && eventTriggersUnreadCount(ev)) {
// We've found a message that counts before we hit
// the user's read receipt, so this room is definitely unread.
return true;
}
const EventTile = sdk.getComponent('rooms.EventTile');
return EventTile.haveTileForEvent(ev);
},
doesRoomHaveUnreadMessages: function(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
// despite the name of the method :((
const readUpToId = room.getEventReadUpTo(myUserId);
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/riot-web/issues/3263
// https://github.com/vector-im/riot-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/riot-web/issues/3363
if (room.timeline.length &&
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
return false;
}
// this just looks at whatever history we have, which if we've only just started
// up probably won't be very much, so if the last couple of events are ones that
// don't count, we don't know if there are any events that do count between where
// we have and the read receipt. We could fetch more history to try & find out,
// but currently we just guess.
// Loop through messages, starting with the most recent...
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
if (ev.getId() == readUpToId) {
// If we've read up to this event, there's nothing more recent
// that counts and we can stop looking because the user's read
// this and everything before.
return false;
} else if (!shouldHideEvent(ev) && this.eventTriggersUnreadCount(ev)) {
// We've found a message that counts before we hit
// the user's read receipt, so this room is definitely unread.
return true;
}
}
// If we got here, we didn't find a message that counted but didn't find
// the user's read receipt either, so we guess and say that the room is
// unread on the theory that false positives are better than false
// negatives here.
return true;
},
};
}
// If we got here, we didn't find a message that counted but didn't find
// the user's read receipt either, so we guess and say that the room is
// unread on the theory that false positives are better than false
// negatives here.
return true;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
@ -14,10 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from 'bluebird';
import {createNewMatrixCall, Room} from "matrix-js-sdk";
import {createNewMatrixCall as jsCreateNewMatrixCall, Room} from "matrix-js-sdk";
import CallHandler from './CallHandler';
import MatrixClientPeg from "./MatrixClientPeg";
import {MatrixClientPeg} from "./MatrixClientPeg";
// FIXME: this is Riot (Vector) specific code, but will be removed shortly when
// we switch over to jitsi entirely for video conferencing.
@ -29,10 +29,10 @@ import MatrixClientPeg from "./MatrixClientPeg";
const USER_PREFIX = "fs_";
const DOMAIN = "matrix.org";
function ConferenceCall(matrixClient, groupChatRoomId) {
export function ConferenceCall(matrixClient, groupChatRoomId) {
this.client = matrixClient;
this.groupRoomId = groupChatRoomId;
this.confUserId = module.exports.getConferenceUserIdForRoom(this.groupRoomId);
this.confUserId = getConferenceUserIdForRoom(this.groupRoomId);
}
ConferenceCall.prototype.setup = function() {
@ -43,7 +43,7 @@ ConferenceCall.prototype.setup = function() {
// return a call for *this* room to be placed. We also tack on
// confUserId to speed up lookups (else we'd need to loop every room
// looking for a 1:1 room with this conf user ID!)
const call = createNewMatrixCall(self.client, room.roomId);
const call = jsCreateNewMatrixCall(self.client, room.roomId);
call.confUserId = self.confUserId;
call.groupRoomId = self.groupRoomId;
return call;
@ -91,7 +91,7 @@ ConferenceCall.prototype._getConferenceUserRoom = function() {
* @param {string} userId The user ID to check.
* @return {boolean} True if it is a conference bot.
*/
module.exports.isConferenceUser = function(userId) {
export function isConferenceUser(userId) {
if (userId.indexOf("@" + USER_PREFIX) !== 0) {
return false;
}
@ -102,26 +102,26 @@ module.exports.isConferenceUser = function(userId) {
return /^!.+:.+/.test(decoded);
}
return false;
};
}
module.exports.getConferenceUserIdForRoom = function(roomId) {
export function getConferenceUserIdForRoom(roomId) {
// abuse browserify's core node Buffer support (strip padding ='s)
const base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, "");
return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN;
};
}
module.exports.createNewMatrixCall = function(client, roomId) {
export function createNewMatrixCall(client, roomId) {
const confCall = new ConferenceCall(
client, roomId,
);
return confCall.setup();
};
}
module.exports.getConferenceCallForRoom = function(roomId) {
export function getConferenceCallForRoom(roomId) {
// search for a conference 1:1 call for this group chat room ID
const activeCall = CallHandler.getAnyActiveCall();
if (activeCall && activeCall.confUserId) {
const thisRoomConfUserId = module.exports.getConferenceUserIdForRoom(
const thisRoomConfUserId = getConferenceUserIdForRoom(
roomId,
);
if (thisRoomConfUserId === activeCall.confUserId) {
@ -129,8 +129,7 @@ module.exports.getConferenceCallForRoom = function(roomId) {
}
}
return null;
};
}
module.exports.ConferenceCall = ConferenceCall;
module.exports.slot = 'conference';
// TODO: Document this.
export const slot = 'conference';

View file

@ -1,8 +1,7 @@
const React = require('react');
const ReactDom = require('react-dom');
import React from "react";
import ReactDom from "react-dom";
import Velocity from "velocity-animate";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const Velocity = require('velocity-animate');
/**
* The Velociraptor contains components and animates transitions with velocity.
@ -11,10 +10,8 @@ const Velocity = require('velocity-animate');
* from DOM order. This makes it a lot simpler and lighter: if you need fully
* automatic positional animation, look at react-shuffle or similar libraries.
*/
module.exports = createReactClass({
displayName: 'Velociraptor',
propTypes: {
export default class Velociraptor extends React.Component {
static propTypes = {
// either a list of child nodes, or a single child.
children: PropTypes.any,
@ -26,82 +23,71 @@ module.exports = createReactClass({
// a list of transition options from the corresponding startStyle
enterTransitionOpts: PropTypes.array,
},
};
getDefaultProps: function() {
return {
startStyles: [],
enterTransitionOpts: [],
};
},
static defaultProps = {
startStyles: [],
enterTransitionOpts: [],
};
constructor(props) {
super(props);
componentWillMount: function() {
this.nodes = {};
this._updateChildren(this.props.children);
},
}
componentWillReceiveProps: function(nextProps) {
this._updateChildren(nextProps.children);
},
componentDidUpdate() {
this._updateChildren(this.props.children);
}
/**
* update `this.children` according to the new list of children given
*/
_updateChildren: function(newChildren) {
const self = this;
_updateChildren(newChildren) {
const oldChildren = this.children || {};
this.children = {};
React.Children.toArray(newChildren).forEach(function(c) {
React.Children.toArray(newChildren).forEach((c) => {
if (oldChildren[c.key]) {
const old = oldChildren[c.key];
const oldNode = ReactDom.findDOMNode(self.nodes[old.key]);
const oldNode = ReactDom.findDOMNode(this.nodes[old.key]);
if (oldNode && oldNode.style.left != c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, self.props.transition).then(function() {
if (oldNode && oldNode.style.left !== c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => {
// special case visibility because it's nonsensical to animate an invisible element
// so we always hidden->visible pre-transition and visible->hidden after
if (oldNode.style.visibility == 'visible' && c.props.style.visibility == 'hidden') {
if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') {
oldNode.style.visibility = c.props.style.visibility;
}
});
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
if (oldNode && oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') {
if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') {
oldNode.style.visibility = c.props.style.visibility;
}
// clone the old element with the props (and children) of the new element
// so prop updates are still received by the children.
self.children[c.key] = React.cloneElement(old, c.props, c.props.children);
this.children[c.key] = React.cloneElement(old, c.props, c.props.children);
} else {
// new element. If we have a startStyle, use that as the style and go through
// the enter animations
const newProps = {};
const restingStyle = c.props.style;
const startStyles = self.props.startStyles;
const startStyles = this.props.startStyles;
if (startStyles.length > 0) {
const startStyle = startStyles[0];
newProps.style = startStyle;
// console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
}
newProps.ref = ((n) => self._collectNode(
newProps.ref = ((n) => this._collectNode(
c.key, n, restingStyle,
));
self.children[c.key] = React.cloneElement(c, newProps);
this.children[c.key] = React.cloneElement(c, newProps);
}
});
},
}
/**
* called when a child element is mounted/unmounted
*
* @param {string} k key of the child
* @param {null|Object} node On mount: React node. On unmount: null
* @param {Object} restingStyle final style
*/
_collectNode: function(k, node, restingStyle) {
_collectNode(k, node, restingStyle) {
if (
node &&
this.nodes[k] === undefined &&
@ -125,12 +111,12 @@ module.exports = createReactClass({
// and then we animate to the resting state
Velocity(domNode, restingStyle,
transitionOpts[i-1])
.then(() => {
// once we've reached the resting state, hide the element if
// appropriate
domNode.style.visibility = restingStyle.visibility;
});
transitionOpts[i-1])
.then(() => {
// once we've reached the resting state, hide the element if
// appropriate
domNode.style.visibility = restingStyle.visibility;
});
/*
console.log("enter:",
@ -153,13 +139,13 @@ module.exports = createReactClass({
if (domNode) Velocity.Utilities.removeData(domNode);
}
this.nodes[k] = node;
},
}
render: function() {
render() {
return (
<span>
{ Object.values(this.children) }
</span>
);
},
});
}
}

View file

@ -1,4 +1,4 @@
const Velocity = require('velocity-animate');
import Velocity from "velocity-animate";
// courtesy of https://github.com/julianshapiro/velocity/issues/283
// We only use easeOutBounce (easeInBounce is just sort of nonsensical)

View file

@ -14,71 +14,69 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from "./MatrixClientPeg";
import {MatrixClientPeg} from "./MatrixClientPeg";
import { _t } from './languageHandler';
module.exports = {
usersTypingApartFromMeAndIgnored: function(room) {
return this.usersTyping(
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
);
},
export function usersTypingApartFromMeAndIgnored(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
);
}
usersTypingApartFromMe: function(room) {
return this.usersTyping(
room, [MatrixClientPeg.get().credentials.userId],
);
},
export function usersTypingApartFromMe(room) {
return usersTyping(
room, [MatrixClientPeg.get().credentials.userId],
);
}
/**
* Given a Room object and, optionally, a list of userID strings
* to exclude, return a list of user objects who are typing.
* @param {Room} room: room object to get users from.
* @param {string[]} exclude: list of user mxids to exclude.
* @returns {string[]} list of user objects who are typing.
*/
usersTyping: function(room, exclude) {
const whoIsTyping = [];
/**
* Given a Room object and, optionally, a list of userID strings
* to exclude, return a list of user objects who are typing.
* @param {Room} room: room object to get users from.
* @param {string[]} exclude: list of user mxids to exclude.
* @returns {string[]} list of user objects who are typing.
*/
export function usersTyping(room, exclude) {
const whoIsTyping = [];
if (exclude === undefined) {
exclude = [];
}
if (exclude === undefined) {
exclude = [];
}
const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i];
const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i];
if (room.currentState.members[userId].typing) {
if (exclude.indexOf(userId) === -1) {
whoIsTyping.push(room.currentState.members[userId]);
}
if (room.currentState.members[userId].typing) {
if (exclude.indexOf(userId) === -1) {
whoIsTyping.push(room.currentState.members[userId]);
}
}
}
return whoIsTyping;
},
return whoIsTyping;
}
whoIsTypingString: function(whoIsTyping, limit) {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
if (whoIsTyping.length === 0) {
return '';
} else if (whoIsTyping.length === 1) {
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
}
const names = whoIsTyping.map(function(m) {
return m.name;
export function whoIsTypingString(whoIsTyping, limit) {
let othersCount = 0;
if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1;
}
if (whoIsTyping.length === 0) {
return '';
} else if (whoIsTyping.length === 1) {
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
}
const names = whoIsTyping.map(function(m) {
return m.name;
});
if (othersCount>=1) {
return _t('%(names)s and %(count)s others are typing …', {
names: names.slice(0, limit - 1).join(', '),
count: othersCount,
});
if (othersCount>=1) {
return _t('%(names)s and %(count)s others are typing …', {
names: names.slice(0, limit - 1).join(', '),
count: othersCount,
});
} else {
const lastPerson = names.pop();
return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson});
}
},
};
} else {
const lastPerson = names.pop();
return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson});
}
}

View file

@ -23,7 +23,7 @@ limitations under the License.
import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
import Modal from "./Modal";
import MatrixClientPeg from "./MatrixClientPeg";
import {MatrixClientPeg} from "./MatrixClientPeg";
import SettingsStore from "./settings/SettingsStore";
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetUtils from "./utils/WidgetUtils";

View file

@ -0,0 +1,224 @@
/*
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, {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useReducer,
} from "react";
import PropTypes from "prop-types";
import {Key} from "../Keyboard";
/**
* Module to simplify implementing the Roving TabIndex accessibility technique
*
* Wrap the Widget in an RovingTabIndexContextProvider
* and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper.
* The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which
* can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique.
* When the active button gets unmounted the closest button will be chosen as expected.
* Initially the first button to mount will be given active state.
*
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
*/
const DOCUMENT_POSITION_PRECEDING = 2;
const RovingTabIndexContext = createContext({
state: {
activeRef: null,
refs: [], // list of refs in DOM order
},
dispatch: () => {},
});
RovingTabIndexContext.displayName = "RovingTabIndexContext";
// TODO use a TypeScript type here
const types = {
REGISTER: "REGISTER",
UNREGISTER: "UNREGISTER",
SET_FOCUS: "SET_FOCUS",
};
const reducer = (state, action) => {
switch (action.type) {
case types.REGISTER: {
if (state.refs.length === 0) {
// Our list of refs was empty, set activeRef to this first item
return {
...state,
activeRef: action.payload.ref,
refs: [action.payload.ref],
};
}
if (state.refs.includes(action.payload.ref)) {
return state; // already in refs, this should not happen
}
// find the index of the first ref which is not preceding this one in DOM order
let newIndex = state.refs.findIndex(ref => {
return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
});
if (newIndex < 0) {
newIndex = state.refs.length; // append to the end
}
// update the refs list
return {
...state,
refs: [
...state.refs.slice(0, newIndex),
action.payload.ref,
...state.refs.slice(newIndex),
],
};
}
case types.UNREGISTER: {
// filter out the ref which we are removing
const refs = state.refs.filter(r => r !== action.payload.ref);
if (refs.length === state.refs.length) {
return state; // already removed, this should not happen
}
if (state.activeRef === action.payload.ref) {
// we just removed the active ref, need to replace it
// pick the ref which is now in the index the old ref was in
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
return {
...state,
activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
refs,
};
}
// update the refs list
return {
...state,
refs,
};
}
case types.SET_FOCUS: {
// update active ref
return {
...state,
activeRef: action.payload.ref,
};
}
default:
return state;
}
};
export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => {
const [state, dispatch] = useReducer(reducer, {
activeRef: null,
refs: [],
});
const context = useMemo(() => ({state, dispatch}), [state]);
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
if (handleHomeEnd) {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:
handled = true;
// move focus to first item
if (context.state.refs.length > 0) {
context.state.refs[0].current.focus();
}
break;
case Key.END:
handled = true;
// move focus to last item
if (context.state.refs.length > 0) {
context.state.refs[context.state.refs.length - 1].current.focus();
}
break;
}
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
} else if (onKeyDown) {
return onKeyDown(ev);
}
}, [context.state, onKeyDown, handleHomeEnd]);
return <RovingTabIndexContext.Provider value={context}>
{ children({onKeyDownHandler}) }
</RovingTabIndexContext.Provider>;
};
RovingTabIndexProvider.propTypes = {
handleHomeEnd: PropTypes.bool,
onKeyDown: PropTypes.func,
};
// Hook to register a roving tab index
// inputRef parameter specifies the ref to use
// onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = (inputRef) => {
const context = useContext(RovingTabIndexContext);
let ref = useRef(null);
if (inputRef) {
// if we are given a ref, use it instead of ours
ref = inputRef;
}
// setup (after refs)
useLayoutEffect(() => {
context.dispatch({
type: types.REGISTER,
payload: {ref},
});
// teardown
return () => {
context.dispatch({
type: types.UNREGISTER,
payload: {ref},
});
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onFocus = useCallback(() => {
context.dispatch({
type: types.SET_FOCUS,
payload: {ref},
});
}, [ref, context]);
const isActive = context.state.activeRef === ref;
return [onFocus, isActive, ref];
};
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref});
};

View file

@ -15,12 +15,11 @@ limitations under the License.
*/
import { asyncAction } from './actionCreators';
import RoomListStore from '../stores/RoomListStore';
import RoomListStore, {TAG_DM} from '../stores/RoomListStore';
import Modal from '../Modal';
import * as Rooms from '../Rooms';
import { _t } from '../languageHandler';
import sdk from '../index';
import * as sdk from '../index';
const RoomListActions = {};
@ -74,11 +73,11 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
const roomId = room.roomId;
// Evil hack to get DMs behaving
if ((oldTag === undefined && newTag === 'im.vector.fake.direct') ||
(oldTag === 'im.vector.fake.direct' && newTag === undefined)
if ((oldTag === undefined && newTag === TAG_DM) ||
(oldTag === TAG_DM && newTag === undefined)
) {
return Rooms.guessAndSetDMRoom(
room, newTag === 'im.vector.fake.direct',
room, newTag === TAG_DM,
).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set direct chat tag " + err);
@ -92,10 +91,10 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
const hasChangedSubLists = oldTag !== newTag;
// More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with 'im.vector.fake.direct`.
// but we avoid ever doing a request with TAG_DM.
//
// if we moved lists, remove the old tag
if (oldTag && oldTag !== 'im.vector.fake.direct' &&
if (oldTag && oldTag !== TAG_DM &&
hasChangedSubLists
) {
const promiseToDelete = matrixClient.deleteRoomTag(
@ -113,7 +112,7 @@ RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex,
}
// if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== 'im.vector.fake.direct' &&
if (newTag && newTag !== TAG_DM &&
(hasChangedSubLists || metaData)
) {
// metaData is the body of the PUT to set the tag, so it must

View file

@ -14,14 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require("react");
import React from "react";
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const sdk = require('../../../index');
const MatrixClientPeg = require("../../../MatrixClientPeg");
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {Key} from "../../../Keyboard";
import * as sdk from "../../../index";
module.exports = createReactClass({
// XXX: This component is not cross-signing aware.
// https://github.com/vector-im/riot-web/issues/11752 tracks either updating this
// component or taking it out to pasture.
export default createReactClass({
displayName: 'EncryptedEventDialog',
propTypes: {
@ -83,7 +87,7 @@ module.exports = createReactClass({
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
if (e.key === Key.ESCAPE) {
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
@ -187,7 +191,7 @@ module.exports = createReactClass({
<h4>{ _t('Event information') }</h4>
{ this._renderEventInfo() }
<h4>{ _t('Sender device information') }</h4>
<h4>{ _t('Sender session information') }</h4>
{ this._renderDeviceInfo() }
</div>
<div className="mx_Dialog_buttons">

View file

@ -15,14 +15,14 @@ limitations under the License.
*/
import FileSaver from 'file-saver';
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
import { MatrixClient } from 'matrix-js-sdk';
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
import sdk from '../../../index';
import * as sdk from '../../../index';
const PHASE_EDIT = 1;
const PHASE_EXPORTING = 2;
@ -44,6 +44,9 @@ export default createReactClass({
componentWillMount: function() {
this._unmounted = false;
this._passphrase1 = createRef();
this._passphrase2 = createRef();
},
componentWillUnmount: function() {
@ -53,8 +56,8 @@ export default createReactClass({
_onPassphraseFormSubmit: function(ev) {
ev.preventDefault();
const passphrase = this.refs.passphrase1.value;
if (passphrase !== this.refs.passphrase2.value) {
const passphrase = this._passphrase1.current.value;
if (passphrase !== this._passphrase2.current.value) {
this.setState({errStr: _t('Passphrases must match')});
return false;
}
@ -148,7 +151,7 @@ export default createReactClass({
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase1' id='passphrase1'
<input ref={this._passphrase1} id='passphrase1'
autoFocus={true} size='64' type='password'
disabled={disableForm}
/>
@ -161,7 +164,7 @@ export default createReactClass({
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase2' id='passphrase2'
<input ref={this._passphrase2} id='passphrase2'
size='64' type='password'
disabled={disableForm}
/>

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { MatrixClient } from 'matrix-js-sdk';
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
import sdk from '../../../index';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
function readFileAsArrayBuffer(file) {
@ -56,6 +56,9 @@ export default createReactClass({
componentWillMount: function() {
this._unmounted = false;
this._file = createRef();
this._passphrase = createRef();
},
componentWillUnmount: function() {
@ -63,15 +66,15 @@ export default createReactClass({
},
_onFormChange: function(ev) {
const files = this.refs.file.files || [];
const files = this._file.current.files || [];
this.setState({
enableSubmit: (this.refs.passphrase.value !== "" && files.length > 0),
enableSubmit: (this._passphrase.current.value !== "" && files.length > 0),
});
},
_onFormSubmit: function(ev) {
ev.preventDefault();
this._startImport(this.refs.file.files[0], this.refs.passphrase.value);
this._startImport(this._file.current.files[0], this._passphrase.current.value);
return false;
},
@ -146,7 +149,10 @@ export default createReactClass({
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='file' id='importFile' type='file'
<input
ref={this._file}
id='importFile'
type='file'
autoFocus={true}
onChange={this._onFormChange}
disabled={disableForm} />
@ -159,8 +165,11 @@ export default createReactClass({
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase' id='passphrase'
size='64' type='password'
<input
ref={this._passphrase}
id='passphrase'
size='64'
type='password'
onChange={this._onFormChange}
disabled={disableForm} />
</div>

View file

@ -0,0 +1,73 @@
/*
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 from 'react';
import * as sdk from '../../../../index';
import PropTypes from 'prop-types';
import dis from "../../../../dispatcher";
import { _t } from '../../../../languageHandler';
import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore";
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
/*
* Allows the user to disable the Event Index.
*/
export default class DisableEventIndexDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = {
disabling: false,
};
}
_onDisable = async () => {
this.setState({
disabling: true,
});
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
this.props.onFinished();
dis.dispatch({ action: 'view_user_settings' });
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Spinner = sdk.getComponent('elements.Spinner');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog onFinished={this.props.onFinished} title={_t("Are you sure?")}>
{_t("If disabled, messages from encrypted rooms won't appear in search results.")}
{this.state.disabling ? <Spinner /> : <div />}
<DialogButtons
primaryButton={_t('Disable')}
onPrimaryButtonClick={this._onDisable}
primaryButtonClass="danger"
cancelButtonClass="warning"
onCancel={this.props.onFinished}
disabled={this.state.disabling}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,198 @@
/*
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 from 'react';
import * as sdk from '../../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../../languageHandler';
import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore";
import Modal from '../../../../Modal';
import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils";
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
/*
* Allows the user to introspect the event index state and disable it.
*/
export default class ManageEventIndexDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = {
eventIndexSize: 0,
eventCount: 0,
crawlingRoomsCount: 0,
roomCount: 0,
currentRoom: null,
crawlerSleepTime:
SettingsStore.getValueAt(SettingLevel.DEVICE, 'crawlerSleepTime'),
};
}
updateCurrentRoom = async (room) => {
const eventIndex = EventIndexPeg.get();
let stats;
try {
stats = await eventIndex.getStats();
} catch {
// This call may fail if sporadically, not a huge issue as we will
// try later again and probably succeed.
return;
}
let currentRoom = null;
if (room) currentRoom = room.name;
const roomStats = eventIndex.crawlingRooms();
const crawlingRoomsCount = roomStats.crawlingRooms.size;
const roomCount = roomStats.totalRooms.size;
this.setState({
eventIndexSize: stats.size,
eventCount: stats.eventCount,
crawlingRoomsCount: crawlingRoomsCount,
roomCount: roomCount,
currentRoom: currentRoom,
});
};
componentWillUnmount(): void {
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.removeListener("changedCheckpoint", this.updateCurrentRoom);
}
}
async componentWillMount(): void {
let eventIndexSize = 0;
let crawlingRoomsCount = 0;
let roomCount = 0;
let eventCount = 0;
let currentRoom = null;
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.on("changedCheckpoint", this.updateCurrentRoom);
try {
const stats = await eventIndex.getStats();
eventIndexSize = stats.size;
eventCount = stats.eventCount;
} catch {
// This call may fail if sporadically, not a huge issue as we
// will try later again in the updateCurrentRoom call and
// probably succeed.
}
const roomStats = eventIndex.crawlingRooms();
crawlingRoomsCount = roomStats.crawlingRooms.size;
roomCount = roomStats.totalRooms.size;
const room = eventIndex.currentRoom();
if (room) currentRoom = room.name;
}
this.setState({
eventIndexSize,
eventCount,
crawlingRoomsCount,
roomCount,
currentRoom,
});
}
_onDisable = async () => {
Modal.createTrackedDialogAsync("Disable message search", "Disable message search",
import("./DisableEventIndexDialog"),
null, null, /* priority = */ false, /* static = */ true,
);
}
_onDone = () => {
this.props.onFinished(true);
}
_onCrawlerSleepTimeChange = (e) => {
this.setState({crawlerSleepTime: e.target.value});
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
}
render() {
let crawlerState;
if (this.state.currentRoom === null) {
crawlerState = _t("Not currently downloading messages for any room.");
} else {
crawlerState = (
_t("Downloading mesages for %(currentRoom)s.", { currentRoom: this.state.currentRoom })
);
}
const Field = sdk.getComponent('views.elements.Field');
const eventIndexingSettings = (
<div>
{
_t( "Riot is securely caching encrypted messages locally for them " +
"to appear in search results:",
)
}
<div className='mx_SettingsTab_subsectionText'>
{_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}<br />
{_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}<br />
{_t("Indexed rooms:")} {_t("%(crawlingRooms)s out of %(totalRooms)s", {
crawlingRooms: formatCountLong(this.state.crawlingRoomsCount),
totalRooms: formatCountLong(this.state.roomCount),
})} <br />
{crawlerState}<br />
<Field
id={"crawlerSleepTimeMs"}
label={_t('Message downloading sleep time(ms)')}
type='number'
value={this.state.crawlerSleepTime}
onChange={this._onCrawlerSleepTimeChange} />
</div>
</div>
);
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_ManageEventIndexDialog'
onFinished={this.props.onFinished}
title={_t("Message search")}
>
{eventIndexingSettings}
<DialogButtons
primaryButton={_t("Done")}
onPrimaryButtonClick={this.props.onFinished}
primaryButtonClass="primary"
cancelButton={_t("Disable")}
onCancel={this._onDisable}
cancelButtonClass="danger"
/>
</BaseDialog>
);
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 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.
@ -15,14 +16,15 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../../../index';
import MatrixClientPeg from '../../../../MatrixClientPeg';
import { scorePassword } from '../../../../utils/PasswordScorer';
import FileSaver from 'file-saver';
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import PropTypes from 'prop-types';
import { scorePassword } from '../../../../utils/PasswordScorer';
import { _t } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager';
import SettingsStore from '../../../../settings/SettingsStore';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
const PHASE_PASSPHRASE = 0;
const PHASE_PASSPHRASE_CONFIRM = 1;
@ -45,40 +47,60 @@ function selectText(target) {
selection.addRange(range);
}
/**
/*
* Walks the user through the process of creating an e2e key backup
* on the server.
*/
export default createReactClass({
getInitialState: function() {
return {
export default class CreateKeyBackupDialog extends React.PureComponent {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this._recoveryKeyNode = null;
this._keyBackupInfo = null;
this._setZxcvbnResultTimeout = null;
this.state = {
secureSecretStorage: null,
phase: PHASE_PASSPHRASE,
passPhrase: '',
passPhraseConfirm: '',
copied: false,
downloaded: false,
zxcvbnResult: null,
setPassPhrase: false,
};
},
}
componentWillMount: function() {
this._recoveryKeyNode = null;
this._keyBackupInfo = null;
this._setZxcvbnResultTimeout = null;
},
async componentDidMount() {
const cli = MatrixClientPeg.get();
const secureSecretStorage = (
SettingsStore.isFeatureEnabled("feature_cross_signing") &&
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")
);
this.setState({ secureSecretStorage });
componentWillUnmount: function() {
// If we're using secret storage, skip ahead to the backing up step, as
// `accessSecretStorage` will handle passphrases as needed.
if (secureSecretStorage) {
this.setState({ phase: PHASE_BACKINGUP });
this._createBackup();
}
}
componentWillUnmount() {
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
},
}
_collectRecoveryKeyNode: function(n) {
_collectRecoveryKeyNode = (n) => {
this._recoveryKeyNode = n;
},
}
_onCopyClick: function() {
_onCopyClick = () => {
selectText(this._recoveryKeyNode);
const successful = document.execCommand('copy');
if (successful) {
@ -87,9 +109,9 @@ export default createReactClass({
phase: PHASE_KEEPITSAFE,
});
}
},
}
_onDownloadClick: function() {
_onDownloadClick = () => {
const blob = new Blob([this._keyBackupInfo.recovery_key], {
type: 'text/plain;charset=us-ascii',
});
@ -99,24 +121,35 @@ export default createReactClass({
downloaded: true,
phase: PHASE_KEEPITSAFE,
});
},
}
_createBackup: async function() {
_createBackup = async () => {
const { secureSecretStorage } = this.state;
this.setState({
phase: PHASE_BACKINGUP,
error: null,
});
let info;
try {
info = await MatrixClientPeg.get().createKeyBackupVersion(
this._keyBackupInfo,
);
if (secureSecretStorage) {
await accessSecretStorage(async () => {
info = await MatrixClientPeg.get().prepareKeyBackupVersion(
null /* random key */,
{ secureSecretStorage: true },
);
info = await MatrixClientPeg.get().createKeyBackupVersion(info);
});
} else {
info = await MatrixClientPeg.get().createKeyBackupVersion(
this._keyBackupInfo,
);
}
await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup();
this.setState({
phase: PHASE_DONE,
});
} catch (e) {
console.log("Error creating key backup", e);
console.error("Error creating key backup", e);
// TODO: If creating a version succeeds, but backup fails, should we
// delete the version, disable backup, or do nothing? If we just
// disable without deleting, we'll enable on next app reload since
@ -128,89 +161,82 @@ export default createReactClass({
error: e,
});
}
},
}
_onCancel: function() {
_onCancel = () => {
this.props.onFinished(false);
},
}
_onDone: function() {
_onDone = () => {
this.props.onFinished(true);
},
}
_onOptOutClick: function() {
_onOptOutClick = () => {
this.setState({phase: PHASE_OPTOUT_CONFIRM});
},
}
_onSetUpClick: function() {
_onSetUpClick = () => {
this.setState({phase: PHASE_PASSPHRASE});
},
}
_onSkipPassPhraseClick: async function() {
_onSkipPassPhraseClick = async () => {
this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion();
this.setState({
copied: false,
downloaded: false,
phase: PHASE_SHOWKEY,
});
},
}
_onPassPhraseNextClick: function() {
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
},
_onPassPhraseNextClick = async (e) => {
e.preventDefault();
_onPassPhraseKeyPress: async function(e) {
if (e.key === 'Enter') {
// If we're waiting for the timeout before updating the result at this point,
// skip ahead and do it now, otherwise we'll deny the attempt to proceed
// even if the user entered a valid passphrase
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
if (this._passPhraseIsValid()) {
this._onPassPhraseNextClick();
}
// If we're waiting for the timeout before updating the result at this point,
// skip ahead and do it now, otherwise we'll deny the attempt to proceed
// even if the user entered a valid passphrase
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
},
if (this._passPhraseIsValid()) {
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
}
};
_onPassPhraseConfirmNextClick = async (e) => {
e.preventDefault();
if (this.state.passPhrase !== this.state.passPhraseConfirm) return;
_onPassPhraseConfirmNextClick: async function() {
this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase);
this.setState({
setPassPhrase: true,
copied: false,
downloaded: false,
phase: PHASE_SHOWKEY,
});
},
};
_onPassPhraseConfirmKeyPress: function(e) {
if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) {
this._onPassPhraseConfirmNextClick();
}
},
_onSetAgainClick: function() {
_onSetAgainClick = () => {
this.setState({
passPhrase: '',
passPhraseConfirm: '',
phase: PHASE_PASSPHRASE,
zxcvbnResult: null,
});
},
}
_onKeepItSafeBackClick: function() {
_onKeepItSafeBackClick = () => {
this.setState({
phase: PHASE_SHOWKEY,
});
},
}
_onPassPhraseChange: function(e) {
_onPassPhraseChange = (e) => {
this.setState({
passPhrase: e.target.value,
});
@ -227,19 +253,19 @@ export default createReactClass({
zxcvbnResult: scorePassword(this.state.passPhrase),
});
}, PASSPHRASE_FEEDBACK_DELAY);
},
}
_onPassPhraseConfirmChange: function(e) {
_onPassPhraseConfirmChange = (e) => {
this.setState({
passPhraseConfirm: e.target.value,
});
},
}
_passPhraseIsValid: function() {
_passPhraseIsValid() {
return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE;
},
}
_renderPhasePassPhrase: function() {
_renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let strengthMeter;
@ -264,9 +290,9 @@ export default createReactClass({
</div>;
}
return <div>
return <form onSubmit={this._onPassPhraseNextClick}>
<p>{_t(
"<b>Warning</b>: you should only set up key backup from a trusted computer.", {},
"<b>Warning</b>: You should only set up key backup from a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
@ -279,7 +305,6 @@ export default createReactClass({
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
<input type="password"
onChange={this._onPassPhraseChange}
onKeyPress={this._onPassPhraseKeyPress}
value={this.state.passPhrase}
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Enter a passphrase...")}
@ -292,7 +317,8 @@ export default createReactClass({
</div>
</div>
<DialogButtons primaryButton={_t('Next')}
<DialogButtons
primaryButton={_t('Next')}
onPrimaryButtonClick={this._onPassPhraseNextClick}
hasCancel={false}
disabled={!this._passPhraseIsValid()}
@ -300,14 +326,14 @@ export default createReactClass({
<details>
<summary>{_t("Advanced")}</summary>
<p><button onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a Recovery Key")}
</button></p>
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a recovery key")}
</AccessibleButton>
</details>
</div>;
},
</form>;
}
_renderPhasePassPhraseConfirm: function() {
_renderPhasePassPhraseConfirm() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let matchText;
@ -336,7 +362,7 @@ export default createReactClass({
</div>;
}
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t(
"Please enter your passphrase a second time to confirm.",
)}</p>
@ -345,7 +371,6 @@ export default createReactClass({
<div>
<input type="password"
onChange={this._onPassPhraseConfirmChange}
onKeyPress={this._onPassPhraseConfirmKeyPress}
value={this.state.passPhraseConfirm}
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Repeat your passphrase...")}
@ -355,37 +380,27 @@ export default createReactClass({
{passPhraseMatch}
</div>
</div>
<DialogButtons primaryButton={_t('Next')}
<DialogButtons
primaryButton={_t('Next')}
onPrimaryButtonClick={this._onPassPhraseConfirmNextClick}
hasCancel={false}
disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
/>
</div>;
},
_renderPhaseShowKey: function() {
let bodyText;
if (this.state.setPassPhrase) {
bodyText = _t(
"As a safety net, you can use it to restore your encrypted message " +
"history if you forget your Recovery Passphrase.",
);
} else {
bodyText = _t("As a safety net, you can use it to restore your encrypted message history.");
}
</form>;
}
_renderPhaseShowKey() {
return <div>
<p>{_t(
"Your recovery key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your passphrase.",
)}</p>
<p>{_t(
"Keep your recovery key somewhere very secure, like a password manager (or a safe)",
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
)}</p>
<p>{bodyText}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKeyHeader">
{_t("Your Recovery Key")}
{_t("Your recovery key")}
</div>
<div className="mx_CreateKeyBackupDialog_recoveryKeyContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKey">
@ -393,7 +408,7 @@ export default createReactClass({
</div>
<div className="mx_CreateKeyBackupDialog_recoveryKeyButtons">
<button className="mx_Dialog_primary" onClick={this._onCopyClick}>
{_t("Copy to clipboard")}
{_t("Copy")}
</button>
<button className="mx_Dialog_primary" onClick={this._onDownloadClick}>
{_t("Download")}
@ -402,18 +417,18 @@ export default createReactClass({
</div>
</div>
</div>;
},
}
_renderPhaseKeepItSafe: function() {
_renderPhaseKeepItSafe() {
let introText;
if (this.state.copied) {
introText = _t(
"Your Recovery Key has been <b>copied to your clipboard</b>, paste it to:",
"Your recovery key has been <b>copied to your clipboard</b>, paste it to:",
{}, {b: s => <b>{s}</b>},
);
} else if (this.state.downloaded) {
introText = _t(
"Your Recovery Key is in your <b>Downloads</b> folder.",
"Your recovery key is in your <b>Downloads</b> folder.",
{}, {b: s => <b>{s}</b>},
);
}
@ -425,22 +440,22 @@ export default createReactClass({
<li>{_t("<b>Save it</b> on a USB key or backup drive", {}, {b: s => <b>{s}</b>})}</li>
<li>{_t("<b>Copy it</b> to your personal cloud storage", {}, {b: s => <b>{s}</b>})}</li>
</ul>
<DialogButtons primaryButton={_t("OK")}
<DialogButtons primaryButton={_t("Continue")}
onPrimaryButtonClick={this._createBackup}
hasCancel={false}>
<button onClick={this._onKeepItSafeBackClick}>{_t("Back")}</button>
</DialogButtons>
</div>;
},
}
_renderBusyPhase: function(text) {
_renderBusyPhase(text) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <div>
<Spinner />
</div>;
},
}
_renderPhaseDone: function() {
_renderPhaseDone() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
<p>{_t(
@ -451,14 +466,14 @@ export default createReactClass({
hasCancel={false}
/>
</div>;
},
}
_renderPhaseOptOutConfirm: function() {
_renderPhaseOptOutConfirm() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
{_t(
"Without setting up Secure Message Recovery, you won't be able to restore your " +
"encrypted message history if you log out or use another device.",
"encrypted message history if you log out or use another session.",
)}
<DialogButtons primaryButton={_t('Set up Secure Message Recovery')}
onPrimaryButtonClick={this._onSetUpClick}
@ -467,9 +482,9 @@ export default createReactClass({
<button onClick={this._onCancel}>I understand, continue without</button>
</DialogButtons>
</div>;
},
}
_titleForPhase: function(phase) {
_titleForPhase(phase) {
switch (phase) {
case PHASE_PASSPHRASE:
return _t('Secure your backup with a passphrase');
@ -478,19 +493,18 @@ export default createReactClass({
case PHASE_OPTOUT_CONFIRM:
return _t('Warning!');
case PHASE_SHOWKEY:
return _t('Recovery key');
case PHASE_KEEPITSAFE:
return _t('Keep it safe');
return _t('Make a copy of your recovery key');
case PHASE_BACKINGUP:
return _t('Starting backup...');
case PHASE_DONE:
return _t('Success!');
default:
return _t("Create Key Backup");
return _t("Create key backup");
}
},
}
render: function() {
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let content;
@ -543,5 +557,5 @@ export default createReactClass({
</div>
</BaseDialog>
);
},
});
}
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import sdk from "../../../../index";
import * as sdk from "../../../../index";
import { _t } from "../../../../languageHandler";
export default class IgnoreRecoveryReminderDialog extends React.PureComponent {

View file

@ -1,5 +1,6 @@
/*
Copyright 2018-2019 New Vector Ltd
Copyright 2018, 2019 New Vector Ltd
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.
@ -16,8 +17,8 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import sdk from "../../../../index";
import MatrixClientPeg from '../../../../MatrixClientPeg';
import * as sdk from "../../../../index";
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import dis from "../../../../dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
@ -40,9 +41,11 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
onSetupClick = async () => {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
onFinished: this.props.onFinished,
});
Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, {
onFinished: this.props.onFinished,
}, null, /* priority = */ false, /* static = */ true,
);
}
render() {
@ -70,7 +73,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
content = <div>
{newMethodDetected}
<p>{_t(
"This device is encrypting history using the new recovery method.",
"This session is encrypting history using the new recovery method.",
)}</p>
{hackWarning}
<DialogButtons

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
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.
@ -16,7 +17,7 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import sdk from "../../../../index";
import * as sdk from "../../../../index";
import dis from "../../../../dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
@ -35,6 +36,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent {
this.props.onFinished();
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("./CreateKeyBackupDialog"),
null, null, /* priority = */ false, /* static = */ true,
);
}
@ -53,12 +55,12 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent {
>
<div>
<p>{_t(
"This device has detected that your recovery passphrase and key " +
"This session has detected that your recovery passphrase and key " +
"for Secure Messages have been removed.",
)}</p>
<p>{_t(
"If you did this accidentally, you can setup Secure Messages on " +
"this device which will re-encrypt this device's message " +
"this session which will re-encrypt this session's message " +
"history with a new recovery method.",
)}</p>
<p className="warning">{_t(

View file

@ -0,0 +1,787 @@
/*
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 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 from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { scorePassword } from '../../../../utils/PasswordScorer';
import FileSaver from 'file-saver';
import { _t } from '../../../../languageHandler';
import Modal from '../../../../Modal';
const PHASE_LOADING = 0;
const PHASE_MIGRATE = 1;
const PHASE_PASSPHRASE = 2;
const PHASE_PASSPHRASE_CONFIRM = 3;
const PHASE_SHOWKEY = 4;
const PHASE_KEEPITSAFE = 5;
const PHASE_STORING = 6;
const PHASE_DONE = 7;
const PHASE_CONFIRM_SKIP = 8;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
// XXX: copied from ShareDialog: factor out into utils
function selectText(target) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/*
* Walks the user through the process of creating a passphrase to guard Secure
* Secret Storage in account data.
*/
export default class CreateSecretStorageDialog extends React.PureComponent {
static propTypes = {
hasCancel: PropTypes.bool,
accountPassword: PropTypes.string,
force: PropTypes.bool,
};
static defaultProps = {
hasCancel: true,
force: false,
};
constructor(props) {
super(props);
this._keyInfo = null;
this._encodedRecoveryKey = null;
this._recoveryKeyNode = null;
this._setZxcvbnResultTimeout = null;
this.state = {
phase: PHASE_LOADING,
passPhrase: '',
passPhraseConfirm: '',
copied: false,
downloaded: false,
zxcvbnResult: null,
backupInfo: null,
backupSigStatus: null,
// does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload?
canUploadKeysWithPasswordOnly: null,
accountPassword: props.accountPassword || "",
accountPasswordCorrect: null,
// status of the key backup toggle switch
useKeyBackup: true,
};
this._fetchBackupInfo();
this._queryKeyUploadAuth();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
}
componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
}
async _fetchBackupInfo() {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = (
// we may not have started crypto yet, in which case we definitely don't trust the backup
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
);
const { force } = this.props;
const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE;
this.setState({
phase,
backupInfo,
backupSigStatus,
});
return {
backupInfo,
backupSigStatus,
};
}
async _queryKeyUploadAuth() {
try {
await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {});
// We should never get here: the server should always require
// UI auth to upload device signing keys. If we do, we upload
// no keys which would be a no-op.
console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
} catch (error) {
if (!error.data.flows) {
console.log("uploadDeviceSigningKeys advertised no flows!");
}
const canUploadKeysWithPasswordOnly = error.data.flows.some(f => {
return f.stages.length === 1 && f.stages[0] === 'm.login.password';
});
this.setState({
canUploadKeysWithPasswordOnly,
});
}
}
_onKeyBackupStatusChange = () => {
if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo();
}
_collectRecoveryKeyNode = (n) => {
this._recoveryKeyNode = n;
}
_onUseKeyBackupChange = (enabled) => {
this.setState({
useKeyBackup: enabled,
});
}
_onMigrateFormSubmit = (e) => {
e.preventDefault();
if (this.state.backupSigStatus.usable) {
this._bootstrapSecretStorage();
} else {
this._restoreBackup();
}
}
_onCopyClick = () => {
selectText(this._recoveryKeyNode);
const successful = document.execCommand('copy');
if (successful) {
this.setState({
copied: true,
phase: PHASE_KEEPITSAFE,
});
}
}
_onDownloadClick = () => {
const blob = new Blob([this._encodedRecoveryKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
this.setState({
downloaded: true,
phase: PHASE_KEEPITSAFE,
});
}
_doBootstrapUIAuth = async (makeRequest) => {
if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) {
await makeRequest({
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: MatrixClientPeg.get().getUserId(),
},
// https://github.com/matrix-org/synapse/issues/5665
user: MatrixClientPeg.get().getUserId(),
password: this.state.accountPassword,
});
} else {
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const { finished } = Modal.createTrackedDialog(
'Cross-signing keys dialog', '', InteractiveAuthDialog,
{
title: _t("Setting up keys"),
matrixClient: MatrixClientPeg.get(),
makeRequest,
},
);
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
}
_bootstrapSecretStorage = async () => {
this.setState({
phase: PHASE_STORING,
error: null,
});
const cli = MatrixClientPeg.get();
const { force } = this.props;
try {
if (force) {
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._keyInfo,
setupNewKeyBackup: true,
setupNewSecretStorage: true,
});
} else {
await cli.bootstrapSecretStorage({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth,
createSecretStorageKey: async () => this._keyInfo,
keyBackupInfo: this.state.backupInfo,
setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup,
});
}
this.setState({
phase: PHASE_DONE,
});
} catch (e) {
if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) {
this.setState({
accountPassword: '',
accountPasswordCorrect: false,
phase: PHASE_MIGRATE,
});
} else {
this.setState({ error: e });
}
console.error("Error bootstrapping secret storage", e);
}
}
_onCancel = () => {
this.props.onFinished(false);
}
_onDone = () => {
this.props.onFinished(true);
}
_restoreBackup = async () => {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
const { finished } = Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, {showSummary: false}, null,
/* priority = */ false, /* static = */ false,
);
await finished;
const { backupSigStatus } = await this._fetchBackupInfo();
if (
backupSigStatus.usable &&
this.state.canUploadKeysWithPasswordOnly &&
this.state.accountPassword
) {
this._bootstrapSecretStorage();
}
}
_onSkipSetupClick = () => {
this.setState({phase: PHASE_CONFIRM_SKIP});
}
_onSetUpClick = () => {
this.setState({phase: PHASE_PASSPHRASE});
}
_onSkipPassPhraseClick = async () => {
const [keyInfo, encodedRecoveryKey] =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase();
this._keyInfo = keyInfo;
this._encodedRecoveryKey = encodedRecoveryKey;
this.setState({
copied: false,
downloaded: false,
phase: PHASE_SHOWKEY,
});
}
_onPassPhraseNextClick = async (e) => {
e.preventDefault();
// If we're waiting for the timeout before updating the result at this point,
// skip ahead and do it now, otherwise we'll deny the attempt to proceed
// even if the user entered a valid passphrase
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
if (this._passPhraseIsValid()) {
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
}
};
_onPassPhraseConfirmNextClick = async (e) => {
e.preventDefault();
if (this.state.passPhrase !== this.state.passPhraseConfirm) return;
const [keyInfo, encodedRecoveryKey] =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase);
this._keyInfo = keyInfo;
this._encodedRecoveryKey = encodedRecoveryKey;
this.setState({
copied: false,
downloaded: false,
phase: PHASE_SHOWKEY,
});
}
_onSetAgainClick = () => {
this.setState({
passPhrase: '',
passPhraseConfirm: '',
phase: PHASE_PASSPHRASE,
zxcvbnResult: null,
});
}
_onKeepItSafeBackClick = () => {
this.setState({
phase: PHASE_SHOWKEY,
});
}
_onPassPhraseChange = (e) => {
this.setState({
passPhrase: e.target.value,
});
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
this._setZxcvbnResultTimeout = setTimeout(() => {
this._setZxcvbnResultTimeout = null;
this.setState({
// precompute this and keep it in state: zxcvbn is fast but
// we use it in a couple of different places so no point recomputing
// it unnecessarily.
zxcvbnResult: scorePassword(this.state.passPhrase),
});
}, PASSPHRASE_FEEDBACK_DELAY);
}
_onPassPhraseConfirmChange = (e) => {
this.setState({
passPhraseConfirm: e.target.value,
});
}
_passPhraseIsValid() {
return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE;
}
_onAccountPasswordChange = (e) => {
this.setState({
accountPassword: e.target.value,
});
}
_renderPhaseMigrate() {
// TODO: This is a temporary screen so people who have the labs flag turned on and
// click the button are aware they're making a change to their account.
// Once we're confident enough in this (and it's supported enough) we can do
// it automatically.
// https://github.com/vector-im/riot-web/issues/11696
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
let authPrompt;
let nextCaption = _t("Next");
if (this.state.canUploadKeysWithPasswordOnly) {
authPrompt = <div>
<div>{_t("Enter your account password to confirm the upgrade:")}</div>
<div><Field
type="password"
id="mx_CreateSecretStorage_accountPassword"
label={_t("Password")}
value={this.state.accountPassword}
onChange={this._onAccountPasswordChange}
flagInvalid={this.state.accountPasswordCorrect === false}
autoFocus={true}
/></div>
</div>;
} else if (!this.state.backupSigStatus.usable) {
authPrompt = <div>
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
</div>;
nextCaption = _t("Restore");
} else {
authPrompt = <p>
{_t("You'll need to authenticate with the server to confirm the upgrade.")}
</p>;
}
return <form onSubmit={this._onMigrateFormSubmit}>
<p>{_t(
"Upgrade this session to allow it to verify other sessions, " +
"granting them access to encrypted messages and marking them " +
"as trusted for other users.",
)}</p>
<div>{authPrompt}</div>
<DialogButtons
primaryButton={nextCaption}
onPrimaryButtonClick={this._onMigrateFormSubmit}
hasCancel={false}
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
>
<button type="button" className="danger" onClick={this._onSkipSetupClick}>
{_t('Skip')}
</button>
</DialogButtons>
</form>;
}
_renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
let strengthMeter;
let helpText;
if (this.state.zxcvbnResult) {
if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) {
helpText = _t("Great! This passphrase looks strong enough.");
} else {
// We take the warning from zxcvbn or failing that, the first
// suggestion. In practice The first is generally the most relevant
// and it's probably better to present the user with one thing to
// improve about their password than a whole collection - it can
// spit out a warning and multiple suggestions which starts getting
// very information-dense.
const suggestion = (
this.state.zxcvbnResult.feedback.warning ||
this.state.zxcvbnResult.feedback.suggestions[0]
);
const suggestionBlock = <div>{suggestion || _t("Keep going...")}</div>;
helpText = <div>
{suggestionBlock}
</div>;
}
strengthMeter = <div>
<progress max={PASSWORD_MIN_SCORE} value={this.state.zxcvbnResult.score} />
</div>;
}
return <form onSubmit={this._onPassPhraseNextClick}>
<p>{_t(
"Set up encryption on this session to allow it to verify other sessions, " +
"granting them access to encrypted messages and marking them as trusted for other users.",
)}</p>
<p>{_t(
"Secure your encryption keys with a passphrase. For maximum security " +
"this should be different to your account password:",
)}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field
type="password"
id="mx_CreateSecretStorageDialog_passPhraseField"
className="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseChange}
value={this.state.passPhrase}
label={_t("Enter a passphrase")}
autoFocus={true}
autoComplete="new-password"
/>
<div className="mx_CreateSecretStorageDialog_passPhraseHelp">
{strengthMeter}
{helpText}
</div>
</div>
<LabelledToggleSwitch
label={ _t("Back up my encryption keys, securing them with the same passphrase")}
onChange={this._onUseKeyBackupChange} value={this.state.useKeyBackup}
/>
<DialogButtons
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNextClick}
hasCancel={false}
disabled={!this._passPhraseIsValid()}
>
<button type="button"
onClick={this._onSkipSetupClick}
className="danger"
>{_t("Skip")}</button>
</DialogButtons>
<details>
<summary>{_t("Advanced")}</summary>
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a recovery key")}
</AccessibleButton>
</details>
</form>;
}
_renderPhasePassPhraseConfirm() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Field = sdk.getComponent('views.elements.Field');
let matchText;
if (this.state.passPhraseConfirm === this.state.passPhrase) {
matchText = _t("That matches!");
} else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) {
// only tell them they're wrong if they've actually gone wrong.
// Security concious readers will note that if you left riot-web unattended
// on this screen, this would make it easy for a malicious person to guess
// your passphrase one letter at a time, but they could get this faster by
// just opening the browser's developer tools and reading it.
// Note that not having typed anything at all will not hit this clause and
// fall through so empty box === no hint.
matchText = _t("That doesn't match.");
}
let passPhraseMatch = null;
if (matchText) {
passPhraseMatch = <div>
<div>{matchText}</div>
<div>
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}>
{_t("Go back to set it again.")}
</AccessibleButton>
</div>
</div>;
}
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{_t(
"Enter your passphrase a second time to confirm it.",
)}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field
type="password"
id="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm}
className="mx_CreateSecretStorageDialog_passPhraseField"
label={_t("Confirm your passphrase")}
autoFocus={true}
autoComplete="new-password"
/>
<div className="mx_CreateSecretStorageDialog_passPhraseMatch">
{passPhraseMatch}
</div>
</div>
<DialogButtons
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseConfirmNextClick}
hasCancel={false}
disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
>
<button type="button"
onClick={this._onSkipSetupClick}
className="danger"
>{_t("Skip")}</button>
</DialogButtons>
</form>;
}
_renderPhaseShowKey() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div>
<p>{_t(
"Your recovery key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your passphrase.",
)}</p>
<p>{_t(
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
)}</p>
<div className="mx_CreateSecretStorageDialog_primaryContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKeyHeader">
{_t("Your recovery key")}
</div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKey">
<code ref={this._collectRecoveryKeyNode}>{this._encodedRecoveryKey}</code>
</div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
<AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onCopyClick}>
{_t("Copy")}
</AccessibleButton>
<AccessibleButton kind='primary' className="mx_Dialog_primary" onClick={this._onDownloadClick}>
{_t("Download")}
</AccessibleButton>
</div>
</div>
</div>
</div>;
}
_renderPhaseKeepItSafe() {
let introText;
if (this.state.copied) {
introText = _t(
"Your recovery key has been <b>copied to your clipboard</b>, paste it to:",
{}, {b: s => <b>{s}</b>},
);
} else if (this.state.downloaded) {
introText = _t(
"Your recovery key is in your <b>Downloads</b> folder.",
{}, {b: s => <b>{s}</b>},
);
}
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
{introText}
<ul>
<li>{_t("<b>Print it</b> and store it somewhere safe", {}, {b: s => <b>{s}</b>})}</li>
<li>{_t("<b>Save it</b> on a USB key or backup drive", {}, {b: s => <b>{s}</b>})}</li>
<li>{_t("<b>Copy it</b> to your personal cloud storage", {}, {b: s => <b>{s}</b>})}</li>
</ul>
<DialogButtons primaryButton={_t("Continue")}
onPrimaryButtonClick={this._bootstrapSecretStorage}
hasCancel={false}>
<button onClick={this._onKeepItSafeBackClick}>{_t("Back")}</button>
</DialogButtons>
</div>;
}
_renderBusyPhase() {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <div>
<Spinner />
</div>;
}
_renderPhaseDone() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
<p>{_t(
"You can now verify your other devices, " +
"and other users to keep your chats safe.",
)}</p>
<DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this._onDone}
hasCancel={false}
/>
</div>;
}
_renderPhaseSkipConfirm() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
)}
<DialogButtons primaryButton={_t('Go back')}
onPrimaryButtonClick={this._onSetUpClick}
hasCancel={false}
>
<button type="button" className="danger" onClick={this._onCancel}>{_t('Skip')}</button>
</DialogButtons>
</div>;
}
_titleForPhase(phase) {
switch (phase) {
case PHASE_MIGRATE:
return _t('Upgrade your encryption');
case PHASE_PASSPHRASE:
return _t('Set up encryption');
case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm passphrase');
case PHASE_CONFIRM_SKIP:
return _t('Are you sure?');
case PHASE_SHOWKEY:
case PHASE_KEEPITSAFE:
return _t('Make a copy of your recovery key');
case PHASE_STORING:
return _t('Setting up keys');
case PHASE_DONE:
return _t("You're done!");
default:
return '';
}
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let content;
if (this.state.error) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
content = <div>
<p>{_t("Unable to set up secret storage")}</p>
<div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._bootstrapSecretStorage}
hasCancel={true}
onCancel={this._onCancel}
/>
</div>
</div>;
} else {
switch (this.state.phase) {
case PHASE_LOADING:
content = this._renderBusyPhase();
break;
case PHASE_MIGRATE:
content = this._renderPhaseMigrate();
break;
case PHASE_PASSPHRASE:
content = this._renderPhasePassPhrase();
break;
case PHASE_PASSPHRASE_CONFIRM:
content = this._renderPhasePassPhraseConfirm();
break;
case PHASE_SHOWKEY:
content = this._renderPhaseShowKey();
break;
case PHASE_KEEPITSAFE:
content = this._renderPhaseKeepItSafe();
break;
case PHASE_STORING:
content = this._renderBusyPhase();
break;
case PHASE_DONE:
content = this._renderPhaseDone();
break;
case PHASE_CONFIRM_SKIP:
content = this._renderPhaseSkipConfirm();
break;
}
}
let headerImage;
if (this._titleForPhase(this.state.phase)) {
headerImage = require("../../../../../res/img/e2e/normal.svg");
}
return (
<BaseDialog className='mx_CreateSecretStorageDialog'
onFinished={this.props.onFinished}
title={this._titleForPhase(this.state.phase)}
headerImage={headerImage}
hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)}
fixedWidth={false}
>
<div>
{content}
</div>
</BaseDialog>
);
}
}

View file

@ -26,7 +26,7 @@ import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import Promise from 'bluebird';
import {timeout} from "../utils/promise";
export type SelectionRange = {
beginning: boolean, // whether the selection is in the first block of the editor or not
@ -77,23 +77,16 @@ export default class Autocompleter {
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
*/
const completionsList = await Promise.all(
// Array of inspections of promises that might timeout. Instead of allowing a
// single timeout to reject the Promise.all, reflect each one and once they've all
// settled, filter for the fulfilled ones
this.providers.map(provider =>
provider
.getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT)
.reflect(),
),
);
const completionsList = await Promise.all(this.providers.map(provider => {
return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT);
}));
// map then filter to maintain the index for the map-operation, for this.providers to line up
return completionsList.map((completions, i) => {
if (!completions || !completions.length) return;
return completionsList.filter(
(inspection) => inspection.isFulfilled(),
).map((completionsState, i) => {
return {
completions: completionsState.value(),
completions,
provider: this.providers[i],
/* the currently matched "command" the completer tried to complete
@ -102,6 +95,6 @@ export default class Autocompleter {
*/
command: this.providers[i].getCurrentCommand(query, selection, force),
};
});
}).filter(Boolean);
}
}

View file

@ -78,8 +78,10 @@ export default class CommandProvider extends AutocompleteProvider {
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{ completions }
</div>;
return (
<div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}>
{ completions }
</div>
);
}
}

View file

@ -18,10 +18,10 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg';
import {MatrixClientPeg} from '../MatrixClientPeg';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import sdk from '../index';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
@ -46,7 +46,7 @@ export default class CommunityProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
// Disable autocompletions when composing commands because of various issues

View file

@ -34,7 +34,7 @@ export class TextualCompletion extends React.Component {
...restProps
} = this.props;
return (
<div className={classNames('mx_Autocomplete_Completion_block', className)} {...restProps}>
<div className={classNames('mx_Autocomplete_Completion_block', className)} role="option" {...restProps}>
<span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>
<span className="mx_Autocomplete_Completion_description">{ description }</span>

View file

@ -19,7 +19,6 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import 'whatwg-fetch';
import {TextualCompletion} from './Components';
import type {SelectionRange} from "./Autocompleter";
@ -37,7 +36,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false) {
async getCompletions(query: string, selection: SelectionRange, force: boolean = false) {
const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];
@ -97,8 +96,14 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{ completions }
</div>;
return (
<div
className="mx_Autocomplete_Completion_container_block"
role="listbox"
aria-label={_t("DuckDuckGo Results")}
>
{ completions }
</div>
);
}
}

View file

@ -2,6 +2,7 @@
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2019 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.
@ -28,7 +29,7 @@ import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import EmojiData from '../stripped-emoji.json';
import EMOJIBASE from 'emojibase-data/en/compact.json';
const LIMIT = 20;
@ -38,19 +39,15 @@ const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', '
// XXX: it's very unclear why we bother with this generated emojidata file.
// all it means is that we end up bloating the bundle with precomputed stuff
// which would be trivial to calculate and cache on demand.
const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort(
(a, b) => {
if (a.category === b.category) {
return a.emoji_order - b.emoji_order;
}
return a.category - b.category;
},
).map((a, index) => {
const EMOJI_SHORTNAMES = EMOJIBASE.sort((a, b) => {
if (a.group === b.group) {
return a.order - b.order;
}
return a.group - b.group;
}).map((emoji, index) => {
return {
name: a.name,
shortname: a.shortname,
aliases: a.aliases ? a.aliases.join(' ') : '',
aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '',
emoji,
shortname: `:${emoji.shortcodes[0]}:`,
// Include the index so that we can preserve the original order
_orderBy: index,
};
@ -69,12 +66,15 @@ export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, {
keys: ['aliases_ascii', 'shortname', 'aliases'],
keys: ['emoji.emoticon', 'shortname'],
funcs: [
(o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases
],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
});
this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, {
keys: ['name'],
keys: ['emoji.annotation'],
// For removing punctuation
shouldMatchWordsOnly: true,
});
@ -96,7 +96,7 @@ export default class EmojiProvider extends AutocompleteProvider {
const sorters = [];
// make sure that emoticons come first
sorters.push((c) => score(matchedString, c.aliases_ascii));
sorters.push((c) => score(matchedString, c.emoji.emoticon || ""));
// then sort by score (Infinity if matchedString not in shortname)
sorters.push((c) => score(matchedString, c.shortname));
@ -110,8 +110,7 @@ export default class EmojiProvider extends AutocompleteProvider {
sorters.push((c) => c._orderBy);
completions = _sortBy(_uniq(completions), sorters);
completions = completions.map((result) => {
const { shortname } = result;
completions = completions.map(({shortname}) => {
const unicode = shortcodeToUnicode(shortname);
return {
completion: unicode,

View file

@ -17,9 +17,9 @@ limitations under the License.
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler';
import MatrixClientPeg from '../MatrixClientPeg';
import {MatrixClientPeg} from '../MatrixClientPeg';
import {PillCompletion} from './Components';
import sdk from '../index';
import * as sdk from '../index';
import type {Completion, SelectionRange} from "./Autocompleter";
const AT_ROOM_REGEX = /@\S*/g;
@ -30,7 +30,7 @@ export default class NotifProvider extends AutocompleteProvider {
this.room = room;
}
async getCompletions(query: string, selection: SelectionRange, force?:boolean = false): Array<Completion> {
async getCompletions(query: string, selection: SelectionRange, force:boolean = false): Array<Completion> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();

View file

@ -1,92 +0,0 @@
/*
Copyright 2018 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.
*/
// Based originally on slate-plain-serializer
import { Block } from 'slate';
/**
* Plain text serializer, which converts a Slate `value` to a plain text string,
* serializing pills into various different formats as required.
*
* @type {PlainWithPillsSerializer}
*/
class PlainWithPillsSerializer {
/*
* @param {String} options.pillFormat - either 'md', 'plain', 'id'
*/
constructor(options = {}) {
const {
pillFormat = 'plain',
} = options;
this.pillFormat = pillFormat;
}
/**
* Serialize a Slate `value` to a plain text string,
* serializing pills as either MD links, plain text representations or
* ID representations as required.
*
* @param {Value} value
* @return {String}
*/
serialize = value => {
return this._serializeNode(value.document);
}
/**
* Serialize a `node` to plain text.
*
* @param {Node} node
* @return {String}
*/
_serializeNode = node => {
if (
node.object == 'document' ||
(node.object == 'block' && Block.isBlockList(node.nodes))
) {
return node.nodes.map(this._serializeNode).join('\n');
} else if (node.type == 'emoji') {
return node.data.get('emojiUnicode');
} else if (node.type == 'pill') {
const completion = node.data.get('completion');
// over the wire the @room pill is just plaintext
if (completion === '@room') return completion;
switch (this.pillFormat) {
case 'plain':
return completion;
case 'md':
return `[${ completion }](${ node.data.get('href') })`;
case 'id':
return node.data.get('completionId') || completion;
}
} else if (node.nodes) {
return node.nodes.map(this._serializeNode).join('');
} else {
return node.text;
}
}
}
/**
* Export.
*
* @type {PlainWithPillsSerializer}
*/
export default PlainWithPillsSerializer;

View file

@ -71,6 +71,7 @@ export default class QueryMatcher {
}
for (const keyValue of keyValues) {
if (!keyValue) continue; // skip falsy keyValues
const key = stripDiacritics(keyValue).toLowerCase();
if (!this._items.has(key)) {
this._items.set(key, []);

View file

@ -20,11 +20,10 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg';
import {MatrixClientPeg} from '../MatrixClientPeg';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
@ -40,15 +39,23 @@ function score(query, space) {
}
}
function matcherObject(room, displayedAlias, matchName = "") {
return {
room,
matchName,
displayedAlias,
};
}
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX);
this.matcher = new QueryMatcher([], {
keys: ['displayedAlias', 'name'],
keys: ['displayedAlias', 'matchName'],
});
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();
@ -56,16 +63,16 @@ export default class RoomProvider extends AutocompleteProvider {
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
let matcherObjects = client.getRooms().filter(
(room) => !!room && !!getDisplayAliasForRoom(room),
).map((room) => {
return {
room: room,
name: room.name,
displayedAlias: getDisplayAliasForRoom(room),
};
});
let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => {
if (room.getCanonicalAlias()) {
aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name));
}
if (room.getAltAliases().length) {
const altAliases = room.getAltAliases().map(alias => matcherObject(room, alias));
aliases = aliases.concat(altAliases);
}
return aliases;
}, []);
// Filter out any matches where the user will have also autocompleted new rooms
matcherObjects = matcherObjects.filter((r) => {
const tombstone = r.room.currentState.getStateEvents("m.room.tombstone", "");
@ -84,16 +91,16 @@ export default class RoomProvider extends AutocompleteProvider {
completions = _sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
]).map((room) => {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
]);
completions = completions.map((room) => {
return {
completion: displayAlias,
completionId: displayAlias,
completion: room.displayedAlias,
completionId: room.room.roomId,
type: "room",
suffix: ' ',
href: makeRoomPermalink(displayAlias),
href: makeRoomPermalink(room.displayedAlias),
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.room.name} description={room.displayedAlias} />
),
range,
};

View file

@ -22,10 +22,10 @@ import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {PillCompletion} from './Components';
import sdk from '../index';
import * as sdk from '../index';
import QueryMatcher from './QueryMatcher';
import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg';
import {MatrixClientPeg} from '../MatrixClientPeg';
import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk';
import {makeUserPermalink} from "../utils/permalinks/Permalinks";
@ -91,7 +91,7 @@ export default class UserProvider extends AutocompleteProvider {
this.users = null;
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// lazy-load user list into matcher

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 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.
@ -20,7 +21,7 @@ import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
module.exports = createReactClass({
export default createReactClass({
displayName: 'CompatibilityPage',
propTypes: {
onAccept: PropTypes.func,

View file

@ -0,0 +1,488 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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, {useRef, useState} from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {Key} from "../../Keyboard";
import * as sdk from "../../index";
import AccessibleButton from "../views/elements/AccessibleButton";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body.
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
function getOrCreateContainer() {
let container = document.getElementById(ContextualMenuContainerId);
if (!container) {
container = document.createElement("div");
container.id = ContextualMenuContainerId;
document.body.appendChild(container);
}
return container;
}
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
// Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
export class ContextMenu extends React.Component {
static propTypes = {
top: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number,
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func.isRequired,
menuPaddingTop: PropTypes.number,
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
zIndex: PropTypes.number,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
hasBackground: PropTypes.bool,
// on resize callback
windowResize: PropTypes.func,
managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
};
static defaultProps = {
hasBackground: true,
managed: true,
};
constructor() {
super();
this.state = {
contextMenuElem: null,
};
// persist what had focus when we got initialized so we can return it after
this.initialFocus = document.activeElement;
}
componentWillUnmount() {
// return focus to the thing which had it before us
this.initialFocus.focus();
}
collectContextMenuRect = (element) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
let first = element.querySelector('[role^="menuitem"]');
if (!first) {
first = element.querySelector('[tab-index]');
}
if (first) {
first.focus();
}
this.setState({
contextMenuElem: element,
});
};
onContextMenu = (e) => {
if (this.props.onFinished) {
this.props.onFinished();
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
// XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst
// a context menu and its click-guard are up without completely rewriting how the context menus work.
setImmediate(() => {
const clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent(
'contextmenu', true, true, window, 0,
0, 0, x, y, false, false,
false, false, 0, null,
);
document.elementFromPoint(x, y).dispatchEvent(clickEvent);
});
}
};
_onMoveFocus = (element, up) => {
let descending = false; // are we currently descending or ascending through the DOM tree?
do {
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
} else if (sibling) {
element = sibling;
} else {
descending = false;
element = element.parentElement;
}
} else {
if (sibling) {
element = sibling;
descending = true;
} else {
element = element.parentElement;
}
}
if (element) {
if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
element = up ? element.lastElementChild : element.firstElementChild;
descending = true;
}
}
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
if (element) {
element.focus();
}
};
_onMoveFocusHomeEnd = (element, up) => {
let results = element.querySelectorAll('[role^="menuitem"]');
if (!results) {
results = element.querySelectorAll('[tab-index]');
}
if (results && results.length) {
if (up) {
results[0].focus();
} else {
results[results.length - 1].focus();
}
}
};
_onKeyDown = (ev) => {
if (!this.props.managed) {
if (ev.key === Key.ESCAPE) {
this.props.onFinished();
ev.stopPropagation();
ev.preventDefault();
}
return;
}
let handled = true;
switch (ev.key) {
case Key.TAB:
case Key.ESCAPE:
this.props.onFinished();
break;
case Key.ARROW_UP:
this._onMoveFocus(ev.target, true);
break;
case Key.ARROW_DOWN:
this._onMoveFocus(ev.target, false);
break;
case Key.HOME:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
break;
case Key.END:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
break;
default:
handled = false;
}
if (handled) {
// consume all other keys in context menu
ev.stopPropagation();
ev.preventDefault();
}
};
renderMenu(hasBackground=this.props.hasBackground) {
const position = {};
let chevronFace = null;
const props = this.props;
if (props.top) {
position.top = props.top;
} else {
position.bottom = props.bottom;
}
if (props.left) {
position.left = props.left;
chevronFace = 'left';
} else {
position.right = props.right;
chevronFace = 'right';
}
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const padding = 10;
const chevronOffset = {};
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
const hasChevron = chevronFace && chevronFace !== "none";
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else if (position.top !== undefined) {
const target = position.top;
// By default, no adjustment is made
let adjusted = target;
// If we know the dimensions of the context menu, adjust its position
// such that it does not leave the (padded) window.
if (contextMenuRect) {
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
}
position.top = adjusted;
chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted);
}
let chevron;
if (hasChevron) {
chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} />;
}
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_left': !hasChevron && position.left,
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
});
const menuStyle = {};
if (props.menuWidth) {
menuStyle.width = props.menuWidth;
}
if (props.menuHeight) {
menuStyle.height = props.menuHeight;
}
if (!isNaN(Number(props.menuPaddingTop))) {
menuStyle["paddingTop"] = props.menuPaddingTop;
}
if (!isNaN(Number(props.menuPaddingLeft))) {
menuStyle["paddingLeft"] = props.menuPaddingLeft;
}
if (!isNaN(Number(props.menuPaddingBottom))) {
menuStyle["paddingBottom"] = props.menuPaddingBottom;
}
if (!isNaN(Number(props.menuPaddingRight))) {
menuStyle["paddingRight"] = props.menuPaddingRight;
}
const wrapperStyle = {};
if (!isNaN(Number(props.zIndex))) {
menuStyle["zIndex"] = props.zIndex + 1;
wrapperStyle["zIndex"] = props.zIndex;
}
let background;
if (hasBackground) {
background = (
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={props.onFinished} onContextMenu={this.onContextMenu} />
);
}
return (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
{ chevron }
{ props.children }
</div>
{ background }
</div>
);
}
render() {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} title={label} aria-label={label} aria-haspopup={true} aria-expanded={isExpanded}>
{ children }
</AccessibleButton>
);
};
ContextMenuButton.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string.isRequired,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
};
// Semantic component for representing a role=menuitem
export const MenuItem = ({children, label, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItem.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};
MenuGroup.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string.isRequired,
className: PropTypes.string, // optional
};
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemCheckbox.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemRadio.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect, chevronOffset=12) => {
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return {left, top, chevronOffset};
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect, chevronFace="none") => {
const menuOptions = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom;
} else {
menuOptions.bottom = window.innerHeight - buttonTop;
}
return menuOptions;
};
export const useContextMenu = () => {
const button = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);
};
const close = () => {
setIsOpen(false);
};
return [isOpen, button, open, close, setIsOpen];
};
export default class LegacyContextMenu extends ContextMenu {
render() {
return this.renderMenu(false);
}
}
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
export function createMenu(ElementClass, props) {
const onFinished = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
if (props && props.onFinished) {
props.onFinished.apply(null, args);
}
};
const menu = <LegacyContextMenu
{...props}
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
>
<ElementClass {...props} onFinished={onFinished} />
</LegacyContextMenu>;
ReactDOM.render(menu, getOrCreateContainer());
return {close: onFinished};
}

View file

@ -1,253 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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 from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {focusCapturedRef} from "../../utils/Accessibility";
import {KeyCode} from "../../Keyboard";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body.
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
function getOrCreateContainer() {
let container = document.getElementById(ContextualMenuContainerId);
if (!container) {
container = document.createElement("div");
container.id = ContextualMenuContainerId;
document.body.appendChild(container);
}
return container;
}
export default class ContextualMenu extends React.Component {
propTypes: {
top: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number,
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func,
menuPaddingTop: PropTypes.number,
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
zIndex: PropTypes.number,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
hasBackground: PropTypes.bool,
// The component to render as the context menu
elementClass: PropTypes.element.isRequired,
// on resize callback
windowResize: PropTypes.func,
// method to close menu
closeMenu: PropTypes.func.isRequired,
};
constructor() {
super();
this.state = {
contextMenuRect: null,
};
this.onContextMenu = this.onContextMenu.bind(this);
this.collectContextMenuRect = this.collectContextMenuRect.bind(this);
}
collectContextMenuRect(element) {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
// For screen readers to find the thing
focusCapturedRef(element);
this.setState({
contextMenuRect: element.getBoundingClientRect(),
});
}
onContextMenu(e) {
if (this.props.closeMenu) {
this.props.closeMenu();
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
// XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst
// a context menu and its click-guard are up without completely rewriting how the context menus work.
setImmediate(() => {
const clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent(
'contextmenu', true, true, window, 0,
0, 0, x, y, false, false,
false, false, 0, null,
);
document.elementFromPoint(x, y).dispatchEvent(clickEvent);
});
}
}
_onKeyDown = (ev) => {
if (ev.keyCode === KeyCode.ESCAPE) {
ev.stopPropagation();
ev.preventDefault();
this.props.closeMenu();
}
};
render() {
const position = {};
let chevronFace = null;
const props = this.props;
if (props.top) {
position.top = props.top;
} else {
position.bottom = props.bottom;
}
if (props.left) {
position.left = props.left;
chevronFace = 'left';
} else {
position.right = props.right;
chevronFace = 'right';
}
const contextMenuRect = this.state.contextMenuRect || null;
const padding = 10;
const chevronOffset = {};
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
const hasChevron = chevronFace && chevronFace !== "none";
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else {
const target = position.top;
// By default, no adjustment is made
let adjusted = target;
// If we know the dimensions of the context menu, adjust its position
// such that it does not leave the (padded) window.
if (contextMenuRect) {
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
}
position.top = adjusted;
chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted);
}
const chevron = hasChevron ?
<div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} /> :
undefined;
const className = 'mx_ContextualMenu_wrapper';
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_left': !hasChevron && position.left,
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
});
const menuStyle = {};
if (props.menuWidth) {
menuStyle.width = props.menuWidth;
}
if (props.menuHeight) {
menuStyle.height = props.menuHeight;
}
if (!isNaN(Number(props.menuPaddingTop))) {
menuStyle["paddingTop"] = props.menuPaddingTop;
}
if (!isNaN(Number(props.menuPaddingLeft))) {
menuStyle["paddingLeft"] = props.menuPaddingLeft;
}
if (!isNaN(Number(props.menuPaddingBottom))) {
menuStyle["paddingBottom"] = props.menuPaddingBottom;
}
if (!isNaN(Number(props.menuPaddingRight))) {
menuStyle["paddingRight"] = props.menuPaddingRight;
}
const wrapperStyle = {};
if (!isNaN(Number(props.zIndex))) {
menuStyle["zIndex"] = props.zIndex + 1;
wrapperStyle["zIndex"] = props.zIndex;
}
const ElementClass = props.elementClass;
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click!
return <div className={className} style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} tabIndex={0}>
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
</div>;
}
}
export function createMenu(ElementClass, props, hasBackground=true) {
const closeMenu = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
if (props && props.onFinished) {
props.onFinished.apply(null, args);
}
};
// We only reference closeMenu once per call to createMenu
const menu = <ContextualMenu
hasBackground={hasBackground}
{...props}
elementClass={ElementClass}
closeMenu={closeMenu} // eslint-disable-line react/jsx-no-bind
windowResize={closeMenu} // eslint-disable-line react/jsx-no-bind
/>;
ReactDOM.render(menu, getOrCreateContainer());
return {close: closeMenu};
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import CustomRoomTagStore from '../../stores/CustomRoomTagStore';
import AutoHideScrollbar from './AutoHideScrollbar';
import sdk from '../../index';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import classNames from 'classnames';
import * as FormattingUtils from '../../utils/FormattingUtils';
@ -61,30 +61,13 @@ class CustomRoomTagPanel extends React.Component {
}
class CustomRoomTagTile extends React.Component {
constructor(props) {
super(props);
this.state = {hover: false};
this.onClick = this.onClick.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
this.onMouseOver = this.onMouseOver.bind(this);
}
onMouseOver() {
this.setState({hover: true});
}
onMouseOut() {
this.setState({hover: false});
}
onClick() {
onClick = () => {
dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name});
}
};
render() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Tooltip = sdk.getComponent('elements.Tooltip');
const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton');
const tag = this.props.tag;
const avatarHeight = 40;
@ -102,12 +85,9 @@ class CustomRoomTagTile extends React.Component {
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
}
const tip = (this.state.hover ?
<Tooltip className="mx_TagTile_tooltip" label={name} /> :
<div />);
return (
<AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<AccessibleTooltipButton className={className} onClick={this.onClick} title={name}>
<div className="mx_TagTile_avatar">
<BaseAvatar
name={tag.avatarLetter}
idName={name}
@ -115,9 +95,8 @@ class CustomRoomTagTile extends React.Component {
height={avatarHeight}
/>
{ badgeElement }
{ tip }
</div>
</AccessibleButton>
</AccessibleTooltipButton>
);
}
}

View file

@ -23,11 +23,11 @@ import PropTypes from 'prop-types';
import request from 'browser-request';
import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html';
import sdk from '../../index';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import MatrixClientPeg from '../../MatrixClientPeg';
import { MatrixClient } from 'matrix-js-sdk';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import classnames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
export default class EmbeddedPage extends React.PureComponent {
static propTypes = {
@ -39,9 +39,7 @@ export default class EmbeddedPage extends React.PureComponent {
scrollbar: PropTypes.bool,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
static contextType = MatrixClientContext;
constructor(props) {
super(props);
@ -104,7 +102,7 @@ export default class EmbeddedPage extends React.PureComponent {
render() {
// HACK: Workaround for the context's MatrixClient not updating.
const client = this.context.matrixClient || MatrixClientPeg.get();
const client = this.context || MatrixClientPeg.get();
const isGuest = client ? client.isGuest() : true;
const className = this.props.className;
const classes = classnames({

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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.
@ -18,9 +19,10 @@ import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
import {Filter} from 'matrix-js-sdk';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import EventIndexPeg from "../../indexing/EventIndexPeg";
import { _t } from '../../languageHandler';
/*
@ -28,6 +30,9 @@ import { _t } from '../../languageHandler';
*/
const FilePanel = createReactClass({
displayName: 'FilePanel',
// This is used to track if a decrypted event was a live event and should be
// added to the timeline.
decryptingEvents: new Set(),
propTypes: {
roomId: PropTypes.string.isRequired,
@ -39,55 +44,147 @@ const FilePanel = createReactClass({
};
},
componentWillMount: function() {
this.updateTimelineSet(this.props.roomId);
},
onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
if (room.roomId !== this.props.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
componentWillReceiveProps: function(nextProps) {
if (nextProps.roomId !== this.props.roomId) {
// otherwise we race between re-rendering the TimelinePanel and setting the new timelineSet.
//
// FIXME: this race only happens because of the promise returned by getOrCreateFilter().
// We should only need to create the containsUrl filter once per login session, so in practice
// it shouldn't be being done here at all. Then we could just update the timelineSet directly
// without resetting it first, and speed up room-change.
this.setState({ timelineSet: null });
this.updateTimelineSet(nextProps.roomId);
if (ev.isBeingDecrypted()) {
this.decryptingEvents.add(ev.getId());
} else {
this.addEncryptedLiveEvent(ev);
}
},
updateTimelineSet: function(roomId) {
onEventDecrypted(ev, err) {
if (ev.getRoomId() !== this.props.roomId) return;
const eventId = ev.getId();
if (!this.decryptingEvents.delete(eventId)) return;
if (err) return;
this.addEncryptedLiveEvent(ev);
},
addEncryptedLiveEvent(ev, toStartOfTimeline) {
if (!this.state.timelineSet) return;
const timeline = this.state.timelineSet.getLiveTimeline();
if (ev.getType() !== "m.room.message") return;
if (["m.file", "m.image", "m.video", "m.audio"].indexOf(ev.getContent().msgtype) == -1) {
return;
}
if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
}
},
async componentDidMount() {
const client = MatrixClientPeg.get();
await this.updateTimelineSet(this.props.roomId);
if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
// The timelineSets filter makes sure that encrypted events that contain
// URLs never get added to the timeline, even if they are live events.
// These methods are here to manually listen for such events and add
// them despite the filter's best efforts.
//
// We do this only for encrypted rooms and if an event index exists,
// this could be made more general in the future or the filter logic
// could be fixed.
if (EventIndexPeg.get() !== null) {
client.on('Room.timeline', this.onRoomTimeline);
client.on('Event.decrypted', this.onEventDecrypted);
}
},
componentWillUnmount() {
const client = MatrixClientPeg.get();
if (client === null) return;
if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
if (EventIndexPeg.get() !== null) {
client.removeListener('Room.timeline', this.onRoomTimeline);
client.removeListener('Event.decrypted', this.onEventDecrypted);
}
},
async fetchFileEventsServer(room) {
const client = MatrixClientPeg.get();
const filter = new Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true,
"types": [
"m.room.message",
],
},
},
},
);
const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter);
filter.filterId = filterId;
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
return timelineSet;
},
onPaginationRequest(timelineWindow, direction, limit) {
const client = MatrixClientPeg.get();
const eventIndex = EventIndexPeg.get();
const roomId = this.props.roomId;
const room = client.getRoom(roomId);
// We override the pagination request for encrypted rooms so that we ask
// the event index to fulfill the pagination request. Asking the server
// to paginate won't ever work since the server can't correctly filter
// out events containing URLs
if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
return eventIndex.paginateTimelineWindow(room, timelineWindow, direction, limit);
} else {
return timelineWindow.paginate(direction, limit);
}
},
async updateTimelineSet(roomId: string) {
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
const eventIndex = EventIndexPeg.get();
this.noRoom = !room;
if (room) {
const filter = new Matrix.Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true,
"types": [
"m.room.message",
],
},
},
},
);
let timelineSet;
// FIXME: we shouldn't be doing this every time we change room - see comment above.
client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then(
(filterId)=>{
filter.filterId = filterId;
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
this.setState({ timelineSet: timelineSet });
},
(error)=>{
console.error("Failed to get or create file panel filter", error);
},
);
try {
timelineSet = await this.fetchFileEventsServer(room);
// If this room is encrypted the file panel won't be populated
// correctly since the defined filter doesn't support encrypted
// events and the server can't check if encrypted events contain
// URLs.
//
// This is where our event index comes into place, we ask the
// event index to populate the timelineSet for us. This call
// will add 10 events to the live timeline of the set. More can
// be requested using pagination.
if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
const timeline = timelineSet.getLiveTimeline();
await eventIndex.populateFileTimeline(timelineSet, timeline, room, 10);
}
this.setState({ timelineSet: timelineSet });
} catch (error) {
console.error("Failed to get or create file panel filter", error);
}
} else {
console.error("Failed to add filtered timelineSet for FilePanel as no room!");
}
@ -117,17 +214,18 @@ const FilePanel = createReactClass({
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
return (
<TimelinePanel key={"filepanel_" + this.props.roomId}
className="mx_FilePanel"
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}
role="tabpanel"
/>
<div className="mx_FilePanel" role="tabpanel">
<TimelinePanel key={"filepanel_" + this.props.roomId}
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
onPaginationRequest={this.onPaginationRequest}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}
/>
</div>
);
} else {
return (
@ -139,4 +237,4 @@ const FilePanel = createReactClass({
},
});
module.exports = FilePanel;
export default FilePanel;

View file

@ -19,9 +19,8 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import { getHostingLink } from '../../utils/HostingLink';
import { sanitizedHtmlNode } from '../../HtmlUtils';
@ -38,6 +37,8 @@ import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk";
import {allSettled, sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -98,11 +99,10 @@ const CategoryRoomList = createReactClass({
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
Promise.all(addrs.map((addr) => {
allSettled(addrs.map((addr) => {
return GroupStore
.addRoomToGroupSummary(this.props.groupId, addr.address)
.catch(() => { errorList.push(addr.address); })
.reflect();
.catch(() => { errorList.push(addr.address); });
})).then(() => {
if (errorList.length === 0) {
return;
@ -275,11 +275,10 @@ const RoleUserList = createReactClass({
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
Promise.all(addrs.map((addr) => {
allSettled(addrs.map((addr) => {
return GroupStore
.addUserToGroupSummary(addr.address)
.catch(() => { errorList.push(addr.address); })
.reflect();
.catch(() => { errorList.push(addr.address); });
})).then(() => {
if (errorList.length === 0) {
return;
@ -482,7 +481,7 @@ export default createReactClass({
group_id: groupId,
},
});
dis.dispatch({action: 'require_registration'});
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}});
willDoOnboarding = true;
}
if (stateKey === GroupStore.STATE_KEY.Summary) {
@ -544,10 +543,6 @@ export default createReactClass({
});
},
_onShowRhsClick: function(ev) {
dis.dispatch({ action: 'show_right_panel' });
},
_onEditClick: function() {
this.setState({
editing: true,
@ -585,6 +580,10 @@ export default createReactClass({
profileForm: null,
});
break;
case 'after_right_panel_phase_change':
// We don't keep state on the right panel, so just re-render to update
this.forceUpdate();
break;
default:
break;
}
@ -638,7 +637,7 @@ export default createReactClass({
title: _t('Error'),
description: _t('Failed to upload image'),
});
}).done();
});
},
_onJoinableChange: function(ev) {
@ -677,7 +676,7 @@ export default createReactClass({
this.setState({
avatarChanged: false,
});
}).done();
});
},
_saveGroup: async function() {
@ -692,7 +691,7 @@ export default createReactClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.acceptGroupInvite(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -711,7 +710,7 @@ export default createReactClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -727,7 +726,7 @@ export default createReactClass({
_onJoinClick: async function() {
if (this._matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
return;
}
@ -735,7 +734,7 @@ export default createReactClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.joinGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -787,7 +786,7 @@ export default createReactClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -822,10 +821,10 @@ export default createReactClass({
{_t(
"Want more than a community? <a>Get your own server</a>", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener">{sub}</a>,
a: sub => <a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener">
<a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">
<img src={require("../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;
@ -1216,25 +1215,25 @@ export default createReactClass({
const EditableText = sdk.getComponent("elements.EditableText");
nameNode = <EditableText ref="nameEditor"
className="mx_GroupView_editable"
placeholderClassName="mx_GroupView_placeholder"
placeholder={_t('Community Name')}
blurToCancel={false}
initialValue={this.state.profileForm.name}
onValueChanged={this._onNameChange}
tabIndex="0"
dir="auto" />;
nameNode = <EditableText
className="mx_GroupView_editable"
placeholderClassName="mx_GroupView_placeholder"
placeholder={_t('Community Name')}
blurToCancel={false}
initialValue={this.state.profileForm.name}
onValueChanged={this._onNameChange}
tabIndex="0"
dir="auto" />;
shortDescNode = <EditableText ref="descriptionEditor"
className="mx_GroupView_editable"
placeholderClassName="mx_GroupView_placeholder"
placeholder={_t("Description")}
blurToCancel={false}
initialValue={this.state.profileForm.short_description}
onValueChanged={this._onShortDescChange}
tabIndex="0"
dir="auto" />;
shortDescNode = <EditableText
className="mx_GroupView_editable"
placeholderClassName="mx_GroupView_placeholder"
placeholder={_t("Description")}
blurToCancel={false}
initialValue={this.state.profileForm.short_description}
onValueChanged={this._onShortDescChange}
tabIndex="0"
dir="auto" />;
} else {
const onGroupHeaderItemClick = this.state.isUserMember ? this._onEditClick : null;
const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null;
@ -1300,7 +1299,9 @@ export default createReactClass({
);
}
const rightPanel = !this.props.collapsedRhs ? <RightPanel groupId={this.props.groupId} /> : undefined;
const rightPanel = RightPanelStore.getSharedInstance().isOpenForGroup
? <RightPanel groupId={this.props.groupId} />
: undefined;
const headerClasses = {
"mx_GroupView_header": true,
@ -1328,9 +1329,9 @@ export default createReactClass({
<div className="mx_GroupView_header_rightCol">
{ rightButtons }
</div>
<GroupHeaderButtons collapsedRhs={this.props.collapsedRhs} />
<GroupHeaderButtons />
</div>
<MainSplit collapsedRhs={this.props.collapsedRhs} panel={rightPanel}>
<MainSplit panel={rightPanel}>
<GeminiScrollbarWrapper className="mx_GroupView_body">
{ this._getMembershipSection() }
{ this._getGroupSection() }

View file

@ -15,16 +15,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
const InteractiveAuth = Matrix.InteractiveAuth;
import React from 'react';
import {InteractiveAuth} from "matrix-js-sdk";
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents';
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
import sdk from '../../index';
import * as sdk from '../../index';
export default createReactClass({
displayName: 'InteractiveAuth',
@ -121,7 +119,7 @@ export default createReactClass({
this.setState({
errorText: msg,
});
}).done();
});
this._intervalId = null;
if (this.props.poll) {
@ -129,6 +127,8 @@ export default createReactClass({
this._authLogic.poll();
}, 2000);
}
this._stageComponent = createRef();
},
componentWillUnmount: function() {
@ -153,14 +153,15 @@ export default createReactClass({
},
tryContinue: function() {
if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) {
this.refs.stageComponent.tryContinue();
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
this._stageComponent.current.tryContinue();
}
},
_authStateUpdated: function(stageType, stageState) {
const oldStage = this.state.authStage;
this.setState({
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
@ -184,16 +185,18 @@ export default createReactClass({
errorText: null,
stageErrorText: null,
});
} else {
this.setState({
busy: false,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any
// immediate work has completed, but that's not really what we want at
// the UI layer, so we ignore this signal and show a spinner until
// there's a new screen to show the user. This is implemented by setting
// `busy: false` in `_authStateUpdated`.
// See also https://github.com/vector-im/riot-web/issues/12546
},
_setFocus: function() {
if (this.refs.stageComponent && this.refs.stageComponent.focus) {
this.refs.stageComponent.focus();
if (this._stageComponent.current && this._stageComponent.current.focus) {
this._stageComponent.current.focus();
}
},
@ -214,7 +217,8 @@ export default createReactClass({
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent ref="stageComponent"
<StageComponent
ref={this._stageComponent}
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this._authLogic.getSessionId()}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
@ -18,12 +19,10 @@ import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { MatrixClient } from 'matrix-js-sdk';
import { KeyCode } from '../../Keyboard';
import sdk from '../../index';
import { Key } from '../../Keyboard';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import VectorConferenceHandler from '../../VectorConferenceHandler';
import TagPanelButtons from './TagPanelButtons';
import * as VectorConferenceHandler from '../../VectorConferenceHandler';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
import Analytics from "../../Analytics";
@ -38,10 +37,6 @@ const LeftPanel = createReactClass({
collapsed: PropTypes.bool.isRequired,
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getInitialState: function() {
return {
searchFilter: '',
@ -115,37 +110,35 @@ const LeftPanel = createReactClass({
this.focusedElement = null;
},
_onKeyDown: function(ev) {
_onFilterKeyDown: function(ev) {
if (!this.focusedElement) return;
let handled = true;
switch (ev.keyCode) {
case KeyCode.TAB:
this._onMoveFocus(ev.shiftKey);
break;
case KeyCode.UP:
this._onMoveFocus(true);
break;
case KeyCode.DOWN:
this._onMoveFocus(false);
break;
case KeyCode.ENTER:
this._onMoveFocus(false);
if (this.focusedElement) {
this.focusedElement.click();
switch (ev.key) {
// On enter of rooms filter select and activate first room if such one exists
case Key.ENTER: {
const firstRoom = ev.target.closest(".mx_LeftPanel").querySelector(".mx_RoomTile");
if (firstRoom) {
firstRoom.click();
}
break;
default:
handled = false;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
}
},
_onMoveFocus: function(up) {
_onKeyDown: function(ev) {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.ARROW_UP:
this._onMoveFocus(ev, true, true);
break;
case Key.ARROW_DOWN:
this._onMoveFocus(ev, false, true);
break;
}
},
_onMoveFocus: function(ev, up, trap) {
let element = this.focusedElement;
// unclear why this isn't needed
@ -179,28 +172,24 @@ const LeftPanel = createReactClass({
if (element) {
classes = element.classList;
if (classes.contains("mx_LeftPanel")) { // we hit the top
element = up ? element.lastElementChild : element.firstElementChild;
descending = true;
}
}
} while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_textinput_search")));
classes.contains("mx_RoomSubList_label") ||
classes.contains("mx_LeftPanel_filterRooms")));
if (element) {
ev.stopPropagation();
ev.preventDefault();
element.focus();
this.focusedElement = element;
this.focusedDescending = descending;
} else if (trap) {
// if navigation is via up/down arrow-keys, trap in the widget so it doesn't send to composer
ev.stopPropagation();
ev.preventDefault();
}
},
onHideClick: function() {
dis.dispatch({
action: 'hide_left_panel',
});
},
onSearch: function(term) {
this.setState({ searchFilter: term });
},
@ -245,7 +234,6 @@ const LeftPanel = createReactClass({
tagPanelContainer = (<div className="mx_LeftPanel_tagPanelContainer">
<TagPanel />
{ isCustomTagsEnabled ? <CustomRoomTagPanel /> : undefined }
<TagPanelButtons />
</div>);
}
@ -268,9 +256,11 @@ const LeftPanel = createReactClass({
}
const searchBox = (<SearchBox
className="mx_LeftPanel_filterRooms"
enableRoomSearchFocus={true}
blurredPlaceholder={ _t('Filter') }
placeholder={ _t('Filter rooms…') }
onKeyDown={this._onFilterKeyDown}
onSearch={ this.onSearch }
onCleared={ this.onSearchCleared }
onFocus={this._onSearchFocus}
@ -285,15 +275,18 @@ const LeftPanel = createReactClass({
return (
<div className={containerClasses}>
{ tagPanelContainer }
<aside className={"mx_LeftPanel dark-panel"} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
<TopLeftMenuButton collapsed={ this.props.collapsed } />
<aside className="mx_LeftPanel dark-panel">
<TopLeftMenuButton collapsed={this.props.collapsed} />
{ breadcrumbs }
<div className="mx_LeftPanel_exploreAndFilterRow">
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<div className="mx_LeftPanel_exploreAndFilterRow" onKeyDown={this._onKeyDown} onFocus={this._onFocus} onBlur={this._onBlur}>
{ exploreButton }
{ searchBox }
</div>
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<RoomList
onKeyDown={this._onKeyDown}
onFocus={this._onFocus}
onBlur={this._onBlur}
ref={this.collectRoomList}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
@ -305,4 +298,4 @@ const LeftPanel = createReactClass({
},
});
module.exports = LeftPanel;
export default LeftPanel;

View file

@ -17,7 +17,7 @@ limitations under the License.
*/
import { MatrixClient } from 'matrix-js-sdk';
import React from 'react';
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { DragDropContext } from 'react-beautiful-dnd';
@ -26,10 +26,10 @@ import { Key, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import sdk from '../../index';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import { getHomePageUrl } from '../../utils/pages';
@ -38,6 +38,7 @@ import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general.
@ -70,7 +71,6 @@ const LoggedInView = createReactClass({
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: PropTypes.func,
collapsedRhs: PropTypes.bool,
// Used by the RoomView to handle joining rooms
viaServers: PropTypes.arrayOf(PropTypes.string),
@ -78,21 +78,6 @@ const LoggedInView = createReactClass({
// and lots and lots of other stuff.
},
childContextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
authCache: PropTypes.object,
},
getChildContext: function() {
return {
matrixClient: this._matrixClient,
authCache: {
auth: {},
lastUpdate: 0,
},
};
},
getInitialState: function() {
return {
// use compact timeline view
@ -129,6 +114,8 @@ const LoggedInView = createReactClass({
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
fixupColorFonts();
this._roomView = createRef();
},
componentDidUpdate(prevProps) {
@ -165,10 +152,10 @@ const LoggedInView = createReactClass({
},
canResetTimelineInRoom: function(roomId) {
if (!this.refs.roomView) {
if (!this._roomView.current) {
return true;
}
return this.refs.roomView.canResetTimeline();
return this._roomView.current.canResetTimeline();
},
_setStateFromSessionStore() {
@ -401,10 +388,8 @@ const LoggedInView = createReactClass({
const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER);
// XXX: Remove after CIDER replaces Slate completely: https://github.com/vector-im/riot-web/issues/11036
// If using Slate, consume the Backspace without first focusing as it causes an implosion
if (ev.key === Key.BACKSPACE && !SettingsStore.getValue("useCiderComposer")) {
ev.stopPropagation();
// Do not capture the context menu key to improve keyboard accessibility
if (ev.key === Key.CONTEXT_MENU) {
return;
}
@ -423,8 +408,8 @@ const LoggedInView = createReactClass({
* @param {Object} ev The key event
*/
_onScrollKeyPressed: function(ev) {
if (this.refs.roomView) {
this.refs.roomView.handleScrollKey(ev);
if (this._roomView.current) {
this._roomView.current.handleScrollKey(ev);
}
},
@ -525,6 +510,7 @@ const LoggedInView = createReactClass({
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const ToastContainer = sdk.getComponent('structures.ToastContainer');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const CookieBar = sdk.getComponent('globals.CookieBar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
@ -537,7 +523,7 @@ const LoggedInView = createReactClass({
switch (this.props.page_type) {
case PageTypes.RoomView:
pageElement = <RoomView
ref='roomView'
ref={this._roomView}
autoJoin={this.props.autoJoin}
onRegistered={this.props.onRegistered}
thirdPartyInvite={this.props.thirdPartyInvite}
@ -546,7 +532,6 @@ const LoggedInView = createReactClass({
eventPixelOffset={this.props.initialEventPixelOffset}
key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler}
resizeNotifier={this.props.resizeNotifier}
/>;
@ -577,7 +562,6 @@ const LoggedInView = createReactClass({
pageElement = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapsedRhs}
/>;
break;
}
@ -601,7 +585,8 @@ const LoggedInView = createReactClass({
limitType={usageLimitEvent.getContent().limit_type}
/>;
} else if (this.props.showCookieBar &&
this.props.config.piwik
this.props.config.piwik &&
navigator.doNotTrack !== "1"
) {
const policyUrl = this.props.config.piwik.policyUrl || null;
topBar = <CookieBar policyUrl={policyUrl} />;
@ -626,20 +611,30 @@ const LoggedInView = createReactClass({
}
return (
<div onPaste={this._onPaste} onKeyDown={this._onReactKeyDown} className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
{ topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<ResizeHandle />
{ pageElement }
</div>
</DragDropContext>
</div>
<MatrixClientContext.Provider value={this._matrixClient}>
<div
onPaste={this._onPaste}
onKeyDown={this._onReactKeyDown}
className='mx_MatrixChat_wrapper'
aria-hidden={this.props.hideToSRUsers}
onMouseDown={this._onMouseDown}
onMouseUp={this._onMouseUp}
>
{ topBar }
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<ResizeHandle />
{ pageElement }
</div>
</DragDropContext>
</div>
</MatrixClientContext.Provider>
);
},
});

View file

@ -62,7 +62,7 @@ export default class MainSplit extends React.Component {
}
componentDidMount() {
if (this.props.panel && !this.props.collapsedRhs) {
if (this.props.panel) {
this._createResizer();
}
}
@ -75,17 +75,15 @@ export default class MainSplit extends React.Component {
}
componentDidUpdate(prevProps) {
const wasExpanded = !this.props.collapsedRhs && prevProps.collapsedRhs;
const wasCollapsed = this.props.collapsedRhs && !prevProps.collapsedRhs;
const wasPanelSet = this.props.panel && !prevProps.panel;
const wasPanelCleared = !this.props.panel && prevProps.panel;
if (this.resizeContainer && (wasExpanded || wasPanelSet)) {
if (this.resizeContainer && wasPanelSet) {
// The resizer can only be created when **both** expanded and the panel is
// set. Once both are true, the container ref will mount, which is required
// for the resizer to work.
this._createResizer();
} else if (this.resizer && (wasCollapsed || wasPanelCleared)) {
} else if (this.resizer && wasPanelCleared) {
this.resizer.detach();
this.resizer = null;
}

View file

@ -2,7 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017-2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@ -17,19 +17,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from 'bluebird';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk";
import * as Matrix from "matrix-js-sdk";
import { isCryptoAvailable } from 'matrix-js-sdk/src/crypto';
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible';
// what-input helps improve keyboard accessibility
import 'what-input';
import Analytics from "../../Analytics";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import MatrixClientPeg from "../../MatrixClientPeg";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter";
@ -38,7 +39,7 @@ import Notifier from '../../Notifier';
import Modal from "../../Modal";
import Tinter from "../../Tinter";
import sdk from '../../index';
import * as sdk from '../../index';
import { showStartChatInviteDialog, showRoomInviteDialog } from '../../RoomInvite';
import * as Rooms from '../../Rooms';
import linkifyMatrix from "../../linkify-matrix";
@ -52,6 +53,7 @@ import createRoom from "../../createRoom";
import KeyRequestHandler from '../../KeyRequestHandler';
import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
@ -59,14 +61,14 @@ import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import DMRoomMap from '../../utils/DMRoomMap';
import { countRoomsWithNotif } from '../../RoomNotifs';
import { setTheme } from "../../theme";
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
Promise.config({warnings: false});
import { ThemeWatcher } from "../../theme";
import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer } from "../../utils/promise";
import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager";
/** constants for MatrixChat.state.view */
const VIEWS = {
export const VIEWS = {
// a special initial state which is only used at startup, while we are
// trying to re-animate a matrix client or register as a guest.
LOADING: 0,
@ -80,25 +82,24 @@ const VIEWS = {
// we are showing the registration view
REGISTER: 3,
// completeing the registration flow
// completing the registration flow
POST_REGISTRATION: 4,
// showing the 'forgot password' view
FORGOT_PASSWORD: 5,
// we have valid matrix credentials (either via an explicit login, via the
// initial re-animation/guest registration, or via a registration), and are
// now setting up a matrixclient to talk to it. This isn't an instant
// process because we need to clear out indexeddb. While it is going on we
// show a big spinner.
LOGGING_IN: 6,
// showing flow to trust this new device with cross-signing
COMPLETE_SECURITY: 6,
// flow to setup SSSS / cross-signing on this account
E2E_SETUP: 7,
// we are logged in with an active matrix client.
LOGGED_IN: 7,
LOGGED_IN: 8,
// We are logged out (invalid token) but have our local state again. The user
// should log back in to rehydrate the client.
SOFT_LOGOUT: 8,
SOFT_LOGOUT: 9,
};
// Actions that are redirected through the onboarding process prior to being
@ -151,16 +152,6 @@ export default createReactClass({
makeRegistrationUrl: PropTypes.func.isRequired,
},
childContextTypes: {
appConfig: PropTypes.object,
},
getChildContext: function() {
return {
appConfig: this.props.config,
};
},
getInitialState: function() {
const s = {
// the master view we are showing.
@ -178,10 +169,9 @@ export default createReactClass({
viewUserId: null,
// this is persisted as mx_lhs_size, loaded in LoggedInView
collapseLhs: false,
collapsedRhs: window.localStorage.getItem("mx_rhs_collapsed") === "true",
leftDisabled: false,
middleDisabled: false,
rightDisabled: false,
// the right panel's disabled state is tracked in its store.
version: null,
newVersion: null,
@ -236,7 +226,7 @@ export default createReactClass({
// Used by _viewRoom before getting state from sync
this.firstSyncComplete = false;
this.firstSyncPromise = Promise.defer();
this.firstSyncPromise = defer();
if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
@ -268,10 +258,15 @@ export default createReactClass({
// logout page.
Lifecycle.loadSession({});
}
this._accountPassword = null;
this._accountPasswordTimer = null;
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
this._themeWatcher = new ThemeWatcher();
this._themeWatcher.start();
this.focusComposer = false;
@ -358,9 +353,12 @@ export default createReactClass({
componentWillUnmount: function() {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
this._themeWatcher.stop();
window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer);
},
componentWillUpdate: function(props, state) {
@ -510,6 +508,8 @@ export default createReactClass({
view: VIEWS.LOGIN,
});
this.notifyNewScreen('login');
ThemeController.isLogin = true;
this._themeWatcher.recheck();
break;
case 'start_post_registration':
this.setState({
@ -540,7 +540,7 @@ export default createReactClass({
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
MatrixClientPeg.get().leave(payload.room_id).done(() => {
MatrixClientPeg.get().leave(payload.room_id).then(() => {
modal.close();
if (this.state.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
@ -559,13 +559,19 @@ export default createReactClass({
case 'view_user_info':
this._viewUser(payload.userId, payload.subAction);
break;
case 'view_room':
case 'view_room': {
// Takes either a room ID or room alias: if switching to a room the client is already
// known to be in (eg. user clicks on a room in the recents panel), supply the ID
// If the user is clicking on a room in the context of the alias being presented
// to them, supply the room alias. If both are supplied, the room ID will be ignored.
this._viewRoom(payload);
const promise = this._viewRoom(payload);
if (payload.deferred_action) {
promise.then(() => {
dis.dispatch(payload.deferred_action);
});
}
break;
}
case 'view_prev_room':
this._viewNextRoom(-1);
break;
@ -627,6 +633,22 @@ export default createReactClass({
case 'view_invite':
showRoomInviteDialog(payload.roomId);
break;
case 'view_last_screen':
// This function does what we want, despite the name. The idea is that it shows
// the last room we were looking at or some reasonable default/guess. We don't
// have to worry about email invites or similar being re-triggered because the
// function will have cleared that state and not execute that path.
this._showScreenAfterLogin();
break;
case 'toggle_my_groups':
// We just dispatch the page change rather than have to worry about
// what the logic is for each of these branches.
if (this.state.page_type === PageTypes.MyGroups) {
dis.dispatch({action: 'view_last_screen'});
} else {
dis.dispatch({action: 'view_my_groups'});
}
break;
case 'notifier_enabled': {
this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()});
}
@ -641,39 +663,22 @@ export default createReactClass({
collapseLhs: false,
});
break;
case 'hide_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", true);
this.setState({
collapsedRhs: true,
});
break;
case 'show_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", false);
this.setState({
collapsedRhs: false,
});
break;
case 'panel_disable': {
this.setState({
leftDisabled: payload.leftDisabled || payload.sideDisabled || false,
middleDisabled: payload.middleDisabled || false,
rightDisabled: payload.rightDisabled || payload.sideDisabled || false,
// We don't track the right panel being disabled here - it's tracked in the store.
});
break;
}
case 'set_theme':
setTheme(payload.value);
break;
case 'on_logging_in':
// We are now logging in, so set the state to reflect that
// NB. This does not touch 'ready' since if our dispatches
// are delayed, the sync could already have completed
this.setStateForNewView({
view: VIEWS.LOGGING_IN,
});
break;
case 'on_logged_in':
if (!Lifecycle.isSoftLogout()) {
if (
!Lifecycle.isSoftLogout() &&
this.state.view !== VIEWS.LOGIN &&
this.state.view !== VIEWS.REGISTER &&
this.state.view !== VIEWS.COMPLETE_SECURITY &&
this.state.view !== VIEWS.E2E_SETUP
) {
this._onLoggedIn();
}
break;
@ -765,6 +770,8 @@ export default createReactClass({
}
this.setStateForNewView(newState);
ThemeController.isLogin = true;
this._themeWatcher.recheck();
this.notifyNewScreen('register');
},
@ -861,12 +868,17 @@ export default createReactClass({
waitFor = this.firstSyncPromise.promise;
}
waitFor.done(() => {
return waitFor.then(() => {
let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) {
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
if (theAlias) {
presentedId = theAlias;
// Store display alias of the presented room in cache to speed future
// navigation.
storeRoomAliasInCache(theAlias, room.roomId);
}
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
@ -879,7 +891,7 @@ export default createReactClass({
presentedId += "/" + roomInfo.event_id;
}
newState.ready = true;
this.setState(newState, ()=>{
this.setState(newState, () => {
this.notifyNewScreen('room/' + presentedId);
});
});
@ -910,6 +922,8 @@ export default createReactClass({
view: VIEWS.WELCOME,
});
this.notifyNewScreen('welcome');
ThemeController.isLogin = true;
this._themeWatcher.recheck();
},
_viewHome: function() {
@ -919,6 +933,8 @@ export default createReactClass({
});
this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
ThemeController.isLogin = false;
this._themeWatcher.recheck();
},
_viewUser: function(userId, subAction) {
@ -971,9 +987,9 @@ export default createReactClass({
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog);
const [shouldCreate, createOpts] = await modal.finished;
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
createRoom({createOpts}).done();
createRoom(opts);
}
},
@ -998,6 +1014,10 @@ export default createReactClass({
// needs to be reset so that they can revisit /user/.. // (and trigger
// `_chatCreateOrReuse` again)
go_welcome_on_cancel: true,
screen_after: {
screen: `user/${this.props.config.welcomeUserId}`,
params: { action: 'chat' },
},
});
return;
}
@ -1165,14 +1185,23 @@ export default createReactClass({
* Called when a new logged in session has started
*/
_onLoggedIn: async function() {
ThemeController.isLogin = false;
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (MatrixClientPeg.currentUserIsJustRegistered()) {
// If a specific screen is set to be shown after login, show that above
// all else, as it probably means the user clicked on something already.
if (this._screenAfterLogin && this._screenAfterLogin.screen) {
this.showScreen(
this._screenAfterLogin.screen,
this._screenAfterLogin.params,
);
this._screenAfterLogin = null;
} else if (MatrixClientPeg.currentUserIsJustRegistered()) {
MatrixClientPeg.setJustRegisteredUserId(null);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
const welcomeUserRoom = await this._startWelcomeUserChat();
if (welcomeUserRoom === null) {
// We didn't rediret to the welcome user room, so show
// We didn't redirect to the welcome user room, so show
// the homepage.
dis.dispatch({action: 'view_home_page'});
}
@ -1184,6 +1213,8 @@ export default createReactClass({
} else {
this._showScreenAfterLogin();
}
StorageManager.tryPersistStorage();
},
_showScreenAfterLogin: function() {
@ -1227,11 +1258,12 @@ export default createReactClass({
view: VIEWS.LOGIN,
ready: false,
collapseLhs: false,
collapsedRhs: false,
currentRoomId: null,
});
this.subTitleStatus = '';
this._setPageSubtitle();
ThemeController.isLogin = true;
this._themeWatcher.recheck();
},
/**
@ -1243,7 +1275,6 @@ export default createReactClass({
view: VIEWS.SOFT_LOGOUT,
ready: false,
collapseLhs: false,
collapsedRhs: false,
currentRoomId: null,
});
this.subTitleStatus = '';
@ -1261,9 +1292,8 @@ export default createReactClass({
// since we're about to start the client and therefore about
// to do the first sync
this.firstSyncComplete = false;
this.firstSyncPromise = Promise.defer();
this.firstSyncPromise = defer();
const cli = MatrixClientPeg.get();
const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog');
// Allow the JS SDK to reap timeline events. This reduces the amount of
// memory consumed as the JS SDK stores multiple distinct copies of room
@ -1308,7 +1338,7 @@ export default createReactClass({
if (state === "SYNCING" && prevState === "SYNCING") {
return;
}
console.log("MatrixClient sync state => %s", state);
console.info("MatrixClient sync state => %s", state);
if (state !== "PREPARED") { return; }
self.firstSyncComplete = true;
@ -1363,23 +1393,13 @@ export default createReactClass({
cancelButton: _t('Dismiss'),
onFinished: (confirmed) => {
if (confirmed) {
window.open(consentUri, '_blank');
const wnd = window.open(consentUri, '_blank');
wnd.opener = null;
}
},
}, null, true);
});
cli.on("accountData", function(ev) {
if (ev.getType() === 'im.vector.web.settings') {
if (ev.getContent() && ev.getContent().theme) {
dis.dispatch({
action: 'set_theme',
value: ev.getContent().theme,
});
}
}
});
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
}, (errorCode) => {
@ -1406,6 +1426,8 @@ export default createReactClass({
cli.on("Session.logged_out", () => dft.stop());
cli.on("Event.decrypted", (e, err) => dft.eventDecrypted(e, err));
// TODO: We can remove this once cross-signing is the only way.
// https://github.com/vector-im/riot-web/issues/11908
const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => {
krh.handleKeyRequest(req);
@ -1473,12 +1495,26 @@ export default createReactClass({
}
});
cli.on("crypto.verification.start", (verifier) => {
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier,
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
cli.on("crypto.verification.request", request => {
if (request.pending) {
ToastStore.sharedInstance().addOrReplaceToast({
key: 'verifreq_' + request.channel.transactionId,
title: _t("Verification Request"),
icon: "verification",
props: {request},
component: sdk.getComponent("toasts.VerificationRequestToast"),
});
}
});
});
} else {
cli.on("crypto.verification.start", (verifier) => {
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier,
}, null, /* priority = */ false, /* static = */ true);
});
}
// Fire the tinter right on startup to ensure the default theme is applied
// A later sync can/will correct the tint to be the right value for the user
const colorScheme = SettingsStore.getValue("roomColor");
@ -1499,6 +1535,15 @@ export default createReactClass({
"blacklistUnverifiedDevices",
);
cli.setGlobalBlacklistUnverifiedDevices(blacklistEnabled);
// With cross-signing enabled, we send to unknown devices
// without prompting. Any bad-device status the user should
// be aware of will be signalled through the room shield
// changing colour. More advanced behaviour will come once
// we implement more settings.
cli.setGlobalErrorOnUnknownDevices(
!SettingsStore.isFeatureEnabled("feature_cross_signing"),
);
}
},
@ -1558,14 +1603,26 @@ export default createReactClass({
dis.dispatch({
action: 'view_my_groups',
});
} else if (screen === 'complete_security') {
dis.dispatch({
action: 'start_complete_security',
});
} else if (screen == 'post_registration') {
dis.dispatch({
action: 'start_post_registration',
});
} else if (screen.indexOf('room/') == 0) {
const segments = screen.substring(5).split('/');
const roomString = segments[0];
let eventId = segments.splice(1).join("/"); // empty string if no event id given
// Rooms can have the following formats:
// #room_alias:domain or !opaque_id:domain
const room = screen.substring(5);
const domainOffset = room.indexOf(':') + 1; // 0 in case room does not contain a :
let eventOffset = room.length;
// room aliases can contain slashes only look for slash after domain
if (room.substring(domainOffset).indexOf('/') > -1) {
eventOffset = domainOffset + room.substring(domainOffset).indexOf('/');
}
const roomString = room.substring(0, eventOffset);
let eventId = room.substring(eventOffset + 1); // empty string if no event id given
// Previously we pulled the eventID from the segments in such a way
// where if there was no eventId then we'd get undefined. However, we
@ -1676,8 +1733,6 @@ export default createReactClass({
handleResize: function(e) {
const hideLhsThreshold = 1000;
const showLhsThreshold = 1000;
const hideRhsThreshold = 820;
const showRhsThreshold = 820;
if (this._windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
dis.dispatch({ action: 'hide_left_panel' });
@ -1685,12 +1740,6 @@ export default createReactClass({
if (this._windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
dis.dispatch({ action: 'show_left_panel' });
}
if (this._windowWidth > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) {
dis.dispatch({ action: 'hide_right_panel' });
}
if (this._windowWidth <= showRhsThreshold && window.innerWidth > showRhsThreshold) {
dis.dispatch({ action: 'show_right_panel' });
}
this.state.resizeNotifier.notifyWindowResized();
this._windowWidth = window.innerWidth;
@ -1719,6 +1768,10 @@ export default createReactClass({
this.showScreen("forgot_password");
},
onRegisterFlowComplete: function(credentials, password) {
return this.onUserCompletedLoginFlow(credentials, password);
},
// returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) {
return Lifecycle.setLoggedIn(credentials);
@ -1749,7 +1802,7 @@ export default createReactClass({
return;
}
cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => {
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
dis.dispatch({action: 'message_sent'});
}, (err) => {
dis.dispatch({action: 'message_send_failed'});
@ -1761,10 +1814,12 @@ export default createReactClass({
const client = MatrixClientPeg.get();
const room = client && client.getRoom(this.state.currentRoomId);
if (room) {
subtitle = `| ${ room.name } ${subtitle}`;
subtitle = `${this.subTitleStatus} | ${ room.name } ${subtitle}`;
}
} else {
subtitle = `${this.subTitleStatus} ${subtitle}`;
}
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle} ${this.subTitleStatus}`;
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`;
},
updateStatusIndicator: function(state, prevState) {
@ -1805,21 +1860,98 @@ export default createReactClass({
this._loggedInView = ref;
},
async onUserCompletedLoginFlow(credentials, password) {
this._accountPassword = password;
// self-destruct the password after 5mins
if (this._accountPasswordTimer !== null) clearTimeout(this._accountPasswordTimer);
this._accountPasswordTimer = setTimeout(() => {
this._accountPassword = null;
this._accountPasswordTimer = null;
}, 60 * 5 * 1000);
// Wait for the client to be logged in (but not started)
// which is enough to ask the server about account data.
const loggedIn = new Promise(resolve => {
const actionHandlerRef = dis.register(payload => {
if (payload.action !== "on_logged_in") {
return;
}
dis.unregister(actionHandlerRef);
resolve();
});
});
// Create and start the client in the background
const setLoggedInPromise = Lifecycle.setLoggedIn(credentials);
await loggedIn;
const cli = MatrixClientPeg.get();
// We're checking `isCryptoAvailable` here instead of `isCryptoEnabled`
// because the client hasn't been started yet.
if (!isCryptoAvailable()) {
this._onLoggedIn();
}
// Test for the master cross-signing key in SSSS as a quick proxy for
// whether cross-signing has been set up on the account.
let masterKeyInStorage = false;
try {
masterKeyInStorage = !!await cli.getAccountDataFromServer("m.cross_signing.master");
} catch (e) {
if (e.errcode !== "M_NOT_FOUND") {
console.warn("Secret storage account data check failed", e);
}
}
if (masterKeyInStorage) {
// Auto-enable cross-signing for the new session when key found in
// secret storage.
SettingsStore.setFeatureEnabled("feature_cross_signing", true);
this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY });
} else if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// This will only work if the feature is set to 'enable' in the config,
// since it's too early in the lifecycle for users to have turned the
// labs flag on.
this.setStateForNewView({ view: VIEWS.E2E_SETUP });
} else {
this._onLoggedIn();
}
return setLoggedInPromise;
},
// complete security / e2e setup has finished
onCompleteSecurityE2eSetupFinished() {
this._onLoggedIn();
},
render: function() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
let view;
if (
this.state.view === VIEWS.LOADING ||
this.state.view === VIEWS.LOGGING_IN
) {
if (this.state.view === VIEWS.LOADING) {
const Spinner = sdk.getComponent('elements.Spinner');
view = (
<div className="mx_MatrixChat_splash">
<Spinner />
</div>
);
} else if (this.state.view === VIEWS.COMPLETE_SECURITY) {
const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity');
view = (
<CompleteSecurity
onFinished={this.onCompleteSecurityE2eSetupFinished}
/>
);
} else if (this.state.view === VIEWS.E2E_SETUP) {
const E2eSetup = sdk.getComponent('structures.auth.E2eSetup');
view = (
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
accountPassword={this._accountPassword}
/>
);
} else if (this.state.view === VIEWS.POST_REGISTRATION) {
// needs to be before normal PageTypes as you are logged in technically
const PostRegistration = sdk.getComponent('structures.auth.PostRegistration');
@ -1884,9 +2016,10 @@ export default createReactClass({
email={this.props.startingFragmentQueryParams.email}
brand={this.props.config.brand}
makeRegistrationUrl={this._makeRegistrationUrl}
onLoggedIn={this.onRegistered}
onLoggedIn={this.onRegisterFlowComplete}
onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
{...this.getServerProperties()}
/>
);
@ -1904,7 +2037,7 @@ export default createReactClass({
const Login = sdk.getComponent('structures.auth.Login');
view = (
<Login
onLoggedIn={Lifecycle.setLoggedIn}
onLoggedIn={this.onUserCompletedLoginFlow}
onRegisterClick={this.onRegisterClick}
fallbackHsUrl={this.getFallbackHsUrl()}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}

File diff suppressed because it is too large Load diff

View file

@ -17,12 +17,11 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../index';
import * as sdk from '../../index';
import { _t } from '../../languageHandler';
import dis from '../../dispatcher';
import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext";
export default createReactClass({
displayName: 'MyGroups',
@ -34,8 +33,8 @@ export default createReactClass({
};
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
statics: {
contextType: MatrixClientContext,
},
componentWillMount: function() {
@ -47,7 +46,7 @@ export default createReactClass({
},
_fetch: function() {
this.context.matrixClient.getJoinedGroups().done((result) => {
this.context.getJoinedGroups().then((result) => {
this.setState({groups: result.groups, error: null});
}, (err) => {
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') {

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 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.
@ -18,8 +19,8 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
import { _t } from '../../languageHandler';
const sdk = require('../../index');
const MatrixClientPeg = require("../../MatrixClientPeg");
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
/*
* Component which shows the global notification list using a TimelinePanel
@ -38,16 +39,16 @@ const NotificationPanel = createReactClass({
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
return (
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
className="mx_NotificationPanel"
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview={false}
tileShape="notif"
empty={_t('You have no visible notifications')}
role="tabpanel"
/>
<div className="mx_NotificationPanel" role="tabpanel">
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview={false}
tileShape="notif"
empty={_t('You have no visible notifications')}
/>
</div>
);
} else {
console.error("No notifTimelineSet available!");
@ -60,4 +61,4 @@ const NotificationPanel = createReactClass({
},
});
module.exports = NotificationPanel;
export default NotificationPanel;

View file

@ -1,9 +1,9 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 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.
@ -21,45 +21,34 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import sdk from '../../index';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import { MatrixClient } from 'matrix-js-sdk';
import RateLimitedFunc from '../../ratelimitedfunc';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore';
import SettingsStore from "../../settings/SettingsStore";
import {RIGHT_PANEL_PHASES, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases";
import RightPanelStore from "../../stores/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
export default class RightPanel extends React.Component {
static get propTypes() {
return {
roomId: PropTypes.string, // if showing panels for a given room, this is set
groupId: PropTypes.string, // if showing panels for a given group, this is set
user: PropTypes.object,
user: PropTypes.object, // used if we know the user ahead of opening the panel
};
}
static get contextTypes() {
return {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
}
static contextType = MatrixClientContext;
static Phase = Object.freeze({
RoomMemberList: 'RoomMemberList',
GroupMemberList: 'GroupMemberList',
GroupRoomList: 'GroupRoomList',
GroupRoomInfo: 'GroupRoomInfo',
FilePanel: 'FilePanel',
NotificationPanel: 'NotificationPanel',
RoomMemberInfo: 'RoomMemberInfo',
Room3pidMemberInfo: 'Room3pidMemberInfo',
GroupMemberInfo: 'GroupMemberInfo',
});
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.state = {
phase: this._getPhaseFromProps(),
isUserPrivilegedInGroup: null,
member: this._getUserForPanel(),
verificationRequest: RightPanelStore.getSharedInstance().roomPanelPhaseParams.verificationRequest,
};
this.onAction = this.onAction.bind(this);
this.onRoomStateMember = this.onRoomStateMember.bind(this);
@ -72,30 +61,64 @@ export default class RightPanel extends React.Component {
}, 500);
}
// Helper function to split out the logic for _getPhaseFromProps() and the constructor
// as both are called at the same time in the constructor.
_getUserForPanel() {
if (this.state && this.state.member) return this.state.member;
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
return this.props.user || lastParams['member'];
}
// gets the current phase from the props and also maybe the store
_getPhaseFromProps() {
const rps = RightPanelStore.getSharedInstance();
const userForPanel = this._getUserForPanel();
if (this.props.groupId) {
return RightPanel.Phase.GroupMemberList;
} else if (this.props.user) {
return RightPanel.Phase.RoomMemberInfo;
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) {
dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.GroupMemberList});
return RIGHT_PANEL_PHASES.GroupMemberList;
}
return rps.groupPanelPhase;
} else if (userForPanel) {
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state
// from its props and some from a store, except if the contents of the store changes
// while it's mounted in which case it replaces all of its state with that of the store,
// except it uses a dispatch instead of a normal store listener?
// Unfortunately rewriting this would almost certainly break showing the right panel
// in some of the many cases, and I don't have time to re-architect it and test all
// the flows now, so adding yet another special case so if the store thinks there is
// a verification going on for the member we're displaying, we show that, otherwise
// we race if a verification is started while the panel isn't displayed because we're
// not mounted in time to get the dispatch.
// Until then, let this code serve as a warning from history.
if (
rps.roomPanelPhaseParams.member &&
userForPanel.userId === rps.roomPanelPhaseParams.member.userId &&
rps.roomPanelPhaseParams.verificationRequest
) {
return rps.roomPanelPhase;
}
return RIGHT_PANEL_PHASES.RoomMemberInfo;
} else {
return RightPanel.Phase.RoomMemberList;
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) {
dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.RoomMemberList});
return RIGHT_PANEL_PHASES.RoomMemberList;
}
return rps.roomPanelPhase;
}
}
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
const cli = this.context.matrixClient;
const cli = this.context;
cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId);
if (this.props.user) {
this.setState({member: this.props.user});
}
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
if (this.context.matrixClient) {
this.context.matrixClient.removeListener("RoomState.members", this.onRoomStateMember);
if (this.context) {
this.context.removeListener("RoomState.members", this.onRoomStateMember);
}
this._unregisterGroupStore(this.props.groupId);
}
@ -125,7 +148,7 @@ export default class RightPanel extends React.Component {
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: RightPanel.Phase.GroupMemberList,
phase: RIGHT_PANEL_PHASES.GroupMemberList,
});
});
}
@ -141,9 +164,9 @@ export default class RightPanel extends React.Component {
return;
}
// redraw the badge on the membership list
if (this.state.phase === RightPanel.Phase.RoomMemberList && member.roomId === this.props.roomId) {
if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList && member.roomId === this.props.roomId) {
this._delayedUpdate();
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo && member.roomId === this.props.roomId &&
} else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo && member.roomId === this.props.roomId &&
member.userId === this.state.member.userId) {
// refresh the member info (e.g. new power level)
this._delayedUpdate();
@ -151,13 +174,14 @@ export default class RightPanel extends React.Component {
}
onAction(payload) {
if (payload.action === "view_right_panel_phase") {
if (payload.action === "after_right_panel_phase_change") {
this.setState({
phase: payload.phase,
groupRoomId: payload.groupRoomId,
groupId: payload.groupId,
member: payload.member,
event: payload.event,
verificationRequest: payload.verificationRequest,
});
}
}
@ -165,6 +189,7 @@ export default class RightPanel extends React.Component {
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
@ -176,30 +201,82 @@ export default class RightPanel extends React.Component {
let panel = <div />;
if (this.props.roomId && this.state.phase === RightPanel.Phase.RoomMemberList) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
} else if (this.props.groupId && this.state.phase === RightPanel.Phase.GroupMemberList) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.GroupRoomList) {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
} else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) {
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
} else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) {
panel = <GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id} />;
} else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) {
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
} else if (this.state.phase === RightPanel.Phase.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === RightPanel.Phase.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
switch (this.state.phase) {
case RIGHT_PANEL_PHASES.RoomMemberList:
if (this.props.roomId) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
}
break;
case RIGHT_PANEL_PHASES.GroupMemberList:
if (this.props.groupId) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
}
break;
case RIGHT_PANEL_PHASES.GroupRoomList:
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
break;
case RIGHT_PANEL_PHASES.RoomMemberInfo:
case RIGHT_PANEL_PHASES.EncryptionPanel:
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null,
});
};
panel = <UserInfo
user={this.state.member}
roomId={this.props.roomId}
key={this.props.roomId || this.state.member.userId}
onClose={onClose}
phase={this.state.phase}
verificationRequest={this.state.verificationRequest}
/>;
} else {
panel = <MemberInfo
member={this.state.member}
key={this.props.roomId || this.state.member.userId}
/>;
}
break;
case RIGHT_PANEL_PHASES.Room3pidMemberInfo:
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
break;
case RIGHT_PANEL_PHASES.GroupMemberInfo:
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: null,
});
};
panel = <UserInfo
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.userId}
onClose={onClose} />;
} else {
panel = (
<GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id}
/>
);
}
break;
case RIGHT_PANEL_PHASES.GroupRoomInfo:
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
break;
case RIGHT_PANEL_PHASES.NotificationPanel:
panel = <NotificationPanel />;
break;
case RIGHT_PANEL_PHASES.FilePanel:
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
break;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {
@ -209,7 +286,7 @@ export default class RightPanel extends React.Component {
});
return (
<aside className={classes}>
<aside className={classes} id="mx_RightPanel">
{ panel }
</aside>
);

View file

@ -18,19 +18,16 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
const MatrixClientPeg = require('../../MatrixClientPeg');
const ContentRepo = require("matrix-js-sdk").ContentRepo;
const Modal = require('../../Modal');
const sdk = require('../../index');
const dis = require('../../dispatcher');
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import dis from "../../dispatcher";
import Modal from "../../Modal";
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import { _t } from '../../languageHandler';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160;
@ -39,7 +36,7 @@ function track(action) {
Analytics.trackEvent('RoomDirectory', action);
}
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomDirectory',
propTypes: {
@ -66,16 +63,6 @@ module.exports = createReactClass({
};
},
childContextTypes: {
matrixClient: PropTypes.object,
},
getChildContext: function() {
return {
matrixClient: MatrixClientPeg.get(),
};
},
componentWillMount: function() {
this._unmounted = false;
this.nextBatch = null;
@ -89,7 +76,7 @@ module.exports = createReactClass({
this.setState({protocolsLoading: false});
return;
}
MatrixClientPeg.get().getThirdpartyProtocols().done((response) => {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response;
this.setState({protocolsLoading: false});
}, (err) => {
@ -109,20 +96,9 @@ module.exports = createReactClass({
),
});
});
// dis.dispatch({
// action: 'panel_disable',
// sideDisabled: true,
// middleDisabled: true,
// });
},
componentWillUnmount: function() {
// dis.dispatch({
// action: 'panel_disable',
// sideDisabled: false,
// middleDisabled: false,
// });
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
@ -135,7 +111,7 @@ module.exports = createReactClass({
publicRooms: [],
loading: true,
});
this.getMoreRooms().done();
this.getMoreRooms();
},
getMoreRooms: function() {
@ -179,7 +155,7 @@ module.exports = createReactClass({
this.nextBatch = data.next_batch;
this.setState((s) => {
s.publicRooms.push(...data.chunk);
s.publicRooms.push(...(data.chunk || []));
s.loading = false;
return s;
});
@ -246,7 +222,7 @@ module.exports = createReactClass({
if (!alias) return;
step = _t('delete the alias.');
return MatrixClientPeg.get().deleteAlias(alias);
}).done(() => {
}).then(() => {
modal.close();
this.refreshRoomList();
}, (err) => {
@ -282,6 +258,7 @@ module.exports = createReactClass({
roomServer: server,
instanceId: instanceId,
includeAll: includeAll,
error: null,
}, this.refreshRoomList);
// We also refresh the room list each time even though this
// filtering is client-side. It hopefully won't be client side
@ -348,7 +325,7 @@ module.exports = createReactClass({
});
return;
}
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => {
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).then((resp) => {
if (resp.length > 0 && resp[0].alias) {
this.showRoomAlias(resp[0].alias, true);
} else {
@ -477,7 +454,7 @@ module.exports = createReactClass({
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
topic = linkifyAndSanitizeHtml(topic);
const avatarUrl = ContentRepo.getHttpUriForMxc(
const avatarUrl = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
room.avatar_url, 32, 32, "crop",
);
@ -573,7 +550,7 @@ module.exports = createReactClass({
if (rows.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
} else {
scrollpanel_content = <table ref="directory_table" className="mx_RoomDirectory_table">
scrollpanel_content = <table className="mx_RoomDirectory_table">
<tbody>
{ rows }
</tbody>

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2019 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.
@ -20,12 +21,12 @@ import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -38,7 +39,7 @@ function getUnsentMessages(room) {
});
}
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomStatusBar',
propTypes: {
@ -219,12 +220,12 @@ module.exports = createReactClass({
});
if (hasUDE) {
title = _t("Message not sent due to unknown devices being present");
title = _t("Message not sent due to unknown sessions being present");
content = _t(
"<showDevicesText>Show devices</showDevicesText>, <sendAnywayText>send anyway</sendAnywayText> or <cancelText>cancel</cancelText>.",
"<showSessionsText>Show sessions</showSessionsText>, <sendAnywayText>send anyway</sendAnywayText> or <cancelText>cancel</cancelText>.",
{},
{
'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
'showSessionsText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
'sendAnywayText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="sendAnyway" onClick={this._onSendWithoutVerifyingClick}>{ sub }</a>,
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
@ -272,7 +273,7 @@ module.exports = createReactClass({
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error
) {
title = unsentMessages[0].error.data.error;
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
} else {
title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
}
@ -289,7 +290,7 @@ module.exports = createReactClass({
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
@ -306,7 +307,7 @@ module.exports = createReactClass({
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 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.
@ -16,38 +17,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import React, {createRef} from 'react';
import classNames from 'classnames';
import sdk from '../../index';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import Unread from '../../Unread';
import * as Unread from '../../Unread';
import * as RoomNotifs from '../../RoomNotifs';
import * as FormattingUtils from '../../utils/FormattingUtils';
import IndicatorScrollbar from './IndicatorScrollbar';
import { KeyCode } from '../../Keyboard';
import {Key} from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
import RoomTile from "../views/rooms/RoomTile";
import LazyRenderList from "../views/elements/LazyRenderList";
import {_t} from "../../languageHandler";
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
// turn this on for drop & drag console debugging galore
const debug = false;
const RoomSubList = createReactClass({
displayName: 'RoomSubList',
export default class RoomSubList extends React.PureComponent {
static displayName = 'RoomSubList';
static debug = debug;
debug: debug,
propTypes: {
static propTypes = {
list: PropTypes.arrayOf(PropTypes.object).isRequired,
label: PropTypes.string.isRequired,
tagName: PropTypes.string,
addRoomLabel: PropTypes.string,
order: PropTypes.string.isRequired,
// passed through to RoomTile and used to highlight room with `!` regardless of notifications count
isInvite: PropTypes.bool,
@ -56,49 +54,63 @@ const RoomSubList = createReactClass({
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: PropTypes.func,
incomingCall: PropTypes.object,
isFiltered: PropTypes.bool,
headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
},
forceExpand: PropTypes.bool,
};
getInitialState: function() {
static defaultProps = {
onHeaderClick: function() {
}, // NOP
extraTiles: [],
isInvite: false,
};
static getDerivedStateFromProps(props, state) {
return {
listLength: props.list.length,
scrollTop: props.list.length === state.listLength ? state.scrollTop : 0,
};
}
constructor(props) {
super(props);
this.state = {
hidden: this.props.startAsHidden || false,
// some values to get LazyRenderList starting
scrollerHeight: 800,
scrollTop: 0,
// React 16's getDerivedStateFromProps(props, state) doesn't give the previous props so
// we have to store the length of the list here so we can see if it's changed or not...
listLength: null,
};
},
getDefaultProps: function() {
return {
onHeaderClick: function() {
}, // NOP
extraTiles: [],
isInvite: false,
};
},
this._header = createRef();
this._subList = createRef();
this._scroller = createRef();
this._headerButton = createRef();
}
componentWillMount: function() {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
},
}
// The header is collapsable if it is hidden or not stuck
// The header is collapsible if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
const stuck = this.refs.header.dataset.stuck;
isCollapsibleOnClick() {
const stuck = this._header.current.dataset.stuck;
if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) {
return true;
} else {
return false;
}
},
}
onAction: function(payload) {
onAction = (payload) => {
// XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched,
// but this is no longer true, so we must do it here (and can apply the small
// optimisation of checking that we care about the room being read).
@ -111,37 +123,76 @@ const RoomSubList = createReactClass({
) {
this.forceUpdate();
}
},
};
onClick: function(ev) {
if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
onClick = (ev) => {
if (this.isCollapsibleOnClick()) {
// The header isCollapsible, so the click is to be interpreted as collapse and truncation logic
const isHidden = !this.state.hidden;
this.setState({hidden: isHidden}, () => {
this.props.onHeaderClick(isHidden);
});
} else {
// The header is stuck, so the click is to be interpreted as a scroll to the header
this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition);
this.props.onHeaderClick(this.state.hidden, this._header.current.dataset.originalPosition);
}
},
};
onRoomTileClick(roomId, ev) {
onHeaderKeyDown = (ev) => {
switch (ev.key) {
case Key.ARROW_LEFT:
// On ARROW_LEFT collapse the room sublist
if (!this.state.hidden && !this.props.forceExpand) {
this.onClick();
}
ev.stopPropagation();
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
if (this.state.hidden && !this.props.forceExpand) {
// sublist is collapsed, expand it
this.onClick();
} else if (!this.props.forceExpand) {
// sublist is expanded, go to first room
const element = this._subList.current && this._subList.current.querySelector(".mx_RoomTile");
if (element) {
element.focus();
}
}
break;
}
}
};
onKeyDown = (ev) => {
switch (ev.key) {
// On ARROW_LEFT go to the sublist header
case Key.ARROW_LEFT:
ev.stopPropagation();
this._headerButton.current.focus();
break;
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
case Key.ARROW_RIGHT:
ev.stopPropagation();
}
};
onRoomTileClick = (roomId, ev) => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
clear_search: (ev && (ev.keyCode === KeyCode.ENTER || ev.keyCode === KeyCode.SPACE)),
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
});
},
};
_updateSubListCount: function() {
_updateSubListCount = () => {
// Force an update by setting the state to the current state
// Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate()
// method is honoured
this.setState(this.state);
},
};
makeRoomTile: function(room) {
makeRoomTile = (room) => {
return <RoomTile
room={room}
roomSubList={this}
@ -156,9 +207,9 @@ const RoomSubList = createReactClass({
incomingCall={null}
onClick={this.onRoomTileClick}
/>;
},
};
_onNotifBadgeClick: function(e) {
_onNotifBadgeClick = (e) => {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
@ -169,9 +220,9 @@ const RoomSubList = createReactClass({
room_id: room.roomId,
});
}
},
};
_onInviteBadgeClick: function(e) {
_onInviteBadgeClick = (e) => {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
@ -191,9 +242,14 @@ const RoomSubList = createReactClass({
});
}
}
},
};
_getHeaderJsx: function(isCollapsed) {
onAddRoom = (e) => {
e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom();
};
_getHeaderJsx(isCollapsed) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton');
const subListNotifications = !this.props.isInvite ?
@ -202,22 +258,6 @@ const RoomSubList = createReactClass({
const subListNotifCount = subListNotifications.count;
const subListNotifHighlight = subListNotifications.highlight;
let badge;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>{this.props.list.length}</div>;
}
}
// When collapsed, allow a long hover on the header to show user
// the full tag name and room count
let title;
@ -233,17 +273,6 @@ const RoomSubList = createReactClass({
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
onClick={ this.props.onAddRoom }
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
const len = this.props.list.length + this.props.extraTiles.length;
let chevron;
if (len) {
@ -255,65 +284,127 @@ const RoomSubList = createReactClass({
chevron = (<div className={chevronClasses} />);
}
const tabindex = this.props.isFiltered ? "0" : "-1";
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex} aria-expanded={!isCollapsed}>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
},
return <RovingTabIndexWrapper inputRef={this._headerButton}>
{({onFocus, isActive, ref}) => {
const tabIndex = isActive ? 0 : -1;
checkOverflow: function() {
if (this.refs.scroller) {
this.refs.scroller.checkOverflow();
let badge;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (subListNotifCount > 0) {
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onNotifBadgeClick}
aria-label={_t("Jump to first unread room.")}
>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onInviteBadgeClick}
aria-label={_t("Jump to first invite.")}
>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
return (
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton
onFocus={onFocus}
tabIndex={tabIndex}
inputRef={ref}
onClick={this.onClick}
className="mx_RoomSubList_label"
aria-expanded={!isCollapsed}
role="treeitem"
aria-level="1"
>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
} }
</RovingTabIndexWrapper>;
}
checkOverflow = () => {
if (this._scroller.current) {
this._scroller.current.checkOverflow();
}
},
};
setHeight: function(height) {
if (this.refs.subList) {
this.refs.subList.style.height = `${height}px`;
setHeight = (height) => {
if (this._subList.current) {
this._subList.current.style.height = `${height}px`;
}
this._updateLazyRenderHeight(height);
},
};
_updateLazyRenderHeight: function(height) {
_updateLazyRenderHeight(height) {
this.setState({scrollerHeight: height});
},
}
_onScroll: function() {
this.setState({scrollTop: this.refs.scroller.getScrollTop()});
},
_onScroll = () => {
this.setState({scrollTop: this._scroller.current.getScrollTop()});
};
_canUseLazyListRendering() {
// for now disable lazy rendering as they are already rendered tiles
// not rooms like props.list we pass to LazyRenderList
return !this.props.extraTiles || !this.props.extraTiles.length;
},
}
render: function() {
render() {
const len = this.props.list.length + this.props.extraTiles.length;
const isCollapsed = this.state.hidden && !this.props.forceExpand;
if (len) {
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": len && isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
let content;
if (len) {
if (isCollapsed) {
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}>
{this._getHeaderJsx(isCollapsed)}
</div>;
// no body
} else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
content = (
<IndicatorScrollbar ref={this._scroller} className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
<LazyRenderList
scrollTop={this.state.scrollTop }
height={ this.state.scrollerHeight }
@ -321,32 +412,34 @@ const RoomSubList = createReactClass({
itemHeight={34}
items={ this.props.list } />
</IndicatorScrollbar>
</div>;
);
} else {
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
const tiles = roomTiles.concat(this.props.extraTiles);
return <div ref="subList" className={subListClasses} role="group" aria-label={this.props.label}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
content = (
<IndicatorScrollbar ref={this._scroller} className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
{ tiles }
</IndicatorScrollbar>
</div>;
);
}
} else {
const Loader = sdk.getComponent("elements.Spinner");
let content;
if (this.props.showSpinner && !isCollapsed) {
const Loader = sdk.getComponent("elements.Spinner");
content = <Loader />;
}
return (
<div ref="subList" className="mx_RoomSubList" role="group" aria-label={this.props.label}>
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);
}
},
});
module.exports = RoomSubList;
return (
<div
ref={this._subList}
className={subListClasses}
role="group"
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
>
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);
}
}

View file

@ -23,28 +23,26 @@ limitations under the License.
import shouldHideEvent from '../../shouldHideEvent';
import React from 'react';
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import classNames from 'classnames';
import {Room} from "matrix-js-sdk";
import { _t } from '../../languageHandler';
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
import MatrixClientPeg from '../../MatrixClientPeg';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import sdk from '../../index';
import * as sdk from '../../index';
import CallHandler from '../../CallHandler';
import dis from '../../dispatcher';
import Tinter from '../../Tinter';
import rate_limited_func from '../../ratelimitedfunc';
import ObjectUtils from '../../ObjectUtils';
import * as ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch from '../../Searching';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
@ -54,6 +52,9 @@ import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import {haveTileForEvent} from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext";
const DEBUG = false;
let debuglog = function() {};
@ -65,13 +66,7 @@ if (DEBUG) {
debuglog = console.log.bind(console);
}
const RoomContext = PropTypes.shape({
canReact: PropTypes.bool.isRequired,
canReply: PropTypes.bool.isRequired,
room: PropTypes.instanceOf(Room),
});
module.exports = createReactClass({
export default createReactClass({
displayName: 'RoomView',
propTypes: {
ConferenceHandler: PropTypes.any,
@ -98,9 +93,6 @@ module.exports = createReactClass({
// * invited us to the room
oobData: PropTypes.object,
// is the RightPanel collapsed?
collapsedRhs: PropTypes.bool,
// Servers the RoomView can use to try and assist joins
viaServers: PropTypes.arrayOf(PropTypes.string),
},
@ -166,23 +158,6 @@ module.exports = createReactClass({
canReact: false,
canReply: false,
useCider: false,
};
},
childContextTypes: {
room: RoomContext,
},
getChildContext: function() {
const {canReact, canReply, room} = this.state;
return {
room: {
canReact,
canReply,
room,
},
};
},
@ -198,19 +173,15 @@ module.exports = createReactClass({
MatrixClientPeg.get().on("accountData", this.onAccountData);
MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged);
MatrixClientPeg.get().on("userTrustStatusChanged", this.onUserVerificationChanged);
// Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate(true);
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
this._onCiderUpdated();
this._ciderWatcherRef = SettingsStore.watchSetting(
"useCiderComposer", null, this._onCiderUpdated);
},
_onCiderUpdated: function() {
this.setState({useCider: SettingsStore.getValue("useCiderComposer")});
this._roomView = createRef();
this._searchResultsPanel = createRef();
},
_onRoomViewStoreUpdate: function(initial) {
@ -357,7 +328,7 @@ module.exports = createReactClass({
if (this.props.autoJoin) {
this.onJoinButtonClicked();
} else if (!room && shouldPeek) {
console.log("Attempting to peek into room %s", roomId);
console.info("Attempting to peek into room %s", roomId);
this.setState({
peekLoading: true,
isPeeking: true, // this will change to false if peeking fails
@ -371,7 +342,7 @@ module.exports = createReactClass({
peekLoading: false,
});
this._onRoomLoaded(room);
}, (err) => {
}).catch((err) => {
if (this.unmounted) {
return;
}
@ -384,7 +355,7 @@ module.exports = createReactClass({
// This won't necessarily be a MatrixError, but we duck-type
// here and say if it's got an 'errcode' key with the right value,
// it means we can't peek.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") {
if (err.errcode === "M_GUEST_ACCESS_FORBIDDEN" || err.errcode === 'M_FORBIDDEN') {
// This is fine: the room just isn't peekable (we assume).
this.setState({
peekLoading: false,
@ -394,8 +365,6 @@ module.exports = createReactClass({
}
});
} else if (room) {
//viewing a previously joined room, try to lazy load members
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.setState({isPeeking: false});
@ -459,8 +428,8 @@ module.exports = createReactClass({
},
componentDidUpdate: function() {
if (this.refs.roomView) {
const roomView = ReactDOM.findDOMNode(this.refs.roomView);
if (this._roomView.current) {
const roomView = this._roomView.current;
if (!roomView.ondrop) {
roomView.addEventListener('drop', this.onDrop);
roomView.addEventListener('dragover', this.onDragOver);
@ -474,10 +443,10 @@ module.exports = createReactClass({
// in render() prevents the ref from being set on first mount, so we try and
// catch the messagePanel when it does mount. Because we only want the ref once,
// we use a boolean flag to avoid duplicate work.
if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) {
if (this._messagePanel && !this.state.atEndOfLiveTimelineInit) {
this.setState({
atEndOfLiveTimelineInit: true,
atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(),
atEndOfLiveTimeline: this._messagePanel.isAtEndOfLiveTimeline(),
});
}
},
@ -489,8 +458,6 @@ module.exports = createReactClass({
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
SettingsStore.unwatchSetting(this._ciderWatcherRef);
// update the scroll map before we get unmounted
if (this.state.roomId) {
RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState());
@ -499,12 +466,12 @@ module.exports = createReactClass({
// stop tracking room changes to format permalinks
this._stopAllPermalinkCreators();
if (this.refs.roomView) {
if (this._roomView.current) {
// disconnect the D&D event listeners from the room view. This
// is really just for hygiene - we're going to be
// deleted anyway, so it doesn't matter if the event listeners
// don't get cleaned up.
const roomView = ReactDOM.findDOMNode(this.refs.roomView);
const roomView = this._roomView.current;
roomView.removeEventListener('drop', this.onDrop);
roomView.removeEventListener('dragover', this.onDragOver);
roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd);
@ -516,11 +483,13 @@ module.exports = createReactClass({
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
MatrixClientPeg.get().removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
}
window.removeEventListener('beforeunload', this.onPageUnload);
@ -560,15 +529,15 @@ module.exports = createReactClass({
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.keyCode) {
case KeyCode.KEY_D:
switch (ev.key) {
case Key.D:
if (ctrlCmdOnly) {
this.onMuteAudioClick();
handled = true;
}
break;
case KeyCode.KEY_E:
case Key.E:
if (ctrlCmdOnly) {
this.onMuteVideoClick();
handled = true;
@ -584,6 +553,10 @@ module.exports = createReactClass({
onAction: function(payload) {
switch (payload.action) {
case 'after_right_panel_phase_change':
// We don't keep state on the right panel, so just re-render to update
this.forceUpdate();
break;
case 'message_send_failed':
case 'message_sent':
this._checkIfAlone(this.state.room);
@ -641,6 +614,22 @@ module.exports = createReactClass({
this.onCancelSearchClick();
}
break;
case 'quote':
if (this.state.searchResults) {
const roomId = payload.event.getRoomId();
if (roomId === this.state.roomId) {
this.onCancelSearchClick();
}
setImmediate(() => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
deferred_action: payload,
});
});
}
break;
}
},
@ -701,10 +690,10 @@ module.exports = createReactClass({
},
canResetTimeline: function() {
if (!this.refs.messagePanel) {
if (!this._messagePanel) {
return true;
}
return this.refs.messagePanel.canResetTimeline();
return this._messagePanel.canResetTimeline();
},
// called when state.room is first initialised (either at initial load,
@ -787,11 +776,20 @@ module.exports = createReactClass({
this._updateE2EStatus(room);
},
_updateE2EStatus: function(room) {
if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) {
onUserVerificationChanged: function(userId, _trustStatus) {
const room = this.state.room;
if (!room || !room.currentState.getMember(userId)) {
return;
}
if (!MatrixClientPeg.get().isCryptoEnabled()) {
this._updateE2EStatus(room);
},
_updateE2EStatus: async function(room) {
const cli = MatrixClientPeg.get();
if (!cli.isRoomEncrypted(room.roomId)) {
return;
}
if (!cli.isCryptoEnabled()) {
// If crypto is not currently enabled, we aren't tracking devices at all,
// so we don't know what the answer is. Let's error on the safe side and show
// a warning for this case.
@ -800,10 +798,50 @@ module.exports = createReactClass({
});
return;
}
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
this.setState({
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
this.setState({
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
});
});
debuglog("e2e check is warning/verified only as cross-signing is off");
return;
}
// Duplication between here and _updateE2eStatus in RoomTile
/* At this point, the user has encryption on and cross-signing on */
const e2eMembers = await room.getEncryptionTargetMembers();
const verified = [];
const unverified = [];
e2eMembers.map(({userId}) => userId)
.filter((userId) => userId !== cli.getUserId())
.forEach((userId) => {
(cli.checkUserTrust(userId).isCrossSigningVerified() ?
verified : unverified).push(userId)
});
debuglog("e2e verified", verified, "unverified", unverified);
/* Check all verified user devices. */
/* Don't alarm if no other users are verified */
const targets = (verified.length > 0) ? [...verified, cli.getUserId()] : verified;
for (const userId of targets) {
const devices = await cli.getStoredDevicesForUser(userId);
const anyDeviceNotVerified = devices.some(({deviceId}) => {
return !cli.checkDeviceTrust(userId, deviceId).isVerified();
});
if (anyDeviceNotVerified) {
this.setState({
e2eStatus: "warning",
});
debuglog("e2e status set to warning as not all users trust all of their sessions." +
" Aborted on user", userId);
return;
}
}
this.setState({
e2eStatus: unverified.length === 0 ? "verified" : "normal",
});
},
@ -882,7 +920,7 @@ module.exports = createReactClass({
// rate limited because a power level change will emit an event for every
// member in the room.
_updateRoomMembers: new rate_limited_func(function(dueToMember) {
_updateRoomMembers: rate_limited_func(function(dueToMember) {
// a member state changed in this room
// refresh the conf call notification state
this._updateConfCallNotification();
@ -1046,7 +1084,7 @@ module.exports = createReactClass({
},
onMessageListScroll: function(ev) {
if (this.refs.messagePanel.isAtEndOfLiveTimeline()) {
if (this._messagePanel.isAtEndOfLiveTimeline()) {
this.setState({
numUnreadMessages: 0,
atEndOfLiveTimeline: true,
@ -1101,7 +1139,7 @@ module.exports = createReactClass({
}
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
.done(undefined, (error) => {
.then(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
return;
@ -1119,8 +1157,8 @@ module.exports = createReactClass({
// if we already have a search panel, we need to tell it to forget
// about its scroll state.
if (this.refs.searchResultsPanel) {
this.refs.searchResultsPanel.resetScrollState();
if (this._searchResultsPanel.current) {
this._searchResultsPanel.current.resetScrollState();
}
// make sure that we don't end up showing results from
@ -1129,23 +1167,12 @@ module.exports = createReactClass({
// todo: should cancel any previous search requests.
this.searchId = new Date().getTime();
let filter;
if (scope === "Room") {
filter = {
// XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
rooms: [
this.state.room.roomId,
],
};
}
let roomId;
if (scope === "Room") roomId = this.state.room.roomId;
debuglog("sending search request");
const searchPromise = MatrixClientPeg.get().searchRoomEvents({
filter: filter,
term: term,
});
this._handleSearchResult(searchPromise).done();
const searchPromise = eventSearch(term, roomId);
this._handleSearchResult(searchPromise);
},
_handleSearchResult: function(searchPromise) {
@ -1236,7 +1263,7 @@ module.exports = createReactClass({
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onHeightChanged = () => {
const scrollPanel = this.refs.searchResultsPanel;
const scrollPanel = this._searchResultsPanel.current;
if (scrollPanel) {
scrollPanel.checkScroll();
}
@ -1251,7 +1278,7 @@ module.exports = createReactClass({
const roomId = mxEv.getRoomId();
const room = cli.getRoom(roomId);
if (!EventTile.haveTileForEvent(mxEv)) {
if (!haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
@ -1316,7 +1343,7 @@ module.exports = createReactClass({
},
onForgetClick: function() {
MatrixClientPeg.get().forget(this.state.room.roomId).done(function() {
MatrixClientPeg.get().forget(this.state.room.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' });
}, function(err) {
const errCode = err.errcode || _t("unknown error code");
@ -1333,7 +1360,7 @@ module.exports = createReactClass({
this.setState({
rejecting: true,
});
MatrixClientPeg.get().leave(this.state.roomId).done(function() {
MatrixClientPeg.get().leave(this.state.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' });
self.setState({
rejecting: false,
@ -1355,6 +1382,41 @@ module.exports = createReactClass({
});
},
onRejectAndIgnoreClick: async function() {
this.setState({
rejecting: true,
});
const cli = MatrixClientPeg.get();
try {
const myMember = this.state.room.getMember(cli.getUserId());
const inviteEvent = myMember.events.member;
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk
await cli.setIgnoredUsers(ignoredUsers);
await cli.leave(this.state.roomId);
dis.dispatch({ action: 'view_next_room' });
this.setState({
rejecting: false,
});
} catch (error) {
console.error("Failed to reject invite: %s", error);
const msg = error.message ? error.message : JSON.stringify(error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, {
title: _t("Failed to reject invite"),
description: msg,
});
self.setState({
rejecting: false,
rejectError: error,
});
}
},
onRejectThreepidInviteButtonClicked: function(ev) {
// We can reject 3pid invites in the same way that we accept them,
// using /leave rather than /join. In the short term though, we
@ -1381,28 +1443,28 @@ module.exports = createReactClass({
// jump down to the bottom of this room, where new events are arriving
jumpToLiveTimeline: function() {
this.refs.messagePanel.jumpToLiveTimeline();
this._messagePanel.jumpToLiveTimeline();
dis.dispatch({action: 'focus_composer'});
},
// jump up to wherever our read marker is
jumpToReadMarker: function() {
this.refs.messagePanel.jumpToReadMarker();
this._messagePanel.jumpToReadMarker();
},
// update the read marker to match the read-receipt
forgetReadMarker: function(ev) {
ev.stopPropagation();
this.refs.messagePanel.forgetReadMarker();
this._messagePanel.forgetReadMarker();
},
// decide whether or not the top 'unread messages' bar should be shown
_updateTopUnreadMessagesBar: function() {
if (!this.refs.messagePanel) {
if (!this._messagePanel) {
return;
}
const showBar = this.refs.messagePanel.canJumpToReadMarker();
const showBar = this._messagePanel.canJumpToReadMarker();
if (this.state.showTopUnreadMessagesBar != showBar) {
this.setState({showTopUnreadMessagesBar: showBar});
}
@ -1412,7 +1474,7 @@ module.exports = createReactClass({
// restored when we switch back to it.
//
_getScrollState: function() {
const messagePanel = this.refs.messagePanel;
const messagePanel = this._messagePanel;
if (!messagePanel) return null;
// if we're following the live timeline, we want to return null; that
@ -1517,10 +1579,10 @@ module.exports = createReactClass({
*/
handleScrollKey: function(ev) {
let panel;
if (this.refs.searchResultsPanel) {
panel = this.refs.searchResultsPanel;
} else if (this.refs.messagePanel) {
panel = this.refs.messagePanel;
if (this._searchResultsPanel.current) {
panel = this._searchResultsPanel.current;
} else if (this._messagePanel) {
panel = this._messagePanel;
}
if (panel) {
@ -1541,7 +1603,7 @@ module.exports = createReactClass({
// this has to be a proper method rather than an unnamed function,
// otherwise react calls it with null on each update.
_gatherTimelinePanelRef: function(r) {
this.refs.messagePanel = r;
this._messagePanel = r;
if (r) {
console.log("updateTint from RoomView._gatherTimelinePanelRef");
this.updateTint();
@ -1659,9 +1721,11 @@ module.exports = createReactClass({
return (
<div className="mx_RoomView">
<ErrorBoundary>
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
onRejectAndIgnoreClick={this.onRejectAndIgnoreClick}
inviterName={inviterName}
canPreview={false}
joining={this.state.joining}
@ -1725,12 +1789,12 @@ module.exports = createReactClass({
let aux = null;
let previewBar;
let hideCancel = false;
let hideRightPanel = false;
let forceHideRightPanel = false;
if (this.state.forwardingEvent !== null) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
hideCancel = true; // has own cancel
aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} />;
aux = <SearchBar searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} />;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
hideCancel = true;
@ -1771,7 +1835,7 @@ module.exports = createReactClass({
</div>
);
} else {
hideRightPanel = true;
forceHideRightPanel = true;
}
} else if (hiddenHighlightCount > 0) {
aux = (
@ -1786,7 +1850,7 @@ module.exports = createReactClass({
}
const auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room}
<AuxPanel room={this.state.room}
fullHeight={false}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
@ -1805,29 +1869,16 @@ module.exports = createReactClass({
myMembership === 'join' && !this.state.searchResults
);
if (canSpeak) {
if (this.state.useCider) {
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
messageComposer =
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
} else {
const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer');
messageComposer =
<SlateMessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
}
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
messageComposer =
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
}
// TODO: Why aren't we storing the term/scope/count in this format
@ -1886,7 +1937,7 @@ module.exports = createReactClass({
searchResultsPanel = (<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />);
} else {
searchResultsPanel = (
<ScrollPanel ref="searchResultsPanel"
<ScrollPanel ref={this._searchResultsPanel}
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest}
resizeNotifier={this.props.resizeNotifier}
@ -1907,9 +1958,10 @@ module.exports = createReactClass({
highlightedEventId = this.state.initialEventId;
}
// console.log("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
const messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef}
<TimelinePanel
ref={this._gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={SettingsStore.getValue('showReadReceipts')}
manageReadReceipts={!this.state.isPeeking}
@ -1929,7 +1981,8 @@ module.exports = createReactClass({
/>);
let topUnreadMessagesBar = null;
if (this.state.showTopUnreadMessagesBar) {
// Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense
if (this.state.showTopUnreadMessagesBar && !this.state.searchResults) {
const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar');
topUnreadMessagesBar = (<TopUnreadMessagesBar
onScrollUpClick={this.jumpToReadMarker}
@ -1937,7 +1990,8 @@ module.exports = createReactClass({
/>);
}
let jumpToBottom;
if (!this.state.atEndOfLiveTimeline) {
// Do not show JumpToBottomButton if we have search results showing, it makes no sense
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = (<JumpToBottomButton
numUnreadMessages={this.state.numUnreadMessages}
@ -1958,52 +2012,54 @@ module.exports = createReactClass({
},
);
const rightPanel = !hideRightPanel && this.state.room &&
<RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} />;
const collapsedRhs = hideRightPanel || this.props.collapsedRhs;
const showRightPanel = !forceHideRightPanel && this.state.room
&& RightPanelStore.getSharedInstance().isOpenForRoom;
const rightPanel = showRightPanel
? <RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} />
: null;
return (
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<ErrorBoundary>
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
oobData={this.props.oobData}
inRoom={myMembership === 'join'}
collapsedRhs={collapsedRhs}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}
/>
<MainSplit
panel={rightPanel}
collapsedRhs={collapsedRhs}
resizeNotifier={this.props.resizeNotifier}
>
<div className={fadableSectionClasses}>
{auxPanel}
<div className="mx_RoomView_timeline">
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
{searchResultsPanel}
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{statusBar}
<RoomContext.Provider value={this.state}>
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref={this._roomView}>
<ErrorBoundary>
<RoomHeader
room={this.state.room}
searchInfo={searchInfo}
oobData={this.props.oobData}
inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}
/>
<MainSplit
panel={rightPanel}
resizeNotifier={this.props.resizeNotifier}
>
<div className={fadableSectionClasses}>
{auxPanel}
<div className="mx_RoomView_timeline">
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
{searchResultsPanel}
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line" />
{statusBar}
</div>
</div>
{previewBar}
{messageComposer}
</div>
{previewBar}
{messageComposer}
</div>
</MainSplit>
</ErrorBoundary>
</main>
</MainSplit>
</ErrorBoundary>
</main>
</RoomContext.Provider>
);
},
});
module.exports.RoomContext = RoomContext;

View file

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, {createRef} from "react";
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard';
import { Key } from '../../Keyboard';
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
@ -85,7 +84,7 @@ if (DEBUG_SCROLL) {
* offset as normal.
*/
module.exports = createReactClass({
export default createReactClass({
displayName: 'ScrollPanel',
propTypes: {
@ -167,6 +166,8 @@ module.exports = createReactClass({
}
this.resetScrollState();
this._itemlist = createRef();
},
componentDidMount: function() {
@ -329,7 +330,7 @@ module.exports = createReactClass({
this._isFilling = true;
}
const itemlist = this.refs.itemlist;
const itemlist = this._itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild;
const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = [];
@ -374,7 +375,7 @@ module.exports = createReactClass({
const origExcessHeight = excessHeight;
const tiles = this.refs.itemlist.children;
const tiles = this._itemlist.current.children;
// The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null;
@ -522,7 +523,7 @@ module.exports = createReactClass({
scrollRelative: function(mult) {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
scrollNode.scrollTop = scrollNode.scrollTop + delta;
scrollNode.scrollBy(0, delta);
this._saveScrollState();
},
@ -531,26 +532,26 @@ module.exports = createReactClass({
* @param {object} ev the keyboard event
*/
handleScrollKey: function(ev) {
switch (ev.keyCode) {
case KeyCode.PAGE_UP:
switch (ev.key) {
case Key.PAGE_UP:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(-1);
}
break;
case KeyCode.PAGE_DOWN:
case Key.PAGE_DOWN:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(1);
}
break;
case KeyCode.HOME:
case Key.HOME:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToTop();
}
break;
case KeyCode.END:
case Key.END:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToBottom();
}
@ -603,7 +604,7 @@ module.exports = createReactClass({
const scrollNode = this._getScrollNode();
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this.refs.itemlist;
const itemlist = this._itemlist.current;
const messages = itemlist.children;
let node = null;
@ -645,7 +646,7 @@ module.exports = createReactClass({
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
} else if (scrollState.trackedScrollToken) {
const itemlist = this.refs.itemlist;
const itemlist = this._itemlist.current;
const trackedNode = this._getTrackedNode();
if (trackedNode) {
const newBottomOffset = this._topFromBottom(trackedNode);
@ -677,8 +678,13 @@ module.exports = createReactClass({
debuglog("updateHeight getting straight to business, no scrolling going on.");
}
// We might have unmounted since the timer finished, so abort if so.
if (this.unmounted) {
return;
}
const sn = this._getScrollNode();
const itemlist = this.refs.itemlist;
const itemlist = this._itemlist.current;
const contentHeight = this._getMessagesHeight();
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
@ -699,17 +705,15 @@ module.exports = createReactClass({
// the currently filled piece of the timeline
if (trackedNode) {
const oldTop = trackedNode.offsetTop;
// changing the height might change the scrollTop
// if the new height is smaller than the scrollTop.
// We calculate the diff that needs to be applied
// ourselves, so be sure to measure the
// scrollTop before changing the height.
const preexistingScrollTop = sn.scrollTop;
itemlist.style.height = `${newHeight}px`;
const newTop = trackedNode.offsetTop;
const topDiff = newTop - oldTop;
sn.scrollTop = preexistingScrollTop + topDiff;
debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop});
// important to scroll by a relative amount as
// reading scrollTop and then setting it might
// yield out of date values and cause a jump
// when setting it
sn.scrollBy(0, topDiff);
debuglog("updateHeight to", {newHeight, topDiff});
}
}
},
@ -720,7 +724,7 @@ module.exports = createReactClass({
if (!trackedNode || !trackedNode.parentElement) {
let node;
const messages = this.refs.itemlist.children;
const messages = this._itemlist.current.children;
const scrollToken = scrollState.trackedScrollToken;
for (let i = messages.length-1; i >= 0; --i) {
@ -752,14 +756,17 @@ module.exports = createReactClass({
},
_getMessagesHeight() {
const itemlist = this.refs.itemlist;
const itemlist = this._itemlist.current;
const lastNode = itemlist.lastElementChild;
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
// 18 is itemlist padding
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
return lastNodeBottom - firstNodeTop + (18 * 2);
},
_topFromBottom(node) {
return this.refs.itemlist.clientHeight - node.offsetTop;
// current capped height - distance from top = distance from bottom of container to top of tracked element
return this._itemlist.current.clientHeight - node.offsetTop;
},
/* get the DOM node which has the scrollTop property we care about for our
@ -791,7 +798,7 @@ module.exports = createReactClass({
the same minimum bottom offset, effectively preventing the timeline to shrink.
*/
preventShrinking: function() {
const messageList = this.refs.itemlist;
const messageList = this._itemlist.current;
const tiles = messageList && messageList.children;
if (!messageList) {
return;
@ -818,7 +825,7 @@ module.exports = createReactClass({
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
clearPreventShrinking: function() {
const messageList = this.refs.itemlist;
const messageList = this._itemlist.current;
const balanceElement = messageList && messageList.parentElement;
if (balanceElement) balanceElement.style.paddingBottom = null;
this.preventShrinkingState = null;
@ -837,7 +844,7 @@ module.exports = createReactClass({
if (this.preventShrinkingState) {
const sn = this._getScrollNode();
const scrollState = this.scrollState;
const messageList = this.refs.itemlist;
const messageList = this._itemlist.current;
const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
// element used to set paddingBottom to balance the typing notifs disappearing
const balanceElement = messageList.parentElement;
@ -869,11 +876,14 @@ module.exports = createReactClass({
// TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway.
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
// list-style-type: none; is no longer a list
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
{ this.props.children }
</ol>
</div>

View file

@ -15,21 +15,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { KeyCode } from '../../Keyboard';
import { Key } from '../../Keyboard';
import dis from '../../dispatcher';
import { throttle } from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import classNames from 'classnames';
module.exports = createReactClass({
export default createReactClass({
displayName: 'SearchBox',
propTypes: {
onSearch: PropTypes.func,
onCleared: PropTypes.func,
onKeyDown: PropTypes.func,
className: PropTypes.string,
placeholder: PropTypes.string.isRequired,
@ -52,6 +53,10 @@ module.exports = createReactClass({
};
},
UNSAFE_componentWillMount: function() {
this._search = createRef();
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
},
@ -65,34 +70,35 @@ module.exports = createReactClass({
switch (payload.action) {
case 'view_room':
if (this.refs.search && payload.clear_search) {
if (this._search.current && payload.clear_search) {
this._clearSearch();
}
break;
case 'focus_room_filter':
if (this.refs.search) {
this.refs.search.focus();
if (this._search.current) {
this._search.current.focus();
}
break;
}
},
onChange: function() {
if (!this.refs.search) return;
this.setState({ searchTerm: this.refs.search.value });
if (!this._search.current) return;
this.setState({ searchTerm: this._search.current.value });
this.onSearch();
},
onSearch: throttle(function() {
this.props.onSearch(this.refs.search.value);
this.props.onSearch(this._search.current.value);
}, 200, {trailing: true, leading: true}),
_onKeyDown: function(ev) {
switch (ev.keyCode) {
case KeyCode.ESCAPE:
switch (ev.key) {
case Key.ESCAPE:
this._clearSearch("keyboard");
break;
}
if (this.props.onKeyDown) this.props.onKeyDown(ev);
},
_onFocus: function(ev) {
@ -111,7 +117,7 @@ module.exports = createReactClass({
},
_clearSearch: function(source) {
this.refs.search.value = "";
this._search.current.value = "";
this.onChange();
if (this.props.onCleared) {
this.props.onCleared(source);
@ -127,9 +133,11 @@ module.exports = createReactClass({
return null;
}
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
(<AccessibleButton
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
// show a shorter placeholder when blurred, if requested
@ -144,7 +152,7 @@ module.exports = createReactClass({
<input
key="searchfield"
type="text"
ref="search"
ref={this._search}
className={"mx_textinput_icon mx_textinput_search " + className}
value={ this.state.searchTerm }
onFocus={ this._onFocus }
@ -152,6 +160,7 @@ module.exports = createReactClass({
onKeyDown={ this._onKeyDown }
onBlur={this._onBlur}
placeholder={ placeholder }
autoComplete="off"
/>
{ clearButton }
</div>

View file

@ -19,7 +19,7 @@ limitations under the License.
import * as React from "react";
import {_t} from '../../languageHandler';
import PropTypes from "prop-types";
import sdk from "../../index";
import * as sdk from "../../index";
/**
* Represents a tab for the TabbedView.
@ -38,7 +38,7 @@ export class Tab {
}
}
export class TabbedView extends React.Component {
export default class TabbedView extends React.Component {
static propTypes = {
// The tabs to show
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,

View file

@ -1,5 +1,6 @@
/*
Copyright 2017, 2018 New Vector Ltd.
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.
@ -16,24 +17,23 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions';
import sdk from '../../index';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
const TagPanel = createReactClass({
displayName: 'TagPanel',
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
statics: {
contextType: MatrixClientContext,
},
getInitialState() {
@ -45,8 +45,8 @@ const TagPanel = createReactClass({
componentWillMount: function() {
this.unmounted = false;
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.on("sync", this._onClientSync);
this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) {
@ -58,21 +58,21 @@ const TagPanel = createReactClass({
});
});
// This could be done by anything with a matrix client
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
},
componentWillUnmount() {
this.unmounted = true;
this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.removeListener("sync", this._onClientSync);
if (this._filterStoreToken) {
this._filterStoreToken.remove();
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.removeListener("sync", this._onClientSync);
if (this._tagOrderStoreToken) {
this._tagOrderStoreToken.remove();
}
},
_onGroupMyMembership() {
if (this.unmounted) return;
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
},
_onClientSync(syncState, prevState) {
@ -81,7 +81,7 @@ const TagPanel = createReactClass({
const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected) {
// Load joined groups
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
}
},
@ -104,6 +104,7 @@ const TagPanel = createReactClass({
render() {
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const ActionButton = sdk.getComponent('elements.ActionButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
@ -154,6 +155,13 @@ const TagPanel = createReactClass({
ref={provided.innerRef}
>
{ tags }
<div>
<ActionButton
tooltip
label={_t("Communities")}
action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus" />
</div>
{ provided.placeholder }
</div>
) }

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../index';
import * as sdk from '../../index';
import dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';

Some files were not shown because too many files have changed in this diff Show more