Give autocomplete providers the room they're in

Removes the gut-wrenching that RoomView does to jam the user list
into the user autocomplete provider.
This commit is contained in:
David Baker 2017-11-02 17:51:08 +00:00
parent 8800081cb9
commit 4953d4de4d
10 changed files with 107 additions and 97 deletions

View file

@ -28,6 +28,9 @@ export default class AutocompleteProvider {
} }
} }
destroy() {
}
/** /**
* 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

@ -45,12 +45,26 @@ 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 {
constructor(room) {
this.room = room;
this.providers = PROVIDERS.map((p) => {
return new p(room);
});
}
destroy() {
this.providers.forEach((p) => {
p.destroy();
});
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
/* Note: That this waits for all providers to return is *intentional* /* Note: That this waits for all providers to return is *intentional*
otherwise, we run into a condition where new completions are displayed otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult while the user is interacting with the list, which makes it difficult
@ -60,7 +74,7 @@ export async function getCompletions(query: string, selection: SelectionRange, f
// Array of inspections of promises that might timeout. Instead of allowing a // 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 // single timeout to reject the Promise.all, reflect each one and once they've all
// settled, filter for the fulfilled ones // settled, filter for the fulfilled ones
PROVIDERS.map((provider) => { this.providers.map((provider) => {
return provider return provider
.getCompletions(query, selection, force) .getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT) .timeout(PROVIDER_COMPLETION_TIMEOUT)
@ -73,13 +87,14 @@ export async function getCompletions(query: string, selection: SelectionRange, f
).map((completionsState, i) => { ).map((completionsState, i) => {
return { return {
completions: completionsState.value(), completions: completionsState.value(),
provider: PROVIDERS[i], provider: this.providers[i],
/* the currently matched "command" the completer tried to complete /* the currently matched "command" the completer tried to complete
* we pass this through so that Autocomplete can figure out when to * we pass this through so that Autocomplete can figure out when to
* re-show itself once hidden. * re-show itself once hidden.
*/ */
command: PROVIDERS[i].getCurrentCommand(query, selection, force), command: this.providers[i].getCurrentCommand(query, selection, force),
}; };
}); });
} }
}

View file

@ -109,8 +109,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 +140,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

@ -25,8 +25,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 +94,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

@ -70,8 +70,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 +149,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

@ -27,8 +27,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 +94,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

@ -30,20 +30,54 @@ 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 (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 +120,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 +152,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,6 @@
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';
@ -8,7 +9,7 @@ import type {Completion} from '../../../autocomplete/Autocompleter';
import Promise from 'bluebird'; import Promise from 'bluebird';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import Autocompleter from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0; const COMPOSER_SELECTED = 0;
@ -17,6 +18,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 +43,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();
}
// 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 +56,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 +94,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 +278,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.object,
}; };

View file

@ -1130,10 +1130,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"