From b979a16199fe1a14474dd7d3b9e32ba0eaffd03f Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Wed, 1 Jun 2016 16:54:21 +0530 Subject: [PATCH 01/12] initial version of autocomplete --- package.json | 1 + src/autocomplete/AutocompleteProvider.js | 3 + src/autocomplete/Autocompleter.js | 7 ++ src/autocomplete/CommandProvider.js | 65 ++++++++++++++++++ src/components/views/rooms/Autocomplete.js | 67 +++++++++++++++++++ src/components/views/rooms/MessageComposer.js | 18 ++++- .../views/rooms/MessageComposerInput.js | 4 ++ 7 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/autocomplete/AutocompleteProvider.js create mode 100644 src/autocomplete/Autocompleter.js create mode 100644 src/autocomplete/CommandProvider.js create mode 100644 src/components/views/rooms/Autocomplete.js diff --git a/package.json b/package.json index 3f4a862f6f..d46e9b2621 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "favico.js": "^0.3.10", "filesize": "^3.1.2", "flux": "^2.0.3", + "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js new file mode 100644 index 0000000000..3b2aae920b --- /dev/null +++ b/src/autocomplete/AutocompleteProvider.js @@ -0,0 +1,3 @@ +export default class AutocompleteProvider { + +} diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js new file mode 100644 index 0000000000..e49dbb7ad6 --- /dev/null +++ b/src/autocomplete/Autocompleter.js @@ -0,0 +1,7 @@ +import CommandProvider from './CommandProvider'; + +const COMPLETERS = [CommandProvider].map(completer => new completer()); + +export function getCompletions(query: String) { + return COMPLETERS.map(completer => completer.getCompletions(query)); +} diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js new file mode 100644 index 0000000000..cc95d96fd3 --- /dev/null +++ b/src/autocomplete/CommandProvider.js @@ -0,0 +1,65 @@ +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import Fuse from 'fuse.js'; + +const COMMANDS = [ + { + command: '/me', + args: '', + description: 'Displays action' + }, + { + command: '/ban', + args: ' [reason]', + description: 'Bans user with given id' + }, + { + command: '/deop' + }, + { + command: '/encrypt' + }, + { + command: '/invite' + }, + { + command: '/join', + args: '', + description: 'Joins room with given alias' + }, + { + command: '/kick', + args: ' [reason]', + description: 'Kicks user with given id' + }, + { + command: '/nick', + args: '', + description: 'Changes your display nickname' + } +]; + +export default class CommandProvider extends AutocompleteProvider { + constructor() { + super(); + this.fuse = new Fuse(COMMANDS, { + keys: ['command', 'args', 'description'] + }); + } + + getCompletions(query: String) { + let completions = []; + const matches = query.match(/(^\/\w+)/); + if(!!matches) { + const command = matches[0]; + completions = this.fuse.search(command).map(result => { + return { + title: result.command, + subtitle: result.args, + description: result.description + }; + }); + } + return Q.when(completions); + } +} diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js new file mode 100644 index 0000000000..80208892b0 --- /dev/null +++ b/src/components/views/rooms/Autocomplete.js @@ -0,0 +1,67 @@ +import React from 'react'; + +import {getCompletions} from '../../../autocomplete/Autocompleter'; + +export default class Autocomplete extends React.Component { + constructor(props) { + super(props); + this.state = { + completions: [] + }; + } + + componentWillReceiveProps(props, state) { + getCompletions(props.query)[0].then(completions => { + console.log(completions); + this.setState({ + completions + }); + }); + } + + render() { + const pinElement = document.querySelector(this.props.pinSelector); + if(!pinElement) return null; + + const position = pinElement.getBoundingClientRect(); + + const style = { + position: 'fixed', + border: '1px solid gray', + background: 'white', + borderRadius: '4px' + }; + + this.props.pinTo.forEach(direction => { + console.log(`${direction} = ${position[direction]}`); + style[direction] = position[direction]; + }); + + const renderedCompletions = this.state.completions.map((completion, i) => { + return ( +
+ {completion.title} + {completion.subtitle} + {completion.description} +
+ ); + }); + + return ( +
+ {renderedCompletions} +
+ ); + } +} + +Autocomplete.propTypes = { + // the query string for which to show autocomplete suggestions + query: React.PropTypes.string.isRequired, + + // CSS selector indicating which element to pin the autocomplete to + pinSelector: React.PropTypes.string.isRequired, + + // attributes on which the autocomplete should match the pinElement + pinTo: React.PropTypes.array.isRequired +}; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 20785c4c70..2d17accd45 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -20,6 +20,7 @@ var MatrixClientPeg = require('../../../MatrixClientPeg'); var Modal = require('../../../Modal'); var sdk = require('../../../index'); var dis = require('../../../dispatcher'); +import Autocomplete from './Autocomplete'; module.exports = React.createClass({ @@ -45,6 +46,12 @@ module.exports = React.createClass({ opacity: React.PropTypes.number, }, + getInitialState: function () { + return { + autocompleteQuery: '' + }; + }, + onUploadClick: function(ev) { this.refs.uploadInput.click(); }, @@ -117,6 +124,12 @@ module.exports = React.createClass({ }); }, + onInputContentChanged(content: String) { + this.setState({ + autocompleteQuery: content + }) + }, + render: function() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; @@ -170,7 +183,8 @@ module.exports = React.createClass({ controls.push( , + onResize={this.props.onResize} room={this.props.room} + onContentChanged={(content) => this.onInputContentChanged(content) } />, uploadButton, hangupButton, callButton, @@ -191,6 +205,8 @@ module.exports = React.createClass({ {controls} + + ); } diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 733d9e6056..e8db496abf 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -73,6 +73,8 @@ module.exports = React.createClass({ // js-sdk Room object room: React.PropTypes.object.isRequired, + + onContentChanged: React.PropTypes.func }, componentWillMount: function() { @@ -276,6 +278,8 @@ module.exports = React.createClass({ { this.resizeInput(); } + + this.props.onContentChanged && this.props.onContentChanged(this.refs.textarea.value); }, onEnter: function(ev) { From 4bc8ec3e6dac96f92458c1d5186ea20539fb5fcc Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Sun, 12 Jun 2016 17:02:46 +0530 Subject: [PATCH 02/12] room, user, ddg autocomplete providers (wip) --- package.json | 3 +- src/MatrixClientPeg.js | 4 +- src/autocomplete/AutocompleteProvider.js | 4 +- src/autocomplete/Autocompleter.js | 17 ++++++- src/autocomplete/CommandProvider.js | 4 ++ src/autocomplete/DuckDuckGoProvider.js | 35 ++++++++++++++ src/autocomplete/RoomProvider.js | 31 +++++++++++++ src/autocomplete/UserProvider.js | 31 +++++++++++++ src/components/views/rooms/Autocomplete.js | 53 ++++++++++++++++------ 9 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 src/autocomplete/DuckDuckGoProvider.js create mode 100644 src/autocomplete/RoomProvider.js create mode 100644 src/autocomplete/UserProvider.js diff --git a/package.json b/package.json index 5c9a67c734..a736024da6 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "react-dom": "^15.0.1", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "sanitize-html": "^1.11.1", - "velocity-vector": "vector-im/velocity#059e3b2" + "velocity-vector": "vector-im/velocity#059e3b2", + "whatwg-fetch": "^1.0.0" }, "//babelversion": [ "brief experiments with babel6 seems to show that it generates source ", diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index cc96503316..c7b77ab88c 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -20,7 +20,7 @@ limitations under the License. var Matrix = require("matrix-js-sdk"); var GuestAccess = require("./GuestAccess"); -var matrixClient = null; +let matrixClient: MatrixClient = null; var localStorage = window.localStorage; @@ -82,7 +82,7 @@ class MatrixClient { this.guestAccess = guestAccess; } - get() { + get(): MatrixClient { return matrixClient; } diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 3b2aae920b..61158d2b56 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,3 +1,5 @@ export default class AutocompleteProvider { - + getName(): string { + return 'Default Provider'; + } } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index e49dbb7ad6..a8ed2da59a 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -1,7 +1,20 @@ import CommandProvider from './CommandProvider'; +import DuckDuckGoProvider from './DuckDuckGoProvider'; +import RoomProvider from './RoomProvider'; +import UserProvider from './UserProvider'; -const COMPLETERS = [CommandProvider].map(completer => new completer()); +const PROVIDERS = [ + CommandProvider, + DuckDuckGoProvider, + RoomProvider, + UserProvider +].map(completer => new completer()); export function getCompletions(query: String) { - return COMPLETERS.map(completer => completer.getCompletions(query)); + return PROVIDERS.map(provider => { + return { + completions: provider.getCompletions(query), + provider + }; + }); } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index cc95d96fd3..e2eac47d16 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -62,4 +62,8 @@ export default class CommandProvider extends AutocompleteProvider { } return Q.when(completions); } + + getName() { + return 'Commands'; + } } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js new file mode 100644 index 0000000000..6545b96cbd --- /dev/null +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -0,0 +1,35 @@ +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import 'whatwg-fetch'; + +const DDG_REGEX = /\/ddg\w+(.+)$/; +const REFERER = 'vector'; + +export default class DuckDuckGoProvider extends AutocompleteProvider { + static getQueryUri(query: String) { + return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&t=${encodeURIComponent(REFERER)}`; + } + + getCompletions(query: String) { + if(!query) + return Q.when([]); + + let promise = Q.defer(); + fetch(DuckDuckGoProvider.getQueryUri(query), { + method: 'GET' + }).then(response => { + let results = response.Results.map(result => { + return { + title: result.Text, + description: result.Result + }; + }); + promise.resolve(results); + }); + return promise; + } + + getName() { + return 'Results from DuckDuckGo'; + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js new file mode 100644 index 0000000000..26dc5733da --- /dev/null +++ b/src/autocomplete/RoomProvider.js @@ -0,0 +1,31 @@ +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import MatrixClientPeg from '../MatrixClientPeg'; + +const ROOM_REGEX = /(?=#)[^\s]*/g; + +export default class RoomProvider extends AutocompleteProvider { + constructor() { + super(); + } + + getCompletions(query: String) { + let client = MatrixClientPeg.get(); + let completions = []; + const matches = query.match(ROOM_REGEX); + if(!!matches) { + const command = matches[0]; + completions = client.getRooms().map(room => { + return { + title: room.name, + subtitle: room.roomId + }; + }); + } + return Q.when(completions); + } + + getName() { + return 'Rooms'; + } +} diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js new file mode 100644 index 0000000000..791dd55a33 --- /dev/null +++ b/src/autocomplete/UserProvider.js @@ -0,0 +1,31 @@ +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import MatrixClientPeg from '../MatrixClientPeg'; + +const ROOM_REGEX = /@[^\s]*/g; + +export default class UserProvider extends AutocompleteProvider { + constructor() { + super(); + } + + getCompletions(query: String) { + let client = MatrixClientPeg.get(); + let completions = []; + const matches = query.match(ROOM_REGEX); + if(!!matches) { + const command = matches[0]; + completions = client.getUsers().map(user => { + return { + title: user.displayName, + description: user.userId + }; + }); + } + return Q.when(completions); + } + + getName() { + return 'Users'; + } +} diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 80208892b0..4bc4102070 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -11,11 +11,28 @@ export default class Autocomplete extends React.Component { } componentWillReceiveProps(props, state) { - getCompletions(props.query)[0].then(completions => { - console.log(completions); - this.setState({ - completions - }); + getCompletions(props.query).map(completionResult => { + try { + completionResult.completions.then(completions => { + let i = this.state.completions.findIndex( + completion => completion.provider === completionResult.provider + ); + + i = i == -1 ? this.state.completions.length : i; + console.log(completionResult); + let newCompletions = Object.assign([], this.state.completions); + completionResult.completions = completions; + newCompletions[i] = completionResult; + console.log(newCompletions); + this.setState({ + completions: newCompletions + }); + }, err => { + + }); + } catch (e) { + // An error in one provider shouldn't mess up the rest. + } }); } @@ -33,18 +50,28 @@ export default class Autocomplete extends React.Component { }; this.props.pinTo.forEach(direction => { - console.log(`${direction} = ${position[direction]}`); style[direction] = position[direction]; }); - const renderedCompletions = this.state.completions.map((completion, i) => { - return ( -
- {completion.title} - {completion.subtitle} - {completion.description} + const renderedCompletions = this.state.completions.map((completionResult, i) => { + console.log(completionResult); + let completions = completionResult.completions.map((completion, i) => { + return ( +
+ {completion.title} + {completion.subtitle} + {completion.description} +
+ ); + }); + + + return completions.length > 0 ? ( +
+ {completionResult.provider.getName()} + {completions}
- ); + ) : null; }); return ( From b9d7743e5a30f91c71c6d57c5f0e2a0695a4852d Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 17 Jun 2016 04:58:09 +0530 Subject: [PATCH 03/12] Emoji provider, DDG working, style improvements --- package.json | 2 + src/autocomplete/Autocompleter.js | 4 +- src/autocomplete/Components.js | 13 +++++ src/autocomplete/DuckDuckGoProvider.js | 47 +++++++++++++------ src/autocomplete/EmojiProvider.js | 41 ++++++++++++++++ src/components/views/rooms/Autocomplete.js | 43 +++++++++-------- src/components/views/rooms/MessageComposer.js | 5 +- .../views/rooms/MessageComposerInput.js | 9 +++- 8 files changed, 127 insertions(+), 37 deletions(-) create mode 100644 src/autocomplete/Components.js create mode 100644 src/autocomplete/EmojiProvider.js diff --git a/package.json b/package.json index 586f060b01..82ac307710 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "draft-js-export-html": "^0.2.2", "draft-js-export-markdown": "^0.2.0", "draft-js-import-markdown": "^0.1.6", + "emojione": "^2.2.2", "favico.js": "^0.3.10", "filesize": "^3.1.2", "flux": "^2.0.3", @@ -39,6 +40,7 @@ "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.0.1", + "react-addons-css-transition-group": "^15.1.0", "react-dom": "^15.0.1", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "sanitize-html": "^1.11.1", diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index a8ed2da59a..c8f3134a3b 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -2,12 +2,14 @@ import CommandProvider from './CommandProvider'; import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; +import EmojiProvider from './EmojiProvider'; const PROVIDERS = [ CommandProvider, DuckDuckGoProvider, RoomProvider, - UserProvider + UserProvider, + EmojiProvider ].map(completer => new completer()); export function getCompletions(query: String) { diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js new file mode 100644 index 0000000000..cb7d56f9bf --- /dev/null +++ b/src/autocomplete/Components.js @@ -0,0 +1,13 @@ +export function TextualCompletion(props: { + title: ?string, + subtitle: ?string, + description: ?string +}) { + return ( +
+ {completion.title} + {completion.subtitle} + {completion.description} +
+ ); +} diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index 6545b96cbd..2acd892498 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -2,31 +2,50 @@ import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import 'whatwg-fetch'; -const DDG_REGEX = /\/ddg\w+(.+)$/; +const DDG_REGEX = /\/ddg\s+(.+)$/; const REFERER = 'vector'; export default class DuckDuckGoProvider extends AutocompleteProvider { static getQueryUri(query: String) { - return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&t=${encodeURIComponent(REFERER)}`; + return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}` + + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERER)}`; } getCompletions(query: String) { - if(!query) + let match = DDG_REGEX.exec(query); + if(!query || !match) return Q.when([]); - let promise = Q.defer(); - fetch(DuckDuckGoProvider.getQueryUri(query), { + return fetch(DuckDuckGoProvider.getQueryUri(match[1]), { method: 'GET' - }).then(response => { - let results = response.Results.map(result => { - return { - title: result.Text, - description: result.Result - }; + }) + .then(response => response.json()) + .then(json => { + let results = json.Results.map(result => { + return { + title: result.Text, + description: result.Result + }; + }); + if(json.Answer) { + results.unshift({ + title: json.Answer, + description: json.AnswerType + }); + } + if(json.RelatedTopics && json.RelatedTopics.length > 0) { + results.unshift({ + title: json.RelatedTopics[0].Text + }); + } + if(json.AbstractText) { + results.unshift({ + title: json.AbstractText + }); + } + // console.log(results); + return results; }); - promise.resolve(results); - }); - return promise; } getName() { diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js new file mode 100644 index 0000000000..fefd00a7fd --- /dev/null +++ b/src/autocomplete/EmojiProvider.js @@ -0,0 +1,41 @@ +import AutocompleteProvider from './AutocompleteProvider'; +import Q from 'q'; +import {emojioneList, shortnameToImage} from 'emojione'; +import Fuse from 'fuse.js'; + +const EMOJI_REGEX = /:\w*:?/g; +const EMOJI_SHORTNAMES = Object.keys(emojioneList); + +export default class EmojiProvider extends AutocompleteProvider { + constructor() { + super(); + console.log(EMOJI_SHORTNAMES); + this.fuse = new Fuse(EMOJI_SHORTNAMES); + } + + getCompletions(query: String) { + let completions = []; + const matches = query.match(EMOJI_REGEX); + console.log(matches); + if(!!matches) { + const command = matches[0]; + completions = this.fuse.search(command).map(result => { + let shortname = EMOJI_SHORTNAMES[result]; + let imageHTML = shortnameToImage(shortname); + return { + title: shortname, + component: ( +
+ {shortname} +
+ ) + }; + }).slice(0, 4); + } + return Q.when(completions); + } + + getName() { + return 'Emoji'; + } +} diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 4bc4102070..673cdc5bf5 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,4 +1,5 @@ import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -11,8 +12,11 @@ export default class Autocomplete extends React.Component { } componentWillReceiveProps(props, state) { + if(props.query == this.props.query) return; + getCompletions(props.query).map(completionResult => { try { + console.log(`${completionResult.provider.getName()}: ${JSON.stringify(completionResult.completions)}`); completionResult.completions.then(completions => { let i = this.state.completions.findIndex( completion => completion.provider === completionResult.provider @@ -23,15 +27,16 @@ export default class Autocomplete extends React.Component { let newCompletions = Object.assign([], this.state.completions); completionResult.completions = completions; newCompletions[i] = completionResult; - console.log(newCompletions); + // console.log(newCompletions); this.setState({ completions: newCompletions }); }, err => { - + console.error(err); }); } catch (e) { // An error in one provider shouldn't mess up the rest. + console.error(e); } }); } @@ -42,23 +47,19 @@ export default class Autocomplete extends React.Component { const position = pinElement.getBoundingClientRect(); - const style = { - position: 'fixed', - border: '1px solid gray', - background: 'white', - borderRadius: '4px' - }; - this.props.pinTo.forEach(direction => { - style[direction] = position[direction]; - }); const renderedCompletions = this.state.completions.map((completionResult, i) => { - console.log(completionResult); + // console.log(completionResult); let completions = completionResult.completions.map((completion, i) => { + let Component = completion.component; + if(Component) { + return Component; + } + return ( -
- {completion.title} +
+ {completion.title} {completion.subtitle} {completion.description}
@@ -67,16 +68,20 @@ export default class Autocomplete extends React.Component { return completions.length > 0 ? ( -
- {completionResult.provider.getName()} - {completions} +
+ {completionResult.provider.getName()} + + {completions} +
) : null; }); return ( -
- {renderedCompletions} +
+ + {renderedCompletions} +
); } diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 889415f243..0f9dd86b09 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -212,13 +212,14 @@ module.exports = React.createClass({ return (
+
+ +
{controls}
- -
); } diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 8a0ee7d8a8..a9ee764864 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -352,6 +352,10 @@ export default class MessageComposerInput extends React.Component { } else { this.onFinishedTyping(); } + + if(this.props.onContentChanged) { + this.props.onContentChanged(editorState.getCurrentContent().getPlainText()); + } } enableRichtext(enabled: boolean) { @@ -521,5 +525,8 @@ MessageComposerInput.propTypes = { onResize: React.PropTypes.func, // js-sdk Room object - room: React.PropTypes.object.isRequired + room: React.PropTypes.object.isRequired, + + // called with current plaintext content (as a string) whenever it changes + onContentChanged: React.PropTypes.func }; From 4af983ed902c767b87c0c39890753f0432fef91c Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Mon, 20 Jun 2016 13:52:55 +0530 Subject: [PATCH 04/12] Style changes and improvements in autocomplete --- src/autocomplete/Autocompleter.js | 4 +- src/autocomplete/CommandProvider.js | 11 ++- src/autocomplete/DuckDuckGoProvider.js | 9 ++ src/autocomplete/EmojiProvider.js | 16 ++-- src/autocomplete/RoomProvider.js | 14 ++- src/autocomplete/UserProvider.js | 19 +++- src/components/structures/RoomView.js | 13 ++- src/components/views/rooms/Autocomplete.js | 32 +++---- src/components/views/rooms/MessageComposer.js | 90 ++++++++++--------- .../views/rooms/MessageComposerInput.js | 20 ++--- 10 files changed, 135 insertions(+), 93 deletions(-) diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index c8f3134a3b..95669d5e0f 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -5,12 +5,12 @@ import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; const PROVIDERS = [ + UserProvider, CommandProvider, DuckDuckGoProvider, RoomProvider, - UserProvider, EmojiProvider -].map(completer => new completer()); +].map(completer => completer.getInstance()); export function getCompletions(query: String) { return PROVIDERS.map(provider => { diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index e2eac47d16..7b950c0ed0 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -39,6 +39,8 @@ const COMMANDS = [ } ]; +let instance = null; + export default class CommandProvider extends AutocompleteProvider { constructor() { super(); @@ -49,7 +51,7 @@ export default class CommandProvider extends AutocompleteProvider { getCompletions(query: String) { let completions = []; - const matches = query.match(/(^\/\w+)/); + const matches = query.match(/(^\/\w*)/); if(!!matches) { const command = matches[0]; completions = this.fuse.search(command).map(result => { @@ -66,4 +68,11 @@ export default class CommandProvider extends AutocompleteProvider { getName() { return 'Commands'; } + + static getInstance(): CommandProvider { + if(instance == null) + instance = new CommandProvider(); + + return instance; + } } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index 2acd892498..496ce72e46 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -5,6 +5,8 @@ import 'whatwg-fetch'; const DDG_REGEX = /\/ddg\s+(.+)$/; const REFERER = 'vector'; +let instance = null; + export default class DuckDuckGoProvider extends AutocompleteProvider { static getQueryUri(query: String) { return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}` @@ -51,4 +53,11 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { getName() { return 'Results from DuckDuckGo'; } + + static getInstance(): DuckDuckGoProvider { + if(instance == null) + instance = new DuckDuckGoProvider(); + + return instance; + } } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index fefd00a7fd..684414d72a 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -6,19 +6,19 @@ import Fuse from 'fuse.js'; const EMOJI_REGEX = /:\w*:?/g; const EMOJI_SHORTNAMES = Object.keys(emojioneList); +let instance = null; + export default class EmojiProvider extends AutocompleteProvider { constructor() { super(); - console.log(EMOJI_SHORTNAMES); this.fuse = new Fuse(EMOJI_SHORTNAMES); } getCompletions(query: String) { let completions = []; - const matches = query.match(EMOJI_REGEX); - console.log(matches); - if(!!matches) { - const command = matches[0]; + let matches = query.match(EMOJI_REGEX); + let command = matches && matches[0]; + if(command) { completions = this.fuse.search(command).map(result => { let shortname = EMOJI_SHORTNAMES[result]; let imageHTML = shortnameToImage(shortname); @@ -38,4 +38,10 @@ export default class EmojiProvider extends AutocompleteProvider { getName() { return 'Emoji'; } + + static getInstance() { + if(instance == null) + instance = new EmojiProvider(); + return instance; + } } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 26dc5733da..c61541617d 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,9 +1,12 @@ import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import MatrixClientPeg from '../MatrixClientPeg'; +import Fuse from 'fuse.js'; const ROOM_REGEX = /(?=#)[^\s]*/g; +let instance = null; + export default class RoomProvider extends AutocompleteProvider { constructor() { super(); @@ -13,8 +16,8 @@ export default class RoomProvider extends AutocompleteProvider { let client = MatrixClientPeg.get(); let completions = []; const matches = query.match(ROOM_REGEX); - if(!!matches) { - const command = matches[0]; + const command = matches && matches[0]; + if(command) { completions = client.getRooms().map(room => { return { title: room.name, @@ -28,4 +31,11 @@ export default class RoomProvider extends AutocompleteProvider { getName() { return 'Rooms'; } + + static getInstance() { + if(instance == null) + instance = new RoomProvider(); + + return instance; + } } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 791dd55a33..c850cea7d9 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -4,20 +4,22 @@ import MatrixClientPeg from '../MatrixClientPeg'; const ROOM_REGEX = /@[^\s]*/g; +let instance = null; + export default class UserProvider extends AutocompleteProvider { constructor() { super(); + this.users = []; } getCompletions(query: String) { - let client = MatrixClientPeg.get(); let completions = []; const matches = query.match(ROOM_REGEX); if(!!matches) { const command = matches[0]; - completions = client.getUsers().map(user => { + completions = this.users.map(user => { return { - title: user.displayName, + title: user.displayName || user.userId, description: user.userId }; }); @@ -28,4 +30,15 @@ export default class UserProvider extends AutocompleteProvider { getName() { return 'Users'; } + + setUserList(users) { + console.log('setUserList'); + this.users = users; + } + + static getInstance(): UserProvider { + if(instance == null) + instance = new UserProvider(); + return instance; + } } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e1b4c00175..9d952e611e 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -41,6 +41,8 @@ var rate_limited_func = require('../../ratelimitedfunc'); var ObjectUtils = require('../../ObjectUtils'); var MatrixTools = require('../../MatrixTools'); +import UserProvider from '../../autocomplete/UserProvider'; + var DEBUG = false; if (DEBUG) { @@ -495,21 +497,26 @@ module.exports = React.createClass({ } }, - _updateTabCompleteList: new rate_limited_func(function() { + _updateTabCompleteList: function() { var cli = MatrixClientPeg.get(); + console.log('_updateTabCompleteList'); + console.log(this.state.room); + console.trace(); - if (!this.state.room || !this.tabComplete) { + if (!this.state.room) { return; } var members = this.state.room.getJoinedMembers().filter(function(member) { if (member.userId !== cli.credentials.userId) return true; }); + + UserProvider.getInstance().setUserList(members); this.tabComplete.setCompletionList( MemberEntry.fromMemberList(members).concat( CommandEntry.fromCommands(SlashCommands.getCommandList()) ) ); - }, 500), + }, componentDidUpdate: function() { if (this.refs.roomView) { diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 673cdc5bf5..0218a88195 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -16,14 +16,14 @@ export default class Autocomplete extends React.Component { getCompletions(props.query).map(completionResult => { try { - console.log(`${completionResult.provider.getName()}: ${JSON.stringify(completionResult.completions)}`); + // console.log(`${completionResult.provider.getName()}: ${JSON.stringify(completionResult.completions)}`); completionResult.completions.then(completions => { let i = this.state.completions.findIndex( completion => completion.provider === completionResult.provider ); i = i == -1 ? this.state.completions.length : i; - console.log(completionResult); + // console.log(completionResult); let newCompletions = Object.assign([], this.state.completions); completionResult.completions = completions; newCompletions[i] = completionResult; @@ -42,13 +42,6 @@ export default class Autocomplete extends React.Component { } render() { - const pinElement = document.querySelector(this.props.pinSelector); - if(!pinElement) return null; - - const position = pinElement.getBoundingClientRect(); - - - const renderedCompletions = this.state.completions.map((completionResult, i) => { // console.log(completionResult); let completions = completionResult.completions.map((completion, i) => { @@ -58,10 +51,11 @@ export default class Autocomplete extends React.Component { } return ( -
- {completion.title} - {completion.subtitle} - {completion.description} +
+ {completion.title} + {completion.subtitle} + + {completion.description}
); }); @@ -70,7 +64,7 @@ export default class Autocomplete extends React.Component { return completions.length > 0 ? (
{completionResult.provider.getName()} - + {completions}
@@ -79,7 +73,7 @@ export default class Autocomplete extends React.Component { return (
- + {renderedCompletions}
@@ -89,11 +83,5 @@ export default class Autocomplete extends React.Component { Autocomplete.propTypes = { // the query string for which to show autocomplete suggestions - query: React.PropTypes.string.isRequired, - - // CSS selector indicating which element to pin the autocomplete to - pinSelector: React.PropTypes.string.isRequired, - - // attributes on which the autocomplete should match the pinElement - pinTo: React.PropTypes.array.isRequired + query: React.PropTypes.string.isRequired }; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 0f9dd86b09..5373ca4dc8 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -25,36 +25,22 @@ import Autocomplete from './Autocomplete'; import UserSettingsStore from '../../../UserSettingsStore'; -module.exports = React.createClass({ - displayName: 'MessageComposer', +export default class MessageComposer extends React.Component { + constructor(props, context) { + super(props, context); + this.onCallClick = this.onCallClick.bind(this); + this.onHangupClick = this.onHangupClick.bind(this); + this.onUploadClick = this.onUploadClick.bind(this); + this.onUploadFileSelected = this.onUploadFileSelected.bind(this); + this.onVoiceCallClick = this.onVoiceCallClick.bind(this); + this.onInputContentChanged = this.onInputContentChanged.bind(this); - propTypes: { - tabComplete: React.PropTypes.any, - - // a callback which is called when the height of the composer is - // changed due to a change in content. - onResize: React.PropTypes.func, - - // js-sdk Room object - room: React.PropTypes.object.isRequired, - - // string representing the current voip call state - callState: React.PropTypes.string, - - // callback when a file to upload is chosen - uploadFile: React.PropTypes.func.isRequired, - - // opacity for dynamic UI fading effects - opacity: React.PropTypes.number, - }, - - getInitialState: function () { - return { + this.state = { autocompleteQuery: '' }; - }, + } - onUploadClick: function(ev) { + onUploadClick(ev) { if (MatrixClientPeg.get().isGuest()) { var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); Modal.createDialog(NeedToRegisterDialog, { @@ -65,9 +51,9 @@ module.exports = React.createClass({ } this.refs.uploadInput.click(); - }, + } - onUploadFileSelected: function(ev) { + onUploadFileSelected(ev) { var files = ev.target.files; var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -103,9 +89,9 @@ module.exports = React.createClass({ this.refs.uploadInput.value = null; } }); - }, + } - onHangupClick: function() { + onHangupClick() { var call = CallHandler.getCallForRoom(this.props.room.roomId); //var call = CallHandler.getAnyActiveCall(); if (!call) { @@ -117,31 +103,32 @@ module.exports = React.createClass({ // (e.g. conferences which will hangup the 1:1 room instead) room_id: call.roomId }); - }, + } - onCallClick: function(ev) { + onCallClick(ev) { dis.dispatch({ action: 'place_call', type: ev.shiftKey ? "screensharing" : "video", room_id: this.props.room.roomId }); - }, + } - onVoiceCallClick: function(ev) { + onVoiceCallClick(ev) { dis.dispatch({ action: 'place_call', type: 'voice', room_id: this.props.room.roomId }); - }, + } - onInputContentChanged(content: String) { + onInputContentChanged(content: string) { this.setState({ autocompleteQuery: content - }) - }, + }); + console.log(content); + } - render: function() { + render() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); @@ -196,7 +183,7 @@ module.exports = React.createClass({ controls.push( this.onInputContentChanged(content) } />, + onContentChanged={this.onInputContentChanged} />, uploadButton, hangupButton, callButton, @@ -213,7 +200,7 @@ module.exports = React.createClass({ return (
- +
@@ -223,5 +210,24 @@ module.exports = React.createClass({
); } -}); +}; +MessageComposer.propTypes = { + tabComplete: React.PropTypes.any, + + // a callback which is called when the height of the composer is + // changed due to a change in content. + onResize: React.PropTypes.func, + + // js-sdk Room object + room: React.PropTypes.object.isRequired, + + // string representing the current voip call state + callState: React.PropTypes.string, + + // callback when a file to upload is chosen + uploadFile: React.PropTypes.func.isRequired, + + // opacity for dynamic UI fading effects + opacity: React.PropTypes.number +}; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index a9ee764864..d82e9fb6c7 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -72,7 +72,7 @@ export default class MessageComposerInput extends React.Component { this.onInputClick = this.onInputClick.bind(this); this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); - this.onChange = this.onChange.bind(this); + this.setEditorState = this.setEditorState.bind(this); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); if(isRichtextEnabled == null) { @@ -207,9 +207,7 @@ export default class MessageComposerInput extends React.Component { let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); if (contentJSON) { let content = convertFromRaw(JSON.parse(contentJSON)); - component.setState({ - editorState: component.createEditorState(component.state.isRichtextEnabled, content) - }); + component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content)); } } }; @@ -344,7 +342,7 @@ export default class MessageComposerInput extends React.Component { this.refs.editor.focus(); } - onChange(editorState: EditorState) { + setEditorState(editorState: EditorState) { this.setState({editorState}); if(editorState.getCurrentContent().hasText()) { @@ -361,15 +359,11 @@ export default class MessageComposerInput extends React.Component { enableRichtext(enabled: boolean) { if (enabled) { let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); - this.setState({ - editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html)) - }); + this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html))); } else { let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), contentState = ContentState.createFromText(markdown); - this.setState({ - editorState: this.createEditorState(enabled, contentState) - }); + this.setEditorState(this.createEditorState(enabled, contentState)); } window.localStorage.setItem('mx_editor_rte_enabled', enabled); @@ -412,7 +406,7 @@ export default class MessageComposerInput extends React.Component { newState = RichUtils.handleKeyCommand(this.state.editorState, command); if (newState != null) { - this.onChange(newState); + this.setEditorState(newState); return true; } return false; @@ -506,7 +500,7 @@ export default class MessageComposerInput extends React.Component { Date: Tue, 21 Jun 2016 05:05:23 +0530 Subject: [PATCH 05/12] Fuzzy matching in User and Room providers --- src/autocomplete/RoomProvider.js | 17 ++++++++++++++--- src/autocomplete/UserProvider.js | 17 ++++++++++------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index c61541617d..b1232358b5 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -3,13 +3,16 @@ import Q from 'q'; import MatrixClientPeg from '../MatrixClientPeg'; import Fuse from 'fuse.js'; -const ROOM_REGEX = /(?=#)[^\s]*/g; +const ROOM_REGEX = /(?=#)([^\s]*)/g; let instance = null; export default class RoomProvider extends AutocompleteProvider { constructor() { super(); + this.fuse = new Fuse([], { + keys: ['name', 'roomId', 'aliases'] + }); } getCompletions(query: String) { @@ -18,12 +21,20 @@ export default class RoomProvider extends AutocompleteProvider { const matches = query.match(ROOM_REGEX); const command = matches && matches[0]; if(command) { - completions = client.getRooms().map(room => { + // the only reason we need to do this is because Fuse only matches on properties + this.fuse.set(client.getRooms().filter(room => !!room).map(room => { + return { + name: room.name, + roomId: room.roomId, + aliases: room.getAliases() + }; + })); + completions = this.fuse.search(command).map(room => { return { title: room.name, subtitle: room.roomId }; - }); + }).slice(0, 4);; } return Q.when(completions); } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index c850cea7d9..51a85adaf1 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -1,6 +1,6 @@ import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; -import MatrixClientPeg from '../MatrixClientPeg'; +import Fuse from 'fuse.js'; const ROOM_REGEX = /@[^\s]*/g; @@ -10,19 +10,23 @@ export default class UserProvider extends AutocompleteProvider { constructor() { super(); this.users = []; + this.fuse = new Fuse([], { + keys: ['displayName', 'userId'] + }) } getCompletions(query: String) { let completions = []; - const matches = query.match(ROOM_REGEX); - if(!!matches) { - const command = matches[0]; - completions = this.users.map(user => { + let matches = query.match(ROOM_REGEX); + let command = matches && matches[0]; + if(command) { + this.fuse.set(this.users); + completions = this.fuse.search(command).map(user => { return { title: user.displayName || user.userId, description: user.userId }; - }); + }).slice(0, 4); } return Q.when(completions); } @@ -32,7 +36,6 @@ export default class UserProvider extends AutocompleteProvider { } setUserList(users) { - console.log('setUserList'); this.users = users; } From fb6eec0f7d25be5714da478ce36aac4faccfbe44 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 21 Jun 2016 15:46:20 +0530 Subject: [PATCH 06/12] Hide/show autocomplete based on selection state --- src/RichText.js | 28 +++++++++++++- src/autocomplete/AutocompleteProvider.js | 37 +++++++++++++++++++ src/autocomplete/Autocompleter.js | 4 +- src/autocomplete/CommandProvider.js | 13 ++++--- src/autocomplete/DuckDuckGoProvider.js | 14 ++++--- src/autocomplete/EmojiProvider.js | 9 ++--- src/autocomplete/RoomProvider.js | 11 +++--- src/autocomplete/UserProvider.js | 13 ++++--- src/components/views/rooms/Autocomplete.js | 13 +++---- src/components/views/rooms/MessageComposer.js | 11 +++--- .../views/rooms/MessageComposerInput.js | 4 +- 11 files changed, 114 insertions(+), 43 deletions(-) diff --git a/src/RichText.js b/src/RichText.js index 7e749bc24a..678a7de190 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -5,7 +5,8 @@ import { convertFromHTML, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, - CompositeDecorator + CompositeDecorator, + SelectionState } from 'draft-js'; import * as sdk from './index'; @@ -168,3 +169,28 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); } + +/** + * Computes the plaintext offsets of the given SelectionState. + * Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc) + * Used by autocomplete to show completions when the current selection lies within, or at the edges of a command. + */ +export function getTextSelectionOffsets(selectionState: SelectionState, + contentBlocks: Array): {start: number, end: number} { + let offset = 0, start = 0, end = 0; + for(let block of contentBlocks) { + if (selectionState.getStartKey() == block.getKey()) { + start = offset + selectionState.getStartOffset(); + } + if (selectionState.getEndKey() == block.getKey()) { + end = offset + selectionState.getEndOffset(); + break; + } + offset += block.getLength(); + } + + return { + start, + end + } +} diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 61158d2b56..f741e085b0 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,4 +1,41 @@ +import Q from 'q'; + export default class AutocompleteProvider { + constructor(commandRegex?: RegExp, fuseOpts?: any) { + if(commandRegex) { + if(!commandRegex.global) { + throw new Error('commandRegex must have global flag set'); + } + this.commandRegex = commandRegex; + } + } + + /** + * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. + */ + getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array { + if(this.commandRegex == null) + return null; + + let match = null; + while((match = this.commandRegex.exec(query)) != null) { + let matchStart = match.index, + matchEnd = matchStart + match[0].length; + + console.log(match); + + if(selection.start <= matchEnd && selection.end >= matchStart) { + return match; + } + } + this.commandRegex.lastIndex = 0; + return null; + } + + getCompletions(query: String, selection: {start: number, end: number}) { + return Q.when([]); + } + getName(): string { return 'Default Provider'; } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 95669d5e0f..6b66d2fbdc 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -12,10 +12,10 @@ const PROVIDERS = [ EmojiProvider ].map(completer => completer.getInstance()); -export function getCompletions(query: String) { +export function getCompletions(query: string, selection: {start: number, end: number}) { return PROVIDERS.map(provider => { return { - completions: provider.getCompletions(query), + completions: provider.getCompletions(query, selection), provider }; }); diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 7b950c0ed0..30b448d7f2 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -39,22 +39,23 @@ const COMMANDS = [ } ]; +let COMMAND_RE = /(^\/\w*)/g; + let instance = null; export default class CommandProvider extends AutocompleteProvider { constructor() { - super(); + super(COMMAND_RE); this.fuse = new Fuse(COMMANDS, { keys: ['command', 'args', 'description'] }); } - getCompletions(query: String) { + getCompletions(query: string, selection: {start: number, end: number}) { let completions = []; - const matches = query.match(/(^\/\w*)/); - if(!!matches) { - const command = matches[0]; - completions = this.fuse.search(command).map(result => { + const command = this.getCurrentCommand(query, selection); + if(command) { + completions = this.fuse.search(command[0]).map(result => { return { title: result.command, subtitle: result.args, diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index 496ce72e46..b2bf27a21a 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -2,23 +2,27 @@ import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import 'whatwg-fetch'; -const DDG_REGEX = /\/ddg\s+(.+)$/; +const DDG_REGEX = /\/ddg\s+(.+)$/g; const REFERER = 'vector'; let instance = null; export default class DuckDuckGoProvider extends AutocompleteProvider { + constructor() { + super(DDG_REGEX); + } + static getQueryUri(query: String) { return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}` + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERER)}`; } - getCompletions(query: String) { - let match = DDG_REGEX.exec(query); - if(!query || !match) + getCompletions(query: string, selection: {start: number, end: number}) { + let command = this.getCurrentCommand(query, selection); + if(!query || !command) return Q.when([]); - return fetch(DuckDuckGoProvider.getQueryUri(match[1]), { + return fetch(DuckDuckGoProvider.getQueryUri(command[1]), { method: 'GET' }) .then(response => response.json()) diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 684414d72a..e1b5f3ea38 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -10,16 +10,15 @@ let instance = null; export default class EmojiProvider extends AutocompleteProvider { constructor() { - super(); + super(EMOJI_REGEX); this.fuse = new Fuse(EMOJI_SHORTNAMES); } - getCompletions(query: String) { + getCompletions(query: string, selection: {start: number, end: number}) { let completions = []; - let matches = query.match(EMOJI_REGEX); - let command = matches && matches[0]; + let command = this.getCurrentCommand(query, selection); if(command) { - completions = this.fuse.search(command).map(result => { + completions = this.fuse.search(command[0]).map(result => { let shortname = EMOJI_SHORTNAMES[result]; let imageHTML = shortnameToImage(shortname); return { diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index b1232358b5..8b5650e7a7 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -9,17 +9,18 @@ let instance = null; export default class RoomProvider extends AutocompleteProvider { constructor() { - super(); + super(ROOM_REGEX, { + keys: ['displayName', 'userId'] + }); this.fuse = new Fuse([], { keys: ['name', 'roomId', 'aliases'] }); } - getCompletions(query: String) { + getCompletions(query: string, selection: {start: number, end: number}) { let client = MatrixClientPeg.get(); let completions = []; - const matches = query.match(ROOM_REGEX); - const command = matches && matches[0]; + const command = this.getCurrentCommand(query, selection); if(command) { // the only reason we need to do this is because Fuse only matches on properties this.fuse.set(client.getRooms().filter(room => !!room).map(room => { @@ -29,7 +30,7 @@ export default class RoomProvider extends AutocompleteProvider { aliases: room.getAliases() }; })); - completions = this.fuse.search(command).map(room => { + completions = this.fuse.search(command[0]).map(room => { return { title: room.name, subtitle: room.roomId diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 51a85adaf1..3edb2bf00c 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -2,26 +2,27 @@ import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import Fuse from 'fuse.js'; -const ROOM_REGEX = /@[^\s]*/g; +const USER_REGEX = /@[^\s]*/g; let instance = null; export default class UserProvider extends AutocompleteProvider { constructor() { - super(); + super(USER_REGEX, { + keys: ['displayName', 'userId'] + }); this.users = []; this.fuse = new Fuse([], { keys: ['displayName', 'userId'] }) } - getCompletions(query: String) { + getCompletions(query: string, selection: {start: number, end: number}) { let completions = []; - let matches = query.match(ROOM_REGEX); - let command = matches && matches[0]; + let command = this.getCurrentCommand(query, selection); if(command) { this.fuse.set(this.users); - completions = this.fuse.search(command).map(user => { + completions = this.fuse.search(command[0]).map(user => { return { title: user.displayName || user.userId, description: user.userId diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 0218a88195..babd349c31 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -7,27 +7,27 @@ export default class Autocomplete extends React.Component { constructor(props) { super(props); this.state = { - completions: [] + completions: [], + + // how far down the completion list we are + selectionOffset: 0 }; } componentWillReceiveProps(props, state) { if(props.query == this.props.query) return; - getCompletions(props.query).map(completionResult => { + getCompletions(props.query, props.selection).map(completionResult => { try { - // console.log(`${completionResult.provider.getName()}: ${JSON.stringify(completionResult.completions)}`); completionResult.completions.then(completions => { let i = this.state.completions.findIndex( completion => completion.provider === completionResult.provider ); i = i == -1 ? this.state.completions.length : i; - // console.log(completionResult); let newCompletions = Object.assign([], this.state.completions); completionResult.completions = completions; newCompletions[i] = completionResult; - // console.log(newCompletions); this.setState({ completions: newCompletions }); @@ -42,8 +42,7 @@ export default class Autocomplete extends React.Component { } render() { - const renderedCompletions = this.state.completions.map((completionResult, i) => { - // console.log(completionResult); + let renderedCompletions = this.state.completions.map((completionResult, i) => { let completions = completionResult.completions.map((completion, i) => { let Component = completion.component; if(Component) { diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 5373ca4dc8..ce1ced2b59 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -36,7 +36,8 @@ export default class MessageComposer extends React.Component { this.onInputContentChanged = this.onInputContentChanged.bind(this); this.state = { - autocompleteQuery: '' + autocompleteQuery: '', + selection: null }; } @@ -121,11 +122,11 @@ export default class MessageComposer extends React.Component { }); } - onInputContentChanged(content: string) { + onInputContentChanged(content: string, selection: {start: number, end: number}) { this.setState({ - autocompleteQuery: content + autocompleteQuery: content, + selection }); - console.log(content); } render() { @@ -200,7 +201,7 @@ export default class MessageComposer extends React.Component { return (
- +
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index d82e9fb6c7..9b615e7e4e 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -352,7 +352,9 @@ export default class MessageComposerInput extends React.Component { } if(this.props.onContentChanged) { - this.props.onContentChanged(editorState.getCurrentContent().getPlainText()); + this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), + RichText.getTextSelectionOffsets(editorState.getSelection(), + editorState.getCurrentContent().getBlocksAsArray())); } } From a74db3a815879558110fcaa4b2f6f322dc1af783 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 21 Jun 2016 18:33:39 +0530 Subject: [PATCH 07/12] Get basic keyboard selection working --- src/components/views/rooms/Autocomplete.js | 21 +++++++++- src/components/views/rooms/MessageComposer.js | 31 +++++++++++++-- .../views/rooms/MessageComposerInput.js | 38 ++++++++++++++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index babd349c31..ca0c5df701 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,5 +1,6 @@ import React from 'react'; import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import classNames from 'classnames'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -41,16 +42,34 @@ export default class Autocomplete extends React.Component { }); } + onUpArrow() { + this.setState({selectionOffset: this.state.selectionOffset - 1}); + return true; + } + + onDownArrow() { + this.setState({selectionOffset: this.state.selectionOffset + 1}); + return true; + } + render() { + let position = 0; let renderedCompletions = this.state.completions.map((completionResult, i) => { let completions = completionResult.completions.map((completion, i) => { let Component = completion.component; + let className = classNames('mx_Autocomplete_Completion', { + 'selected': position == this.state.selectionOffset + }); + let componentPosition = position; + position++; if(Component) { return Component; } return ( -
+
this.setState({selectionOffset: componentPosition})}> {completion.title} {completion.subtitle} diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index ce1ced2b59..24d0bd2510 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -34,6 +34,9 @@ export default class MessageComposer extends React.Component { this.onUploadFileSelected = this.onUploadFileSelected.bind(this); this.onVoiceCallClick = this.onVoiceCallClick.bind(this); this.onInputContentChanged = this.onInputContentChanged.bind(this); + this.onUpArrow = this.onUpArrow.bind(this); + this.onDownArrow = this.onDownArrow.bind(this); + this.onTab = this.onTab.bind(this); this.state = { autocompleteQuery: '', @@ -129,6 +132,18 @@ export default class MessageComposer extends React.Component { }); } + onUpArrow() { + return this.refs.autocomplete.onUpArrow(); + } + + onDownArrow() { + return this.refs.autocomplete.onDownArrow(); + } + + onTab() { + return this.refs.autocomplete.onTab(); + } + render() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; @@ -182,9 +197,14 @@ export default class MessageComposer extends React.Component { ); controls.push( - , + , uploadButton, hangupButton, callButton, @@ -201,7 +221,10 @@ export default class MessageComposer extends React.Component { return (
- +
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 9b615e7e4e..313216d54c 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -73,6 +73,9 @@ export default class MessageComposerInput extends React.Component { this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); this.setEditorState = this.setEditorState.bind(this); + this.onUpArrow = this.onUpArrow.bind(this); + this.onDownArrow = this.onDownArrow.bind(this); + this.onTab = this.onTab.bind(this); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); if(isRichtextEnabled == null) { @@ -489,6 +492,30 @@ export default class MessageComposerInput extends React.Component { return true; } + onUpArrow(e) { + if(this.props.onUpArrow) { + if(this.props.onUpArrow()) { + e.preventDefault(); + } + } + } + + onDownArrow(e) { + if(this.props.onDownArrow) { + if(this.props.onDownArrow()) { + e.preventDefault(); + } + } + } + + onTab(e) { + if(this.props.onTab) { + if(this.props.onTab()) { + e.preventDefault(); + } + } + } + render() { let className = "mx_MessageComposer_input"; @@ -507,6 +534,9 @@ export default class MessageComposerInput extends React.Component { handleKeyCommand={this.handleKeyCommand} handleReturn={this.handleReturn} stripPastedStyles={!this.state.isRichtextEnabled} + onTab={this.onTab} + onUpArrow={this.onUpArrow} + onDownArrow={this.onDownArrow} spellCheck={true} />
); @@ -524,5 +554,11 @@ MessageComposerInput.propTypes = { room: React.PropTypes.object.isRequired, // called with current plaintext content (as a string) whenever it changes - onContentChanged: React.PropTypes.func + onContentChanged: React.PropTypes.func, + + onUpArrow: React.PropTypes.func, + + onDownArrow: React.PropTypes.func, + + onTab: React.PropTypes.func }; From 8961c87cf95e1aa7edb349a9c7fdaf6d6c131228 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Sun, 3 Jul 2016 01:11:34 +0530 Subject: [PATCH 08/12] feat: Autocomplete selection wraparound --- .eslintrc | 7 ++- src/RichText.js | 14 ++++++ src/components/views/rooms/Autocomplete.js | 54 ++++++++++++++++------ 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/.eslintrc b/.eslintrc index 761fd2af2b..3f6c8e6953 100644 --- a/.eslintrc +++ b/.eslintrc @@ -36,7 +36,12 @@ "no-new-wrappers": ["error"], "no-invalid-regexp": ["error"], "no-extra-bind": ["error"], - "no-magic-numbers": ["error"], + "no-magic-numbers": ["error", { + "ignore": [-1, 0, 1], // usually used in array/string indexing + "ignoreArrayIndexes": true, + "enforceConst": true, + "detectObjects": true + }], "consistent-return": ["error"], "valid-jsdoc": ["error"], "no-use-before-define": ["error"], diff --git a/src/RichText.js b/src/RichText.js index 678a7de190..f4fa4883cb 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -9,6 +9,7 @@ import { SelectionState } from 'draft-js'; import * as sdk from './index'; +import * as emojione from 'emojione'; const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { element: 'span' @@ -35,6 +36,8 @@ const MARKDOWN_REGEX = { const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; +let EMOJI_REGEX = null; +window.EMOJI_REGEX = EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); export function contentStateToHTML(contentState: ContentState): string { return contentState.getBlockMap().map((block) => { @@ -89,6 +92,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { return {avatar} {props.children}; } }; + let roomDecorator = { strategy: (contentBlock, callback) => { findWithRegex(ROOM_REGEX, contentBlock, callback); @@ -98,6 +102,16 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { } }; + // Unused for now, due to https://github.com/facebook/draft-js/issues/414 + let emojiDecorator = { + strategy: (contentBlock, callback) => { + findWithRegex(EMOJI_REGEX, contentBlock, callback); + }, + component: (props) => { + return + } + }; + return [usernameDecorator, roomDecorator]; } diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index ca0c5df701..414b0f1ebb 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -11,26 +11,28 @@ export default class Autocomplete extends React.Component { completions: [], // how far down the completion list we are - selectionOffset: 0 + selectionOffset: 0, }; } componentWillReceiveProps(props, state) { - if(props.query == this.props.query) return; + if (props.query === this.props.query) { + return; + } - getCompletions(props.query, props.selection).map(completionResult => { + getCompletions(props.query, props.selection).forEach(completionResult => { try { completionResult.completions.then(completions => { let i = this.state.completions.findIndex( completion => completion.provider === completionResult.provider ); - i = i == -1 ? this.state.completions.length : i; + i = i === -1 ? this.state.completions.length : i; let newCompletions = Object.assign([], this.state.completions); completionResult.completions = completions; newCompletions[i] = completionResult; this.setState({ - completions: newCompletions + completions: newCompletions, }); }, err => { console.error(err); @@ -42,13 +44,25 @@ export default class Autocomplete extends React.Component { }); } - onUpArrow() { - this.setState({selectionOffset: this.state.selectionOffset - 1}); + countCompletions(): number { + return this.state.completions.map(completionResult => { + return completionResult.completions.length; + }).reduce((l, r) => l + r); + } + + // called from MessageComposerInput + onUpArrow(): boolean { + let completionCount = this.countCompletions(), + selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount; + this.setState({selectionOffset}); return true; } - onDownArrow() { - this.setState({selectionOffset: this.state.selectionOffset + 1}); + // called from MessageComposerInput + onDownArrow(): boolean { + let completionCount = this.countCompletions(), + selectionOffset = (this.state.selectionOffset + 1) % completionCount; + this.setState({selectionOffset}); return true; } @@ -58,18 +72,20 @@ export default class Autocomplete extends React.Component { let completions = completionResult.completions.map((completion, i) => { let Component = completion.component; let className = classNames('mx_Autocomplete_Completion', { - 'selected': position == this.state.selectionOffset + 'selected': position === this.state.selectionOffset, }); let componentPosition = position; position++; - if(Component) { + if (Component) { return Component; } + + let onMouseOver = () => this.setState({selectionOffset: componentPosition}); return (
this.setState({selectionOffset: componentPosition})}> + onMouseOver={onMouseOver}> {completion.title} {completion.subtitle} @@ -82,7 +98,11 @@ export default class Autocomplete extends React.Component { return completions.length > 0 ? (
{completionResult.provider.getName()} - + {completions}
@@ -91,7 +111,11 @@ export default class Autocomplete extends React.Component { return (
- + {renderedCompletions}
@@ -101,5 +125,5 @@ export default class Autocomplete extends React.Component { Autocomplete.propTypes = { // the query string for which to show autocomplete suggestions - query: React.PropTypes.string.isRequired + query: React.PropTypes.string.isRequired, }; From cccc58b47f77f0eec644bc2455916496ed468318 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Sun, 3 Jul 2016 22:15:13 +0530 Subject: [PATCH 09/12] feat: implement autocomplete replacement --- package.json | 1 + src/RichText.js | 51 ++++++++++++++--- src/autocomplete/AutocompleteProvider.js | 29 +++++++--- src/autocomplete/Autocompleter.js | 4 +- src/autocomplete/CommandProvider.js | 43 ++++++++------ src/autocomplete/Components.js | 14 +++-- src/autocomplete/DuckDuckGoProvider.js | 57 +++++++++++++------ src/autocomplete/EmojiProvider.js | 14 +++-- src/autocomplete/RoomProvider.js | 24 +++++--- src/autocomplete/UserProvider.js | 23 +++++--- src/components/views/rooms/Autocomplete.js | 51 ++++++++++++----- src/components/views/rooms/MessageComposer.js | 34 ++++++----- .../views/rooms/MessageComposerInput.js | 47 +++++++++++---- 13 files changed, 271 insertions(+), 121 deletions(-) diff --git a/package.json b/package.json index fc3b1c8f24..13cabf32d9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "glob": "^5.0.14", "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", + "lodash": "^4.13.1", "marked": "^0.3.5", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", diff --git a/src/RichText.js b/src/RichText.js index f4fa4883cb..abbe860863 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -1,12 +1,14 @@ +import React from 'react'; import { Editor, Modifier, ContentState, + ContentBlock, convertFromHTML, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, CompositeDecorator, - SelectionState + SelectionState, } from 'draft-js'; import * as sdk from './index'; import * as emojione from 'emojione'; @@ -25,7 +27,7 @@ const STYLES = { CODE: 'code', ITALIC: 'em', STRIKETHROUGH: 's', - UNDERLINE: 'u' + UNDERLINE: 'u', }; const MARKDOWN_REGEX = { @@ -168,7 +170,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection text = ""; - for(let currentKey = startKey; + for (let currentKey = startKey; currentKey && currentKey !== endKey; currentKey = contentState.getKeyAfter(currentKey)) { let blockText = getText(currentKey); @@ -189,14 +191,14 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection * Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc) * Used by autocomplete to show completions when the current selection lies within, or at the edges of a command. */ -export function getTextSelectionOffsets(selectionState: SelectionState, - contentBlocks: Array): {start: number, end: number} { +export function selectionStateToTextOffsets(selectionState: SelectionState, + contentBlocks: Array): {start: number, end: number} { let offset = 0, start = 0, end = 0; for(let block of contentBlocks) { - if (selectionState.getStartKey() == block.getKey()) { + if (selectionState.getStartKey() === block.getKey()) { start = offset + selectionState.getStartOffset(); } - if (selectionState.getEndKey() == block.getKey()) { + if (selectionState.getEndKey() === block.getKey()) { end = offset + selectionState.getEndOffset(); break; } @@ -205,6 +207,37 @@ export function getTextSelectionOffsets(selectionState: SelectionState, return { start, - end - } + end, + }; +} + +export function textOffsetsToSelectionState({start, end}: {start: number, end: number}, + contentBlocks: Array): SelectionState { + let selectionState = SelectionState.createEmpty(); + + for (let block of contentBlocks) { + let blockLength = block.getLength(); + + if (start !== -1 && start < blockLength) { + selectionState = selectionState.merge({ + anchorKey: block.getKey(), + anchorOffset: start, + }); + start = -1; + } else { + start -= blockLength; + } + + if (end !== -1 && end <= blockLength) { + selectionState = selectionState.merge({ + focusKey: block.getKey(), + focusOffset: end, + }); + end = -1; + } else { + end -= blockLength; + } + } + + return selectionState; } diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index f741e085b0..05bbeacfab 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -14,25 +14,36 @@ export default class AutocompleteProvider { * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. */ getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array { - if(this.commandRegex == null) + if (this.commandRegex == null) { return null; + } - let match = null; - while((match = this.commandRegex.exec(query)) != null) { + let match; + while ((match = this.commandRegex.exec(query)) != null) { let matchStart = match.index, matchEnd = matchStart + match[0].length; - - console.log(match); - if(selection.start <= matchEnd && selection.end >= matchStart) { - return match; + if (selection.start <= matchEnd && selection.end >= matchStart) { + return { + command: match, + range: { + start: matchStart, + end: matchEnd, + }, + }; } } this.commandRegex.lastIndex = 0; - return null; + return { + command: null, + range: { + start: -1, + end: -1, + }, + }; } - getCompletions(query: String, selection: {start: number, end: number}) { + getCompletions(query: string, selection: {start: number, end: number}) { return Q.when([]); } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 6b66d2fbdc..7f32e0ca40 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -9,14 +9,14 @@ const PROVIDERS = [ CommandProvider, DuckDuckGoProvider, RoomProvider, - EmojiProvider + EmojiProvider, ].map(completer => completer.getInstance()); export function getCompletions(query: string, selection: {start: number, end: number}) { return PROVIDERS.map(provider => { return { completions: provider.getCompletions(query, selection), - provider + provider, }; }); } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 30b448d7f2..19a366ac63 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,42 +1,45 @@ +import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import Fuse from 'fuse.js'; +import {TextualCompletion} from './Components'; const COMMANDS = [ { command: '/me', args: '', - description: 'Displays action' + description: 'Displays action', }, { command: '/ban', args: ' [reason]', - description: 'Bans user with given id' + description: 'Bans user with given id', }, { - command: '/deop' + command: '/deop', + args: '', + description: 'Deops user with given id', }, { - command: '/encrypt' - }, - { - command: '/invite' + command: '/invite', + args: '', + description: 'Invites user with given id to current room' }, { command: '/join', args: '', - description: 'Joins room with given alias' + description: 'Joins room with given alias', }, { command: '/kick', args: ' [reason]', - description: 'Kicks user with given id' + description: 'Kicks user with given id', }, { command: '/nick', args: '', - description: 'Changes your display nickname' - } + description: 'Changes your display nickname', + }, ]; let COMMAND_RE = /(^\/\w*)/g; @@ -47,19 +50,23 @@ export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); this.fuse = new Fuse(COMMANDS, { - keys: ['command', 'args', 'description'] + keys: ['command', 'args', 'description'], }); } getCompletions(query: string, selection: {start: number, end: number}) { let completions = []; - const command = this.getCurrentCommand(query, selection); - if(command) { + let {command, range} = this.getCurrentCommand(query, selection); + if (command) { completions = this.fuse.search(command[0]).map(result => { return { - title: result.command, - subtitle: result.args, - description: result.description + completion: result.command + ' ', + component: (), + range, }; }); } @@ -71,7 +78,7 @@ export default class CommandProvider extends AutocompleteProvider { } static getInstance(): CommandProvider { - if(instance == null) + if (instance == null) instance = new CommandProvider(); return instance; diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js index cb7d56f9bf..d9d1c7b3ff 100644 --- a/src/autocomplete/Components.js +++ b/src/autocomplete/Components.js @@ -1,13 +1,19 @@ -export function TextualCompletion(props: { +import React from 'react'; + +export function TextualCompletion({ + title, + subtitle, + description, +}: { title: ?string, subtitle: ?string, description: ?string }) { return (
- {completion.title} - {completion.subtitle} - {completion.description} + {title} + {subtitle} + {description}
); } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index b2bf27a21a..cfd3cb2ff6 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -1,9 +1,12 @@ +import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import 'whatwg-fetch'; +import {TextualCompletion} from './Components'; + const DDG_REGEX = /\/ddg\s+(.+)$/g; -const REFERER = 'vector'; +const REFERRER = 'vector'; let instance = null; @@ -14,42 +17,62 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { static getQueryUri(query: String) { return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}` - + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERER)}`; + + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; } getCompletions(query: string, selection: {start: number, end: number}) { - let command = this.getCurrentCommand(query, selection); - if(!query || !command) + let {command, range} = this.getCurrentCommand(query, selection); + if (!query || !command) { return Q.when([]); + } return fetch(DuckDuckGoProvider.getQueryUri(command[1]), { - method: 'GET' + method: 'GET', }) .then(response => response.json()) .then(json => { let results = json.Results.map(result => { return { - title: result.Text, - description: result.Result + completion: result.Text, + component: ( + + ), + range, }; }); - if(json.Answer) { + if (json.Answer) { results.unshift({ - title: json.Answer, - description: json.AnswerType + completion: json.Answer, + component: ( + + ), + range, }); } - if(json.RelatedTopics && json.RelatedTopics.length > 0) { + if (json.RelatedTopics && json.RelatedTopics.length > 0) { results.unshift({ - title: json.RelatedTopics[0].Text + completion: json.RelatedTopics[0].Text, + component: ( + + ), + range, }); } - if(json.AbstractText) { + if (json.AbstractText) { results.unshift({ - title: json.AbstractText + completion: json.AbstractText, + component: ( + + ), + range, }); } - // console.log(results); return results; }); } @@ -59,9 +82,9 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { } static getInstance(): DuckDuckGoProvider { - if(instance == null) + if (instance == null) { instance = new DuckDuckGoProvider(); - + } return instance; } } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index e1b5f3ea38..574144e95b 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -1,6 +1,7 @@ +import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; -import {emojioneList, shortnameToImage} from 'emojione'; +import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import Fuse from 'fuse.js'; const EMOJI_REGEX = /:\w*:?/g; @@ -16,18 +17,19 @@ export default class EmojiProvider extends AutocompleteProvider { getCompletions(query: string, selection: {start: number, end: number}) { let completions = []; - let command = this.getCurrentCommand(query, selection); - if(command) { + let {command, range} = this.getCurrentCommand(query, selection); + if (command) { completions = this.fuse.search(command[0]).map(result => { let shortname = EMOJI_SHORTNAMES[result]; let imageHTML = shortnameToImage(shortname); return { - title: shortname, + completion: shortnameToUnicode(shortname), component: (
{shortname}
- ) + ), + range, }; }).slice(0, 4); } @@ -39,7 +41,7 @@ export default class EmojiProvider extends AutocompleteProvider { } static getInstance() { - if(instance == null) + if (instance == null) instance = new EmojiProvider(); return instance; } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 8b5650e7a7..e38be65987 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,7 +1,9 @@ +import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import MatrixClientPeg from '../MatrixClientPeg'; import Fuse from 'fuse.js'; +import {TextualCompletion} from './Components'; const ROOM_REGEX = /(?=#)([^\s]*)/g; @@ -10,32 +12,35 @@ let instance = null; export default class RoomProvider extends AutocompleteProvider { constructor() { super(ROOM_REGEX, { - keys: ['displayName', 'userId'] + keys: ['displayName', 'userId'], }); this.fuse = new Fuse([], { - keys: ['name', 'roomId', 'aliases'] + keys: ['name', 'roomId', 'aliases'], }); } getCompletions(query: string, selection: {start: number, end: number}) { let client = MatrixClientPeg.get(); let completions = []; - const command = this.getCurrentCommand(query, selection); - if(command) { + const {command, range} = this.getCurrentCommand(query, selection); + if (command) { // the only reason we need to do this is because Fuse only matches on properties this.fuse.set(client.getRooms().filter(room => !!room).map(room => { return { name: room.name, roomId: room.roomId, - aliases: room.getAliases() + aliases: room.getAliases(), }; })); completions = this.fuse.search(command[0]).map(room => { return { - title: room.name, - subtitle: room.roomId + completion: room.roomId, + component: ( + + ), + range, }; - }).slice(0, 4);; + }).slice(0, 4); } return Q.when(completions); } @@ -45,8 +50,9 @@ export default class RoomProvider extends AutocompleteProvider { } static getInstance() { - if(instance == null) + if (instance == null) { instance = new RoomProvider(); + } return instance; } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 3edb2bf00c..3e65a65676 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -1,6 +1,8 @@ +import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import Fuse from 'fuse.js'; +import {TextualCompletion} from './Components'; const USER_REGEX = /@[^\s]*/g; @@ -9,23 +11,27 @@ let instance = null; export default class UserProvider extends AutocompleteProvider { constructor() { super(USER_REGEX, { - keys: ['displayName', 'userId'] + keys: ['displayName', 'userId'], }); this.users = []; this.fuse = new Fuse([], { - keys: ['displayName', 'userId'] - }) + keys: ['displayName', 'userId'], + }); } getCompletions(query: string, selection: {start: number, end: number}) { let completions = []; - let command = this.getCurrentCommand(query, selection); - if(command) { + let {command, range} = this.getCurrentCommand(query, selection); + if (command) { this.fuse.set(this.users); completions = this.fuse.search(command[0]).map(user => { return { - title: user.displayName || user.userId, - description: user.userId + completion: user.userId, + component: ( + + ), }; }).slice(0, 4); } @@ -41,8 +47,9 @@ export default class UserProvider extends AutocompleteProvider { } static getInstance(): UserProvider { - if(instance == null) + if (instance == null) { instance = new UserProvider(); + } return instance; } } diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 414b0f1ebb..dfeda96845 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,15 +1,23 @@ import React from 'react'; import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import classNames from 'classnames'; +import _ from 'lodash'; import {getCompletions} from '../../../autocomplete/Autocompleter'; export default class Autocomplete extends React.Component { constructor(props) { super(props); + + this.onConfirm = this.onConfirm.bind(this); + this.state = { + // list of completionResults, each containing completions completions: [], + // array of completions, so we can look up current selection by offset quickly + completionList: [], + // how far down the completion list we are selectionOffset: 0, }; @@ -31,8 +39,10 @@ export default class Autocomplete extends React.Component { let newCompletions = Object.assign([], this.state.completions); completionResult.completions = completions; newCompletions[i] = completionResult; + this.setState({ completions: newCompletions, + completionList: _.flatMap(newCompletions, provider => provider.completions), }); }, err => { console.error(err); @@ -54,7 +64,7 @@ export default class Autocomplete extends React.Component { onUpArrow(): boolean { let completionCount = this.countCompletions(), selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount; - this.setState({selectionOffset}); + this.setSelection(selectionOffset); return true; } @@ -62,34 +72,49 @@ export default class Autocomplete extends React.Component { onDownArrow(): boolean { let completionCount = this.countCompletions(), selectionOffset = (this.state.selectionOffset + 1) % completionCount; - this.setState({selectionOffset}); + this.setSelection(selectionOffset); return true; } + /** called from MessageComposerInput + * @returns {boolean} whether confirmation was handled + */ + onConfirm(): boolean { + if (this.countCompletions() === 0) + return false; + + let selectedCompletion = this.state.completionList[this.state.selectionOffset]; + this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion); + + return true; + } + + setSelection(selectionOffset: number) { + this.setState({selectionOffset}); + } + render() { let position = 0; let renderedCompletions = this.state.completions.map((completionResult, i) => { let completions = completionResult.completions.map((completion, i) => { - let Component = completion.component; let className = classNames('mx_Autocomplete_Completion', { 'selected': position === this.state.selectionOffset, }); let componentPosition = position; position++; - if (Component) { - return Component; - } - let onMouseOver = () => this.setState({selectionOffset: componentPosition}); - + let onMouseOver = () => this.setSelection(componentPosition), + onClick = () => { + this.setSelection(componentPosition); + this.onConfirm(); + }; + return (
- {completion.title} - {completion.subtitle} - - {completion.description} + onMouseOver={onMouseOver} + onClick={onClick}> + {completion.component}
); }); diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 24d0bd2510..4dc28e73c5 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -40,16 +40,17 @@ export default class MessageComposer extends React.Component { this.state = { autocompleteQuery: '', - selection: null + selection: null, }; + } onUploadClick(ev) { if (MatrixClientPeg.get().isGuest()) { - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); + let NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); Modal.createDialog(NeedToRegisterDialog, { title: "Please Register", - description: "Guest users can't upload files. Please register to upload." + description: "Guest users can't upload files. Please register to upload.", }); return; } @@ -58,13 +59,13 @@ export default class MessageComposer extends React.Component { } onUploadFileSelected(ev) { - var files = ev.target.files; + let files = ev.target.files; - var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + let TintableSvg = sdk.getComponent("elements.TintableSvg"); - var fileList = []; - for(var i=0; i {files[i].name} ); @@ -91,7 +92,7 @@ export default class MessageComposer extends React.Component { } this.refs.uploadInput.value = null; - } + }, }); } @@ -105,7 +106,7 @@ export default class MessageComposer extends React.Component { action: 'hangup', // hangup the call for this room, which may not be the room in props // (e.g. conferences which will hangup the 1:1 room instead) - room_id: call.roomId + room_id: call.roomId, }); } @@ -113,7 +114,7 @@ export default class MessageComposer extends React.Component { dis.dispatch({ action: 'place_call', type: ev.shiftKey ? "screensharing" : "video", - room_id: this.props.room.roomId + room_id: this.props.room.roomId, }); } @@ -121,14 +122,14 @@ export default class MessageComposer extends React.Component { dis.dispatch({ action: 'place_call', type: 'voice', - room_id: this.props.room.roomId + room_id: this.props.room.roomId, }); } onInputContentChanged(content: string, selection: {start: number, end: number}) { this.setState({ autocompleteQuery: content, - selection + selection, }); } @@ -171,11 +172,11 @@ export default class MessageComposer extends React.Component { callButton =
-
+
; videoCallButton =
-
+
; } var canSendMessages = this.props.room.currentState.maySendMessage( @@ -198,9 +199,11 @@ export default class MessageComposer extends React.Component { controls.push( this.messageComposerInput = c} key="controls_input" onResize={this.props.onResize} room={this.props.room} + tryComplete={this.refs.autocomplete && this.refs.autocomplete.onConfirm} onUpArrow={this.onUpArrow} onDownArrow={this.onDownArrow} onTab={this.onTab} @@ -223,6 +226,7 @@ export default class MessageComposer extends React.Component {
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 313216d54c..46abc20ed6 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -76,6 +76,7 @@ export default class MessageComposerInput extends React.Component { this.onUpArrow = this.onUpArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this); this.onTab = this.onTab.bind(this); + this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); if(isRichtextEnabled == null) { @@ -85,7 +86,7 @@ export default class MessageComposerInput extends React.Component { this.state = { isRichtextEnabled: isRichtextEnabled, - editorState: null + editorState: null, }; // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled @@ -96,7 +97,7 @@ export default class MessageComposerInput extends React.Component { static getKeyBinding(e: SyntheticKeyboardEvent): string { // C-m => Toggles between rich text and markdown modes - if(e.keyCode == KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { + if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { return 'toggle-mode'; } @@ -212,7 +213,7 @@ export default class MessageComposerInput extends React.Component { let content = convertFromRaw(JSON.parse(contentJSON)); component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content)); } - } + }, }; } @@ -234,7 +235,7 @@ export default class MessageComposerInput extends React.Component { } onAction(payload) { - var editor = this.refs.editor; + let editor = this.refs.editor; switch (payload.action) { case 'focus_composer': @@ -252,7 +253,7 @@ export default class MessageComposerInput extends React.Component { payload.displayname ); this.setState({ - editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters') + editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'), }); editor.focus(); } @@ -356,7 +357,7 @@ export default class MessageComposerInput extends React.Component { if(this.props.onContentChanged) { this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), - RichText.getTextSelectionOffsets(editorState.getSelection(), + RichText.selectionStateToTextOffsets(editorState.getSelection(), editorState.getCurrentContent().getBlocksAsArray())); } } @@ -418,12 +419,21 @@ export default class MessageComposerInput extends React.Component { } handleReturn(ev) { - if(ev.shiftKey) + if (ev.shiftKey) { return false; + } + + if(this.props.tryComplete) { + if(this.props.tryComplete()) { + return true; + } + } const contentState = this.state.editorState.getCurrentContent(); - if(!contentState.hasText()) + if (!contentState.hasText()) { return true; + } + let contentText = contentState.getPlainText(), contentHTML; @@ -509,17 +519,32 @@ export default class MessageComposerInput extends React.Component { } onTab(e) { - if(this.props.onTab) { - if(this.props.onTab()) { + if (this.props.onTab) { + if (this.props.onTab()) { e.preventDefault(); } } } + onConfirmAutocompletion(range, content: string) { + let contentState = Modifier.replaceText( + this.state.editorState.getCurrentContent(), + RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()), + content + ); + + this.setState({ + editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'), + }); + + // for some reason, doing this right away does not update the editor :( + setTimeout(() => this.refs.editor.focus(), 50); + } + render() { let className = "mx_MessageComposer_input"; - if(this.state.isRichtextEnabled) { + if (this.state.isRichtextEnabled) { className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode } From 30b7efd5857d6334f0b24e45c5ea8eff46ffa315 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Mon, 4 Jul 2016 21:44:35 +0530 Subject: [PATCH 10/12] fix: code cleanup, fix getCurrentCommand --- src/RichText.js | 5 ++--- src/autocomplete/AutocompleteProvider.js | 3 ++- src/autocomplete/Components.js | 4 ++-- src/components/structures/RoomView.js | 3 --- src/components/views/rooms/Autocomplete.js | 16 ++++++++++------ 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/RichText.js b/src/RichText.js index abbe860863..c24a510e05 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -33,13 +33,12 @@ const STYLES = { const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, ITALIC: /([\*_])([\w\s]+?)\1/g, - BOLD: /([\*_])\1([\w\s]+?)\1\1/g + BOLD: /([\*_])\1([\w\s]+?)\1\1/g, }; const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; -let EMOJI_REGEX = null; -window.EMOJI_REGEX = EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); +const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); export function contentStateToHTML(contentState: ContentState): string { return contentState.getBlockMap().map((block) => { diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 05bbeacfab..41d5d035d1 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -18,6 +18,8 @@ export default class AutocompleteProvider { return null; } + this.commandRegex.lastIndex = 0; + let match; while ((match = this.commandRegex.exec(query)) != null) { let matchStart = match.index, @@ -33,7 +35,6 @@ export default class AutocompleteProvider { }; } } - this.commandRegex.lastIndex = 0; return { command: null, range: { diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js index d9d1c7b3ff..4a24c79966 100644 --- a/src/autocomplete/Components.js +++ b/src/autocomplete/Components.js @@ -10,10 +10,10 @@ export function TextualCompletion({ description: ?string }) { return ( -
+
{title} {subtitle} - {description} + {description}
); } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index bee038a9e2..dc4b21a300 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -520,9 +520,6 @@ module.exports = React.createClass({ _updateTabCompleteList: function() { var cli = MatrixClientPeg.get(); - console.log('_updateTabCompleteList'); - console.log(this.state.room); - console.trace(); if (!this.state.room) { return; diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index dfeda96845..5ee638c479 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -80,8 +80,9 @@ export default class Autocomplete extends React.Component { * @returns {boolean} whether confirmation was handled */ onConfirm(): boolean { - if (this.countCompletions() === 0) + if (this.countCompletions() === 0) { return false; + } let selectedCompletion = this.state.completionList[this.state.selectionOffset]; this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion); @@ -103,11 +104,11 @@ export default class Autocomplete extends React.Component { let componentPosition = position; position++; - let onMouseOver = () => this.setSelection(componentPosition), - onClick = () => { - this.setSelection(componentPosition); - this.onConfirm(); - }; + let onMouseOver = () => this.setSelection(componentPosition); + let onClick = () => { + this.setSelection(componentPosition); + this.onConfirm(); + }; return (
Date: Mon, 4 Jul 2016 21:56:09 +0530 Subject: [PATCH 11/12] feat: import only flatMap from lodash --- src/components/views/rooms/Autocomplete.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 5ee638c479..95133778ba 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,7 +1,7 @@ import React from 'react'; import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import classNames from 'classnames'; -import _ from 'lodash'; +import flatMap from 'lodash/flatMap'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -42,7 +42,7 @@ export default class Autocomplete extends React.Component { this.setState({ completions: newCompletions, - completionList: _.flatMap(newCompletions, provider => provider.completions), + completionList: flatMap(newCompletions, provider => provider.completions), }); }, err => { console.error(err); From b3d82921137a28edf5b511390c5d6bc34ee402ff Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Mon, 4 Jul 2016 22:34:58 +0530 Subject: [PATCH 12/12] feat: use canonical room alias for room completion --- src/autocomplete/Components.js | 2 +- src/autocomplete/RoomProvider.js | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js index 4a24c79966..168da00c1c 100644 --- a/src/autocomplete/Components.js +++ b/src/autocomplete/Components.js @@ -13,7 +13,7 @@ export function TextualCompletion({
{title} {subtitle} - {description} + {description}
); } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index e38be65987..b34fdeb59a 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -4,6 +4,7 @@ import Q from 'q'; import MatrixClientPeg from '../MatrixClientPeg'; import Fuse from 'fuse.js'; import {TextualCompletion} from './Components'; +import {getDisplayAliasForRoom} from '../MatrixTools'; const ROOM_REGEX = /(?=#)([^\s]*)/g; @@ -27,16 +28,18 @@ export default class RoomProvider extends AutocompleteProvider { // the only reason we need to do this is because Fuse only matches on properties this.fuse.set(client.getRooms().filter(room => !!room).map(room => { return { + room: room, name: room.name, roomId: room.roomId, aliases: room.getAliases(), }; })); completions = this.fuse.search(command[0]).map(room => { + let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; return { - completion: room.roomId, + completion: displayAlias, component: ( - + ), range, };