diff --git a/package.json b/package.json index 16e7f943f1..3686966870 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "@peculiar/webcrypto": "^1.0.22", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", - "chokidar": "^2.1.2", + "chokidar": "^3.3.1", "concurrently": "^4.0.1", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.15.1", diff --git a/res/css/_components.scss b/res/css/_components.scss index a9a114a4cf..60f749de9c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -57,13 +57,13 @@ @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; -@import "./views/dialogs/_DMInviteDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DeviceVerifyDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EncryptedEventDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; +@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 4c5e746e66..5634a97c53 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -51,7 +51,7 @@ limitations under the License. &.mx_Toast_hasIcon { &::after { content: ""; - width: 20px; + width: 21px; height: 20px; grid-column: 1; grid-row: 1; @@ -64,6 +64,10 @@ limitations under the License. background-color: $primary-fg-color; } + &.mx_Toast_icon_verification_warning::after { + background-image: url("$(res)/img/e2e/warning.svg"); + } + h2, .mx_Toast_body { grid-column: 2; } diff --git a/res/css/views/dialogs/_DMInviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss similarity index 82% rename from res/css/views/dialogs/_DMInviteDialog.scss rename to res/css/views/dialogs/_InviteDialog.scss index f806e85120..d0b53b7766 100644 --- a/res/css/views/dialogs/_DMInviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DMInviteDialog_addressBar { +.mx_InviteDialog_addressBar { display: flex; flex-direction: row; - .mx_DMInviteDialog_editor { + .mx_InviteDialog_editor { flex: 1; width: 100%; // Needed to make the Field inside grow background-color: $user-tile-hover-bg-color; @@ -28,7 +28,7 @@ limitations under the License. overflow-x: hidden; overflow-y: auto; - .mx_DMInviteDialog_userTile { + .mx_InviteDialog_userTile { display: inline-block; float: left; position: relative; @@ -61,15 +61,26 @@ limitations under the License. } } - .mx_DMInviteDialog_goButton { + .mx_InviteDialog_goButton { width: 48px; margin-left: 10px; height: 25px; line-height: 25px; } + + .mx_InviteDialog_buttonAndSpinner { + .mx_Spinner { + // Width and height are required to trick the layout engine. + width: 20px; + height: 20px; + margin-left: 5px; + display: inline-block; + vertical-align: middle; + } + } } -.mx_DMInviteDialog_section { +.mx_InviteDialog_section { padding-bottom: 10px; h3 { @@ -80,7 +91,7 @@ limitations under the License. } } -.mx_DMInviteDialog_roomTile { +.mx_InviteDialog_roomTile { cursor: pointer; padding: 5px 10px; @@ -93,7 +104,7 @@ limitations under the License. vertical-align: middle; } - .mx_DMInviteDialog_roomTile_avatarStack { + .mx_InviteDialog_roomTile_avatarStack { display: inline-block; position: relative; width: 36px; @@ -106,7 +117,7 @@ limitations under the License. } } - .mx_DMInviteDialog_roomTile_selected { + .mx_InviteDialog_roomTile_selected { width: 36px; height: 36px; border-radius: 36px; @@ -130,20 +141,20 @@ limitations under the License. } } - .mx_DMInviteDialog_roomTile_name { + .mx_InviteDialog_roomTile_name { font-weight: 600; font-size: 14px; color: $primary-fg-color; margin-left: 7px; } - .mx_DMInviteDialog_roomTile_userId { + .mx_InviteDialog_roomTile_userId { font-size: 12px; color: $muted-fg-color; margin-left: 7px; } - .mx_DMInviteDialog_roomTile_time { + .mx_InviteDialog_roomTile_time { text-align: right; font-size: 12px; color: $muted-fg-color; @@ -151,16 +162,16 @@ limitations under the License. line-height: 36px; // Height of the avatar to keep the time vertically aligned } - .mx_DMInviteDialog_roomTile_highlight { + .mx_InviteDialog_roomTile_highlight { font-weight: 900; } } // Many of these styles are stolen from mx_UserPill, but adjusted for the invite dialog. -.mx_DMInviteDialog_userTile { +.mx_InviteDialog_userTile { margin-right: 8px; - .mx_DMInviteDialog_userTile_pill { + .mx_InviteDialog_userTile_pill { background-color: $username-variant1-color; border-radius: 12px; display: inline-block; @@ -170,27 +181,27 @@ limitations under the License. padding-right: 8px; color: #ffffff; // this is fine without a var because it's for both themes - .mx_DMInviteDialog_userTile_avatar { + .mx_InviteDialog_userTile_avatar { border-radius: 20px; position: relative; left: -5px; top: 2px; } - img.mx_DMInviteDialog_userTile_avatar { + img.mx_InviteDialog_userTile_avatar { vertical-align: top; } - .mx_DMInviteDialog_userTile_name { + .mx_InviteDialog_userTile_name { vertical-align: top; } - .mx_DMInviteDialog_userTile_threepidAvatar { + .mx_InviteDialog_userTile_threepidAvatar { background-color: #ffffff; // this is fine without a var because it's for both themes } } - .mx_DMInviteDialog_userTile_remove { + .mx_InviteDialog_userTile_remove { display: inline-block; margin-left: 4px; } diff --git a/src/DeviceListener.js b/src/DeviceListener.js new file mode 100644 index 0000000000..15ca931fc8 --- /dev/null +++ b/src/DeviceListener.js @@ -0,0 +1,92 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClientPeg } from './MatrixClientPeg'; +import SettingsStore from './settings/SettingsStore'; +import * as sdk from './index'; +import { _t } from './languageHandler'; +import ToastStore from './stores/ToastStore'; + +function toastKey(device) { + return 'newsession_' + device.deviceId; +} + +export default class DeviceListener { + static sharedInstance() { + if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener(); + return global.mx_DeviceListener; + } + + constructor() { + // device IDs for which the user has dismissed the verify toast ('Later') + this._dismissed = new Set(); + } + + start() { + MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); + MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); + this.recheck(); + } + + stop() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); + MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); + } + this._dismissed.clear(); + } + + dismissVerification(deviceId) { + this._dismissed.add(deviceId); + this.recheck(); + } + + _onDevicesUpdated = (users) => { + if (!users.includes(MatrixClientPeg.get().getUserId())) return; + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return; + this.recheck(); + } + + _onDeviceVerificationChanged = (users) => { + if (!users.includes(MatrixClientPeg.get().getUserId())) return; + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return; + this.recheck(); + } + + async recheck() { + const cli = MatrixClientPeg.get(); + + if (!cli.isCryptoEnabled()) return false; + + const devices = await cli.getStoredDevicesForUser(cli.getUserId()); + for (const device of devices) { + if (device.deviceId == cli.deviceId) continue; + + const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId); + if (deviceTrust.isVerified() || this._dismissed.has(device.deviceId)) { + ToastStore.sharedInstance().dismissToast(toastKey(device)); + } else { + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey(device), + title: _t("New Session"), + icon: "verification_warning", + props: {deviceId: device.deviceId}, + component: sdk.getComponent("toasts.NewSessionToast"), + }); + } + } + } +} diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 65dc7fdb0f..30f3b7d50e 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,7 +17,10 @@ limitations under the License. import * as sdk from './index'; import Modal from './Modal'; +import SettingsStore from './settings/SettingsStore'; +// TODO: We can remove this once cross-signing is the only way. +// https://github.com/vector-im/riot-web/issues/11908 export default class KeyRequestHandler { constructor(matrixClient) { this._matrixClient = matrixClient; @@ -30,6 +34,11 @@ export default class KeyRequestHandler { } handleKeyRequest(keyRequest) { + // Ignore own device key requests if cross-signing lab enabled + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + return; + } + const userId = keyRequest.userId; const deviceId = keyRequest.deviceId; const requestId = keyRequest.requestId; @@ -60,6 +69,11 @@ export default class KeyRequestHandler { } handleKeyRequestCancellation(cancellation) { + // Ignore own device key requests if cross-signing lab enabled + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + return; + } + // see if we can find the request in the queue const userId = cancellation.userId; const deviceId = cancellation.deviceId; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0796e326a0..1603c73d25 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -35,8 +36,10 @@ import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; +import ToastStore from "./stores/ToastStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {Mjolnir} from "./mjolnir/Mjolnir"; +import DeviceListener from "./DeviceListener"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -575,6 +578,7 @@ async function startMatrixClient(startSyncing=true) { Notifier.start(); UserActivity.sharedInstance().start(); TypingStore.sharedInstance().reset(); // just in case + ToastStore.sharedInstance().reset(); if (!SettingsStore.getValue("lowBandwidth")) { Presence.start(); } @@ -595,6 +599,9 @@ async function startMatrixClient(startSyncing=true) { await MatrixClientPeg.assign(); } + // This needs to be started after crypto is set up + DeviceListener.sharedInstance().start(); + // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. dis.dispatch({action: 'client_started'}); @@ -651,6 +658,7 @@ export function stopMatrixClient(unsetClient=true) { ActiveWidgetStore.stop(); IntegrationManagers.sharedInstance().stopWatching(); Mjolnir.sharedInstance().stop(); + DeviceListener.sharedInstance().stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); EventIndexPeg.stop(); const cli = MatrixClientPeg.get(); diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 2fe64c994f..2eccf69b0f 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -26,6 +27,7 @@ import dis from './dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import { _t } from './languageHandler'; import SettingsStore from "./settings/SettingsStore"; +import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; /** * Invites multiple addresses to a room @@ -36,21 +38,19 @@ import SettingsStore from "./settings/SettingsStore"; * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. * @returns {Promise} Promise */ -function inviteMultipleToRoom(roomId, addrs) { +export function inviteMultipleToRoom(roomId, addrs) { const inviter = new MultiInviter(roomId); return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); } export function showStartChatInviteDialog() { if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { - const DMInviteDialog = sdk.getComponent("dialogs.DMInviteDialog"); - Modal.createTrackedDialog('Start DM', '', DMInviteDialog, { - onFinished: (inviteIds) => { - // TODO: Replace _onStartDmFinished with less hacks - if (inviteIds.length > 0) _onStartDmFinished(true, inviteIds.map(i => ({address: i}))); - // else ignore and just do nothing - }, - }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); + // This new dialog handles the room creation internally - we don't need to worry about it. + const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); + Modal.createTrackedDialog( + 'Start DM', '', InviteDialog, {kind: KIND_DM}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); return; } @@ -74,6 +74,16 @@ export function showStartChatInviteDialog() { } export function showRoomInviteDialog(roomId) { + if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { + // This new dialog handles the room creation internally - we don't need to worry about it. + const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); + Modal.createTrackedDialog( + 'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); + return; + } + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0361cdaeb9..7cbec11e08 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -63,6 +63,7 @@ import { countRoomsWithNotif } from '../../RoomNotifs'; import { ThemeWatcher } from "../../theme"; import { storeRoomAliasInCache } from '../../RoomAliasCache'; import { defer } from "../../utils/promise"; +import ToastStore from "../../stores/ToastStore"; /** constants for MatrixChat.state.view */ export const VIEWS = { @@ -1381,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); @@ -1453,15 +1456,12 @@ export default createReactClass({ console.log(`MatrixChat got a .request ${request.channel.transactionId}`, request.event.getRoomId()); if (request.pending) { console.log(`emitting toast for verification request with txnid ${request.channel.transactionId}`, request.event && request.event.getId()); - dis.dispatch({ - action: "show_toast", - toast: { - key: request.channel.transactionId, - title: _t("Verification Request"), - icon: "verification", - props: {request}, - component: sdk.getComponent("toasts.VerificationRequestToast"), - }, + ToastStore.sharedInstance().addOrReplaceToast({ + key: 'verifreq_' + request.channel.transactionId, + title: _t("Verification Request"), + icon: "verification", + props: {request}, + component: sdk.getComponent("toasts.VerificationRequestToast"), }); } }); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index ef28dd2c69..9b02f6d503 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -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", }); }, diff --git a/src/components/structures/ToastContainer.js b/src/components/structures/ToastContainer.js index a9c8267d0d..8a05f62e61 100644 --- a/src/components/structures/ToastContainer.js +++ b/src/components/structures/ToastContainer.js @@ -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,38 +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() { - console.log("ToastContainer mounted"); - 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() { @@ -63,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 = (

{title}{countIndicator}

diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index aaac61ce7d..54f11e8e91 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -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; diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/InviteDialog.js similarity index 74% rename from src/components/views/dialogs/DMInviteDialog.js rename to src/components/views/dialogs/InviteDialog.js index c0ff9b96fe..1b7a50c084 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -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 ? : ; return ( - - + + {avatar} - {this.props.member.name} + {this.props.member.name} {_t('Remove')} @@ -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({substr}); + result.push({substr}); 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 = {humanTs}; + timestamp = {humanTs}; } 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 =
; + checkmark =
; } // 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 = ( - + {avatar} {checkmark} ); return ( -
+
{stackedAvatar} - {this._highlightName(this.props.member.name)} - {this._highlightName(this.props.member.userId)} + {this._highlightName(this.props.member.name)} + {this._highlightName(this.props.member.userId)} {timestamp}
); } } -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, @@ -384,12 +426,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 +730,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 +766,7 @@ export default class DMInviteDialog extends React.PureComponent { if (sourceMembers.length === 0 && additionalMembers.length === 0) { return ( -
+

{sectionName}

{_t("No results")}

@@ -672,7 +807,7 @@ export default class DMInviteDialog extends React.PureComponent { /> )); return ( -
+

{sectionName}

{tiles} {showMore} @@ -695,7 +830,7 @@ export default class DMInviteDialog extends React.PureComponent { /> ); return ( -
+
{targets} {input}
@@ -739,35 +874,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 = ; + } + + + 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 profile link.", + {userId}, + {a: (sub) => {sub}}, + ); + 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 " + + "share this room.", {}, + {a: (sub) => {sub}}, + ); + buttonText = _t("Invite"); + goButtonFn = this._inviteUsers; + } - const userId = MatrixClientPeg.get().getUserId(); return ( -
-

- {_t( - "If you can't find someone, ask them for their username, or share your " + - "username (%(userId)s) or profile link.", - {userId}, - {a: (sub) => {sub}}, - )} -

-
+
+

{helpText}

+
{this._renderEditor()} - {this._renderIdentityServerWarning()} - - {_t("Go")} - +
+ + {buttonText} + + {spinner} +
+ {this._renderIdentityServerWarning()} +
{this.state.errorText}
{this._renderSection('recents')} {this._renderSection('suggestions')}
diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index 3afb5546cf..507f8b4678 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -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. diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 6ffa584711..15a7c29e3a 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -210,8 +210,8 @@ 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 + replaceRangeAndMoveCaret(range, []); } event.preventDefault(); } diff --git a/src/components/views/settings/tabs/room/BridgeSettingsTab.js b/src/components/views/settings/tabs/room/BridgeSettingsTab.js index 71b0169788..19c19d3bc6 100644 --- a/src/components/views/settings/tabs/room/BridgeSettingsTab.js +++ b/src/components/views/settings/tabs/room/BridgeSettingsTab.js @@ -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] || {}), )); diff --git a/src/components/views/toasts/NewSessionToast.js b/src/components/views/toasts/NewSessionToast.js new file mode 100644 index 0000000000..f83326121b --- /dev/null +++ b/src/components/views/toasts/NewSessionToast.js @@ -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 (
+
{_t("Other users may not trust it")}
+
+ + +
+
); + } +} diff --git a/src/components/views/toasts/VerificationRequestToast.js b/src/components/views/toasts/VerificationRequestToast.js index 8d75f84a55..c681ca0951 100644 --- a/src/components/views/toasts/VerificationRequestToast.js +++ b/src/components/views/toasts/VerificationRequestToast.js @@ -22,6 +22,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; 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) { @@ -48,12 +49,12 @@ export default class VerificationRequestToast extends React.PureComponent { _checkRequestIsPending = () => { const {request} = this.props; if (request.ready || request.started || request.done || request.cancelled || request.observeOnly) { - this.props.dismiss(); + ToastStore.sharedInstance().dismissToast(this.props.toastKey); } }; cancel = () => { - this.props.dismiss(); + ToastStore.sharedInstance().dismissToast(this.props.toastKey); try { this.props.request.cancel(); } catch (err) { @@ -62,7 +63,7 @@ export default class VerificationRequestToast extends React.PureComponent { } accept = async () => { - this.props.dismiss(); + ToastStore.sharedInstance().dismissToast(this.props.toastKey); const {request} = this.props; const {event} = request; // no room id for to_device requests @@ -111,6 +112,6 @@ export default class VerificationRequestToast extends React.PureComponent { } VerificationRequestToast.propTypes = { - dismiss: PropTypes.func.isRequired, request: PropTypes.object.isRequired, + toastKey: PropTypes.string.isRequired, }; diff --git a/src/editor/position.js b/src/editor/position.js index 4693f62999..726377ef48 100644 --- a/src/editor/position.js +++ b/src/editor/position.js @@ -117,7 +117,7 @@ export default class DocumentPosition { } offset += this.offset; const lastPart = model.parts[this.index]; - const atEnd = offset >= lastPart.text.length; + const atEnd = !lastPart || offset >= lastPart.text.length; // if no last part, we're at the end return new DocumentOffset(offset, atEnd); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 975d045915..5f1886cd56 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -85,6 +85,7 @@ "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", + "New Session": "New Session", "Who would you like to add to this community?": "Who would you like to add to this community?", "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID", "Invite new community members": "Invite new community members", @@ -372,7 +373,7 @@ "Render simple counters in room header": "Render simple counters in room header", "Multiple integration managers": "Multiple integration managers", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", - "New DM invite dialog (under development)": "New DM invite dialog (under development)", + "New invite dialog": "New invite dialog", "Show a presence dot next to DMs in the room list": "Show a presence dot next to DMs in the room list", "Enable cross-signing to verify per-user instead of per-device (in development)": "Enable cross-signing to verify per-user instead of per-device (in development)", "Enable local event indexing and E2EE search (requires restart)": "Enable local event indexing and E2EE search (requires restart)", @@ -513,6 +514,9 @@ "Headphones": "Headphones", "Folder": "Folder", "Pin": "Pin", + "Other users may not trust it": "Other users may not trust it", + "Later": "Later", + "Verify": "Verify", "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", "Upload": "Upload", @@ -1134,7 +1138,6 @@ "This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.", "Messages in this room are not end-to-end encrypted.": "Messages in this room are not end-to-end encrypted.", "Messages in this room are end-to-end encrypted.": "Messages in this room are end-to-end encrypted.", - "Verify": "Verify", "Security": "Security", "Sunday": "Sunday", "Monday": "Monday", @@ -1442,14 +1445,6 @@ "View Servers in Room": "View Servers in Room", "Toolbox": "Toolbox", "Developer Tools": "Developer Tools", - "Failed to find the following users": "Failed to find the following users", - "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", - "Recent Conversations": "Recent Conversations", - "Suggestions": "Suggestions", - "Show more": "Show more", - "Direct Messages": "Direct Messages", - "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.": "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.", - "Go": "Go", "An error has occurred.": "An error has occurred.", "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.", "Verifying this user will mark their device as trusted, and also mark your device as trusted to them.": "Verifying this user will mark their device as trusted, and also mark your device as trusted to them.", @@ -1459,6 +1454,20 @@ "Enable 'Manage Integrations' in Settings to do this.": "Enable 'Manage Integrations' in Settings to do this.", "Integrations not allowed": "Integrations not allowed", "Your Riot doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "Your Riot doesn't allow you to use an Integration Manager to do this. Please contact an admin.", + "Failed to invite the following users to chat: %(csvUsers)s": "Failed to invite the following users to chat: %(csvUsers)s", + "We couldn't create your DM. Please check the users you want to invite and try again.": "We couldn't create your DM. Please check the users you want to invite and try again.", + "Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.", + "We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.", + "Failed to find the following users": "Failed to find the following users", + "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", + "Recent Conversations": "Recent Conversations", + "Suggestions": "Suggestions", + "Recently Direct Messaged": "Recently Direct Messaged", + "Show more": "Show more", + "Direct Messages": "Direct Messages", + "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.": "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.", + "Go": "Go", + "If you can't find someone, ask them for their username (e.g. @user:server.com) or share this room.": "If you can't find someone, ask them for their username (e.g. @user:server.com) or share this room.", "You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.", "Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys.", "Start verification": "Start verification", diff --git a/src/integrations/IntegrationManagers.js b/src/integrations/IntegrationManagers.js index b482ec73ce..c933e5c433 100644 --- a/src/integrations/IntegrationManagers.js +++ b/src/integrations/IntegrationManagers.js @@ -83,6 +83,7 @@ export class IntegrationManagers { } async _setupHomeserverManagers() { + if (!MatrixClientPeg.get()) return; try { console.log("Updating homeserver-configured integration managers..."); const homeserverDomain = MatrixClientPeg.getHomeserverName(); diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 2b8c0aef89..eacf63e55d 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -130,7 +130,7 @@ export const SETTINGS = { }, "feature_ftue_dms": { isFeature: true, - displayName: _td("New DM invite dialog (under development)"), + displayName: _td("New invite dialog"), supportedLevels: LEVELS_FEATURE, default: false, }, diff --git a/src/stores/ToastStore.js b/src/stores/ToastStore.js new file mode 100644 index 0000000000..2c4464813b --- /dev/null +++ b/src/stores/ToastStore.js @@ -0,0 +1,56 @@ +/* +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 EventEmitter from 'events'; + +/** + * Holds the active toasts + */ +export default class ToastStore extends EventEmitter { + static sharedInstance() { + if (!global.mx_ToastStore) global.mx_ToastStore = new ToastStore(); + return global.mx_ToastStore; + } + + constructor() { + super(); + this._dispatcherRef = null; + this._toasts = []; + } + + reset() { + this._toasts = []; + } + + addOrReplaceToast(newToast) { + const oldIndex = this._toasts.findIndex(t => t.key === newToast.key); + if (oldIndex === -1) { + this._toasts.push(newToast); + } else { + this._toasts[oldIndex] = newToast; + } + this.emit('update'); + } + + dismissToast(key) { + this._toasts = this._toasts.filter(t => t.key !== key); + this.emit('update'); + } + + getToasts() { + return this._toasts; + } +} diff --git a/src/utils/DMRoomMap.js b/src/utils/DMRoomMap.js index 547da0863b..43ef0035fc 100644 --- a/src/utils/DMRoomMap.js +++ b/src/utils/DMRoomMap.js @@ -124,6 +124,27 @@ export default class DMRoomMap { return this._getUserToRooms()[userId] || []; } + /** + * Gets the DM room which the given IDs share, if any. + * @param {string[]} ids The identifiers (user IDs and email addresses) to look for. + * @returns {Room} The DM room which all IDs given share, or falsey if no common room. + */ + getDMRoomForIdentifiers(ids) { + // TODO: [Canonical DMs] Handle lookups for email addresses. + // For now we'll pretend we only get user IDs and end up returning nothing for email addresses + + let commonRooms = this.getDMRoomsForUserId(ids[0]); + for (let i = 1; i < ids.length; i++) { + const userRooms = this.getDMRoomsForUserId(ids[i]); + commonRooms = commonRooms.filter(r => userRooms.includes(r)); + } + + const joinedRooms = commonRooms.map(r => MatrixClientPeg.get().getRoom(r)) + .filter(r => r && r.getMyMembership() === 'join'); + + return joinedRooms[0]; + } + getUserIdForRoomId(roomId) { if (this.roomToUser == null) { // we lazily populate roomToUser so you can use diff --git a/yarn.lock b/yarn.lock index d2135f7aa6..81602b4e3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1570,6 +1570,14 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" +anymatch@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -1884,6 +1892,11 @@ binary-extensions@^1.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== +binary-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" + integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== + bluebird@^3.5.0, bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1928,6 +1941,13 @@ braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" +braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + brorand@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" @@ -2232,7 +2252,7 @@ cheerio@^1.0.0-rc.3: lodash "^4.15.0" parse5 "^3.0.1" -chokidar@^2.0.2, chokidar@^2.1.2, chokidar@^2.1.8: +chokidar@^2.0.2, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== @@ -2251,6 +2271,21 @@ chokidar@^2.0.2, chokidar@^2.1.2, chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" +chokidar@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" + integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.3.0" + optionalDependencies: + fsevents "~2.1.2" + chownr@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142" @@ -3654,6 +3689,13 @@ fill-range@^4.0.0: repeat-string "^1.6.1" to-regex-range "^2.1.0" +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + find-cache-dir@^2.0.0, find-cache-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" @@ -3815,6 +3857,11 @@ fsevents@^1.2.7: nan "^2.12.1" node-pre-gyp "^0.12.0" +fsevents@~2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -3909,6 +3956,13 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" +glob-parent@~5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2" + integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw== + dependencies: + is-glob "^4.0.1" + glob-to-regexp@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" @@ -4463,6 +4517,13 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-boolean-object@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.0.tgz#98f8b28030684219a95f375cfbd88ce3405dff93" @@ -4622,7 +4683,7 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" -is-glob@^4.0.0, is-glob@^4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== @@ -4663,6 +4724,11 @@ is-number@^3.0.0: dependencies: kind-of "^3.0.2" +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + is-obj@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" @@ -6104,7 +6170,7 @@ normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -6578,6 +6644,11 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picomatch@^2.0.4, picomatch@^2.0.7: + version "2.2.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" + integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== + pify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" @@ -7169,6 +7240,13 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" +readdirp@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" + integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ== + dependencies: + picomatch "^2.0.7" + realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -8375,6 +8453,13 @@ to-regex-range@^2.1.0: is-number "^3.0.0" repeat-string "^1.6.1" +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + to-regex@^3.0.1, to-regex@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"