diff --git a/package.json b/package.json
index 7e2c242b3a..513a9a613a 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,7 @@
"glob-to-regexp": "^0.4.1",
"highlight.js": "^9.15.8",
"html-entities": "^1.2.1",
+ "humanize": "^0.0.9",
"is-ip": "^2.0.0",
"isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.6",
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 7b8ca77739..7a9ebfdf26 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -56,6 +56,7 @@
@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";
diff --git a/res/css/views/dialogs/_DMInviteDialog.scss b/res/css/views/dialogs/_DMInviteDialog.scss
new file mode 100644
index 0000000000..1153ecb0d4
--- /dev/null
+++ b/res/css/views/dialogs/_DMInviteDialog.scss
@@ -0,0 +1,81 @@
+/*
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_DMInviteDialog_addressBar {
+ display: flex;
+ flex-direction: row;
+
+ .mx_DMInviteDialog_editor {
+ flex: 1;
+ width: 100%; // Needed to make the Field inside grow
+ }
+
+ .mx_Field {
+ margin: 0;
+ }
+
+ .mx_DMInviteDialog_goButton {
+ width: 48px;
+ margin-left: 10px;
+ }
+}
+
+.mx_DMInviteDialog_section {
+ padding-bottom: 10px;
+
+ h3 {
+ font-size: 12px;
+ color: $muted-fg-color;
+ font-weight: bold;
+ text-transform: uppercase;
+ }
+}
+
+.mx_DMInviteDialog_roomTile {
+ cursor: pointer;
+ padding: 5px 10px;
+
+ &:hover {
+ background-color: $user-tile-hover-bg-color;
+ border-radius: 4px;
+ }
+
+ * {
+ vertical-align: middle;
+ }
+
+ .mx_DMInviteDialog_roomTile_name {
+ font-weight: 600;
+ font-size: 14px;
+ color: $primary-fg-color;
+ margin-left: 7px;
+ }
+
+ .mx_DMInviteDialog_roomTile_userId {
+ font-size: 12px;
+ color: $muted-fg-color;
+ margin-left: 7px;
+ }
+
+ .mx_DMInviteDialog_roomTile_time {
+ text-align: right;
+ font-size: 12px;
+ color: $muted-fg-color;
+ float: right;
+ line-height: 36px; // Height of the avatar to keep the time vertically aligned
+ }
+}
+
diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index 5359992f84..fbac1e932a 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -353,7 +354,6 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
left: 46px;
width: 15px;
height: 15px;
- cursor: pointer;
display: block;
bottom: 0;
right: 0;
diff --git a/res/css/views/rooms/_RoomRecoveryReminder.scss b/res/css/views/rooms/_RoomRecoveryReminder.scss
index 68e2bf861e..85d42ca4b4 100644
--- a/res/css/views/rooms/_RoomRecoveryReminder.scss
+++ b/res/css/views/rooms/_RoomRecoveryReminder.scss
@@ -40,4 +40,5 @@ limitations under the License.
.mx_RoomRecoveryReminder_secondary {
font-size: 90%;
+ margin-top: 1em;
}
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index eadde4c672..d28efbb11f 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -16,6 +16,7 @@ $room-highlight-color: #343a46;
// typical text (dark-on-white in light skin)
$primary-fg-color: $text-primary-color;
$primary-bg-color: $bg-color;
+$muted-fg-color: $header-panel-text-primary-color;
// used for dialog box text
$light-fg-color: $header-panel-text-secondary-color;
@@ -172,6 +173,8 @@ $interactive-tooltip-fg-color: #ffffff;
$breadcrumb-placeholder-bg-color: #272c35;
+$user-tile-hover-bg-color: $header-panel-bg-color;
+
// ***** Mixins! *****
@define-mixin mx_DialogButton {
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 0a3ef812b8..ac9cb261d3 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -21,6 +21,7 @@ $header-panel-bg-color: #f3f8fd;
// typical text (dark-on-white in light skin)
$primary-fg-color: #2e2f32;
$primary-bg-color: #ffffff;
+$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
// used for dialog box text
$light-fg-color: #747474;
@@ -293,6 +294,8 @@ $interactive-tooltip-fg-color: #ffffff;
$breadcrumb-placeholder-bg-color: #e8eef5;
+$user-tile-hover-bg-color: $header-panel-bg-color;
+
// ***** Mixins! *****
@define-mixin mx_DialogButton {
diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh
index ae88ef70c7..a592888292 100755
--- a/scripts/ci/end-to-end-tests.sh
+++ b/scripts/ci/end-to-end-tests.sh
@@ -36,7 +36,8 @@ echo "--- Install synapse & other dependencies"
./install.sh
# install static webserver to server symlinked local copy of riot
./riot/install-webserver.sh
-mkdir logs || rm -r logs/*
+rm -r logs || true
+mkdir logs
echo "+++ Running end-to-end tests"
TESTS_STARTED=1
./run.sh --no-sandbox --log-directory logs/
diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js
index ab0a22e4d5..f3953b1897 100644
--- a/src/CrossSigningManager.js
+++ b/src/CrossSigningManager.js
@@ -97,7 +97,7 @@ export const crossSigningCallbacks = {
*
* Additionally, the secret storage keys are cached during the scope of this function
* to ensure the user is prompted only once for their secret storage
- * passphrase. The cache is then
+ * passphrase. The cache is then cleared once the provided function completes.
*
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js
index c3de7988b2..4ee258da53 100644
--- a/src/KeyRequestHandler.js
+++ b/src/KeyRequestHandler.js
@@ -111,6 +111,12 @@ export default class KeyRequestHandler {
this._currentUser = null;
this._currentDevice = null;
+ if (!this._pendingKeyRequests[userId] || !this._pendingKeyRequests[userId][deviceId]) {
+ // request was removed in the time the dialog was displayed
+ this._processNextRequest();
+ return;
+ }
+
if (r) {
for (const req of this._pendingKeyRequests[userId][deviceId]) {
req.share();
diff --git a/src/RoomInvite.js b/src/RoomInvite.js
index 48baad5d9f..ba9fe1f541 100644
--- a/src/RoomInvite.js
+++ b/src/RoomInvite.js
@@ -25,6 +25,7 @@ import sdk from './';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import { _t } from './languageHandler';
+import SettingsStore from "./settings/SettingsStore";
/**
* Invites multiple addresses to a room
@@ -41,6 +42,18 @@ function inviteMultipleToRoom(roomId, addrs) {
}
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);
+ return;
+ }
+
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, {
@@ -99,7 +112,7 @@ export function isValid3pidInvite(event) {
return true;
}
-// TODO: Immutable DMs replaces this
+// TODO: Canonical DMs replaces this
function _onStartDmFinished(shouldInvite, addrs) {
if (!shouldInvite) return;
diff --git a/src/SlashCommands.js b/src/SlashCommands.js
index a9c015fdaf..21fa4a134e 100644
--- a/src/SlashCommands.js
+++ b/src/SlashCommands.js
@@ -780,54 +780,52 @@ export const CommandMap = {
const deviceId = matches[2];
const fingerprint = matches[3];
- return success(
- // Promise.resolve to handle transition from static result to promise; can be removed
- // in future
- Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => {
- if (!device) {
- throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`);
- }
+ return success((async () => {
+ const device = await cli.getStoredDevice(userId, deviceId);
+ if (!device) {
+ throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`);
+ }
+ const deviceTrust = await cli.checkDeviceTrust(userId, deviceId);
- if (device.isVerified()) {
- if (device.getFingerprint() === fingerprint) {
- throw new Error(_t('Device already verified!'));
- } else {
- throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!'));
- }
+ if (deviceTrust.isVerified()) {
+ if (device.getFingerprint() === fingerprint) {
+ throw new Error(_t('Device already verified!'));
+ } else {
+ throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!'));
}
+ }
- if (device.getFingerprint() !== fingerprint) {
- const fprint = device.getFingerprint();
- throw new Error(
- _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
- ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
- '"%(fingerprint)s". This could mean your communications are being intercepted!',
- {
- fprint,
- userId,
- deviceId,
- fingerprint,
- }));
- }
+ if (device.getFingerprint() !== fingerprint) {
+ const fprint = device.getFingerprint();
+ throw new Error(
+ _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
+ ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
+ '"%(fingerprint)s". This could mean your communications are being intercepted!',
+ {
+ fprint,
+ userId,
+ deviceId,
+ fingerprint,
+ }));
+ }
- return cli.setDeviceVerified(userId, deviceId, true);
- }).then(() => {
- // Tell the user we verified everything
- const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
- Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
- title: _t('Verified key'),
- description:
-
- {
- _t('The signing key you provided matches the signing key you received ' +
- 'from %(userId)s\'s device %(deviceId)s. Device marked as verified.',
- {userId, deviceId})
- }
-
-
,
- });
- }),
- );
+ await cli.setDeviceVerified(userId, deviceId, true);
+
+ // Tell the user we verified everything
+ const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
+ Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
+ title: _t('Verified key'),
+ description:
+
+ {
+ _t('The signing key you provided matches the signing key you received ' +
+ 'from %(userId)s\'s device %(deviceId)s. Device marked as verified.',
+ {userId, deviceId})
+ }
+
+
,
+ });
+ })());
}
}
return reject(this.getUsage());
diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js
index 15bb1e046b..ea3c109e05 100644
--- a/src/async-components/views/dialogs/EncryptedEventDialog.js
+++ b/src/async-components/views/dialogs/EncryptedEventDialog.js
@@ -23,6 +23,9 @@ import { _t } from '../../../languageHandler';
const sdk = require('../../../index');
const MatrixClientPeg = require("../../../MatrixClientPeg");
+// XXX: This component is not cross-signing aware.
+// https://github.com/vector-im/riot-web/issues/11752 tracks either updating this
+// component or taking it out to pasture.
module.exports = createReactClass({
displayName: 'EncryptedEventDialog',
diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js
index 3fac00c1b3..19720e077a 100644
--- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js
+++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js
@@ -1,6 +1,6 @@
/*
Copyright 2018, 2019 New Vector Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,11 +17,14 @@ limitations under the License.
import React from 'react';
import FileSaver from 'file-saver';
+import PropTypes from 'prop-types';
import sdk from '../../../../index';
import MatrixClientPeg from '../../../../MatrixClientPeg';
import { scorePassword } from '../../../../utils/PasswordScorer';
import { _t } from '../../../../languageHandler';
+import { accessSecretStorage } from '../../../../CrossSigningManager';
+import SettingsStore from '../../../../../lib/settings/SettingsStore';
const PHASE_PASSPHRASE = 0;
const PHASE_PASSPHRASE_CONFIRM = 1;
@@ -49,10 +52,20 @@ function selectText(target) {
* on the server.
*/
export default class CreateKeyBackupDialog extends React.PureComponent {
+ static propTypes = {
+ secureSecretStorage: PropTypes.bool,
+ onFinished: PropTypes.func.isRequired,
+ }
+
constructor(props) {
super(props);
+ this._recoveryKeyNode = null;
+ this._keyBackupInfo = null;
+ this._setZxcvbnResultTimeout = null;
+
this.state = {
+ secureSecretStorage: props.secureSecretStorage,
phase: PHASE_PASSPHRASE,
passPhrase: '',
passPhraseConfirm: '',
@@ -61,12 +74,25 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
zxcvbnResult: null,
setPassPhrase: false,
};
+
+ if (this.state.secureSecretStorage === undefined) {
+ this.state.secureSecretStorage =
+ SettingsStore.isFeatureEnabled("feature_cross_signing");
+ }
+
+ // If we're using secret storage, skip ahead to the backing up step, as
+ // `accessSecretStorage` will handle passphrases as needed.
+ if (this.state.secureSecretStorage) {
+ this.state.phase = PHASE_BACKINGUP;
+ }
}
- componentWillMount() {
- this._recoveryKeyNode = null;
- this._keyBackupInfo = null;
- this._setZxcvbnResultTimeout = null;
+ componentDidMount() {
+ // If we're using secret storage, skip ahead to the backing up step, as
+ // `accessSecretStorage` will handle passphrases as needed.
+ if (this.state.secureSecretStorage) {
+ this._createBackup();
+ }
}
componentWillUnmount() {
@@ -103,15 +129,26 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
}
_createBackup = async () => {
+ const { secureSecretStorage } = this.state;
this.setState({
phase: PHASE_BACKINGUP,
error: null,
});
let info;
try {
- info = await MatrixClientPeg.get().createKeyBackupVersion(
- this._keyBackupInfo,
- );
+ if (secureSecretStorage) {
+ await accessSecretStorage(async () => {
+ info = await MatrixClientPeg.get().prepareKeyBackupVersion(
+ null /* random key */,
+ { secureSecretStorage: true },
+ );
+ info = await MatrixClientPeg.get().createKeyBackupVersion(info);
+ });
+ } else {
+ info = await MatrixClientPeg.get().createKeyBackupVersion(
+ this._keyBackupInfo,
+ );
+ }
await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup();
this.setState({
phase: PHASE_DONE,
diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js
index 28281af771..147f109113 100644
--- a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js
+++ b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js
@@ -1,5 +1,6 @@
/*
-Copyright 2018-2019 New Vector Ltd
+Copyright 2018, 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -40,9 +41,11 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
onSetupClick = async () => {
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
- Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
- onFinished: this.props.onFinished,
- });
+ Modal.createTrackedDialog(
+ 'Restore Backup', '', RestoreKeyBackupDialog, {
+ onFinished: this.props.onFinished,
+ }, null, /* priority = */ false, /* static = */ true,
+ );
}
render() {
diff --git a/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js
index 1975fbe6d6..4383908e23 100644
--- a/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js
+++ b/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js
@@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -35,6 +36,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent {
this.props.onFinished();
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("./CreateKeyBackupDialog"),
+ null, null, /* priority = */ false, /* static = */ true,
);
}
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js
index fad57f5d52..af3f4d2598 100644
--- a/src/components/structures/MatrixChat.js
+++ b/src/components/structures/MatrixChat.js
@@ -1569,9 +1569,17 @@ export default createReactClass({
action: 'start_post_registration',
});
} else if (screen.indexOf('room/') == 0) {
- const segments = screen.substring(5).split('/');
- const roomString = segments[0];
- let eventId = segments.splice(1).join("/"); // empty string if no event id given
+ // Rooms can have the following formats:
+ // #room_alias:domain or !opaque_id:domain
+ const room = screen.substring(5);
+ const domainOffset = room.indexOf(':') + 1; // 0 in case room does not contain a :
+ let eventOffset = room.length;
+ // room aliases can contain slashes only look for slash after domain
+ if (room.substring(domainOffset).indexOf('/') > -1) {
+ eventOffset = domainOffset + room.substring(domainOffset).indexOf('/');
+ }
+ const roomString = room.substring(0, eventOffset);
+ let eventId = room.substring(eventOffset + 1); // empty string if no event id given
// Previously we pulled the eventID from the segments in such a way
// where if there was no eventId then we'd get undefined. However, we
diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js
new file mode 100644
index 0000000000..ff498e3e75
--- /dev/null
+++ b/src/components/views/dialogs/DMInviteDialog.js
@@ -0,0 +1,217 @@
+/*
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import {_t} from "../../../languageHandler";
+import sdk from "../../../index";
+import MatrixClientPeg from "../../../MatrixClientPeg";
+import {makeUserPermalink} from "../../../utils/permalinks/Permalinks";
+import DMRoomMap from "../../../utils/DMRoomMap";
+import {RoomMember} from "matrix-js-sdk/lib/matrix";
+import * as humanize from "humanize";
+
+// TODO: [TravisR] Make this generic for all kinds of invites
+
+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
+
+class DMRoomTile extends React.PureComponent {
+ static propTypes = {
+ member: PropTypes.object.isRequired,
+ lastActiveTs: PropTypes.number,
+ onToggle: PropTypes.func.isRequired,
+ };
+
+ constructor() {
+ super();
+ }
+
+ _onClick = (e) => {
+ // Stop the browser from highlighting text
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.props.onToggle(this.props.member.userId);
+ };
+
+ render() {
+ const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
+
+ let timestamp = null;
+ if (this.props.lastActiveTs) {
+ // TODO: [TravisR] Figure out how to i18n this
+ // `humanize` wants seconds for a timestamp, so divide by 1000
+ const humanTs = humanize.relativeTime(this.props.lastActiveTs / 1000);
+ timestamp = {humanTs};
+ }
+
+ return (
+
+
+ {this.props.member.name}
+ {this.props.member.userId}
+ {timestamp}
+
+ );
+ }
+}
+
+export default class DMInviteDialog extends React.PureComponent {
+ static propTypes = {
+ // Takes an array of user IDs/emails to invite.
+ onFinished: PropTypes.func.isRequired,
+ };
+
+ constructor() {
+ super();
+
+ this.state = {
+ targets: [], // string[] of mxids/email addresses
+ filterText: "",
+ recents: this._buildRecents(),
+ numRecentsShown: INITIAL_ROOMS_SHOWN,
+ };
+ }
+
+ _buildRecents(): {userId: string, user: RoomMember, lastActive: number} {
+ const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals();
+ const recents = [];
+ for (const userId in rooms) {
+ const room = rooms[userId];
+ const member = room.getMember(userId);
+ if (!member) continue; // just skip people who don't have memberships for some reason
+
+ const lastEventTs = room.timeline && room.timeline.length
+ ? room.timeline[room.timeline.length - 1].getTs()
+ : 0;
+ if (!lastEventTs) continue; // something weird is going on with this room
+
+ recents.push({userId, user: member, lastActive: lastEventTs});
+ }
+
+ // Sort the recents by last active to save us time later
+ recents.sort((a, b) => b.lastActive - a.lastActive);
+
+ return recents;
+ }
+
+ _startDm = () => {
+ this.props.onFinished(this.state.targets);
+ };
+
+ _cancel = () => {
+ this.props.onFinished([]);
+ };
+
+ _updateFilter = (e) => {
+ this.setState({filterText: e.target.value});
+ };
+
+ _showMoreRecents = () => {
+ this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN});
+ };
+
+ _toggleMember = (userId) => {
+ const targets = this.state.targets.map(t => t); // cheap clone for mutation
+ const idx = targets.indexOf(userId);
+ if (idx >= 0) targets.splice(idx, 1);
+ else targets.push(userId);
+ this.setState({targets});
+ };
+
+ _renderRecents() {
+ if (!this.state.recents || this.state.recents.length === 0) return null;
+
+ // .slice() will return an incomplete array but won't error on us if we go too far
+ const toRender = this.state.recents.slice(0, this.state.numRecentsShown);
+ const hasMore = toRender.length < this.state.recents.length;
+
+ const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
+ let showMore = null;
+ if (hasMore) {
+ showMore = (
+
+ {_t("Show more")}
+
+ );
+ }
+
+ const tiles = toRender.map(r => (
+
+ ));
+ return (
+
+
{_t("Recent Conversations")}
+ {tiles}
+ {showMore}
+
+ );
+ }
+
+ render() {
+ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
+ const Field = sdk.getComponent("elements.Field");
+ const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
+
+ // Dev note: The use of Field is temporary/incomplete pending https://github.com/vector-im/riot-web/issues/11197
+ // For now, we just list who the targets are.
+ const editor = (
+
+
+
+ );
+ const targets = this.state.targets.map(t => {t}
);
+
+ 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}},
+ )}
+
+ {targets}
+
+ {editor}
+
+ {_t("Go")}
+
+
+ {this._renderRecents()}
+
+
+ );
+ }
+}
diff --git a/src/components/views/dialogs/LogoutDialog.js b/src/components/views/dialogs/LogoutDialog.js
index 47d4153494..ede03f13cc 100644
--- a/src/components/views/dialogs/LogoutDialog.js
+++ b/src/components/views/dialogs/LogoutDialog.js
@@ -1,5 +1,6 @@
/*
Copyright 2018, 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -94,10 +95,14 @@ export default class LogoutDialog extends React.Component {
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
- Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {});
+ Modal.createTrackedDialog(
+ 'Restore Backup', '', RestoreKeyBackupDialog, null, null,
+ /* priority = */ false, /* static = */ true,
+ );
} else {
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
+ null, null, /* priority = */ false, /* static = */ true,
);
}
diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js
index 45168c381e..106d8cd6f8 100644
--- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js
+++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js
@@ -1,5 +1,6 @@
/*
Copyright 2018, 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,17 +16,18 @@ limitations under the License.
*/
import React from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+
import sdk from '../../../../index';
import MatrixClientPeg from '../../../../MatrixClientPeg';
import Modal from '../../../../Modal';
-
-import { MatrixClient } from 'matrix-js-sdk';
-
import { _t } from '../../../../languageHandler';
import {Key} from "../../../../Keyboard";
+import { accessSecretStorage } from '../../../../CrossSigningManager';
const RESTORE_TYPE_PASSPHRASE = 0;
const RESTORE_TYPE_RECOVERYKEY = 1;
+const RESTORE_TYPE_SECRET_STORAGE = 2;
/*
* Dialog for restoring e2e keys from a backup and the user's recovery key
@@ -35,6 +37,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
super(props);
this.state = {
backupInfo: null,
+ backupKeyStored: null,
loading: false,
loadError: null,
restoreError: null,
@@ -73,7 +76,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
onFinished: () => {
this._loadBackupStatus();
},
- },
+ }, null, /* priority = */ false, /* static = */ true,
);
}
@@ -148,6 +151,32 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
}
}
+ async _restoreWithSecretStorage() {
+ this.setState({
+ loading: true,
+ restoreError: null,
+ restoreType: RESTORE_TYPE_SECRET_STORAGE,
+ });
+ try {
+ // `accessSecretStorage` may prompt for storage access as needed.
+ const recoverInfo = await accessSecretStorage(async () => {
+ return MatrixClientPeg.get().restoreKeyBackupWithSecretStorage(
+ this.state.backupInfo,
+ );
+ });
+ this.setState({
+ loading: false,
+ recoverInfo,
+ });
+ } catch (e) {
+ console.log("Error restoring backup", e);
+ this.setState({
+ restoreError: e,
+ loading: false,
+ });
+ }
+ }
+
async _loadBackupStatus() {
this.setState({
loading: true,
@@ -155,10 +184,20 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
});
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
+ const backupKeyStored = await MatrixClientPeg.get().isKeyBackupKeyStored();
+ this.setState({
+ backupInfo,
+ backupKeyStored,
+ });
+
+ // If the backup key is stored, we can proceed directly to restore.
+ if (backupKeyStored) {
+ return this._restoreWithSecretStorage();
+ }
+
this.setState({
loadError: null,
loading: false,
- backupInfo,
});
} catch (e) {
console.log("Error loading backup status", e);
diff --git a/src/components/views/elements/DeviceVerifyButtons.js b/src/components/views/elements/DeviceVerifyButtons.js
index bb08f8b234..14b4ad1760 100644
--- a/src/components/views/elements/DeviceVerifyButtons.js
+++ b/src/components/views/elements/DeviceVerifyButtons.js
@@ -22,6 +22,8 @@ import sdk from '../../../index';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
+// XXX: This component is *not* cross-signing aware. Once everything is
+// cross-signing, this component should just go away.
export default createReactClass({
displayName: 'DeviceVerifyButtons',
diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js
index 208c5e8906..809fdcb6d7 100644
--- a/src/components/views/right_panel/UserInfo.js
+++ b/src/components/views/right_panel/UserInfo.js
@@ -74,17 +74,6 @@ const _getE2EStatus = (cli, userId, devices) => {
return "warning";
};
-async function unverifyUser(matrixClient, userId) {
- const devices = await matrixClient.getStoredDevicesForUser(userId);
- for (const device of devices) {
- if (device.isVerified()) {
- matrixClient.setDeviceVerified(
- userId, device.deviceId, false,
- );
- }
- }
-}
-
function openDMForUser(matrixClient, userId) {
const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
@@ -331,14 +320,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
);
}
- let unverifyButton;
- if (devices && devices.some(device => device.isVerified())) {
- unverifyButton = (
- unverifyUser(cli, member.userId)} className="mx_UserInfo_field mx_UserInfo_destructive">
- { _t('Unverify user') }
-
- );
- }
return (
@@ -350,7 +331,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
{ insertPillButton }
{ inviteUserButton }
{ ignoreButton }
- { unverifyButton }
);
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 784c4071aa..b71771a916 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -1,8 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
+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.
@@ -24,7 +24,6 @@ import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const classNames = require("classnames");
import { _t, _td } from '../../../languageHandler';
-const Modal = require('../../../Modal');
const sdk = require('../../../index');
const TextForEvent = require('../../../TextForEvent');
@@ -443,15 +442,6 @@ module.exports = createReactClass({
});
},
- onCryptoClick: function(e) {
- const event = this.props.mxEvent;
-
- Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
- import('../../../async-components/views/dialogs/EncryptedEventDialog'),
- {event},
- );
- },
-
onRequestKeysClick: function() {
this.setState({
// Indicate in the UI that the keys have been requested (this is expected to
@@ -479,11 +469,10 @@ module.exports = createReactClass({
_renderE2EPadlock: function() {
const ev = this.props.mxEvent;
- const props = {onClick: this.onCryptoClick};
// event could not be decrypted
if (ev.getContent().msgtype === 'm.bad.encrypted') {
- return ;
+ return ;
}
// event is encrypted, display padlock corresponding to whether or not it is verified
@@ -491,7 +480,7 @@ module.exports = createReactClass({
if (this.state.verified) {
return; // no icon for verified
} else {
- return ();
+ return ();
}
}
@@ -508,7 +497,7 @@ module.exports = createReactClass({
return; // we expect this to be unencrypted
}
// if the event is not encrypted, but it's an e2e room, show the open padlock
- return ;
+ return ;
}
// no padlock needed
@@ -920,7 +909,6 @@ class E2ePadlock extends React.Component {
static propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
- onClick: PropTypes.func,
};
constructor() {
@@ -931,10 +919,6 @@ class E2ePadlock extends React.Component {
};
}
- onClick = (e) => {
- if (this.props.onClick) this.props.onClick(e);
- };
-
onHoverStart = () => {
this.setState({hover: true});
};
diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js
index ff88c6f6e6..ba90850b35 100644
--- a/src/components/views/rooms/MemberDeviceInfo.js
+++ b/src/components/views/rooms/MemberDeviceInfo.js
@@ -23,6 +23,8 @@ import classNames from 'classnames';
export default class MemberDeviceInfo extends React.Component {
render() {
const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
+ // XXX: These checks are not cross-signing aware but this component is only used
+ // from the old, pre-cross-signing memberinfopanel
const iconClasses = classNames({
mx_MemberDeviceInfo_icon: true,
mx_MemberDeviceInfo_icon_blacklisted: this.props.device.isBlocked(),
diff --git a/src/components/views/rooms/RoomRecoveryReminder.js b/src/components/views/rooms/RoomRecoveryReminder.js
index 6b7366bc4f..aa8134d680 100644
--- a/src/components/views/rooms/RoomRecoveryReminder.js
+++ b/src/components/views/rooms/RoomRecoveryReminder.js
@@ -1,5 +1,6 @@
/*
Copyright 2018, 2019 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -70,10 +71,14 @@ export default class RoomRecoveryReminder extends React.PureComponent {
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
- Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {});
+ Modal.createTrackedDialog(
+ 'Restore Backup', '', RestoreKeyBackupDialog, null, null,
+ /* priority = */ false, /* static = */ true,
+ );
} else {
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
+ null, null, /* priority = */ false, /* static = */ true,
);
}
}
@@ -150,14 +155,14 @@ export default class RoomRecoveryReminder extends React.PureComponent {
onClick={this.onSetupClick}>
{setupCaption}
-
{ _t("Not now") }
-
-
+
{ _t("Don't ask me again") }
-
+
);
diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js
index 559b1e0ba1..bfa96f277f 100644
--- a/src/components/views/settings/KeyBackupPanel.js
+++ b/src/components/views/settings/KeyBackupPanel.js
@@ -1,6 +1,6 @@
/*
Copyright 2018 New Vector Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,7 +22,6 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import SettingsStore from '../../../../lib/settings/SettingsStore';
-import { accessSecretStorage } from '../../../CrossSigningManager';
export default class KeyBackupPanel extends React.PureComponent {
constructor(props) {
@@ -128,36 +127,24 @@ export default class KeyBackupPanel extends React.PureComponent {
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),
{
+ secureSecretStorage: false,
onFinished: () => {
this._loadBackupStatus();
},
- },
+ }, null, /* priority = */ false, /* static = */ true,
);
}
_startNewBackupWithSecureSecretStorage = async () => {
- const cli = MatrixClientPeg.get();
- let info;
- try {
- await accessSecretStorage(async () => {
- info = await cli.prepareKeyBackupVersion(
- null /* random key */,
- { secureSecretStorage: true },
- );
- info = await cli.createKeyBackupVersion(info);
- });
- await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup();
- this._loadBackupStatus();
- } catch (e) {
- console.error("Error creating key backup", e);
- // TODO: If creating a version succeeds, but backup fails, should we
- // delete the version, disable backup, or do nothing? If we just
- // disable without deleting, we'll enable on next app reload since
- // it is trusted.
- if (info && info.version) {
- MatrixClientPeg.get().deleteKeyBackupVersion(info.version);
- }
- }
+ Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
+ import('../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),
+ {
+ secureSecretStorage: true,
+ onFinished: () => {
+ this._loadBackupStatus();
+ },
+ }, null, /* priority = */ false, /* static = */ true,
+ );
}
_deleteBackup = () => {
@@ -181,22 +168,11 @@ export default class KeyBackupPanel extends React.PureComponent {
}
_restoreBackup = async () => {
- // Use legacy path if backup key not stored in secret storage
- if (!this.state.backupKeyStored) {
- const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
- Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog);
- return;
- }
-
- try {
- await accessSecretStorage(async () => {
- await MatrixClientPeg.get().restoreKeyBackupWithSecretStorage(
- this.state.backupInfo,
- );
- });
- } catch (e) {
- console.log("Error restoring backup", e);
- }
+ const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
+ Modal.createTrackedDialog(
+ 'Restore Backup', '', RestoreKeyBackupDialog, null, null,
+ /* priority = */ false, /* static = */ true,
+ );
}
render() {
@@ -270,7 +246,7 @@ export default class KeyBackupPanel extends React.PureComponent {
{sub}
;
const verify = sub =>
-
+
{sub}
;
const device = sub => {deviceName};
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 6979759cd2..cac3f2f619 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -358,6 +358,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)",
"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)",
"Use the new, faster, composer for writing messages": "Use the new, faster, composer for writing messages",
@@ -1114,7 +1115,6 @@
"%(count)s verified sessions|other": "%(count)s verified sessions",
"%(count)s verified sessions|one": "1 verified session",
"Direct message": "Direct message",
- "Unverify user": "Unverify user",
"Remove from community": "Remove from community",
"Disinvite this user from community?": "Disinvite this user from community?",
"Remove this user from community?": "Remove this user from community?",
@@ -1431,6 +1431,11 @@
"View Servers in Room": "View Servers in Room",
"Toolbox": "Toolbox",
"Developer Tools": "Developer Tools",
+ "Show more": "Show more",
+ "Recent Conversations": "Recent Conversations",
+ "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.",
diff --git a/src/rageshake/submit-rageshake.js b/src/rageshake/submit-rageshake.js
index 457958eb82..44f1039016 100644
--- a/src/rageshake/submit-rageshake.js
+++ b/src/rageshake/submit-rageshake.js
@@ -27,6 +27,7 @@ import rageshake from './rageshake';
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
+import SettingsStore from "../settings/SettingsStore";
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
@@ -85,6 +86,12 @@ export default async function sendBugReport(bugReportEndpoint, opts) {
body.append('label', opts.label);
}
+ // add labs options
+ const enabledLabs = SettingsStore.getLabsFeatures().filter(SettingsStore.isFeatureEnabled);
+ if (enabledLabs.length) {
+ body.append('enabled_labs', enabledLabs.join(', '));
+ }
+
if (opts.sendLogs) {
progressCallback(_t("Collecting logs"));
const logs = await rageshake.getLogsForReport();
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index f1299a9045..d606528ca3 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -128,6 +128,12 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
+ "feature_ftue_dms": {
+ isFeature: true,
+ displayName: _td("New DM invite dialog (under development)"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
"mjolnirRooms": {
supportedLevels: ['account'],
default: [],
diff --git a/src/utils/DMRoomMap.js b/src/utils/DMRoomMap.js
index af65b6f001..498c073e0e 100644
--- a/src/utils/DMRoomMap.js
+++ b/src/utils/DMRoomMap.js
@@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -16,6 +17,7 @@ limitations under the License.
import MatrixClientPeg from '../MatrixClientPeg';
import _uniq from 'lodash/uniq';
+import {Room} from "matrix-js-sdk/lib/matrix";
/**
* Class that takes a Matrix Client and flips the m.direct map
@@ -144,6 +146,13 @@ export default class DMRoomMap {
return this.roomToUser[roomId];
}
+ getUniqueRoomsWithIndividuals(): {[userId: string]: Room} {
+ return Object.keys(this.roomToUser)
+ .map(r => ({userId: this.getUserIdForRoomId(r), room: this.matrixClient.getRoom(r)}))
+ .filter(r => r.userId && r.room && r.room.getInvitedAndJoinedMemberCount() === 2)
+ .reduce((obj, r) => (obj[r.userId] = r.room) && obj, {});
+ }
+
_getUserToRooms() {
if (!this.userToRooms) {
const userToRooms = this.mDirectEvent;
diff --git a/yarn.lock b/yarn.lock
index a977b97259..306ebf590e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4094,6 +4094,11 @@ humanize-ms@^1.2.1:
dependencies:
ms "^2.0.0"
+humanize@^0.0.9:
+ version "0.0.9"
+ resolved "https://registry.yarnpkg.com/humanize/-/humanize-0.0.9.tgz#1994ffaecdfe9c441ed2bdac7452b7bb4c9e41a4"
+ integrity sha1-GZT/rs3+nEQe0r2sdFK3u0yeQaQ=
+
iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"