Merge branch 'develop' of https://github.com/matrix-org/matrix-react-sdk into t3chguy/roving

This commit is contained in:
Michael Telatynski 2020-01-20 20:48:11 +00:00
commit 397e116efb
68 changed files with 1948 additions and 626 deletions

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.
@ -21,6 +21,7 @@ import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
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';
@ -62,7 +63,7 @@ import { countRoomsWithNotif } from '../../RoomNotifs';
import { ThemeWatcher } from "../../theme";
import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer } from "../../utils/promise";
import KeyVerificationStateObserver from '../../utils/KeyVerificationStateObserver';
import ToastStore from "../../stores/ToastStore";
/** constants for MatrixChat.state.view */
export const VIEWS = {
@ -79,18 +80,14 @@ export 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,
// we are logged in with an active matrix client.
LOGGED_IN: 7,
@ -656,16 +653,12 @@ export default createReactClass({
});
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.COMPLETE_SECURITY
) {
this._onLoggedIn();
}
break;
@ -1169,7 +1162,7 @@ export default createReactClass({
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'});
}
@ -1389,6 +1382,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);
@ -1458,22 +1453,14 @@ export default createReactClass({
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
cli.on("crypto.verification.request", request => {
let requestObserver;
if (request.event.getRoomId()) {
requestObserver = new KeyVerificationStateObserver(
request.event, MatrixClientPeg.get());
}
if (!requestObserver || requestObserver.pending) {
dis.dispatch({
action: "show_toast",
toast: {
key: request.event.getId(),
title: _t("Verification Request"),
icon: "verification",
props: {request, requestObserver},
component: sdk.getComponent("toasts.VerificationRequestToast"),
},
console.log(`MatrixChat got a .request ${request.channel.transactionId}`, request.event.getRoomId());
if (request.pending) {
ToastStore.sharedInstance().addOrReplaceToast({
key: 'verifreq_' + request.channel.transactionId,
title: _t("Verification Request"),
icon: "verification",
props: {request},
component: sdk.getComponent("toasts.VerificationRequestToast"),
});
}
});
@ -1573,6 +1560,10 @@ 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',
@ -1822,21 +1813,69 @@ export default createReactClass({
this._loggedInView = ref;
},
async onUserCompletedLoginFlow(credentials) {
// 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
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") throw e;
}
if (masterKeyInStorage) {
this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY });
} else {
this._onLoggedIn();
}
},
onCompleteSecurityFinished() {
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.onCompleteSecurityFinished}
/>
);
} 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');
@ -1921,7 +1960,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}

View file

@ -160,6 +160,7 @@ export default class RightPanel extends React.Component {
groupId: payload.groupId,
member: payload.member,
event: payload.event,
verificationRequest: payload.verificationRequest,
});
}
}
@ -168,6 +169,7 @@ export default class RightPanel extends React.Component {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
const EncryptionPanel = sdk.getComponent('right_panel.EncryptionPanel');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
@ -235,6 +237,8 @@ export default class RightPanel extends React.Component {
panel = <NotificationPanel />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel) {
panel = <EncryptionPanel member={this.state.member} verificationRequest={this.state.verificationRequest} />;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {

View file

@ -173,6 +173,7 @@ export default 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);
@ -492,6 +493,7 @@ export default createReactClass({
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);
@ -762,6 +764,14 @@ export default createReactClass({
this._updateE2EStatus(room);
},
onUserVerificationChanged: function(userId, _trustStatus) {
const room = this.state.room;
if (!room.currentState.getMember(userId)) {
return;
}
this._updateE2EStatus(room);
},
_updateE2EStatus: async function(room) {
const cli = MatrixClientPeg.get();
if (!cli.isRoomEncrypted(room.roomId)) {
@ -782,32 +792,41 @@ export default createReactClass({
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
});
});
debuglog("e2e check is warning/verified only as cross-signing is off");
return;
}
/* At this point, the user has encryption on and cross-signing on */
const e2eMembers = await room.getEncryptionTargetMembers();
for (const member of e2eMembers) {
const { userId } = member;
const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified();
if (!userVerified) {
this.setState({
e2eStatus: "warning",
});
return;
}
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. */
for (const userId of verified) {
const devices = await cli.getStoredDevicesForUser(userId);
const allDevicesVerified = devices.every(device => {
const { deviceId } = device;
return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified();
const allDevicesVerified = devices.every(({deviceId}) => {
return cli.checkDeviceTrust(userId, deviceId).isVerified();
});
if (!allDevicesVerified) {
this.setState({
e2eStatus: "warning",
});
debuglog("e2e status set to warning as not all users trust all of their devices." +
" Aborted on user", userId);
return;
}
}
this.setState({
e2eStatus: "verified",
e2eStatus: unverified.length === 0 ? "verified" : "normal",
});
},

View file

@ -1134,9 +1134,11 @@ const TimelinePanel = createReactClass({
const allowPartial = opts.allowPartial || false;
const messagePanel = this._messagePanel.current;
if (messagePanel === undefined) return null;
if (!messagePanel) return null;
const wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
const messagePanelNode = ReactDOM.findDOMNode(messagePanel);
if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync
const wrapperRect = messagePanelNode.getBoundingClientRect();
const myUserId = MatrixClientPeg.get().credentials.userId;
const isNodeInView = (node) => {

View file

@ -1,5 +1,5 @@
/*
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.
@ -15,37 +15,26 @@ limitations under the License.
*/
import * as React from "react";
import dis from "../../dispatcher";
import { _t } from '../../languageHandler';
import ToastStore from "../../stores/ToastStore";
import classNames from "classnames";
export default class ToastContainer extends React.Component {
constructor() {
super();
this.state = {toasts: []};
this.state = {toasts: ToastStore.sharedInstance().getToasts()};
}
componentDidMount() {
this._dispatcherRef = dis.register(this.onAction);
ToastStore.sharedInstance().on('update', this._onToastStoreUpdate);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
ToastStore.sharedInstance().removeListener('update', this._onToastStoreUpdate);
}
onAction = (payload) => {
if (payload.action === "show_toast") {
this._addToast(payload.toast);
}
};
_addToast(toast) {
this.setState({toasts: this.state.toasts.concat(toast)});
}
dismissTopToast = () => {
const [, ...remaining] = this.state.toasts;
this.setState({toasts: remaining});
_onToastStoreUpdate = () => {
this.setState({toasts: ToastStore.sharedInstance().getToasts()});
};
render() {
@ -62,8 +51,8 @@ export default class ToastContainer extends React.Component {
const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null;
const toastProps = Object.assign({}, props, {
dismiss: this.dismissTopToast,
key,
toastKey: key,
});
toast = (<div className={toastClasses}>
<h2>{title}{countIndicator}</h2>

View file

@ -0,0 +1,177 @@
/*
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 PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { accessSecretStorage } from '../../../CrossSigningManager';
const PHASE_INTRO = 0;
const PHASE_DONE = 1;
const PHASE_CONFIRM_SKIP = 2;
export default class CompleteSecurity extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
constructor() {
super();
this.state = {
phase: PHASE_INTRO,
};
}
onStartClick = async () => {
const cli = MatrixClientPeg.get();
try {
await accessSecretStorage(async () => {
await cli.checkOwnCrossSigningTrust();
});
this.setState({
phase: PHASE_DONE,
});
} catch (e) {
// this will throw if the user hits cancel, so ignore
}
}
onSkipClick = () => {
this.setState({
phase: PHASE_CONFIRM_SKIP,
});
}
onSkipConfirmClick = () => {
this.props.onFinished();
}
onSkipBackClick = () => {
this.setState({
phase: PHASE_INTRO,
});
}
onDoneClick = () => {
this.props.onFinished();
}
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {
phase,
} = this.state;
let icon;
let title;
let body;
if (phase === PHASE_INTRO) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Complete security");
body = (
<div>
<p>{_t(
"Verify this session to grant it access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="danger"
onClick={this.onSkipClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.onStartClick}
>
{_t("Start")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_DONE) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified"></span>;
title = _t("Session verified");
body = (
<div>
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified"></div>
<p>{_t(
"Your new session is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="primary"
onClick={this.onDoneClick}
>
{_t("Done")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_CONFIRM_SKIP) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning"></span>;
title = _t("Are you sure?");
body = (
<div>
<p>{_t(
"Without completing security on this device, it wont have " +
"access to encrypted messages.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
className="warning"
kind="secondary"
onClick={this.onSkipConfirmClick}
>
{_t("Skip")}
</AccessibleButton>
<AccessibleButton
kind="danger"
onClick={this.onSkipBackClick}
>
{_t("Go Back")}
</AccessibleButton>
</div>
</div>
);
} else {
throw new Error(`Unknown phase ${phase}`);
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2 className="mx_CompleteSecurity_header">
{icon}
{title}
</h2>
<div className="mx_CompleteSecurity_body">
{body}
</div>
</AuthBody>
</AuthPage>
);
}
}

View file

@ -66,7 +66,7 @@ export default class SoftLogout extends React.Component {
componentDidMount(): void {
// We've ended up here when we don't need to - navigate to login
if (!Lifecycle.isSoftLogout()) {
dis.dispatch({action: "on_logged_in"});
dis.dispatch({action: "start_login"});
return;
}

View file

@ -83,7 +83,7 @@ export default createReactClass({
if (viewUserOnClick) {
onClick = () => {
dispatcher.dispatch({
dis.dispatch({
action: 'view_user',
member: this.props.member,
});

View file

@ -63,7 +63,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
componentWillUmount() {
componentWillUnmount() {
const { user } = this.props.member;
if (!user) {
return;

View file

@ -23,6 +23,7 @@ import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import sendBugReport from '../../../rageshake/submit-rageshake';
export default class BugReportDialog extends React.Component {
constructor(props) {
@ -67,32 +68,30 @@ export default class BugReportDialog extends React.Component {
this.setState({ busy: true, progress: null, err: null });
this._sendProgressCallback(_t("Preparing to send logs"));
require(['../../../rageshake/submit-rageshake'], (s) => {
s(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
progressCallback: this._sendProgressCallback,
label: this.props.label,
}).then(() => {
if (!this._unmounted) {
this.props.onFinished(false);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// N.B. first param is passed to piwik and so doesn't want i18n
Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, {
title: _t('Logs sent'),
description: _t('Thank you!'),
hasCancelButton: false,
});
}
}, (err) => {
if (!this._unmounted) {
this.setState({
busy: false,
progress: null,
err: _t("Failed to send logs: ") + `${err.message}`,
});
}
});
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
progressCallback: this._sendProgressCallback,
label: this.props.label,
}).then(() => {
if (!this._unmounted) {
this.props.onFinished(false);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// N.B. first param is passed to piwik and so doesn't want i18n
Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, {
title: _t('Logs sent'),
description: _t('Thank you!'),
hasCancelButton: false,
});
}
}, (err) => {
if (!this._unmounted) {
this.setState({
busy: false,
progress: null,
err: _t("Failed to send logs: ") + `${err.message}`,
});
}
});
}

View file

@ -24,8 +24,7 @@ import * as sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import { _t } from '../../../languageHandler';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import DMRoomMap from '../../../utils/DMRoomMap';
import createRoom from "../../../createRoom";
import {ensureDMExists} from "../../../createRoom";
import dis from "../../../dispatcher";
import SettingsStore from '../../../settings/SettingsStore';
@ -100,9 +99,15 @@ export default class DeviceVerifyDialog extends React.Component {
if (!verifyingOwnDevice && SettingsStore.getValue("feature_cross_signing")) {
const roomId = await ensureDMExistsAndOpen(this.props.userId);
// throws upon cancellation before having started
this._verifier = await client.requestVerificationDM(
const request = await client.requestVerificationDM(
this.props.userId, roomId, [verificationMethods.SAS],
);
await request.waitFor(r => r.ready || r.started);
if (request.ready) {
this._verifier = request.beginKeyVerification(verificationMethods.SAS);
} else {
this._verifier = request.verifier;
}
} else {
this._verifier = client.beginKeyVerification(
verificationMethods.SAS, this.props.userId, this.props.device.deviceId,
@ -316,23 +321,7 @@ export default class DeviceVerifyDialog extends React.Component {
}
async function ensureDMExistsAndOpen(userId) {
const client = MatrixClientPeg.get();
const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId);
const rooms = roomIds.map(id => client.getRoom(id));
const suitableDMRooms = rooms.filter(r => {
if (r && r.getMyMembership() === "join") {
const member = r.getMember(userId);
return member && (member.membership === "invite" || member.membership === "join");
}
return false;
});
let roomId;
if (suitableDMRooms.length) {
const room = suitableDMRooms[0];
roomId = room.roomId;
} else {
roomId = await createRoom({dmUserId: userId, spinner: false, andView: false});
}
const roomId = ensureDMExists(MatrixClientPeg.get(), userId);
// don't use andView and spinner in createRoom, together, they cause this dialog to close and reopen,
// we causes us to loose the verifier and restart, and we end up having two verification requests
dis.dispatch({

View file

@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import DMRoomMap from "../../../utils/DMRoomMap";
import {RoomMember} from "matrix-js-sdk/src/matrix";
import SdkConfig from "../../../SdkConfig";
@ -31,8 +31,11 @@ import dis from "../../../dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import createRoom from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite";
// TODO: [TravisR] Make this generic for all kinds of invites
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
@ -138,11 +141,11 @@ class DMUserTile extends React.PureComponent {
const avatarSize = 20;
const avatar = this.props.member.isEmail
? <img
className='mx_DMInviteDialog_userTile_avatar mx_DMInviteDialog_userTile_threepidAvatar'
className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar'
src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} />
: <BaseAvatar
className='mx_DMInviteDialog_userTile_avatar'
className='mx_InviteDialog_userTile_avatar'
url={getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(),
avatarSize, avatarSize, "crop")}
@ -152,13 +155,13 @@ class DMUserTile extends React.PureComponent {
height={avatarSize} />;
return (
<span className='mx_DMInviteDialog_userTile'>
<span className='mx_DMInviteDialog_userTile_pill'>
<span className='mx_InviteDialog_userTile'>
<span className='mx_InviteDialog_userTile_pill'>
{avatar}
<span className='mx_DMInviteDialog_userTile_name'>{this.props.member.name}</span>
<span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
<AccessibleButton
className='mx_DMInviteDialog_userTile_remove'
className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
@ -209,7 +212,7 @@ class DMRoomTile extends React.PureComponent {
// Highlight the word the user entered
const substr = str.substring(i, filterStr.length + i);
result.push(<span className='mx_DMInviteDialog_roomTile_highlight' key={i + 'bold'}>{substr}</span>);
result.push(<span className='mx_InviteDialog_roomTile_highlight' key={i + 'bold'}>{substr}</span>);
i += substr.length;
}
@ -227,7 +230,7 @@ class DMRoomTile extends React.PureComponent {
let timestamp = null;
if (this.props.lastActiveTs) {
const humanTs = humanizeTime(this.props.lastActiveTs);
timestamp = <span className='mx_DMInviteDialog_roomTile_time'>{humanTs}</span>;
timestamp = <span className='mx_InviteDialog_roomTile_time'>{humanTs}</span>;
}
const avatarSize = 36;
@ -247,61 +250,95 @@ class DMRoomTile extends React.PureComponent {
let checkmark = null;
if (this.props.isSelected) {
// To reduce flickering we put the 'selected' room tile above the real avatar
checkmark = <div className='mx_DMInviteDialog_roomTile_selected' />;
checkmark = <div className='mx_InviteDialog_roomTile_selected' />;
}
// To reduce flickering we put the checkmark on top of the actual avatar (prevents
// the browser from reloading the image source when the avatar remounts).
const stackedAvatar = (
<span className='mx_DMInviteDialog_roomTile_avatarStack'>
<span className='mx_InviteDialog_roomTile_avatarStack'>
{avatar}
{checkmark}
</span>
);
return (
<div className='mx_DMInviteDialog_roomTile' onClick={this._onClick}>
<div className='mx_InviteDialog_roomTile' onClick={this._onClick}>
{stackedAvatar}
<span className='mx_DMInviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span>
<span className='mx_DMInviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
<span className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span>
<span className='mx_InviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
{timestamp}
</div>
);
}
}
export default class DMInviteDialog extends React.PureComponent {
export default class InviteDialog extends React.PureComponent {
static propTypes = {
// Takes an array of user IDs/emails to invite.
onFinished: PropTypes.func.isRequired,
// The kind of invite being performed. Assumed to be KIND_DM if
// not provided.
kind: PropTypes.string,
// The room ID this dialog is for. Only required for KIND_INVITE.
roomId: PropTypes.string,
};
static defaultProps = {
kind: KIND_DM,
};
_debounceTimer: number = null;
_editorRef: any = null;
constructor() {
super();
constructor(props) {
super(props);
if (props.kind === KIND_INVITE && !props.roomId) {
throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog");
}
let alreadyInvited = [];
if (props.roomId) {
const room = MatrixClientPeg.get().getRoom(props.roomId);
if (!room) throw new Error("Room ID given to InviteDialog does not look like a room");
alreadyInvited = [
...room.getMembersWithMembership('invite'),
...room.getMembersWithMembership('join'),
...room.getMembersWithMembership('ban'), // so we don't try to invite them
].map(m => m.userId);
}
this.state = {
targets: [], // array of Member objects (see interface above)
filterText: "",
recents: this._buildRecents(),
recents: this._buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(),
suggestions: this._buildSuggestions(alreadyInvited),
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
tryingIdentityServer: false,
// These two flags are used for the 'Go' button to communicate what is going on.
busy: false,
errorText: null,
};
this._editorRef = createRef();
}
_buildRecents(): {userId: string, user: RoomMember, lastActive: number} {
_buildRecents(excludedTargetIds: string[]): {userId: string, user: RoomMember, lastActive: number} {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals();
const recents = [];
for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.includes(userId)) continue;
const room = rooms[userId];
const member = room.getMember(userId);
if (!member) continue; // just skip people who don't have memberships for some reason
@ -320,7 +357,7 @@ export default class DMInviteDialog extends React.PureComponent {
return recents;
}
_buildSuggestions(): {userId: string, user: RoomMember} {
_buildSuggestions(excludedTargetIds: string[]): {userId: string, user: RoomMember} {
const maxConsideredMembers = 200;
const client = MatrixClientPeg.get();
const excludedUserIds = [client.getUserId(), SdkConfig.get()['welcomeUserId']];
@ -337,6 +374,11 @@ export default class DMInviteDialog extends React.PureComponent {
const joinedMembers = room.getJoinedMembers().filter(u => !excludedUserIds.includes(u.userId));
for (const member of joinedMembers) {
// Filter out user IDs that are already in the room / should be excluded
if (excludedTargetIds.includes(member.userId)) {
continue;
}
if (!members[member.userId]) {
members[member.userId] = {
member: member,
@ -369,6 +411,58 @@ export default class DMInviteDialog extends React.PureComponent {
return scores;
}, {});
// Now that we have scores for being in rooms, boost those people who have sent messages
// recently, as a way to improve the quality of suggestions. We do this by checking every
// room to see who has sent a message in the last few hours, and giving them a score
// which correlates to the freshness of their message. In theory, this results in suggestions
// which are closer to "continue this conversation" rather than "this person exists".
const trueJoinedRooms = client.getRooms().filter(r => r.getMyMembership() === 'join');
const now = (new Date()).getTime();
const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago
const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
const lastSpoke = {}; // userId: timestamp
const lastSpokeMembers = {}; // userId: room member
for (const room of trueJoinedRooms) {
// Skip low priority rooms and DMs
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (Object.keys(room.tags).includes("m.lowpriority") || isDm) {
continue;
}
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i >= Math.max(0, events.length - maxMessagesConsidered); i--) {
const ev = events[i];
if (excludedUserIds.includes(ev.getSender())) {
continue;
}
if (ev.getTs() <= earliestAgeConsidered) {
break; // give up: all events from here on out are too old
}
if (!lastSpoke[ev.getSender()] || lastSpoke[ev.getSender()] < ev.getTs()) {
lastSpoke[ev.getSender()] = ev.getTs();
lastSpokeMembers[ev.getSender()] = room.getMember(ev.getSender());
}
}
}
for (const userId in lastSpoke) {
const ts = lastSpoke[userId];
const member = lastSpokeMembers[userId];
if (!member) continue; // skip people we somehow don't have profiles for
// Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
// boost we'll try and award at least +1.0 for making the list, with +4.0 being
// an approximate maximum for being selected.
const distanceFromNow = Math.abs(now - ts); // abs to account for slight future messages
const inverseTime = (now - earliestAgeConsidered) - distanceFromNow;
const scoreBoost = Math.max(1, inverseTime / (15 * 60 * 1000)); // 15min segments to keep scores sane
let record = memberScores[userId];
if (!record) record = memberScores[userId] = {score: 0};
record.member = member;
record.score += scoreBoost;
}
const members = Object.values(memberScores);
members.sort((a, b) => {
if (a.score === b.score) {
@ -384,12 +478,101 @@ export default class DMInviteDialog extends React.PureComponent {
return members.map(m => ({userId: m.member.userId, user: m.member}));
}
_shouldAbortAfterInviteError(result): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error');
if (failedUsers.length > 0) {
console.log("Failed to invite users: ", result);
this.setState({
busy: false,
errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", {
csvUsers: failedUsers.join(", "),
}),
});
return true; // abort
}
return false;
}
_startDm = () => {
this.props.onFinished(this.state.targets.map(t => t.userId));
this.setState({busy: true});
const targetIds = this.state.targets.map(t => t.userId);
// Check if there is already a DM with these people and reuse it if possible.
const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds);
if (existingRoom) {
dis.dispatch({
action: 'view_room',
room_id: existingRoom.roomId,
should_peek: false,
joining: false,
});
this.props.onFinished();
return;
}
// Check if it's a traditional DM and create the room if required.
// TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM
let createRoomPromise = Promise.resolve();
if (targetIds.length === 1) {
createRoomPromise = createRoom({dmUserId: targetIds[0]});
} else {
// Create a boring room and try to invite the targets manually.
createRoomPromise = createRoom().then(roomId => {
return inviteMultipleToRoom(roomId, targetIds);
}).then(result => {
if (this._shouldAbortAfterInviteError(result)) {
return true; // abort
}
});
}
// the createRoom call will show the room for us, so we don't need to worry about that.
createRoomPromise.then(abort => {
if (abort === true) return; // only abort on true booleans, not roomIds or something
this.props.onFinished();
}).catch(err => {
console.error(err);
this.setState({
busy: false,
errorText: _t("We couldn't create your DM. Please check the users you want to invite and try again."),
});
});
};
_inviteUsers = () => {
this.setState({busy: true});
const targetIds = this.state.targets.map(t => t.userId);
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (!room) {
console.error("Failed to find the room to invite users to");
this.setState({
busy: false,
errorText: _t("Something went wrong trying to invite the users."),
});
return;
}
inviteMultipleToRoom(this.props.roomId, targetIds).then(result => {
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too
this.props.onFinished();
}
}).catch(err => {
console.error(err);
this.setState({
busy: false,
errorText: _t(
"We couldn't invite those users. Please check the users you want to invite and try again.",
),
});
});
};
_cancel = () => {
this.props.onFinished([]);
// We do not want the user to close the dialog while an action is in progress
if (this.state.busy) return;
this.props.onFinished();
};
_updateFilter = (e) => {
@ -599,7 +782,11 @@ export default class DMInviteDialog extends React.PureComponent {
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this);
const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
const sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
if (this.props.kind === KIND_INVITE) {
sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions");
}
// Mix in the server results if we have any, but only if we're searching. We track the additional
// members separately because we want to filter sourceMembers but trust the mixin arrays to have
@ -631,7 +818,7 @@ export default class DMInviteDialog extends React.PureComponent {
if (sourceMembers.length === 0 && additionalMembers.length === 0) {
return (
<div className='mx_DMInviteDialog_section'>
<div className='mx_InviteDialog_section'>
<h3>{sectionName}</h3>
<p>{_t("No results")}</p>
</div>
@ -672,7 +859,7 @@ export default class DMInviteDialog extends React.PureComponent {
/>
));
return (
<div className='mx_DMInviteDialog_section'>
<div className='mx_InviteDialog_section'>
<h3>{sectionName}</h3>
{tiles}
{showMore}
@ -695,7 +882,7 @@ export default class DMInviteDialog extends React.PureComponent {
/>
);
return (
<div className='mx_DMInviteDialog_editor' onClick={this._onClickInputArea}>
<div className='mx_InviteDialog_editor' onClick={this._onClickInputArea}>
{targets}
{input}
</div>
@ -739,35 +926,67 @@ export default class DMInviteDialog extends React.PureComponent {
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const Spinner = sdk.getComponent("elements.Spinner");
let spinner = null;
if (this.state.busy) {
spinner = <Spinner w={20} h={20} />;
}
let title;
let helpText;
let buttonText;
let goButtonFn;
if (this.props.kind === KIND_DM) {
const userId = MatrixClientPeg.get().getUserId();
title = _t("Direct Messages");
helpText = _t(
"If you can't find someone, ask them for their username, or share your " +
"username (%(userId)s) or <a>profile link</a>.",
{userId},
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>},
);
buttonText = _t("Go");
goButtonFn = this._startDm;
} else { // KIND_INVITE
title = _t("Invite to this room");
helpText = _t(
"If you can't find someone, ask them for their username (e.g. @user:server.com) or " +
"<a>share this room</a>.", {},
{a: (sub) => <a href={makeRoomPermalink(this.props.roomId)} rel="noopener" target="_blank">{sub}</a>},
);
buttonText = _t("Invite");
goButtonFn = this._inviteUsers;
}
const userId = MatrixClientPeg.get().getUserId();
return (
<BaseDialog
className='mx_DMInviteDialog'
className='mx_InviteDialog'
hasCancel={true}
onFinished={this._cancel}
title={_t("Direct Messages")}
title={title}
>
<div className='mx_DMInviteDialog_content'>
<p>
{_t(
"If you can't find someone, ask them for their username, or share your " +
"username (%(userId)s) or <a>profile link</a>.",
{userId},
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>},
)}
</p>
<div className='mx_DMInviteDialog_addressBar'>
<div className='mx_InviteDialog_content'>
<p>{helpText}</p>
<div className='mx_InviteDialog_addressBar'>
{this._renderEditor()}
{this._renderIdentityServerWarning()}
<AccessibleButton
kind="primary"
onClick={this._startDm}
className='mx_DMInviteDialog_goButton'
>
{_t("Go")}
</AccessibleButton>
<div className='mx_InviteDialog_buttonAndSpinner'>
<AccessibleButton
kind="primary"
onClick={goButtonFn}
className='mx_InviteDialog_goButton'
disabled={this.state.busy}
>
{buttonText}
</AccessibleButton>
{spinner}
</div>
</div>
{this._renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div>
{this._renderSection('recents')}
{this._renderSection('suggestions')}
</div>

View file

@ -22,6 +22,9 @@ import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
// TODO: We can remove this once cross-signing is the only way.
// https://github.com/vector-im/riot-web/issues/11908
/**
* Dialog which asks the user whether they want to share their keys with
* an unverified device.

View file

@ -17,7 +17,7 @@ limitations under the License.
*/
import url from 'url';
import qs from 'querystring';
import qs from 'qs';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';

View file

@ -19,102 +19,83 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import KeyVerificationStateObserver, {getNameForEventRoom, userLabelForEventRoom}
import {getNameForEventRoom, userLabelForEventRoom}
from '../../../utils/KeyVerificationStateObserver';
export default class MKeyVerificationConclusion extends React.Component {
constructor(props) {
super(props);
this.keyVerificationState = null;
this.state = {
done: false,
cancelled: false,
otherPartyUserId: null,
cancelPartyUserId: null,
};
const rel = this.props.mxEvent.getRelation();
if (rel) {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.mxEvent.getRoomId());
const requestEvent = room.findEventById(rel.event_id);
if (requestEvent) {
this._createStateObserver(requestEvent, client);
this.state = this._copyState();
} else {
const findEvent = event => {
if (event.getId() === rel.event_id) {
this._createStateObserver(event, client);
this.setState(this._copyState());
room.removeListener("Room.timeline", findEvent);
}
};
room.on("Room.timeline", findEvent);
}
}
}
_createStateObserver(requestEvent, client) {
this.keyVerificationState = new KeyVerificationStateObserver(requestEvent, client, () => {
this.setState(this._copyState());
});
}
_copyState() {
const {done, cancelled, otherPartyUserId, cancelPartyUserId} = this.keyVerificationState;
return {done, cancelled, otherPartyUserId, cancelPartyUserId};
}
componentDidMount() {
if (this.keyVerificationState) {
this.keyVerificationState.attach();
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.on("change", this._onRequestChanged);
}
}
componentWillUnmount() {
if (this.keyVerificationState) {
this.keyVerificationState.detach();
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.off("change", this._onRequestChanged);
}
}
_getName(userId) {
const roomId = this.props.mxEvent.getRoomId();
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
const member = room.getMember(userId);
return member ? member.name : userId;
}
_onRequestChanged = () => {
this.forceUpdate();
};
_userLabel(userId) {
const name = this._getName(userId);
if (name !== userId) {
return _t("%(name)s (%(userId)s)", {name, userId});
} else {
return userId;
_shouldRender(mxEvent, request) {
// normally should not happen
if (!request) {
return false;
}
// .cancel event that was sent after the verification finished, ignore
if (mxEvent.getType() === "m.key.verification.cancel" && !request.cancelled) {
return false;
}
// .done event that was sent after the verification cancelled, ignore
if (mxEvent.getType() === "m.key.verification.done" && !request.done) {
return false;
}
// request hasn't concluded yet
if (request.pending) {
return false;
}
return true;
}
render() {
const {mxEvent} = this.props;
const request = mxEvent.verificationRequest;
if (!this._shouldRender(mxEvent, request)) {
return null;
}
const client = MatrixClientPeg.get();
const myUserId = client.getUserId();
let title;
if (this.state.done) {
title = _t("You verified %(name)s", {name: getNameForEventRoom(this.state.otherPartyUserId, mxEvent)});
} else if (this.state.cancelled) {
if (mxEvent.getSender() === myUserId) {
if (request.done) {
title = _t("You verified %(name)s", {name: getNameForEventRoom(request.otherUserId, mxEvent)});
} else if (request.cancelled) {
const userId = request.cancellingUserId;
if (userId === myUserId) {
title = _t("You cancelled verifying %(name)s",
{name: getNameForEventRoom(this.state.otherPartyUserId, mxEvent)});
} else if (mxEvent.getSender() === this.state.otherPartyUserId) {
{name: getNameForEventRoom(request.otherUserId, mxEvent)});
} else {
title = _t("%(name)s cancelled verifying",
{name: getNameForEventRoom(this.state.otherPartyUserId, mxEvent)});
{name: getNameForEventRoom(userId, mxEvent)});
}
}
if (title) {
const subtitle = userLabelForEventRoom(this.state.otherPartyUserId, mxEvent);
const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent);
const classes = classNames("mx_EventTile_bubble", "mx_KeyVerification", "mx_KeyVerification_icon", {
mx_KeyVerification_icon_verified: this.state.done,
mx_KeyVerification_icon_verified: request.done,
});
return (<div className={classes}>
<div className="mx_KeyVerification_title">{title}</div>

View file

@ -17,48 +17,66 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import * as sdk from '../../../index';
import Modal from "../../../Modal";
import { _t } from '../../../languageHandler';
import KeyVerificationStateObserver, {getNameForEventRoom, userLabelForEventRoom}
import {getNameForEventRoom, userLabelForEventRoom}
from '../../../utils/KeyVerificationStateObserver';
import dis from "../../../dispatcher";
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
export default class MKeyVerificationRequest extends React.Component {
constructor(props) {
super(props);
this.keyVerificationState = new KeyVerificationStateObserver(this.props.mxEvent, MatrixClientPeg.get(), () => {
this.setState(this._copyState());
});
this.state = this._copyState();
}
_copyState() {
const {accepted, done, cancelled, cancelPartyUserId, otherPartyUserId} = this.keyVerificationState;
return {accepted, done, cancelled, cancelPartyUserId, otherPartyUserId};
}
componentDidMount() {
this.keyVerificationState.attach();
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.on("change", this._onRequestChanged);
}
}
componentWillUnmount() {
this.keyVerificationState.detach();
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.off("change", this._onRequestChanged);
}
}
_onAcceptClicked = () => {
const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog');
// todo: validate event, for example if it has sas in the methods.
const verifier = MatrixClientPeg.get().acceptVerificationDM(this.props.mxEvent, verificationMethods.SAS);
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier,
}, null, /* priority = */ false, /* static = */ true);
_openRequest = () => {
const {verificationRequest} = this.props.mxEvent;
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {verificationRequest},
});
};
_onRejectClicked = () => {
// todo: validate event, for example if it has sas in the methods.
const verifier = MatrixClientPeg.get().acceptVerificationDM(this.props.mxEvent, verificationMethods.SAS);
verifier.cancel("User declined");
_onRequestChanged = () => {
this.forceUpdate();
};
_onAcceptClicked = async () => {
const request = this.props.mxEvent.verificationRequest;
if (request) {
try {
await request.accept();
this._openRequest();
} catch (err) {
console.error(err.message);
}
}
};
_onRejectClicked = async () => {
const request = this.props.mxEvent.verificationRequest;
if (request) {
try {
await request.cancel();
} catch (err) {
console.error(err.message);
}
}
};
_acceptedLabel(userId) {
@ -82,46 +100,49 @@ export default class MKeyVerificationRequest extends React.Component {
}
render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const FormButton = sdk.getComponent("elements.FormButton");
const {mxEvent} = this.props;
const fromUserId = mxEvent.getSender();
const content = mxEvent.getContent();
const toUserId = content.to;
const client = MatrixClientPeg.get();
const myUserId = client.getUserId();
const isOwn = fromUserId === myUserId;
const request = mxEvent.verificationRequest;
if (!request || request.invalid) {
return null;
}
let title;
let subtitle;
let stateNode;
if (this.state.accepted || this.state.cancelled) {
const accepted = request.ready || request.started || request.done;
if (accepted || request.cancelled) {
let stateLabel;
if (this.state.accepted) {
stateLabel = this._acceptedLabel(toUserId);
} else if (this.state.cancelled) {
stateLabel = this._cancelledLabel(this.state.cancelPartyUserId);
if (accepted) {
stateLabel = (<AccessibleButton onClick={this._openRequest}>
{this._acceptedLabel(request.receivingUserId)}
</AccessibleButton>);
} else {
stateLabel = this._cancelledLabel(request.cancellingUserId);
}
stateNode = (<div className="mx_KeyVerification_state">{stateLabel}</div>);
}
if (toUserId === myUserId) { // request sent to us
if (!request.initiatedByMe) {
title = (<div className="mx_KeyVerification_title">{
_t("%(name)s wants to verify", {name: getNameForEventRoom(fromUserId, mxEvent)})}</div>);
_t("%(name)s wants to verify", {name: getNameForEventRoom(request.requestingUserId, mxEvent)})}</div>);
subtitle = (<div className="mx_KeyVerification_subtitle">{
userLabelForEventRoom(fromUserId, mxEvent)}</div>);
const isResolved = !(this.state.accepted || this.state.cancelled || this.state.done);
if (isResolved) {
const FormButton = sdk.getComponent("elements.FormButton");
userLabelForEventRoom(request.requestingUserId, mxEvent)}</div>);
if (request.requested && !request.observeOnly) {
stateNode = (<div className="mx_KeyVerification_buttons">
<FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
<FormButton onClick={this._onAcceptClicked} label={_t("Accept")} />
</div>);
}
} else if (isOwn) { // request sent by us
} else { // request sent by us
title = (<div className="mx_KeyVerification_title">{
_t("You sent a verification request")}</div>);
subtitle = (<div className="mx_KeyVerification_subtitle">{
userLabelForEventRoom(this.state.otherPartyUserId, mxEvent)}</div>);
userLabelForEventRoom(request.receivingUserId, mxEvent)}</div>);
}
if (title) {

View file

@ -0,0 +1,31 @@
/*
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 * as sdk from '../../../index';
import {_t} from "../../../languageHandler";
export default class EncryptionInfo extends React.PureComponent {
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (<div className="mx_UserInfo"><div className="mx_UserInfo_container">
<h3>{_t("Verify User")}</h3>
<p>{_t("For extra security, verify this user by checking a one-time code on both of your devices.")}</p>
<p>{_t("For maximum security, do this in person.")}</p>
<AccessibleButton kind="primary" onClick={this.props.onStartVerification}>{_t("Start Verification")}</AccessibleButton>
</div></div>);
}
}

View file

@ -0,0 +1,48 @@
/*
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 EncryptionInfo from "./EncryptionInfo";
import VerificationPanel from "./VerificationPanel";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {ensureDMExists} from "../../../createRoom";
export default class EncryptionPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
}
render() {
const request = this.props.verificationRequest || this.state.verificationRequest;
const {member} = this.props;
if (request) {
return <VerificationPanel request={request} key={request.channel.transactionId} />;
} else if (member) {
return <EncryptionInfo onStartVerification={this._onStartVerification} member={member} />;
} else {
return <p>Not a member nor request, not sure what to render</p>;
}
}
_onStartVerification = async () => {
const client = MatrixClientPeg.get();
const {member} = this.props;
const roomId = await ensureDMExists(client, member.userId);
const verificationRequest = await client.requestVerificationDM(member.userId, roomId);
this.setState({verificationRequest});
};
}

View file

@ -66,8 +66,13 @@ export default class GroupHeaderButtons extends HeaderButtons {
}
_onMembersClicked() {
// This toggles for us, if needed
this.setPhase(RIGHT_PANEL_PHASES.GroupMemberList);
if (this.state.phase === RIGHT_PANEL_PHASES.GroupMemberInfo) {
// send the active phase to trigger a toggle
this.setPhase(RIGHT_PANEL_PHASES.GroupMemberInfo);
} else {
// This toggles for us, if needed
this.setPhase(RIGHT_PANEL_PHASES.GroupMemberList);
}
}
_onRoomsClicked() {

View file

@ -23,10 +23,12 @@ import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton';
import HeaderButtons, {HEADER_KIND_ROOM} from './HeaderButtons';
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
import RightPanelStore from "../../../stores/RightPanelStore";
const MEMBER_PHASES = [
RIGHT_PANEL_PHASES.RoomMemberList,
RIGHT_PANEL_PHASES.RoomMemberInfo,
RIGHT_PANEL_PHASES.EncryptionPanel,
RIGHT_PANEL_PHASES.Room3pidMemberInfo,
];
@ -56,8 +58,13 @@ export default class RoomHeaderButtons extends HeaderButtons {
}
_onMembersClicked() {
// This toggles for us, if needed
this.setPhase(RIGHT_PANEL_PHASES.RoomMemberList);
if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo) {
// send the active phase to trigger a toggle
this.setPhase(RIGHT_PANEL_PHASES.RoomMemberInfo, RightPanelStore.getSharedInstance().roomPanelPhaseParams);
} else {
// This toggles for us, if needed
this.setPhase(RIGHT_PANEL_PHASES.RoomMemberList);
}
}
_onFilesClicked() {

View file

@ -40,6 +40,7 @@ import E2EIcon from "../rooms/E2EIcon";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {textualPowerLevel} from '../../../Roles';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
const _disambiguateDevices = (devices) => {
const names = Object.create(null);
@ -117,6 +118,14 @@ function verifyDevice(userId, device) {
}, null, /* priority = */ false, /* static = */ true);
}
function verifyUser(user) {
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {member: user},
});
}
function DeviceItem({userId, device}) {
const cli = useContext(MatrixClientContext);
const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
@ -1225,15 +1234,13 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
setDevices(null);
}
}
if (isRoomEncrypted) {
_downloadDeviceList();
}
_downloadDeviceList();
// Handle being unmounted
return () => {
cancelled = true;
};
}, [cli, user.userId, isRoomEncrypted]);
}, [cli, user.userId]);
// Listen to changes
useEffect(() => {
@ -1249,18 +1256,13 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
});
}
};
if (isRoomEncrypted) {
cli.on("deviceVerificationChanged", onDeviceVerificationChanged);
}
cli.on("deviceVerificationChanged", onDeviceVerificationChanged);
// Handle being unmounted
return () => {
cancel = true;
if (isRoomEncrypted) {
cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged);
}
cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged);
};
}, [cli, user.userId, isRoomEncrypted]);
}, [cli, user.userId]);
let text;
if (!isRoomEncrypted) {
@ -1275,22 +1277,24 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
text = _t("Messages in this room are end-to-end encrypted.");
}
const devicesSection = isRoomEncrypted ?
(<DevicesSection loading={devices === undefined} devices={devices} userId={user.userId} />) : null;
const userVerified = cli.checkUserTrust(user.userId).isVerified();
const isMe = user.userId === cli.getUserId();
let verifyButton;
if (!userVerified) {
verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyDevice(user.userId, null)}>
if (!userVerified && !isMe) {
verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}>
{_t("Verify")}
</AccessibleButton>;
}
const devicesSection = <DevicesSection
loading={devices === undefined}
devices={devices} userId={user.userId} />;
const securitySection = (
<div className="mx_UserInfo_container">
<h3>{ _t("Security") }</h3>
<p>{ text }</p>
{verifyButton}
{ verifyButton }
{ devicesSection }
</div>
);
@ -1308,7 +1312,7 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
<div className="mx_UserInfo_container">
<div className="mx_UserInfo_profile">
<div >
<div>
<h2 aria-label={displayName}>
{ e2eIcon }
{ displayName }

View file

@ -0,0 +1,119 @@
/*
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 * as sdk from '../../../index';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
export default class VerificationPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
this._hasVerifier = !!props.request.verifier;
}
render() {
return <div className="mx_UserInfo">
<div className="mx_UserInfo_container">
{ this.renderStatus() }
</div>
</div>;
}
renderStatus() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Spinner = sdk.getComponent('elements.Spinner');
const {request} = this.props;
if (request.requested) {
return (<p>Waiting for {request.otherUserId} to accept ... <Spinner /></p>);
} else if (request.ready) {
const verifyButton = <AccessibleButton kind="primary" onClick={this._startSAS}>
Verify by emoji
</AccessibleButton>;
return (<p>{request.otherUserId} is ready, start {verifyButton}</p>);
} else if (request.started) {
if (this.state.sasWaitingForOtherParty) {
return <p>Waiting for {request.otherUserId} to confirm ...</p>;
} else if (this.state.sasEvent) {
const VerificationShowSas = sdk.getComponent('views.verification.VerificationShowSas');
return (<div>
<VerificationShowSas
sas={this.state.sasEvent.sas}
onCancel={this._onSasMismatchesClick}
onDone={this._onSasMatchesClick}
/>
</div>);
} else {
return (<p>Setting up SAS verification...</p>);
}
} else if (request.done) {
return <p>verified {request.otherUserId}!!</p>;
} else if (request.cancelled) {
return <p>cancelled by {request.cancellingUserId}!</p>;
}
}
_startSAS = async () => {
const verifier = this.props.request.beginKeyVerification(verificationMethods.SAS);
try {
await verifier.verify();
} catch (err) {
console.error(err);
} finally {
this.setState({sasEvent: null});
}
};
_onSasMatchesClick = () => {
this.setState({sasWaitingForOtherParty: true});
this.state.sasEvent.confirm();
};
_onSasMismatchesClick = () => {
this.state.sasEvent.cancel();
};
_onVerifierShowSas = (sasEvent) => {
this.setState({sasEvent});
};
_onRequestChange = async () => {
const {request} = this.props;
if (!this._hasVerifier && !!request.verifier) {
request.verifier.on('show_sas', this._onVerifierShowSas);
try {
// on the requester side, this is also awaited in _startSAS,
// but that's ok as verify should return the same promise.
await request.verifier.verify();
} catch (err) {
console.error("error verify", err);
}
} else if (this._hasVerifier && !request.verifier) {
request.verifier.removeListener('show_sas', this._onVerifierShowSas);
}
this._hasVerifier = !!request.verifier;
this.forceUpdate();
};
componentDidMount() {
this.props.request.on("change", this._onRequestChange);
}
componentWillUnmount() {
this.props.request.off("change", this._onRequestChange);
}
}

View file

@ -209,8 +209,9 @@ export default class BasicMessageEditor extends React.Component {
const selectedParts = range.parts.map(p => p.serialize());
event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts));
if (type === "cut") {
selection.deleteFromDocument();
range.replace([]);
// Remove the text, updating the model as appropriate
this._modifiedFlag = true;
replaceRangeAndMoveCaret(range, []);
}
event.preventDefault();
}
@ -259,8 +260,8 @@ export default class BasicMessageEditor extends React.Component {
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
caret.offset += textToInsert.length;
this.props.model.update(newText, inputType, caret);
this._modifiedFlag = true;
this.props.model.update(newText, inputType, caret);
}
// this is used later to see if we need to recalculate the caret

View file

@ -60,14 +60,14 @@ export default class CrossSigningPanel extends React.PureComponent {
this.setState(this._getUpdatedStatus());
};
_getUpdatedStatus() {
async _getUpdatedStatus() {
// XXX: Add public accessors if we keep this around in production
const cli = MatrixClientPeg.get();
const crossSigning = cli._crypto._crossSigningInfo;
const secretStorage = cli._crypto._secretStorage;
const crossSigningPublicKeysOnDevice = crossSigning.getId();
const crossSigningPrivateKeysInStorage = crossSigning.isStoredInSecretStorage(secretStorage);
const secretStorageKeyInAccount = secretStorage.hasKey();
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
const secretStorageKeyInAccount = await secretStorage.hasKey();
return {
crossSigningPublicKeysOnDevice,

View file

@ -137,7 +137,7 @@ export default class BridgeSettingsTab extends React.Component {
const client = MatrixClientPeg.get();
const roomState = (client.getRoom(roomId)).currentState;
const bridgeEvents = Array.concat(...BRIDGE_EVENT_TYPES.map((typeName) =>
const bridgeEvents = [].concat(...BRIDGE_EVENT_TYPES.map((typeName) =>
Object.values(roomState.events[typeName] || {}),
));

View file

@ -160,8 +160,8 @@ export default class GeneralUserSettingsTab extends React.Component {
// for free. So we might as well use that for our own purposes.
const idServerUrl = MatrixClientPeg.get().getIdentityServerUrl();
const authClient = new IdentityAuthClient();
const idAccessToken = await authClient.getAccessToken({ check: false });
try {
const idAccessToken = await authClient.getAccessToken({ check: false });
await startTermsFlow([new Service(
SERVICE_TYPES.IS,
idServerUrl,

View file

@ -0,0 +1,57 @@
/*
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 PropTypes from 'prop-types';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import Modal from "../../../Modal";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import DeviceListener from '../../../DeviceListener';
export default class VerifySessionToast extends React.PureComponent {
static propTypes = {
toastKey: PropTypes.string.isRequired,
deviceId: PropTypes.string,
};
_onLaterClick = () => {
DeviceListener.sharedInstance().dismissVerification(this.props.deviceId);
};
_onVerifyClick = async () => {
const cli = MatrixClientPeg.get();
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
const device = await cli.getStoredDevice(cli.getUserId(), this.props.deviceId);
Modal.createTrackedDialog('New Session Verify', 'Starting dialog', DeviceVerifyDialog, {
userId: MatrixClientPeg.get().getUserId(),
device,
}, null, /* priority = */ false, /* static = */ true);
};
render() {
const FormButton = sdk.getComponent("elements.FormButton");
return (<div>
<div className="mx_Toast_description">{_t("Other users may not trust it")}</div>
<div className="mx_Toast_buttons" aria-live="off">
<FormButton label={_t("Later")} kind="danger" onClick={this._onLaterClick} />
<FormButton label={_t("Verify")} onClick={this._onVerifyClick} />
</div>
</div>);
}
}

View file

@ -18,57 +18,43 @@ import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import Modal from "../../../Modal";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import KeyVerificationStateObserver, {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver";
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
import {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver";
import dis from "../../../dispatcher";
import ToastStore from "../../../stores/ToastStore";
export default class VerificationRequestToast extends React.PureComponent {
constructor(props) {
super(props);
const {event, timeout} = props.request;
// to_device requests don't have a timestamp, so consider them age=0
const age = event.getTs() ? event.getLocalAge() : 0;
const remaining = Math.max(0, timeout - age);
const counter = Math.ceil(remaining / 1000);
this.state = {counter};
if (this.props.requestObserver) {
this.props.requestObserver.setCallback(this._checkRequestIsPending);
}
this.state = {counter: Math.ceil(props.request.timeout / 1000)};
}
componentDidMount() {
if (this.props.requestObserver) {
this.props.requestObserver.attach();
this._checkRequestIsPending();
}
const {request} = this.props;
this._intervalHandle = setInterval(() => {
let {counter} = this.state;
counter -= 1;
if (counter <= 0) {
this.cancel();
} else {
this.setState({counter});
}
counter = Math.max(0, counter - 1);
this.setState({counter});
}, 1000);
request.on("change", this._checkRequestIsPending);
}
componentWillUnmount() {
clearInterval(this._intervalHandle);
if (this.props.requestObserver) {
this.props.requestObserver.detach();
}
const {request} = this.props;
request.off("change", this._checkRequestIsPending);
}
_checkRequestIsPending = () => {
if (!this.props.requestObserver.pending) {
this.props.dismiss();
const {request} = this.props;
if (request.ready || request.started || request.done || request.cancelled || request.observeOnly) {
ToastStore.sharedInstance().dismissToast(this.props.toastKey);
}
}
};
cancel = () => {
this.props.dismiss();
ToastStore.sharedInstance().dismissToast(this.props.toastKey);
try {
this.props.request.cancel();
} catch (err) {
@ -76,9 +62,10 @@ export default class VerificationRequestToast extends React.PureComponent {
}
}
accept = () => {
this.props.dismiss();
const {event} = this.props.request;
accept = async () => {
ToastStore.sharedInstance().dismissToast(this.props.toastKey);
const {request} = this.props;
const {event} = request;
// no room id for to_device requests
if (event.getRoomId()) {
dis.dispatch({
@ -87,18 +74,23 @@ export default class VerificationRequestToast extends React.PureComponent {
should_peek: false,
});
}
const verifier = this.props.request.beginKeyVerification(verificationMethods.SAS);
const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog');
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier,
}, null, /* priority = */ false, /* static = */ true);
try {
await request.accept();
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {verificationRequest: request},
});
} catch (err) {
console.error(err.message);
}
};
render() {
const FormButton = sdk.getComponent("elements.FormButton");
const {event} = this.props.request;
const userId = event.getSender();
const {request} = this.props;
const {event} = request;
const userId = request.otherUserId;
let nameLabel = event.getRoomId() ? userLabelForEventRoom(userId, event) : userId;
// for legacy to_device verification requests
if (nameLabel === userId) {
@ -119,7 +111,6 @@ export default class VerificationRequestToast extends React.PureComponent {
}
VerificationRequestToast.propTypes = {
dismiss: PropTypes.func.isRequired,
request: PropTypes.object.isRequired,
requestObserver: PropTypes.instanceOf(KeyVerificationStateObserver),
toastKey: PropTypes.string.isRequired,
};