diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 4c7d039da4..c93ae4fb2a 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -28,6 +29,10 @@ export default class AutocompleteProvider { } } + destroy() { + // stub + } + /** * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. */ diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 5b10110f04..94d2ed28de 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -1,5 +1,6 @@ /* Copyright 2016 Aviral Dasgupta +Copyright 2017 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. @@ -45,41 +46,56 @@ const PROVIDERS = [ EmojiProvider, CommandProvider, DuckDuckGoProvider, -].map((completer) => completer.getInstance()); +]; // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; -export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { - /* Note: That this waits for all providers to return is *intentional* - otherwise, we run into a condition where new completions are displayed - while the user is interacting with the list, which makes it difficult - to predict whether an action will actually do what is intended - */ - const completionsList = await Promise.all( - // Array of inspections of promises that might timeout. Instead of allowing a - // single timeout to reject the Promise.all, reflect each one and once they've all - // settled, filter for the fulfilled ones - PROVIDERS.map((provider) => { - return provider - .getCompletions(query, selection, force) - .timeout(PROVIDER_COMPLETION_TIMEOUT) - .reflect(); - }), - ); +export default class Autocompleter { + constructor(room) { + this.room = room; + this.providers = PROVIDERS.map((p) => { + return new p(room); + }); + } - return completionsList.filter( - (inspection) => inspection.isFulfilled(), - ).map((completionsState, i) => { - return { - completions: completionsState.value(), - provider: PROVIDERS[i], + destroy() { + this.providers.forEach((p) => { + p.destroy(); + }); + } - /* the currently matched "command" the completer tried to complete - * we pass this through so that Autocomplete can figure out when to - * re-show itself once hidden. - */ - command: PROVIDERS[i].getCurrentCommand(query, selection, force), - }; - }); + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { + /* Note: This intentionally waits for all providers to return, + otherwise, we run into a condition where new completions are displayed + while the user is interacting with the list, which makes it difficult + to predict whether an action will actually do what is intended + */ + const completionsList = await Promise.all( + // Array of inspections of promises that might timeout. Instead of allowing a + // single timeout to reject the Promise.all, reflect each one and once they've all + // settled, filter for the fulfilled ones + this.providers.map((provider) => { + return provider + .getCompletions(query, selection, force) + .timeout(PROVIDER_COMPLETION_TIMEOUT) + .reflect(); + }), + ); + + return completionsList.filter( + (inspection) => inspection.isFulfilled(), + ).map((completionsState, i) => { + return { + completions: completionsState.value(), + provider: this.providers[i], + + /* the currently matched "command" the completer tried to complete + * we pass this through so that Autocomplete can figure out when to + * re-show itself once hidden. + */ + command: this.providers[i].getCurrentCommand(query, selection, force), + }; + }); + } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index e85457e6aa..d47f1a161a 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -109,8 +110,6 @@ const COMMANDS = [ const COMMAND_RE = /(^\/\w*)/g; -let instance = null; - export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); @@ -142,12 +141,6 @@ export default class CommandProvider extends AutocompleteProvider { return '*️⃣ ' + _t('Commands'); } - static getInstance(): CommandProvider { - if (instance === null) instance = new CommandProvider(); - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index b2e85c4668..68d4915f56 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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,8 +26,6 @@ import {TextualCompletion} from './Components'; const DDG_REGEX = /\/ddg\s+(.+)$/g; const REFERRER = 'vector'; -let instance = null; - export default class DuckDuckGoProvider extends AutocompleteProvider { constructor() { super(DDG_REGEX); @@ -96,13 +95,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { return '🔍 ' + _t('Results from DuckDuckGo'); } - static getInstance(): DuckDuckGoProvider { - if (instance == null) { - instance = new DuckDuckGoProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index a5b80e3b0e..9f1f40dbe7 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -70,8 +71,6 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor }; }); -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -151,11 +150,6 @@ export default class EmojiProvider extends AutocompleteProvider { return '😃 ' + _t('Emoji'); } - static getInstance() { - if (instance == null) {instance = new EmojiProvider();} - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index cc04f54dda..1e1928a1ee 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -27,8 +28,6 @@ import _sortBy from 'lodash/sortBy'; const ROOM_REGEX = /(?=#)(\S*)/g; -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -96,14 +95,6 @@ export default class RoomProvider extends AutocompleteProvider { return '💬 ' + _t('Rooms'); } - static getInstance() { - if (instance == null) { - instance = new RoomProvider(); - } - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 296399c06c..8b43964b1a 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -2,6 +2,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +Copyright 2017 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. @@ -30,20 +31,55 @@ import type {Room, RoomMember} from 'matrix-js-sdk'; const USER_REGEX = /@\S*/g; -let instance = null; - export default class UserProvider extends AutocompleteProvider { users: Array = null; room: Room = null; - constructor() { + constructor(room) { super(USER_REGEX, { keys: ['name'], }); + this.room = room; this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], shouldMatchPrefix: true, }); + + this._onRoomTimelineBound = this._onRoomTimeline.bind(this); + this._onRoomStateMemberBound = this._onRoomStateMember.bind(this); + + MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound); + } + + destroy() { + MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound); + } + + _onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + if (!room) return; + if (removed) return; + if (room.roomId !== this.room.roomId) return; + + // ignore events from filtered timelines + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + + // ignore anything but real-time updates at the end of the room: + // updates from pagination will happen when the paginate completes. + if (toStartOfTimeline || !data || !data.liveEvent) return; + + this.onUserSpoke(ev.sender); + } + + _onRoomStateMember(ev, state, member) { + // ignore members in other rooms + if (member.roomId !== this.room.roomId) { + return; + } + + // blow away the users cache + this.users = null; } async getCompletions(query: string, selection: {start: number, end: number}, force = false) { @@ -86,11 +122,6 @@ export default class UserProvider extends AutocompleteProvider { return '👥 ' + _t('Users'); } - setUserListFromRoom(room: Room) { - this.room = room; - this.users = null; - } - _makeUsers() { const events = this.room.getLiveTimeline().getEvents(); const lastSpoken = {}; @@ -123,13 +154,6 @@ export default class UserProvider extends AutocompleteProvider { this.matcher.setObjects(this.users); } - static getInstance(): UserProvider { - if (instance == null) { - instance = new UserProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 9b6dbb4c27..a40fff274c 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -44,8 +44,6 @@ const Rooms = require('../../Rooms'); import KeyCode from '../../KeyCode'; -import UserProvider from '../../autocomplete/UserProvider'; - import RoomViewStore from '../../stores/RoomViewStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; @@ -541,12 +539,6 @@ module.exports = React.createClass({ }); } } - - // update the tab complete list as it depends on who most recently spoke, - // and that has probably just changed - if (ev.sender) { - UserProvider.getInstance().onUserSpoke(ev.sender); - } }, onRoomName: function(room) { @@ -568,7 +560,6 @@ module.exports = React.createClass({ this._warnAboutEncryption(room); this._calculatePeekRules(room); this._updatePreviewUrlVisibility(room); - UserProvider.getInstance().setUserListFromRoom(room); }, _warnAboutEncryption: function(room) { @@ -722,9 +713,6 @@ module.exports = React.createClass({ // refresh the conf call notification state this._updateConfCallNotification(); - // refresh the tab complete list - UserProvider.getInstance().setUserListFromRoom(this.state.room); - // if we are now a member of the room, where we were not before, that // means we have finished joining a room we were previously peeking // into. diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index ecc908a02c..958d16073c 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,5 +1,23 @@ +/* +Copyright 2016 Aviral Dasgupta +Copyright 2017 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. +*/ + import React from 'react'; import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; import flatMap from 'lodash/flatMap'; import isEqual from 'lodash/isEqual'; @@ -7,8 +25,9 @@ import sdk from '../../../index'; import type {Completion} from '../../../autocomplete/Autocompleter'; import Promise from 'bluebird'; import UserSettingsStore from '../../../UserSettingsStore'; +import { Room } from 'matrix-js-sdk'; -import {getCompletions} from '../../../autocomplete/Autocompleter'; +import Autocompleter from '../../../autocomplete/Autocompleter'; const COMPOSER_SELECTED = 0; @@ -17,6 +36,7 @@ export default class Autocomplete extends React.Component { constructor(props) { super(props); + this.autocompleter = new Autocompleter(props.room); this.completionPromise = null; this.hide = this.hide.bind(this); this.onCompletionClicked = this.onCompletionClicked.bind(this); @@ -41,6 +61,11 @@ export default class Autocomplete extends React.Component { } componentWillReceiveProps(newProps, state) { + if (this.props.room.roomId !== newProps.room.roomId) { + this.autocompleter.destroy(); + this.autocompleter = new Autocompleter(newProps.room); + } + // Query hasn't changed so don't try to complete it if (newProps.query === this.props.query) { return; @@ -49,6 +74,10 @@ export default class Autocomplete extends React.Component { this.complete(newProps.query, newProps.selection); } + componentWillUnmount() { + this.autocompleter.destroy(); + } + complete(query, selection) { this.queryRequested = query; if (this.debounceCompletionsRequest) { @@ -83,7 +112,7 @@ export default class Autocomplete extends React.Component { } processQuery(query, selection) { - return getCompletions( + return this.autocompleter.getCompletions( query, selection, this.state.forceComplete, ).then((completions) => { // Only ever process the completions for the most recent query being processed @@ -267,8 +296,11 @@ export default class Autocomplete extends React.Component { Autocomplete.propTypes = { // the query string for which to show autocomplete suggestions - query: React.PropTypes.string.isRequired, + query: PropTypes.string.isRequired, // method invoked with range and text content when completion is confirmed - onConfirm: React.PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + + // The room in which we're autocompleting + room: PropTypes.instanceOf(Room), }; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 4850428621..43f3aa5d88 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 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. @@ -1130,10 +1131,12 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete = e} + room={this.props.room} onConfirm={this.setDisplayedCompletion} onSelectionChange={this.setDisplayedCompletion} query={this.getAutocompleteQuery(content)} - selection={selection} /> + selection={selection} + />