diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 80d2cd3418..525855f3ed 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -392,6 +392,7 @@ limitations under the License. overflow-x: overlay; overflow-y: visible; max-height: 30vh; + position: static; } .mx_EventTile_content .markdown-body code { @@ -406,7 +407,7 @@ limitations under the License. visibility: hidden; cursor: pointer; top: 6px; - right: 6px; + right: 36px; width: 19px; height: 19px; background-image: url($copy-button-url); diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 7fe625f8b9..fd21977108 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -243,6 +243,7 @@ function uploadFile(matrixClient, roomId, file, progressHandler) { const blob = new Blob([encryptResult.data]); return matrixClient.uploadContent(blob, { progressHandler: progressHandler, + includeFilename: false, }).then(function(url) { // If the attachment is encrypted then bundle the URL along // with the information needed to decrypt the attachment and diff --git a/src/DecryptionFailureTracker.js b/src/DecryptionFailureTracker.js new file mode 100644 index 0000000000..b1c6a71289 --- /dev/null +++ b/src/DecryptionFailureTracker.js @@ -0,0 +1,169 @@ +/* +Copyright 2018 New Vector Ltd + +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. +*/ + +class DecryptionFailure { + constructor(failedEventId) { + this.failedEventId = failedEventId; + this.ts = Date.now(); + } +} + +export default class DecryptionFailureTracker { + // Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list + // is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did + // are added to `failuresToTrack`. + failures = []; + + // Every TRACK_INTERVAL_MS (so as to spread the number of hits done on Analytics), + // one DecryptionFailure of this FIFO is removed and tracked. + failuresToTrack = []; + + // Event IDs of failures that were tracked previously + trackedEventHashMap = { + // [eventId]: true + }; + + // Set to an interval ID when `start` is called + checkInterval = null; + trackInterval = null; + + // Spread the load on `Analytics` by sending at most 1 event per + // `TRACK_INTERVAL_MS`. + static TRACK_INTERVAL_MS = 1000; + + // Call `checkFailures` every `CHECK_INTERVAL_MS`. + static CHECK_INTERVAL_MS = 5000; + + // Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before moving + // the failure to `failuresToTrack`. + static GRACE_PERIOD_MS = 5000; + + constructor(fn) { + if (!fn || typeof fn !== 'function') { + throw new Error('DecryptionFailureTracker requires tracking function'); + } + + this.trackDecryptionFailure = fn; + } + + // loadTrackedEventHashMap() { + // this.trackedEventHashMap = JSON.parse(localStorage.getItem('mx-decryption-failure-event-id-hashes')) || {}; + // } + + // saveTrackedEventHashMap() { + // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap)); + // } + + eventDecrypted(e) { + if (e.isDecryptionFailure()) { + this.addDecryptionFailureForEvent(e); + } else { + // Could be an event in the failures, remove it + this.removeDecryptionFailuresForEvent(e); + } + } + + addDecryptionFailureForEvent(e) { + this.failures.push(new DecryptionFailure(e.getId())); + } + + removeDecryptionFailuresForEvent(e) { + this.failures = this.failures.filter((f) => f.failedEventId !== e.getId()); + } + + /** + * Start checking for and tracking failures. + */ + start() { + this.checkInterval = setInterval( + () => this.checkFailures(Date.now()), + DecryptionFailureTracker.CHECK_INTERVAL_MS, + ); + + this.trackInterval = setInterval( + () => this.trackFailure(), + DecryptionFailureTracker.TRACK_INTERVAL_MS, + ); + } + + /** + * Clear state and stop checking for and tracking failures. + */ + stop() { + clearInterval(this.checkInterval); + clearInterval(this.trackInterval); + + this.failures = []; + this.failuresToTrack = []; + } + + /** + * Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be + * tracked. Only mark one failure per event ID. + * @param {number} nowTs the timestamp that represents the time now. + */ + checkFailures(nowTs) { + const failuresGivenGrace = []; + const failuresNotReady = []; + while (this.failures.length > 0) { + const f = this.failures.shift(); + if (nowTs > f.ts + DecryptionFailureTracker.GRACE_PERIOD_MS) { + failuresGivenGrace.push(f); + } else { + failuresNotReady.push(f); + } + } + this.failures = failuresNotReady; + + // Only track one failure per event + const dedupedFailuresMap = failuresGivenGrace.reduce( + (map, failure) => { + if (!this.trackedEventHashMap[failure.failedEventId]) { + return map.set(failure.failedEventId, failure); + } else { + return map; + } + }, + // Use a map to preseve key ordering + new Map(), + ); + + const trackedEventIds = [...dedupedFailuresMap.keys()]; + + this.trackedEventHashMap = trackedEventIds.reduce( + (result, eventId) => ({...result, [eventId]: true}), + this.trackedEventHashMap, + ); + + // Commented out for now for expediency, we need to consider unbound nature of storing + // this in localStorage + // this.saveTrackedEventHashMap(); + + const dedupedFailures = dedupedFailuresMap.values(); + + this.failuresToTrack = [...this.failuresToTrack, ...dedupedFailures]; + } + + /** + * If there is a failure that should be tracked, call the given trackDecryptionFailure + * function with the first failure in the FIFO of failures that should be tracked. + */ + trackFailure() { + if (this.failuresToTrack.length > 0) { + this.trackDecryptionFailure(this.failuresToTrack.shift()); + } + } +} diff --git a/src/SlashCommands.js b/src/SlashCommands.js index d45e45e84c..211a68e7b0 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -14,28 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from "./MatrixClientPeg"; -import dis from "./dispatcher"; -import Tinter from "./Tinter"; + +import React from 'react'; +import MatrixClientPeg from './MatrixClientPeg'; +import dis from './dispatcher'; +import Tinter from './Tinter'; import sdk from './index'; -import { _t } from './languageHandler'; +import {_t, _td} from './languageHandler'; import Modal from './Modal'; -import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; +import SettingsStore, {SettingLevel} from './settings/SettingsStore'; class Command { - constructor(name, paramArgs, runFn) { - this.name = name; - this.paramArgs = paramArgs; + constructor({name, args='', description, runFn}) { + this.command = '/' + name; + this.args = args; + this.description = description; this.runFn = runFn; } getCommand() { - return "/" + this.name; + return this.command; } getCommandWithArgs() { - return this.getCommand() + " " + this.paramArgs; + return this.getCommand() + " " + this.args; } run(roomId, args) { @@ -47,16 +50,12 @@ class Command { } } -function reject(msg) { - return { - error: msg, - }; +function reject(error) { + return {error}; } function success(promise) { - return { - promise: promise, - }; + return {promise}; } /* Disable the "unexpected this" error for these commands - all of the run @@ -65,352 +64,408 @@ function success(promise) { /* eslint-disable babel/no-invalid-this */ -const commands = { - ddg: new Command("ddg", "", function(roomId, args) { - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); - // TODO Don't explain this away, actually show a search UI here. - Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { - title: _t('/ddg is not a command'), - description: _t('To use it, just wait for autocomplete results to load and tab through them.'), - }); - return success(); +export const CommandMap = { + ddg: new Command({ + name: 'ddg', + args: '', + description: _td('Searches DuckDuckGo for results'), + runFn: function(roomId, args) { + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + // TODO Don't explain this away, actually show a search UI here. + Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { + title: _t('/ddg is not a command'), + description: _t('To use it, just wait for autocomplete results to load and tab through them.'), + }); + return success(); + }, }), - // Change your nickname - nick: new Command("nick", "", function(roomId, args) { - if (args) { - return success( - MatrixClientPeg.get().setDisplayName(args), - ); - } - return reject(this.getUsage()); - }), - - // Changes the colorscheme of your current room - tint: new Command("tint", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); - if (matches) { - Tinter.tint(matches[1], matches[4]); - const colorScheme = {}; - colorScheme.primary_color = matches[1]; - if (matches[4]) { - colorScheme.secondary_color = matches[4]; - } else { - colorScheme.secondary_color = colorScheme.primary_color; - } - return success( - SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), - ); + nick: new Command({ + name: 'nick', + args: '', + description: _td('Changes your display nickname'), + runFn: function(roomId, args) { + if (args) { + return success(MatrixClientPeg.get().setDisplayName(args)); } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - // Change the room topic - topic: new Command("topic", "", function(roomId, args) { - if (args) { - return success( - MatrixClientPeg.get().setRoomTopic(roomId, args), - ); - } - return reject(this.getUsage()); - }), - - // Invite a user - invite: new Command("invite", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - return success( - MatrixClientPeg.get().invite(roomId, matches[1]), - ); - } - } - return reject(this.getUsage()); - }), - - // Join a room - join: new Command("join", "#alias:domain", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - let roomAlias = matches[1]; - if (roomAlias[0] !== '#') { - return reject(this.getUsage()); - } - if (!roomAlias.match(/:/)) { - roomAlias += ':' + MatrixClientPeg.get().getDomain(); - } - - dis.dispatch({ - action: 'view_room', - room_alias: roomAlias, - auto_join: true, - }); - - return success(); - } - } - return reject(this.getUsage()); - }), - - part: new Command("part", "[#alias:domain]", function(roomId, args) { - let targetRoomId; - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - let roomAlias = matches[1]; - if (roomAlias[0] !== '#') { - return reject(this.getUsage()); - } - if (!roomAlias.match(/:/)) { - roomAlias += ':' + MatrixClientPeg.get().getDomain(); - } - - // Try to find a room with this alias - const rooms = MatrixClientPeg.get().getRooms(); - for (let i = 0; i < rooms.length; i++) { - const aliasEvents = rooms[i].currentState.getStateEvents( - "m.room.aliases", - ); - for (let j = 0; j < aliasEvents.length; j++) { - const aliases = aliasEvents[j].getContent().aliases || []; - for (let k = 0; k < aliases.length; k++) { - if (aliases[k] === roomAlias) { - targetRoomId = rooms[i].roomId; - break; - } - } - if (targetRoomId) { break; } + tint: new Command({ + name: 'tint', + args: ' []', + description: _td('Changes colour scheme of current room'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(#([\da-fA-F]{3}|[\da-fA-F]{6}))( +(#([\da-fA-F]{3}|[\da-fA-F]{6})))?$/); + if (matches) { + Tinter.tint(matches[1], matches[4]); + const colorScheme = {}; + colorScheme.primary_color = matches[1]; + if (matches[4]) { + colorScheme.secondary_color = matches[4]; + } else { + colorScheme.secondary_color = colorScheme.primary_color; } - if (targetRoomId) { break; } - } - if (!targetRoomId) { - return reject(_t("Unrecognised room alias:") + ' ' + roomAlias); + return success( + SettingsStore.setValue('roomColor', roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), + ); } } - } - if (!targetRoomId) targetRoomId = roomId; - return success( - MatrixClientPeg.get().leave(targetRoomId).then( - function() { - dis.dispatch({action: 'view_next_room'}); - }, - ), - ); + return reject(this.getUsage()); + }, }), - // Kick a user from the room with an optional reason - kick: new Command("kick", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+?)( +(.*))?$/); - if (matches) { - return success( - MatrixClientPeg.get().kick(roomId, matches[1], matches[3]), - ); + topic: new Command({ + name: 'topic', + args: '', + description: _td('Sets the room topic'), + runFn: function(roomId, args) { + if (args) { + return success(MatrixClientPeg.get().setRoomTopic(roomId, args)); } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, + }), + + invite: new Command({ + name: 'invite', + args: '', + description: _td('Invites user with given id to current room'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + return success(MatrixClientPeg.get().invite(roomId, matches[1])); + } + } + return reject(this.getUsage()); + }, + }), + + join: new Command({ + name: 'join', + args: '', + description: _td('Joins room with given alias'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') return reject(this.getUsage()); + + if (!roomAlias.includes(':')) { + roomAlias += ':' + MatrixClientPeg.get().getDomain(); + } + + dis.dispatch({ + action: 'view_room', + room_alias: roomAlias, + auto_join: true, + }); + + return success(); + } + } + return reject(this.getUsage()); + }, + }), + + part: new Command({ + name: 'part', + args: '[]', + description: _td('Leave room'), + runFn: function(roomId, args) { + const cli = MatrixClientPeg.get(); + + let targetRoomId; + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') return reject(this.getUsage()); + + if (!roomAlias.includes(':')) { + roomAlias += ':' + cli.getDomain(); + } + + // Try to find a room with this alias + const rooms = cli.getRooms(); + for (let i = 0; i < rooms.length; i++) { + const aliasEvents = rooms[i].currentState.getStateEvents('m.room.aliases'); + for (let j = 0; j < aliasEvents.length; j++) { + const aliases = aliasEvents[j].getContent().aliases || []; + for (let k = 0; k < aliases.length; k++) { + if (aliases[k] === roomAlias) { + targetRoomId = rooms[i].roomId; + break; + } + } + if (targetRoomId) break; + } + if (targetRoomId) break; + } + if (!targetRoomId) return reject(_t('Unrecognised room alias:') + ' ' + roomAlias); + } + } + + if (!targetRoomId) targetRoomId = roomId; + return success( + cli.leave(targetRoomId).then(function() { + dis.dispatch({action: 'view_next_room'}); + }), + ); + }, + }), + + kick: new Command({ + name: 'kick', + args: ' [reason]', + description: _td('Kicks user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + return success(MatrixClientPeg.get().kick(roomId, matches[1], matches[3])); + } + } + return reject(this.getUsage()); + }, }), // Ban a user from the room with an optional reason - ban: new Command("ban", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+?)( +(.*))?$/); - if (matches) { - return success( - MatrixClientPeg.get().ban(roomId, matches[1], matches[3]), - ); + ban: new Command({ + name: 'ban', + args: ' [reason]', + description: _td('Bans user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + return success(MatrixClientPeg.get().ban(roomId, matches[1], matches[3])); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - // Unban a user from the room - unban: new Command("unban", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - // Reset the user membership to "leave" to unban him - return success( - MatrixClientPeg.get().unban(roomId, matches[1]), - ); + // Unban a user from ythe room + unban: new Command({ + name: 'unban', + args: '', + description: _td('Unbans user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + // Reset the user membership to "leave" to unban him + return success(MatrixClientPeg.get().unban(roomId, matches[1])); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - ignore: new Command("ignore", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - const userId = matches[1]; - const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); - ignoredUsers.push(userId); // de-duped internally in the js-sdk - return success( - MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { - title: _t("Ignored user"), - description: ( -
-

{ _t("You are now ignoring %(userId)s", {userId: userId}) }

-
- ), - hasCancelButton: false, - }); - }), - ); + ignore: new Command({ + name: 'ignore', + args: '', + description: _td('Ignores a user, hiding their messages from you'), + runFn: function(roomId, args) { + if (args) { + const cli = MatrixClientPeg.get(); + + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = cli.getIgnoredUsers(); + ignoredUsers.push(userId); // de-duped internally in the js-sdk + return success( + cli.setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { + title: _t('Ignored user'), + description:
+

{ _t('You are now ignoring %(userId)s', {userId}) }

+
, + hasCancelButton: false, + }); + }), + ); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - unignore: new Command("unignore", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - const userId = matches[1]; - const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); - const index = ignoredUsers.indexOf(userId); - if (index !== -1) ignoredUsers.splice(index, 1); - return success( - MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { - title: _t("Unignored user"), - description: ( -
-

{ _t("You are no longer ignoring %(userId)s", {userId: userId}) }

-
- ), - hasCancelButton: false, - }); - }), - ); + unignore: new Command({ + name: 'unignore', + args: '', + description: _td('Stops ignoring a user, showing their messages going forward'), + runFn: function(roomId, args) { + if (args) { + const cli = MatrixClientPeg.get(); + + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(userId); + if (index !== -1) ignoredUsers.splice(index, 1); + return success( + cli.setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { + title: _t('Unignored user'), + description:
+

{ _t('You are no longer ignoring %(userId)s', {userId}) }

+
, + hasCancelButton: false, + }); + }), + ); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), // Define the power level of a user - op: new Command("op", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+?)( +(-?\d+))?$/); - let powerLevel = 50; // default power level for op - if (matches) { - const userId = matches[1]; - if (matches.length === 4 && undefined !== matches[3]) { - powerLevel = parseInt(matches[3]); - } - if (!isNaN(powerLevel)) { - const room = MatrixClientPeg.get().getRoom(roomId); - if (!room) { - return reject("Bad room ID: " + roomId); + op: new Command({ + name: 'op', + args: ' []', + description: _td('Define the power level of a user'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+?)( +(-?\d+))?$/); + let powerLevel = 50; // default power level for op + if (matches) { + const userId = matches[1]; + if (matches.length === 4 && undefined !== matches[3]) { + powerLevel = parseInt(matches[3]); + } + if (!isNaN(powerLevel)) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + if (!room) return reject('Bad room ID: ' + roomId); + + const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', ''); + return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent)); } - const powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "", - ); - return success( - MatrixClientPeg.get().setPowerLevel( - roomId, userId, powerLevel, powerLevelEvent, - ), - ); } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), // Reset the power level of a user - deop: new Command("deop", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - const room = MatrixClientPeg.get().getRoom(roomId); - if (!room) { - return reject("Bad room ID: " + roomId); - } + deop: new Command({ + name: 'deop', + args: '', + description: _td('Deops user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + if (!room) return reject('Bad room ID: ' + roomId); - const powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "", - ); - return success( - MatrixClientPeg.get().setPowerLevel( - roomId, args, undefined, powerLevelEvent, - ), - ); + const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', ''); + return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent)); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - // Open developer tools - devtools: new Command("devtools", "", function(roomId) { - const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog"); - Modal.createDialog(DevtoolsDialog, { roomId }); - return success(); + devtools: new Command({ + name: 'devtools', + description: _td('Opens the Developer Tools dialog'), + runFn: function(roomId) { + const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog'); + Modal.createDialog(DevtoolsDialog, {roomId}); + return success(); + }, }), // Verify a user, device, and pubkey tuple - verify: new Command("verify", " ", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); - if (matches) { - const userId = matches[1]; - const deviceId = matches[2]; - const fingerprint = matches[3]; + verify: new Command({ + name: 'verify', + args: ' ', + description: _td('Verifies a user, device, and pubkey tuple'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); + if (matches) { + const cli = MatrixClientPeg.get(); - return success( - // Promise.resolve to handle transition from static result to promise; can be removed - // in future - Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => { - if (!device) { - throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`); - } + const userId = matches[1]; + const deviceId = matches[2]; + const fingerprint = matches[3]; - 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!`)); + 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})`); } - } - 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!', - {deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint})); - } + 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!')); + } + } - return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true); - }).then(() => { - // Tell the user we verified everything - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, { - 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: userId, deviceId: deviceId}) - } -

-
- ), - hasCancelButton: false, - }); - }), - ); + 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 QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, { + 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}) + } +

+
, + hasCancelButton: false, + }); + }), + ); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, + }), + + // Command definitions for autocompletion ONLY: + + // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes + me: new Command({ + name: 'me', + args: '', + description: _td('Displays action'), }), }; /* eslint-enable babel/no-invalid-this */ @@ -421,50 +476,39 @@ const aliases = { j: "join", }; -module.exports = { - /** - * Process the given text for /commands and perform them. - * @param {string} roomId The room in which the command was performed. - * @param {string} input The raw text input by the user. - * @return {Object|null} An object with the property 'error' if there was an error - * processing the command, or 'promise' if a request was sent out. - * Returns null if the input didn't match a command. - */ - processInput: function(roomId, input) { - // trim any trailing whitespace, as it can confuse the parser for - // IRC-style commands - input = input.replace(/\s+$/, ""); - if (input[0] === "/" && input[1] !== "/") { - const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); - let cmd; - let args; - if (bits) { - cmd = bits[1].substring(1).toLowerCase(); - args = bits[3]; - } else { - cmd = input; - } - if (cmd === "me") return null; - if (aliases[cmd]) { - cmd = aliases[cmd]; - } - if (commands[cmd]) { - return commands[cmd].run(roomId, args); - } else { - return reject(_t("Unrecognised command:") + ' ' + input); - } - } - return null; // not a command - }, +/** + * Process the given text for /commands and perform them. + * @param {string} roomId The room in which the command was performed. + * @param {string} input The raw text input by the user. + * @return {Object|null} An object with the property 'error' if there was an error + * processing the command, or 'promise' if a request was sent out. + * Returns null if the input didn't match a command. + */ +export function processCommandInput(roomId, input) { + // trim any trailing whitespace, as it can confuse the parser for + // IRC-style commands + input = input.replace(/\s+$/, ''); + if (input[0] !== '/' || input[1] === '/') return null; // not a command - getCommandList: function() { - // Return all the commands plus /me and /markdown which aren't handled like normal commands - const cmds = Object.keys(commands).sort().map(function(cmdKey) { - return commands[cmdKey]; - }); - cmds.push(new Command("me", "", function() {})); - cmds.push(new Command("markdown", "", function() {})); + const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); + let cmd; + let args; + if (bits) { + cmd = bits[1].substring(1).toLowerCase(); + args = bits[3]; + } else { + cmd = input; + } - return cmds; - }, -}; + if (aliases[cmd]) { + cmd = aliases[cmd]; + } + if (CommandMap[cmd]) { + // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` + if (!CommandMap[cmd].runFn) return null; + + return CommandMap[cmd].run(roomId, args); + } else { + return reject(_t('Unrecognised command:') + ' ' + input); + } +} diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index e33fa7861f..891ef97d65 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -2,6 +2,7 @@ Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd Copyright 2017 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,103 +18,16 @@ limitations under the License. */ import React from 'react'; -import { _t, _td } from '../languageHandler'; +import {_t} from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; +import {CommandMap} from '../SlashCommands'; +import type {SelectionRange} from "./Autocompleter"; -// TODO merge this with the factory mechanics of SlashCommands? -// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file -const COMMANDS = [ - { - command: '/me', - args: '', - description: _td('Displays action'), - }, - { - command: '/ban', - args: ' [reason]', - description: _td('Bans user with given id'), - }, - { - command: '/unban', - args: '', - description: _td('Unbans user with given id'), - }, - { - command: '/op', - args: ' []', - description: _td('Define the power level of a user'), - }, - { - command: '/deop', - args: '', - description: _td('Deops user with given id'), - }, - { - command: '/invite', - args: '', - description: _td('Invites user with given id to current room'), - }, - { - command: '/join', - args: '', - description: _td('Joins room with given alias'), - }, - { - command: '/part', - args: '[]', - description: _td('Leave room'), - }, - { - command: '/topic', - args: '', - description: _td('Sets the room topic'), - }, - { - command: '/kick', - args: ' [reason]', - description: _td('Kicks user with given id'), - }, - { - command: '/nick', - args: '', - description: _td('Changes your display nickname'), - }, - { - command: '/ddg', - args: '', - description: _td('Searches DuckDuckGo for results'), - }, - { - command: '/tint', - args: ' []', - description: _td('Changes colour scheme of current room'), - }, - { - command: '/verify', - args: ' ', - description: _td('Verifies a user, device, and pubkey tuple'), - }, - { - command: '/ignore', - args: '', - description: _td('Ignores a user, hiding their messages from you'), - }, - { - command: '/unignore', - args: '', - description: _td('Stops ignoring a user, showing their messages going forward'), - }, - { - command: '/devtools', - args: '', - description: _td('Opens the Developer Tools dialog'), - }, - // Omitting `/markdown` as it only seems to apply to OldComposer -]; +const COMMANDS = Object.values(CommandMap); -const COMMAND_RE = /(^\/\w*)/g; +const COMMAND_RE = /(^\/\w*)(?: .*)?/g; export default class CommandProvider extends AutocompleteProvider { constructor() { @@ -123,23 +37,36 @@ export default class CommandProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: {start: number, end: number}) { - let completions = []; + async getCompletions(query: string, selection: SelectionRange, force?: boolean) { const {command, range} = this.getCurrentCommand(query, selection); - if (command) { - completions = this.matcher.match(command[0]).map((result) => { - return { - completion: result.command + ' ', - component: (), - range, - }; - }); + if (!command) return []; + + let matches = []; + if (command[0] !== command[1]) { + // The input looks like a command with arguments, perform exact match + const name = command[1].substr(1); // strip leading `/` + if (CommandMap[name]) { + matches = [CommandMap[name]]; + } + } else { + if (query === '/') { + // If they have just entered `/` show everything + matches = COMMANDS; + } else { + // otherwise fuzzy match against all of the fields + matches = this.matcher.match(command[1]); + } } - return completions; + + return matches.map((result) => ({ + // If the command is the same as the one they entered, we don't want to discard their arguments + completion: result.command === command[1] ? command[0] : (result.command + ' '), + component: , + range, + })); } getName() { diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 3249cae22c..927449750c 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -68,8 +68,8 @@ const FilePanel = React.createClass({ "room": { "timeline": { "contains_url": true, - "not_types": [ - "m.sticker", + "types": [ + "m.room.message", ], }, }, diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index a343d1c971..801d2e282e 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -1059,7 +1059,7 @@ export default React.createClass({
{ _t('Only people who have been invited') } @@ -1071,7 +1071,7 @@ export default React.createClass({
{ _t('Everyone') } @@ -1134,10 +1134,6 @@ export default React.createClass({ let avatarNode; let nameNode; let shortDescNode; - const bodyNodes = [ - this._getMembershipSection(), - this._getGroupSection(), - ]; const rightButtons = []; if (this.state.editing && this.state.isUserPrivileged) { let avatarImage; @@ -1282,7 +1278,8 @@ export default React.createClass({
- { bodyNodes } + { this._getMembershipSection() } + { this._getGroupSection() } ); diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 5acceb1009..ebe5d7f507 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -82,17 +82,26 @@ var LeftPanel = React.createClass({ _onKeyDown: function(ev) { if (!this.focusedElement) return; - let handled = false; + let handled = true; switch (ev.keyCode) { + case KeyCode.TAB: + this._onMoveFocus(ev.shiftKey); + break; case KeyCode.UP: this._onMoveFocus(true); - handled = true; break; case KeyCode.DOWN: this._onMoveFocus(false); - handled = true; break; + case KeyCode.ENTER: + this._onMoveFocus(false); + if (this.focusedElement) { + this.focusedElement.click(); + } + break; + default: + handled = false; } if (handled) { @@ -102,37 +111,33 @@ var LeftPanel = React.createClass({ }, _onMoveFocus: function(up) { - var element = this.focusedElement; + let element = this.focusedElement; // unclear why this isn't needed // var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending; // this.focusDirection = up; - var descending = false; // are we currently descending or ascending through the DOM tree? - var classes; + let descending = false; // are we currently descending or ascending through the DOM tree? + let classes; do { - var child = up ? element.lastElementChild : element.firstElementChild; - var sibling = up ? element.previousElementSibling : element.nextElementSibling; + const child = up ? element.lastElementChild : element.firstElementChild; + const sibling = up ? element.previousElementSibling : element.nextElementSibling; if (descending) { if (child) { element = child; - } - else if (sibling) { + } else if (sibling) { element = sibling; - } - else { + } else { descending = false; element = element.parentElement; } - } - else { + } else { if (sibling) { element = sibling; descending = true; - } - else { + } else { element = element.parentElement; } } @@ -144,8 +149,7 @@ var LeftPanel = React.createClass({ descending = true; } } - - } while(element && !( + } while (element && !( classes.contains("mx_RoomTile") || classes.contains("mx_SearchBox_search") || classes.contains("mx_RoomSubList_ellipsis"))); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 50a1f70496..4b8b75ad74 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -23,6 +23,7 @@ import PropTypes from 'prop-types'; import Matrix from "matrix-js-sdk"; import Analytics from "../../Analytics"; +import DecryptionFailureTracker from "../../DecryptionFailureTracker"; import MatrixClientPeg from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; @@ -1303,6 +1304,21 @@ export default React.createClass({ } }); + const dft = new DecryptionFailureTracker((failure) => { + // TODO: Pass reason for failure as third argument to trackEvent + Analytics.trackEvent('E2E', 'Decryption failure'); + }); + + // Shelved for later date when we have time to think about persisting history of + // tracked events across sessions. + // dft.loadTrackedEventHashMap(); + + dft.start(); + + // When logging out, stop tracking failures and destroy state + cli.on("Session.logged_out", () => dft.stop()); + cli.on("Event.decrypted", (e) => dft.eventDecrypted(e)); + const krh = new KeyRequestHandler(cli); cli.on("crypto.roomKeyRequest", (req) => { krh.handleKeyRequest(req); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 50bdb37734..f5fa2ceabf 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,6 +26,9 @@ import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; +const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes +const continuedTypes = ['m.sticker', 'm.room.message']; + /* (almost) stateless UI component which builds the event tiles in the room timeline. */ module.exports = React.createClass({ @@ -189,7 +193,7 @@ module.exports = React.createClass({ /** * Page up/down. * - * mult: -1 to page up, +1 to page down + * @param {number} mult: -1 to page up, +1 to page down */ scrollRelative: function(mult) { if (this.refs.scrollPanel) { @@ -199,6 +203,8 @@ module.exports = React.createClass({ /** * Scroll up/down in response to a scroll key + * + * @param {KeyboardEvent} ev: the keyboard event to handle */ handleScrollKey: function(ev) { if (this.refs.scrollPanel) { @@ -257,6 +263,7 @@ module.exports = React.createClass({ this.eventNodes = {}; + let visible = false; let i; // first figure out which is the last event in the list which we're @@ -297,7 +304,7 @@ module.exports = React.createClass({ // if the readmarker has moved, cancel any active ghost. if (this.currentReadMarkerEventId && this.props.readMarkerEventId && this.props.readMarkerVisible && - this.currentReadMarkerEventId != this.props.readMarkerEventId) { + this.currentReadMarkerEventId !== this.props.readMarkerEventId) { this.currentGhostEventId = null; } @@ -404,8 +411,8 @@ module.exports = React.createClass({ let isVisibleReadMarker = false; - if (eventId == this.props.readMarkerEventId) { - var visible = this.props.readMarkerVisible; + if (eventId === this.props.readMarkerEventId) { + visible = this.props.readMarkerVisible; // if the read marker comes at the end of the timeline (except // for local echoes, which are excluded from RMs, because they @@ -423,11 +430,11 @@ module.exports = React.createClass({ // XXX: there should be no need for a ghost tile - we should just use a // a dispatch (user_activity_end) to start the RM animation. - if (eventId == this.currentGhostEventId) { + if (eventId === this.currentGhostEventId) { // if we're showing an animation, continue to show it. ret.push(this._getReadMarkerGhostTile()); } else if (!isVisibleReadMarker && - eventId == this.currentReadMarkerEventId) { + eventId === this.currentReadMarkerEventId) { // there is currently a read-up-to marker at this point, but no // more. Show an animation of it disappearing. ret.push(this._getReadMarkerGhostTile()); @@ -449,16 +456,17 @@ module.exports = React.createClass({ // Some events should appear as continuations from previous events of // different types. - const continuedTypes = ['m.sticker', 'm.room.message']; + const eventTypeContinues = prevEvent !== null && continuedTypes.includes(mxEv.getType()) && continuedTypes.includes(prevEvent.getType()); - if (prevEvent !== null - && prevEvent.sender && mxEv.sender - && mxEv.sender.userId === prevEvent.sender.userId - && (mxEv.getType() == prevEvent.getType() || eventTypeContinues)) { + // if there is a previous event and it has the same sender as this event + // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL + if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId && + (mxEv.getType() === prevEvent.getType() || eventTypeContinues) && + (mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) { continuation = true; } @@ -493,7 +501,7 @@ module.exports = React.createClass({ } const eventId = mxEv.getId(); - const highlight = (eventId == this.props.highlightedEventId); + const highlight = (eventId === this.props.highlightedEventId); // we can't use local echoes as scroll tokens, because their event IDs change. // Local echos have a send "status". @@ -632,7 +640,8 @@ module.exports = React.createClass({ render: function() { const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const Spinner = sdk.getComponent("elements.Spinner"); - let topSpinner, bottomSpinner; + let topSpinner; + let bottomSpinner; if (this.props.backPaginating) { topSpinner =
  • ; } diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 7a93cfb886..edb50fcedb 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -70,7 +70,7 @@ export default withMatrixClient(React.createClass({ if (this.state.groups) { const groupNodes = []; this.state.groups.forEach((g) => { - groupNodes.push(); + groupNodes.push(); }); contentHeader = groupNodes.length > 0 ?

    { _t('Your Communities') }

    :
    ; content = groupNodes.length > 0 ? @@ -124,7 +124,7 @@ export default withMatrixClient(React.createClass({ ) }
    -
    + {/*
    @@ -140,7 +140,7 @@ export default withMatrixClient(React.createClass({ { 'i': (sub) => { sub } }) }
    -
    + */}
    { contentHeader } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 97e575ff4e..4beafb099c 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -913,6 +913,8 @@ module.exports = React.createClass({ }, uploadFile: async function(file) { + dis.dispatch({action: 'focus_composer'}); + if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'view_set_mxid'}); return; diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 2622d7bd89..6397e73434 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -429,7 +429,6 @@ module.exports = React.createClass({ "push notifications on other devices until you log back in to them", ) + ".", }); - dis.dispatch({action: 'password_changed'}); }, _onAddEmailEditFinished: function(value, shouldSubmit) { diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index bdd267c4ee..59cdb61fd6 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -253,13 +253,11 @@ module.exports = React.createClass({
    ); - if (SettingsStore.isFeatureEnabled("feature_rich_quoting")) { - replyButton = ( -
    - { _t('Reply') } -
    - ); - } + replyButton = ( +
    + { _t('Reply') } +
    + ); if (this.state.canPin) { pinButton = ( diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 0d0b7456b5..abc52f7b1d 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import Promise from 'bluebird'; @@ -27,6 +27,13 @@ import GroupStore from '../../../stores/GroupStore'; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; +const addressTypeName = { + 'mx-user-id': _td("Matrix ID"), + 'mx-room-id': _td("Matrix Room ID"), + 'email': _td("email address"), +}; + + module.exports = React.createClass({ displayName: "AddressPickerDialog", @@ -66,7 +73,7 @@ module.exports = React.createClass({ // List of UserAddressType objects representing // the list of addresses we're going to invite - userList: [], + selectedList: [], // Whether a search is ongoing busy: false, @@ -76,10 +83,9 @@ module.exports = React.createClass({ serverSupportsUserDirectory: true, // The query being searched for query: "", - // List of UserAddressType objects representing - // the set of auto-completion results for the current search - // query. - queryList: [], + // List of UserAddressType objects representing the set of + // auto-completion results for the current search query. + suggestedList: [], }; }, @@ -91,14 +97,14 @@ module.exports = React.createClass({ }, onButtonClick: function() { - let userList = this.state.userList.slice(); + let selectedList = this.state.selectedList.slice(); // Check the text input field to see if user has an unconverted address - // If there is and it's valid add it to the local userList + // If there is and it's valid add it to the local selectedList if (this.refs.textinput.value !== '') { - userList = this._addInputToList(); - if (userList === null) return; + selectedList = this._addInputToList(); + if (selectedList === null) return; } - this.props.onFinished(true, userList); + this.props.onFinished(true, selectedList); }, onCancel: function() { @@ -118,18 +124,18 @@ module.exports = React.createClass({ e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.moveSelectionDown(); - } else if (this.state.queryList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab + } else if (this.state.suggestedList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.chooseSelection(); - } else if (this.refs.textinput.value.length === 0 && this.state.userList.length && e.keyCode === 8) { // backspace + } else if (this.refs.textinput.value.length === 0 && this.state.selectedList.length && e.keyCode === 8) { // backspace e.stopPropagation(); e.preventDefault(); - this.onDismissed(this.state.userList.length - 1)(); + this.onDismissed(this.state.selectedList.length - 1)(); } else if (e.keyCode === 13) { // enter e.stopPropagation(); e.preventDefault(); - if (this.refs.textinput.value == '') { + if (this.refs.textinput.value === '') { // if there's nothing in the input box, submit the form this.onButtonClick(); } else { @@ -148,7 +154,7 @@ module.exports = React.createClass({ clearTimeout(this.queryChangedDebouncer); } // Only do search if there is something to search - if (query.length > 0 && query != '@' && query.length >= 2) { + if (query.length > 0 && query !== '@' && query.length >= 2) { this.queryChangedDebouncer = setTimeout(() => { if (this.props.pickerType === 'user') { if (this.props.groupId) { @@ -170,7 +176,7 @@ module.exports = React.createClass({ }, QUERY_USER_DIRECTORY_DEBOUNCE_MS); } else { this.setState({ - queryList: [], + suggestedList: [], query: "", searchError: null, }); @@ -179,11 +185,11 @@ module.exports = React.createClass({ onDismissed: function(index) { return () => { - const userList = this.state.userList.slice(); - userList.splice(index, 1); + const selectedList = this.state.selectedList.slice(); + selectedList.splice(index, 1); this.setState({ - userList: userList, - queryList: [], + selectedList, + suggestedList: [], query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); @@ -197,11 +203,11 @@ module.exports = React.createClass({ }, onSelected: function(index) { - const userList = this.state.userList.slice(); - userList.push(this.state.queryList[index]); + const selectedList = this.state.selectedList.slice(); + selectedList.push(this.state.suggestedList[index]); this.setState({ - userList: userList, - queryList: [], + selectedList, + suggestedList: [], query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); @@ -379,10 +385,10 @@ module.exports = React.createClass({ }, _processResults: function(results, query) { - const queryList = []; + const suggestedList = []; results.forEach((result) => { if (result.room_id) { - queryList.push({ + suggestedList.push({ addressType: 'mx-room-id', address: result.room_id, displayName: result.name, @@ -399,7 +405,7 @@ module.exports = React.createClass({ // Return objects, structure of which is defined // by UserAddressType - queryList.push({ + suggestedList.push({ addressType: 'mx-user-id', address: result.user_id, displayName: result.display_name, @@ -413,18 +419,18 @@ module.exports = React.createClass({ // a perfectly valid address if there are close matches. const addrType = getAddressType(query); if (this.props.validAddressTypes.includes(addrType)) { - queryList.unshift({ + suggestedList.unshift({ addressType: addrType, address: query, isKnown: false, }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - if (addrType == 'email') { + if (addrType === 'email') { this._lookupThreepid(addrType, query).done(); } } this.setState({ - queryList, + suggestedList, error: false, }, () => { if (this.addressSelector) this.addressSelector.moveSelectionTop(); @@ -442,14 +448,14 @@ module.exports = React.createClass({ if (!this.props.validAddressTypes.includes(addrType)) { this.setState({ error: true }); return null; - } else if (addrType == 'mx-user-id') { + } else if (addrType === 'mx-user-id') { const user = MatrixClientPeg.get().getUser(addrObj.address); if (user) { addrObj.displayName = user.displayName; addrObj.avatarMxc = user.avatarUrl; addrObj.isKnown = true; } - } else if (addrType == 'mx-room-id') { + } else if (addrType === 'mx-room-id') { const room = MatrixClientPeg.get().getRoom(addrObj.address); if (room) { addrObj.displayName = room.name; @@ -458,15 +464,15 @@ module.exports = React.createClass({ } } - const userList = this.state.userList.slice(); - userList.push(addrObj); + const selectedList = this.state.selectedList.slice(); + selectedList.push(addrObj); this.setState({ - userList: userList, - queryList: [], + selectedList, + suggestedList: [], query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - return userList; + return selectedList; }, _lookupThreepid: function(medium, address) { @@ -492,7 +498,7 @@ module.exports = React.createClass({ if (res === null) return null; if (cancelled) return null; this.setState({ - queryList: [{ + suggestedList: [{ // a UserAddressType addressType: medium, address: address, @@ -510,15 +516,27 @@ module.exports = React.createClass({ const AddressSelector = sdk.getComponent("elements.AddressSelector"); this.scrollElement = null; + // map addressType => set of addresses to avoid O(n*m) operation + const selectedAddresses = {}; + this.state.selectedList.forEach(({address, addressType}) => { + if (!selectedAddresses[addressType]) selectedAddresses[addressType] = new Set(); + selectedAddresses[addressType].add(address); + }); + + // Filter out any addresses in the above already selected addresses (matching both type and address) + const filteredSuggestedList = this.state.suggestedList.filter(({address, addressType}) => { + return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address)); + }); + const query = []; // create the invite list - if (this.state.userList.length > 0) { + if (this.state.selectedList.length > 0) { const AddressTile = sdk.getComponent("elements.AddressTile"); - for (let i = 0; i < this.state.userList.length; i++) { + for (let i = 0; i < this.state.selectedList.length; i++) { query.push( , @@ -528,7 +546,7 @@ module.exports = React.createClass({ // Add the query at the end query.push( -