diff --git a/res/css/_components.scss b/res/css/_components.scss index e39003fbec..c47222da59 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -25,6 +25,7 @@ @import "./structures/_TabbedView.scss"; @import "./structures/_TagPanel.scss"; @import "./structures/_TagPanelButtons.scss"; +@import "./structures/_ToastContainer.scss"; @import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_ViewSource.scss"; @@ -91,6 +92,7 @@ @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_Field.scss"; +@import "./views/elements/_FormButton.scss"; @import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InlineSpinner.scss"; diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss new file mode 100644 index 0000000000..4c5e746e66 --- /dev/null +++ b/res/css/structures/_ToastContainer.scss @@ -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; + } + } +} diff --git a/res/css/views/elements/_FormButton.scss b/res/css/views/elements/_FormButton.scss new file mode 100644 index 0000000000..1483fe2091 --- /dev/null +++ b/res/css/views/elements/_FormButton.scss @@ -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; + } +} diff --git a/res/css/views/messages/_MKeyVerificationRequest.scss b/res/css/views/messages/_MKeyVerificationRequest.scss index 87a75dee82..ee20751083 100644 --- a/res/css/views/messages/_MKeyVerificationRequest.scss +++ b/res/css/views/messages/_MKeyVerificationRequest.scss @@ -65,23 +65,6 @@ limitations under the License. .mx_KeyVerification_buttons { align-items: center; 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 { diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index dcd7ce166e..0a3ef812b8 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -12,9 +12,9 @@ $monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emo // unified palette // try to use these colors when possible $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-bg-color: rgba(255, 75, 85, 0.08); +$notice-primary-bg-color: rgba(255, 75, 85, 0.16); $notice-secondary-color: #61708b; $header-panel-bg-color: #f3f8fd; diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 889b0cdc8b..d071ba1d79 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -525,6 +525,7 @@ const LoggedInView = createReactClass({ const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); const GroupView = sdk.getComponent('structures.GroupView'); const MyGroups = sdk.getComponent('structures.MyGroups'); + const ToastContainer = sdk.getComponent('structures.ToastContainer'); const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); const CookieBar = sdk.getComponent('globals.CookieBar'); const NewVersionBar = sdk.getComponent('globals.NewVersionBar'); @@ -628,6 +629,7 @@ const LoggedInView = createReactClass({ return (
{ topBar } +
{ - Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { - verifier, - }); - }); + if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { + 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"), + }, + }); + } + }); + } 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 // A later sync can/will correct the tint to be the right value for the user const colorScheme = SettingsStore.getValue("roomColor"); diff --git a/src/components/structures/ToastContainer.js b/src/components/structures/ToastContainer.js new file mode 100644 index 0000000000..b8ced1e9de --- /dev/null +++ b/src/components/structures/ToastContainer.js @@ -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 ( +
+
+

{title}{countIndicator}

+
{React.createElement(component, toastProps)}
+
+
+ ); + } +} diff --git a/src/components/views/elements/FormButton.js b/src/components/views/elements/FormButton.js new file mode 100644 index 0000000000..f6b4c986f5 --- /dev/null +++ b/src/components/views/elements/FormButton.js @@ -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; diff --git a/src/components/views/messages/MKeyVerificationRequest.js b/src/components/views/messages/MKeyVerificationRequest.js index 21d82309ed..b2a1724fc6 100644 --- a/src/components/views/messages/MKeyVerificationRequest.js +++ b/src/components/views/messages/MKeyVerificationRequest.js @@ -111,10 +111,10 @@ export default class MKeyVerificationRequest extends React.Component { userLabelForEventRoom(fromUserId, mxEvent)}
); const isResolved = !(this.state.accepted || this.state.cancelled || this.state.done); if (isResolved) { - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + const FormButton = sdk.getComponent("elements.FormButton"); stateNode = (
- {_t("Decline")} - {_t("Accept")} + +
); } } else if (isOwn) { // request sent by us diff --git a/src/components/views/toasts/VerificationRequestToast.js b/src/components/views/toasts/VerificationRequestToast.js new file mode 100644 index 0000000000..c6f7f3a363 --- /dev/null +++ b/src/components/views/toasts/VerificationRequestToast.js @@ -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 (
+
{nameLabel}
+
+ + +
+
); + } +} + +VerificationRequestToast.propTypes = { + dismiss: PropTypes.func.isRequired, + request: PropTypes.object.isRequired, + requestObserver: PropTypes.instanceOf(KeyVerificationStateObserver), +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index dd7812fbff..ddc7c016cc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -481,6 +481,7 @@ "Headphones": "Headphones", "Folder": "Folder", "Pin": "Pin", + "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", "Failed to upload profile picture!": "Failed to upload profile picture!", "Upload new:": "Upload new:", @@ -1693,6 +1694,7 @@ "Review terms and conditions": "Review terms and conditions", "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.", + "Verification Request": "Verification Request", "Logout": "Logout", "%(creator)s created and configured the room.": "%(creator)s created and configured the room.", "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 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", + " (1/%(totalCount)s)": " (1/%(totalCount)s)", "Guest": "Guest", "Your profile": "Your profile", "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", diff --git a/src/utils/KeyVerificationStateObserver.js b/src/utils/KeyVerificationStateObserver.js index 7de50ec4bf..2f7c0367ad 100644 --- a/src/utils/KeyVerificationStateObserver.js +++ b/src/utils/KeyVerificationStateObserver.js @@ -30,6 +30,18 @@ export default class KeyVerificationStateObserver { this._updateVerificationState(); } + get concluded() { + return this.accepted || this.done || this.cancelled; + } + + get pending() { + return !this.concluded; + } + + setCallback(callback) { + this._updateCallback = callback; + } + attach() { this._requestEvent.on("Event.relationsCreated", this._onRelationsCreated); for (const phaseName of SUB_EVENT_TYPES_OF_INTEREST) { @@ -83,7 +95,7 @@ export default class KeyVerificationStateObserver { _onRelationsUpdated = (event) => { this._updateVerificationState(); - this._updateCallback(); + this._updateCallback && this._updateCallback(); }; _updateVerificationState() {