Merge pull request #1568 from matrix-org/dbkr/autocomplete_provider_room

Give autocomplete providers the room they're in
This commit is contained in:
David Baker 2017-11-03 14:29:59 +00:00 committed by GitHub
commit 43a23d7e89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 135 additions and 97 deletions

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -45,41 +46,56 @@ const PROVIDERS = [
EmojiProvider, EmojiProvider,
CommandProvider, CommandProvider,
DuckDuckGoProvider, DuckDuckGoProvider,
].map((completer) => completer.getInstance()); ];
// Providers will get rejected if they take longer than this. // Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000; const PROVIDER_COMPLETION_TIMEOUT = 3000;
export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { export default class Autocompleter {
/* Note: That this waits for all providers to return is *intentional* constructor(room) {
otherwise, we run into a condition where new completions are displayed this.room = room;
while the user is interacting with the list, which makes it difficult this.providers = PROVIDERS.map((p) => {
to predict whether an action will actually do what is intended return new p(room);
*/ });
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();
}),
);
return completionsList.filter( destroy() {
(inspection) => inspection.isFulfilled(), this.providers.forEach((p) => {
).map((completionsState, i) => { p.destroy();
return { });
completions: completionsState.value(), }
provider: PROVIDERS[i],
/* the currently matched "command" the completer tried to complete async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
* we pass this through so that Autocomplete can figure out when to /* Note: This intentionally waits for all providers to return,
* re-show itself once hidden. otherwise, we run into a condition where new completions are displayed
*/ while the user is interacting with the list, which makes it difficult
command: PROVIDERS[i].getCurrentCommand(query, selection, force), 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),
};
});
}
} }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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; const COMMAND_RE = /(^\/\w*)/g;
let instance = null;
export default class CommandProvider extends AutocompleteProvider { export default class CommandProvider extends AutocompleteProvider {
constructor() { constructor() {
super(COMMAND_RE); super(COMMAND_RE);
@ -142,12 +141,6 @@ export default class CommandProvider extends AutocompleteProvider {
return '*️⃣ ' + _t('Commands'); return '*️⃣ ' + _t('Commands');
} }
static getInstance(): CommandProvider {
if (instance === null) instance = new CommandProvider();
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block"> return <div className="mx_Autocomplete_Completion_container_block">
{ completions } { completions }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector'; const REFERRER = 'vector';
let instance = null;
export default class DuckDuckGoProvider extends AutocompleteProvider { export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() { constructor() {
super(DDG_REGEX); super(DDG_REGEX);
@ -96,13 +95,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
return '🔍 ' + _t('Results from DuckDuckGo'); return '🔍 ' + _t('Results from DuckDuckGo');
} }
static getInstance(): DuckDuckGoProvider {
if (instance == null) {
instance = new DuckDuckGoProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block"> return <div className="mx_Autocomplete_Completion_container_block">
{ completions } { completions }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
if (index === -1) { if (index === -1) {
@ -151,11 +150,6 @@ export default class EmojiProvider extends AutocompleteProvider {
return '😃 ' + _t('Emoji'); return '😃 ' + _t('Emoji');
} }
static getInstance() {
if (instance == null) {instance = new EmojiProvider();}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill">
{ completions } { completions }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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; const ROOM_REGEX = /(?=#)(\S*)/g;
let instance = null;
function score(query, space) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
if (index === -1) { if (index === -1) {
@ -96,14 +95,6 @@ export default class RoomProvider extends AutocompleteProvider {
return '💬 ' + _t('Rooms'); return '💬 ' + _t('Rooms');
} }
static getInstance() {
if (instance == null) {
instance = new RoomProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions } { completions }

View file

@ -2,6 +2,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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; const USER_REGEX = /@\S*/g;
let instance = null;
export default class UserProvider extends AutocompleteProvider { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null; users: Array<RoomMember> = null;
room: Room = null; room: Room = null;
constructor() { constructor(room) {
super(USER_REGEX, { super(USER_REGEX, {
keys: ['name'], keys: ['name'],
}); });
this.room = room;
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
shouldMatchPrefix: true, 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) { async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
@ -86,11 +122,6 @@ export default class UserProvider extends AutocompleteProvider {
return '👥 ' + _t('Users'); return '👥 ' + _t('Users');
} }
setUserListFromRoom(room: Room) {
this.room = room;
this.users = null;
}
_makeUsers() { _makeUsers() {
const events = this.room.getLiveTimeline().getEvents(); const events = this.room.getLiveTimeline().getEvents();
const lastSpoken = {}; const lastSpoken = {};
@ -123,13 +154,6 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }
static getInstance(): UserProvider {
if (instance == null) {
instance = new UserProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions } { completions }

View file

@ -44,8 +44,6 @@ const Rooms = require('../../Rooms');
import KeyCode from '../../KeyCode'; import KeyCode from '../../KeyCode';
import UserProvider from '../../autocomplete/UserProvider';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; 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) { onRoomName: function(room) {
@ -568,7 +560,6 @@ module.exports = React.createClass({
this._warnAboutEncryption(room); this._warnAboutEncryption(room);
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
UserProvider.getInstance().setUserListFromRoom(room);
}, },
_warnAboutEncryption: function(room) { _warnAboutEncryption: function(room) {
@ -722,9 +713,6 @@ module.exports = React.createClass({
// refresh the conf call notification state // refresh the conf call notification state
this._updateConfCallNotification(); 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 // 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 // means we have finished joining a room we were previously peeking
// into. // into.

View file

@ -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 React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
@ -7,8 +25,9 @@ import sdk from '../../../index';
import type {Completion} from '../../../autocomplete/Autocompleter'; import type {Completion} from '../../../autocomplete/Autocompleter';
import Promise from 'bluebird'; import Promise from 'bluebird';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
import { Room } from 'matrix-js-sdk';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import Autocompleter from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0; const COMPOSER_SELECTED = 0;
@ -17,6 +36,7 @@ export default class Autocomplete extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.autocompleter = new Autocompleter(props.room);
this.completionPromise = null; this.completionPromise = null;
this.hide = this.hide.bind(this); this.hide = this.hide.bind(this);
this.onCompletionClicked = this.onCompletionClicked.bind(this); this.onCompletionClicked = this.onCompletionClicked.bind(this);
@ -41,6 +61,11 @@ export default class Autocomplete extends React.Component {
} }
componentWillReceiveProps(newProps, state) { 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 // Query hasn't changed so don't try to complete it
if (newProps.query === this.props.query) { if (newProps.query === this.props.query) {
return; return;
@ -49,6 +74,10 @@ export default class Autocomplete extends React.Component {
this.complete(newProps.query, newProps.selection); this.complete(newProps.query, newProps.selection);
} }
componentWillUnmount() {
this.autocompleter.destroy();
}
complete(query, selection) { complete(query, selection) {
this.queryRequested = query; this.queryRequested = query;
if (this.debounceCompletionsRequest) { if (this.debounceCompletionsRequest) {
@ -83,7 +112,7 @@ export default class Autocomplete extends React.Component {
} }
processQuery(query, selection) { processQuery(query, selection) {
return getCompletions( return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete, query, selection, this.state.forceComplete,
).then((completions) => { ).then((completions) => {
// Only ever process the completions for the most recent query being processed // 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 = { Autocomplete.propTypes = {
// the query string for which to show autocomplete suggestions // 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 // 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),
}; };

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 {
<div className="mx_MessageComposer_autocomplete_wrapper"> <div className="mx_MessageComposer_autocomplete_wrapper">
<Autocomplete <Autocomplete
ref={(e) => this.autocomplete = e} ref={(e) => this.autocomplete = e}
room={this.props.room}
onConfirm={this.setDisplayedCompletion} onConfirm={this.setDisplayedCompletion}
onSelectionChange={this.setDisplayedCompletion} onSelectionChange={this.setDisplayedCompletion}
query={this.getAutocompleteQuery(content)} query={this.getAutocompleteQuery(content)}
selection={selection} /> selection={selection}
/>
</div> </div>
<div className={className}> <div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor" <img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"