Merge pull request #3661 from matrix-org/bwindels/verif-toasts

Show incoming verification requests in in-app notifications
This commit is contained in:
Bruno Windels 2019-11-22 16:39:18 +00:00 committed by GitHub
commit 4a684d01a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 424 additions and 29 deletions

View file

@ -25,6 +25,7 @@
@import "./structures/_TabbedView.scss"; @import "./structures/_TabbedView.scss";
@import "./structures/_TagPanel.scss"; @import "./structures/_TagPanel.scss";
@import "./structures/_TagPanelButtons.scss"; @import "./structures/_TagPanelButtons.scss";
@import "./structures/_ToastContainer.scss";
@import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_TopLeftMenuButton.scss";
@import "./structures/_UploadBar.scss"; @import "./structures/_UploadBar.scss";
@import "./structures/_ViewSource.scss"; @import "./structures/_ViewSource.scss";
@ -91,6 +92,7 @@
@import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_ErrorBoundary.scss";
@import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_EventListSummary.scss";
@import "./views/elements/_Field.scss"; @import "./views/elements/_Field.scss";
@import "./views/elements/_FormButton.scss";
@import "./views/elements/_IconButton.scss"; @import "./views/elements/_IconButton.scss";
@import "./views/elements/_ImageView.scss"; @import "./views/elements/_ImageView.scss";
@import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InlineSpinner.scss";

View file

@ -0,0 +1,98 @@
/*
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.
*/
.mx_ToastContainer {
position: absolute;
top: 0;
left: 70px;
z-index: 101;
padding: 4px;
display: grid;
grid-template-rows: 1fr 14px 6px;
&.mx_ToastContainer_stacked::before {
content: "";
margin: 0 4px;
grid-row: 2 / 4;
grid-column: 1;
background-color: white;
box-shadow: 0px 4px 12px $menu-box-shadow-color;
border-radius: 8px;
}
.mx_Toast_toast {
grid-row: 1 / 3;
grid-column: 1;
color: $primary-fg-color;
background-color: $primary-bg-color;
box-shadow: 0px 4px 12px $menu-box-shadow-color;
border-radius: 8px;
overflow: hidden;
display: grid;
grid-template-columns: 20px 1fr;
column-gap: 10px;
row-gap: 4px;
padding: 8px;
padding-right: 16px;
&.mx_Toast_hasIcon {
&::after {
content: "";
width: 20px;
height: 20px;
grid-column: 1;
grid-row: 1;
mask-size: 100%;
mask-repeat: no-repeat;
}
&.mx_Toast_icon_verification::after {
mask-image: url("$(res)/img/e2e/normal.svg");
background-color: $primary-fg-color;
}
h2, .mx_Toast_body {
grid-column: 2;
}
}
h2 {
grid-column: 1 / 3;
grid-row: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
}
.mx_Toast_body {
grid-column: 1 / 3;
grid-row: 2;
}
.mx_Toast_buttons {
display: flex;
}
.mx_Toast_description {
max-width: 400px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 4px 0 11px 0;
font-size: 12px;
}
}
}

View file

@ -0,0 +1,36 @@
/*
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.
*/
.mx_FormButton {
line-height: 16px;
padding: 5px 15px;
font-size: 12px;
height: min-content;
&:not(:last-child) {
margin-right: 8px;
}
&.mx_AccessibleButton_kind_primary {
color: $accent-color;
background-color: $accent-bg-color;
}
&.mx_AccessibleButton_kind_danger {
color: $notice-primary-color;
background-color: $notice-primary-bg-color;
}
}

View file

@ -65,23 +65,6 @@ limitations under the License.
.mx_KeyVerification_buttons { .mx_KeyVerification_buttons {
align-items: center; align-items: center;
display: flex; display: flex;
.mx_AccessibleButton_kind_decline {
color: $notice-primary-color;
background-color: $notice-primary-bg-color;
}
.mx_AccessibleButton_kind_accept {
color: $accent-color;
background-color: $accent-bg-color;
}
[role=button] {
margin: 10px;
padding: 7px 15px;
border-radius: 5px;
height: min-content;
}
} }
.mx_KeyVerification_state { .mx_KeyVerification_state {

View file

@ -12,9 +12,9 @@ $monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emo
// unified palette // unified palette
// try to use these colors when possible // try to use these colors when possible
$accent-color: #03b381; $accent-color: #03b381;
$accent-bg-color: rgba(115, 247, 91, 0.08); $accent-bg-color: rgba(3, 179, 129, 0.16);
$notice-primary-color: #ff4b55; $notice-primary-color: #ff4b55;
$notice-primary-bg-color: rgba(255, 75, 85, 0.08); $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
$notice-secondary-color: #61708b; $notice-secondary-color: #61708b;
$header-panel-bg-color: #f3f8fd; $header-panel-bg-color: #f3f8fd;

View file

@ -525,6 +525,7 @@ const LoggedInView = createReactClass({
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView'); const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups'); const MyGroups = sdk.getComponent('structures.MyGroups');
const ToastContainer = sdk.getComponent('structures.ToastContainer');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const CookieBar = sdk.getComponent('globals.CookieBar'); const CookieBar = sdk.getComponent('globals.CookieBar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar'); const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
@ -628,6 +629,7 @@ const LoggedInView = createReactClass({
return ( return (
<div onPaste={this._onPaste} onKeyDown={this._onReactKeyDown} className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}> <div onPaste={this._onPaste} onKeyDown={this._onReactKeyDown} className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
{ topBar } { topBar }
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}> <DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}> <div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel <LeftPanel

View file

@ -60,6 +60,7 @@ import { countRoomsWithNotif } from '../../RoomNotifs';
import { ThemeWatcher } from "../../theme"; import { ThemeWatcher } from "../../theme";
import { storeRoomAliasInCache } from '../../RoomAliasCache'; import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer } from "../../utils/promise"; import { defer } from "../../utils/promise";
import KeyVerificationStateObserver from '../../utils/KeyVerificationStateObserver';
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
const VIEWS = { const VIEWS = {
@ -1264,7 +1265,6 @@ export default createReactClass({
this.firstSyncComplete = false; this.firstSyncComplete = false;
this.firstSyncPromise = defer(); this.firstSyncPromise = defer();
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog');
// Allow the JS SDK to reap timeline events. This reduces the amount of // 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 // memory consumed as the JS SDK stores multiple distinct copies of room
@ -1463,12 +1463,35 @@ export default createReactClass({
} }
}); });
cli.on("crypto.verification.start", (verifier) => { if (SettingsStore.isFeatureEnabled("feature_dm_verification")) {
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { cli.on("crypto.verification.request", request => {
verifier, 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"),
},
});
}
});
} else {
cli.on("crypto.verification.start", (verifier) => {
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier,
});
});
}
// Fire the tinter right on startup to ensure the default theme is applied // 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 // A later sync can/will correct the tint to be the right value for the user
const colorScheme = SettingsStore.getValue("roomColor"); const colorScheme = SettingsStore.getValue("roomColor");

View file

@ -0,0 +1,85 @@
/*
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 * as React from "react";
import dis from "../../dispatcher";
import { _t } from '../../languageHandler';
import classNames from "classnames";
export default class ToastContainer extends React.Component {
constructor() {
super();
this.state = {toasts: []};
}
componentDidMount() {
this._dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}
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});
};
render() {
const totalCount = this.state.toasts.length;
if (totalCount === 0) {
return null;
}
const isStacked = totalCount > 1;
const topToast = this.state.toasts[0];
const {title, icon, key, component, props} = topToast;
const containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
});
const toastClasses = classNames("mx_Toast_toast", {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
});
const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null;
const toastProps = Object.assign({}, props, {
dismiss: this.dismissTopToast,
key,
});
return (
<div className={containerClasses}>
<div className={toastClasses}>
<h2>{title}{countIndicator}</h2>
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,28 @@
/*
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 AccessibleButton from "./AccessibleButton";
export default function FormButton(props) {
const {className, label, kind, ...restProps} = props;
const newClassName = (className || "") + " mx_FormButton";
const allProps = Object.assign({}, restProps,
{className: newClassName, kind: kind || "primary", children: [label]});
return React.createElement(AccessibleButton, allProps);
}
FormButton.propTypes = AccessibleButton.propTypes;

View file

@ -111,10 +111,10 @@ export default class MKeyVerificationRequest extends React.Component {
userLabelForEventRoom(fromUserId, mxEvent)}</div>); userLabelForEventRoom(fromUserId, mxEvent)}</div>);
const isResolved = !(this.state.accepted || this.state.cancelled || this.state.done); const isResolved = !(this.state.accepted || this.state.cancelled || this.state.done);
if (isResolved) { if (isResolved) {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const FormButton = sdk.getComponent("elements.FormButton");
stateNode = (<div className="mx_KeyVerification_buttons"> stateNode = (<div className="mx_KeyVerification_buttons">
<AccessibleButton kind="decline" onClick={this._onRejectClicked}>{_t("Decline")}</AccessibleButton> <FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
<AccessibleButton kind="accept" onClick={this._onAcceptClicked}>{_t("Accept")}</AccessibleButton> <FormButton onClick={this._onAcceptClicked} label={_t("Accept")} />
</div>); </div>);
} }
} else if (isOwn) { // request sent by us } else if (isOwn) { // request sent by us

View file

@ -0,0 +1,123 @@
/*
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 PropTypes from 'prop-types';
import sdk from "../../../index";
import { _t } from '../../../languageHandler';
import Modal from "../../../Modal";
import MatrixClientPeg from '../../../MatrixClientPeg';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
import KeyVerificationStateObserver, {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver";
import dis from "../../../dispatcher";
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);
}
}
componentDidMount() {
if (this.props.requestObserver) {
this.props.requestObserver.attach();
this._checkRequestIsPending();
}
this._intervalHandle = setInterval(() => {
let {counter} = this.state;
counter -= 1;
if (counter <= 0) {
this.cancel();
} else {
this.setState({counter});
}
}, 1000);
}
componentWillUnmount() {
clearInterval(this._intervalHandle);
if (this.props.requestObserver) {
this.props.requestObserver.detach();
}
}
_checkRequestIsPending = () => {
if (!this.props.requestObserver.pending) {
this.props.dismiss();
}
}
cancel = () => {
this.props.dismiss();
try {
this.props.request.cancel();
} catch (err) {
console.error("Error while cancelling verification request", err);
}
}
accept = () => {
this.props.dismiss();
const {event} = this.props.request;
// no room id for to_device requests
if (event.getRoomId()) {
dis.dispatch({
action: 'view_room',
room_id: event.getRoomId(),
should_peek: false,
});
}
const verifier = this.props.request.beginKeyVerification(verificationMethods.SAS);
const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog');
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {verifier});
};
render() {
const FormButton = sdk.getComponent("elements.FormButton");
const {event} = this.props.request;
const userId = event.getSender();
let nameLabel = event.getRoomId() ? userLabelForEventRoom(userId, event) : userId;
// for legacy to_device verification requests
if (nameLabel === userId) {
const client = MatrixClientPeg.get();
const user = client.getUser(event.getSender());
if (user && user.displayName) {
nameLabel = _t("%(name)s (%(userId)s)", {name: user.displayName, userId});
}
}
return (<div>
<div className="mx_Toast_description">{nameLabel}</div>
<div className="mx_Toast_buttons">
<FormButton label={_t("Decline (%(counter)s)", {counter: this.state.counter})} kind="danger" onClick={this.cancel} />
<FormButton label={_t("Accept")} onClick={this.accept} />
</div>
</div>);
}
}
VerificationRequestToast.propTypes = {
dismiss: PropTypes.func.isRequired,
request: PropTypes.object.isRequired,
requestObserver: PropTypes.instanceOf(KeyVerificationStateObserver),
};

View file

@ -481,6 +481,7 @@
"Headphones": "Headphones", "Headphones": "Headphones",
"Folder": "Folder", "Folder": "Folder",
"Pin": "Pin", "Pin": "Pin",
"Decline (%(counter)s)": "Decline (%(counter)s)",
"Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:", "Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:",
"Failed to upload profile picture!": "Failed to upload profile picture!", "Failed to upload profile picture!": "Failed to upload profile picture!",
"Upload new:": "Upload new:", "Upload new:": "Upload new:",
@ -1693,6 +1694,7 @@
"Review terms and conditions": "Review terms and conditions", "Review terms and conditions": "Review terms and conditions",
"Old cryptography data detected": "Old cryptography data detected", "Old cryptography data detected": "Old cryptography data detected",
"Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.", "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.",
"Verification Request": "Verification Request",
"Logout": "Logout", "Logout": "Logout",
"%(creator)s created and configured the room.": "%(creator)s created and configured the room.", "%(creator)s created and configured the room.": "%(creator)s created and configured the room.",
"Your Communities": "Your Communities", "Your Communities": "Your Communities",
@ -1758,6 +1760,7 @@
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position", "Failed to load timeline position": "Failed to load timeline position",
" (1/%(totalCount)s)": " (1/%(totalCount)s)",
"Guest": "Guest", "Guest": "Guest",
"Your profile": "Your profile", "Your profile": "Your profile",
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",

View file

@ -30,6 +30,18 @@ export default class KeyVerificationStateObserver {
this._updateVerificationState(); this._updateVerificationState();
} }
get concluded() {
return this.accepted || this.done || this.cancelled;
}
get pending() {
return !this.concluded;
}
setCallback(callback) {
this._updateCallback = callback;
}
attach() { attach() {
this._requestEvent.on("Event.relationsCreated", this._onRelationsCreated); this._requestEvent.on("Event.relationsCreated", this._onRelationsCreated);
for (const phaseName of SUB_EVENT_TYPES_OF_INTEREST) { for (const phaseName of SUB_EVENT_TYPES_OF_INTEREST) {
@ -83,7 +95,7 @@ export default class KeyVerificationStateObserver {
_onRelationsUpdated = (event) => { _onRelationsUpdated = (event) => {
this._updateVerificationState(); this._updateVerificationState();
this._updateCallback(); this._updateCallback && this._updateCallback();
}; };
_updateVerificationState() { _updateVerificationState() {