From ab3b6497f99ccd35e1be8db9e8867efdb164a79e Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 11 Oct 2016 19:16:35 +0530 Subject: [PATCH 01/37] Disable "syntax highlighting" in MD mode (RTE) --- src/RichText.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/RichText.js b/src/RichText.js index b1793d0ddf..e662c22d6a 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -146,9 +146,9 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator { ) }); - markdownDecorators.push(emojiDecorator); - - return markdownDecorators; + // markdownDecorators.push(emojiDecorator); + // TODO Consider renabling "syntax highlighting" when we can do it properly + return [emojiDecorator]; } /** From f2ad4bee8b5243766616cab150ea86e18660035f Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 11 Oct 2016 19:17:57 +0530 Subject: [PATCH 02/37] Disable force completion for RoomProvider (RTE) --- src/autocomplete/RoomProvider.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 8d1e555e56..b589425b20 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -66,8 +66,4 @@ export default class RoomProvider extends AutocompleteProvider { {completions} ; } - - shouldForceComplete(): boolean { - return true; - } } From f4c0baaa2f02b5650597eddbe2f2b75344b9e8e3 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Wed, 30 Nov 2016 22:46:33 +0530 Subject: [PATCH 03/37] refactor MessageComposerInput: bind -> class props --- package.json | 2 +- .../views/rooms/MessageComposerInput.js | 156 ++++++++---------- 2 files changed, 73 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index a07e2236aa..1e5ee29d2d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "browser-request": "^0.3.3", "classnames": "^2.1.2", "commonmark": "^0.27.0", - "draft-js": "^0.8.1", + "draft-js": "^0.9.1", "draft-js-export-html": "^0.5.0", "draft-js-export-markdown": "^0.2.0", "emojione": "2.2.3", diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 61dd1e1b1c..9ae420fde4 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -59,6 +59,29 @@ function stateToMarkdown(state) { * The textInput part of the MessageComposer */ export default class MessageComposerInput extends React.Component { + static 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, + + // called with current plaintext content (as a string) whenever it changes + onContentChanged: React.PropTypes.func, + + onUpArrow: React.PropTypes.func, + + onDownArrow: React.PropTypes.func, + + // attempts to confirm currently selected completion, returns whether actually confirmed + tryComplete: React.PropTypes.func, + + onInputStateChanged: React.PropTypes.func, + }; + static getKeyBinding(e: SyntheticKeyboardEvent): string { // C-m => Toggles between rich text and markdown modes if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { @@ -81,17 +104,6 @@ export default class MessageComposerInput extends React.Component { constructor(props, context) { super(props, context); - this.onAction = this.onAction.bind(this); - this.handleReturn = this.handleReturn.bind(this); - this.handleKeyCommand = this.handleKeyCommand.bind(this); - this.onEditorContentChanged = this.onEditorContentChanged.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); - this.onEscape = this.onEscape.bind(this); - this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this); - this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); @@ -120,7 +132,7 @@ export default class MessageComposerInput extends React.Component { */ createEditorState(richText: boolean, contentState: ?ContentState): EditorState { let decorators = richText ? RichText.getScopedRTDecorators(this.props) : - RichText.getScopedMDDecorators(this.props), + RichText.getScopedMDDecorators(this.props), compositeDecorator = new CompositeDecorator(decorators); let editorState = null; @@ -147,7 +159,7 @@ export default class MessageComposerInput extends React.Component { // The textarea element to set text to. element: null, - init: function(element, roomId) { + init: function (element, roomId) { this.roomId = roomId; this.element = element; this.position = -1; @@ -162,7 +174,7 @@ export default class MessageComposerInput extends React.Component { } }, - push: function(text) { + push: function (text) { // store a message in the sent history this.data.unshift(text); window.sessionStorage.setItem( @@ -175,7 +187,7 @@ export default class MessageComposerInput extends React.Component { }, // move in the history. Returns true if we managed to move. - next: function(offset) { + next: function (offset) { if (this.position === -1) { // user is going into the history, save the current line. this.originalText = this.element.value; @@ -208,7 +220,7 @@ export default class MessageComposerInput extends React.Component { return true; }, - saveLastTextEntry: function() { + saveLastTextEntry: function () { // save the currently entered text in order to restore it later. // NB: This isn't 'originalText' because we want to restore // sent history items too! @@ -216,7 +228,7 @@ export default class MessageComposerInput extends React.Component { window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON); }, - setLastTextEntry: function() { + setLastTextEntry: function () { let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); if (contentJSON) { let content = convertFromRaw(JSON.parse(contentJSON)); @@ -248,7 +260,7 @@ export default class MessageComposerInput extends React.Component { } } - onAction(payload) { + onAction = payload => { let editor = this.refs.editor; let contentState = this.state.editorState.getCurrentContent(); @@ -270,7 +282,7 @@ export default class MessageComposerInput extends React.Component { this.onEditorContentChanged(editorState); editor.focus(); } - break; + break; case 'quote': { let {body, formatted_body} = payload.event.getContent(); @@ -297,9 +309,9 @@ export default class MessageComposerInput extends React.Component { editor.focus(); } } - break; + break; } - } + }; onTypingActivity() { this.isTyping = true; @@ -320,7 +332,7 @@ export default class MessageComposerInput extends React.Component { startUserTypingTimer() { this.stopUserTypingTimer(); var self = this; - this.userTypingTimer = setTimeout(function() { + this.userTypingTimer = setTimeout(function () { self.isTyping = false; self.sendTyping(self.isTyping); self.userTypingTimer = null; @@ -337,7 +349,7 @@ export default class MessageComposerInput extends React.Component { startServerTypingTimer() { if (!this.serverTypingTimer) { var self = this; - this.serverTypingTimer = setTimeout(function() { + this.serverTypingTimer = setTimeout(function () { if (self.isTyping) { self.sendTyping(self.isTyping); self.startServerTypingTimer(); @@ -368,7 +380,7 @@ export default class MessageComposerInput extends React.Component { } // Called by Draft to change editor contents, and by setEditorState - onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) { + onEditorContentChanged = (editorState: EditorState, didRespondToUserInput: boolean = true) => { editorState = RichText.attachImmutableEntitiesToEmoji(editorState); const contentChanged = Q.defer(); @@ -392,11 +404,11 @@ export default class MessageComposerInput extends React.Component { this.props.onContentChanged(textContent, selection); } return contentChanged.promise; - } + }; - setEditorState(editorState: EditorState) { + setEditorState = (editorState: EditorState) => { return this.onEditorContentChanged(editorState, false); - } + }; enableRichtext(enabled: boolean) { let contentState = null; @@ -420,7 +432,7 @@ export default class MessageComposerInput extends React.Component { }); } - handleKeyCommand(command: string): boolean { + handleKeyCommand = (command: string): boolean => { if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichtextEnabled); return true; @@ -451,7 +463,7 @@ export default class MessageComposerInput extends React.Component { 'code': text => `\`${text}\``, 'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''), 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), - 'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''), + 'ordered-list-item': text => text.split('\n').map((line, i) => `${i + 1}. ${line}\n`).join(''), }[command]; if (modifyFn) { @@ -473,9 +485,9 @@ export default class MessageComposerInput extends React.Component { } return false; - } + }; - handleReturn(ev) { + handleReturn = ev => { if (ev.shiftKey) { this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); return true; @@ -497,9 +509,9 @@ export default class MessageComposerInput extends React.Component { }); } if (cmd.promise) { - cmd.promise.then(function() { + cmd.promise.then(function () { console.log("Command success."); - }, function(err) { + }, function (err) { console.error("Command failure: %s", err); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { @@ -567,45 +579,44 @@ export default class MessageComposerInput extends React.Component { this.autocomplete.hide(); return true; - } + }; - async onUpArrow(e) { + onUpArrow = async e => { const completion = this.autocomplete.onUpArrow(); if (completion != null) { e.preventDefault(); } return await this.setDisplayedCompletion(completion); - } + }; - async onDownArrow(e) { + onDownArrow = async e => { const completion = this.autocomplete.onDownArrow(); e.preventDefault(); return await this.setDisplayedCompletion(completion); - } + }; // tab and shift-tab are mapped to down and up arrow respectively - async onTab(e) { + onTab = async e => { e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); if (!didTab && this.autocomplete) { - this.autocomplete.forceComplete().then(() => { - this.onDownArrow(e); - }); + await this.autocomplete.forceComplete(); + this.onDownArrow(e); } - } + }; - onEscape(e) { + onEscape = e => { e.preventDefault(); if (this.autocomplete) { this.autocomplete.onEscape(e); } this.setDisplayedCompletion(null); // restore originalEditorState - } + }; /* If passed null, restores the original editor content from state.originalEditorState. * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState. */ - async setDisplayedCompletion(displayedCompletion: ?Completion): boolean { + setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => { const activeEditorState = this.state.originalEditorState || this.state.editorState; if (displayedCompletion == null) { @@ -633,21 +644,21 @@ export default class MessageComposerInput extends React.Component { // for some reason, doing this right away does not update the editor :( setTimeout(() => this.refs.editor.focus(), 50); return true; - } + }; onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) { e.preventDefault(); // don't steal focus from the editor! const command = { - code: 'code-block', - quote: 'blockquote', - bullet: 'unordered-list-item', - numbullet: 'ordered-list-item', - }[name] || name; + code: 'code-block', + quote: 'blockquote', + bullet: 'unordered-list-item', + numbullet: 'ordered-list-item', + }[name] || name; this.handleKeyCommand(command); } /* returns inline style and block type of current SelectionState so MessageComposer can render formatting - buttons. */ + buttons. */ getSelectionInfo(editorState: EditorState) { const styleName = { BOLD: 'bold', @@ -658,8 +669,8 @@ export default class MessageComposerInput extends React.Component { const originalStyle = editorState.getCurrentInlineStyle().toArray(); const style = originalStyle - .map(style => styleName[style] || null) - .filter(styleName => !!styleName); + .map(style => styleName[style] || null) + .filter(styleName => !!styleName); const blockName = { 'code-block': 'code', @@ -678,10 +689,10 @@ export default class MessageComposerInput extends React.Component { }; } - onMarkdownToggleClicked(e) { + onMarkdownToggleClicked = e => { e.preventDefault(); // don't steal focus from the editor! this.handleKeyCommand('toggle-mode'); - } + }; render() { const activeEditorState = this.state.originalEditorState || this.state.editorState; @@ -698,7 +709,7 @@ export default class MessageComposerInput extends React.Component { } const className = classNames('mx_MessageComposer_input', { - mx_MessageComposer_input_empty: hidePlaceholder, + mx_MessageComposer_input_empty: hidePlaceholder, }); const content = activeEditorState.getCurrentContent(); @@ -713,13 +724,13 @@ export default class MessageComposerInput extends React.Component { ref={(e) => this.autocomplete = e} onConfirm={this.setDisplayedCompletion} query={contentText} - selection={selection} /> + selection={selection}/>
+ src={`img/button-md-${!this.state.isRichtextEnabled}.png`}/> + spellCheck={true}/>
); } } - -MessageComposerInput.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, - - // called with current plaintext content (as a string) whenever it changes - onContentChanged: React.PropTypes.func, - - onUpArrow: React.PropTypes.func, - - onDownArrow: React.PropTypes.func, - - // attempts to confirm currently selected completion, returns whether actually confirmed - tryComplete: React.PropTypes.func, - - onInputStateChanged: React.PropTypes.func, -}; From edd5903ed7e6bd6522eea588e752e59f45623d7e Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Wed, 30 Nov 2016 23:12:03 +0530 Subject: [PATCH 04/37] autocomplete: add space after completing room name --- src/autocomplete/RoomProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index b589425b20..85f94926d9 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -38,7 +38,7 @@ export default class RoomProvider extends AutocompleteProvider { completions = this.fuse.search(command[0]).map(room => { let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; return { - completion: displayAlias, + completion: displayAlias + ' ', component: ( } title={room.name} description={displayAlias} /> ), From 78641a80ddf7a6fa2cb951fa526249406a814495 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Thu, 1 Dec 2016 12:06:57 +0530 Subject: [PATCH 05/37] autocomplete: replace Fuse.js with liblevenshtein --- package.json | 2 +- src/autocomplete/AutocompleteProvider.js | 2 +- src/autocomplete/CommandProvider.js | 6 +- src/autocomplete/EmojiProvider.js | 6 +- src/autocomplete/FuzzyMatcher.js | 74 ++++++++++++++++++++++++ src/autocomplete/RoomProvider.js | 14 ++--- src/autocomplete/UserProvider.js | 8 +-- 7 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 src/autocomplete/FuzzyMatcher.js diff --git a/package.json b/package.json index 1e5ee29d2d..1015eb3fe9 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,10 @@ "file-saver": "^1.3.3", "filesize": "^3.1.2", "flux": "^2.0.3", - "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", "isomorphic-fetch": "^2.2.1", + "liblevenshtein": "^2.0.4", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 5c90990295..c361dd295b 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -2,7 +2,7 @@ import React from 'react'; import type {Completion, SelectionRange} from './Autocompleter'; export default class AutocompleteProvider { - constructor(commandRegex?: RegExp, fuseOpts?: any) { + constructor(commandRegex?: RegExp) { if (commandRegex) { if (!commandRegex.global) { throw new Error('commandRegex must have global flag set'); diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 60171bc72f..8f98bf1aa5 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,6 +1,6 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; -import Fuse from 'fuse.js'; +import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; const COMMANDS = [ @@ -53,7 +53,7 @@ let instance = null; export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); - this.fuse = new Fuse(COMMANDS, { + this.matcher = new FuzzyMatcher(COMMANDS, { keys: ['command', 'args', 'description'], }); } @@ -62,7 +62,7 @@ export default class CommandProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection); if (command) { - completions = this.fuse.search(command[0]).map(result => { + completions = this.matcher.match(command[0]).map(result => { return { completion: result.command + ' ', component: ( { + completions = this.matcher.match(command[0]).map(result => { const shortname = EMOJI_SHORTNAMES[result]; const unicode = shortnameToUnicode(shortname); return { diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js new file mode 100644 index 0000000000..c02ee9bbc0 --- /dev/null +++ b/src/autocomplete/FuzzyMatcher.js @@ -0,0 +1,74 @@ +import Levenshtein from 'liblevenshtein'; +import _at from 'lodash/at'; +import _flatMap from 'lodash/flatMap'; +import _sortBy from 'lodash/sortBy'; +import _sortedUniq from 'lodash/sortedUniq'; +import _keys from 'lodash/keys'; + +class KeyMap { + keys: Array; + objectMap: {[String]: Array}; + priorityMap: {[String]: number} +} + +const DEFAULT_RESULT_COUNT = 10; +const DEFAULT_DISTANCE = 5; + +export default class FuzzyMatcher { + /** + * Given an array of objects and keys, returns a KeyMap + * Keys can refer to object properties by name and as in JavaScript (for nested properties) + * + * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the + * resulting KeyMap. + * + * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it) + */ + static valuesToKeyMap(objects: Array, keys: Array): KeyMap { + const keyMap = new KeyMap(); + const map = {}; + const priorities = {}; + + objects.forEach((object, i) => { + const keyValues = _at(object, keys); + console.log(object, keyValues, keys); + for (const keyValue of keyValues) { + if (!map.hasOwnProperty(keyValue)) { + map[keyValue] = []; + } + map[keyValue].push(object); + } + priorities[object] = i; + }); + + keyMap.objectMap = map; + keyMap.priorityMap = priorities; + keyMap.keys = _sortBy(_keys(map), [value => priorities[value]]); + return keyMap; + } + + constructor(objects: Array, options: {[Object]: Object} = {}) { + this.options = options; + this.keys = options.keys; + this.setObjects(objects); + } + + setObjects(objects: Array) { + this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys); + console.log(this.keyMap.keys); + this.matcher = new Levenshtein.Builder() + .dictionary(this.keyMap.keys, true) + .algorithm('transposition') + .sort_candidates(false) + .case_insensitive_sort(true) + .include_distance(false) + .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense + .build(); + } + + match(query: String): Array { + const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE); + return _sortedUniq(_sortBy(_flatMap(candidates, candidate => this.keyMap.objectMap[candidate]), + candidate => this.keyMap.priorityMap[candidate])); + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 85f94926d9..8659b8501f 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,7 +1,7 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import MatrixClientPeg from '../MatrixClientPeg'; -import Fuse from 'fuse.js'; +import FuzzyMatcher from './FuzzyMatcher'; import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; @@ -12,11 +12,9 @@ let instance = null; export default class RoomProvider extends AutocompleteProvider { constructor() { - super(ROOM_REGEX, { - keys: ['displayName', 'userId'], - }); - this.fuse = new Fuse([], { - keys: ['name', 'roomId', 'aliases'], + super(ROOM_REGEX); + this.matcher = new FuzzyMatcher([], { + keys: ['name', 'aliases'], }); } @@ -28,14 +26,14 @@ export default class RoomProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection, force); 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 => { + this.matcher.setObjects(client.getRooms().filter(room => !!room).map(room => { return { room: room, name: room.name, aliases: room.getAliases(), }; })); - completions = this.fuse.search(command[0]).map(room => { + completions = this.matcher.match(command[0]).map(room => { let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; return { completion: displayAlias + ' ', diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 4d40fbdf94..b65439181c 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -1,9 +1,9 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; -import Fuse from 'fuse.js'; import {PillCompletion} from './Components'; import sdk from '../index'; +import FuzzyMatcher from './FuzzyMatcher'; const USER_REGEX = /@\S*/g; @@ -15,7 +15,7 @@ export default class UserProvider extends AutocompleteProvider { keys: ['name', 'userId'], }); this.users = []; - this.fuse = new Fuse([], { + this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], }); } @@ -26,8 +26,7 @@ export default class UserProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection, force); if (command) { - this.fuse.set(this.users); - completions = this.fuse.search(command[0]).map(user => { + completions = this.matcher.match(command[0]).map(user => { let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done let completion = displayName; if (range.start === 0) { @@ -56,6 +55,7 @@ export default class UserProvider extends AutocompleteProvider { setUserList(users) { this.users = users; + this.matcher.setObjects(this.users); } static getInstance(): UserProvider { From 48376a32c251d463d525541c1edc0a4370300e04 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 30 Dec 2016 19:42:36 +0530 Subject: [PATCH 06/37] refactor: MessageComposer.setEditorState to overridden setState The old approach led to a confusing proliferation of repeated setState calls. --- .../views/rooms/MessageComposerInput.js | 97 +++++++++++-------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 9ae420fde4..b830d52239 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -232,7 +232,9 @@ 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.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content)); + component.setState({ + editorState: component.createEditorState(component.state.isRichtextEnabled, content) + }); } }, }; @@ -379,36 +381,54 @@ export default class MessageComposerInput extends React.Component { } } - // Called by Draft to change editor contents, and by setEditorState - onEditorContentChanged = (editorState: EditorState, didRespondToUserInput: boolean = true) => { + // Called by Draft to change editor contents + onEditorContentChanged = (editorState: EditorState) => { editorState = RichText.attachImmutableEntitiesToEmoji(editorState); - const contentChanged = Q.defer(); - /* If a modification was made, set originalEditorState to null, since newState is now our original */ + /* Since a modification was made, set originalEditorState to null, since newState is now our original */ this.setState({ editorState, - originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState, - }, () => contentChanged.resolve()); - - if (editorState.getCurrentContent().hasText()) { - this.onTypingActivity(); - } else { - this.onFinishedTyping(); - } - - if (this.props.onContentChanged) { - const textContent = editorState.getCurrentContent().getPlainText(); - const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), - editorState.getCurrentContent().getBlocksAsArray()); - - this.props.onContentChanged(textContent, selection); - } - return contentChanged.promise; + originalEditorState: null, + }); }; - setEditorState = (editorState: EditorState) => { - return this.onEditorContentChanged(editorState, false); - }; + /** + * We're overriding setState here because it's the most convenient way to monitor changes to the editorState. + * Doing it using a separate function that calls setState is a possibility (and was the old approach), but that + * approach requires a callback and an extra setState whenever trying to set multiple state properties. + * + * @param state + * @param callback + */ + setState(state, callback) { + if (state.editorState != null) { + state.editorState = RichText.attachImmutableEntitiesToEmoji(state.editorState); + + if (state.editorState.getCurrentContent().hasText()) { + this.onTypingActivity(); + } else { + this.onFinishedTyping(); + } + + if (!state.hasOwnProperty('originalEditorState')) { + state.originalEditorState = null; + } + } + + super.setState(state, (state, props, context) => { + if (callback != null) { + callback(state, props, context); + } + + if (this.props.onContentChanged) { + const textContent = state.editorState.getCurrentContent().getPlainText(); + const selection = RichText.selectionStateToTextOffsets(state.editorState.getSelection(), + state.editorState.getCurrentContent().getBlocksAsArray()); + + this.props.onContentChanged(textContent, selection); + } + }); + } enableRichtext(enabled: boolean) { let contentState = null; @@ -423,13 +443,11 @@ export default class MessageComposerInput extends React.Component { contentState = ContentState.createFromText(markdown); } - this.setEditorState(this.createEditorState(enabled, contentState)).then(() => { - this.setState({ - isRichtextEnabled: enabled, - }); - - UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); + this.setState({ + editorState: this.createEditorState(enabled, contentState), + isRichtextEnabled: enabled, }); + UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); } handleKeyCommand = (command: string): boolean => { @@ -446,10 +464,14 @@ export default class MessageComposerInput extends React.Component { const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; if (blockCommands.includes(command)) { - this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command)); + this.setState({ + editorState: RichUtils.toggleBlockType(this.state.editorState, command) + }); } else if (command === 'strike') { // this is the only inline style not handled by Draft by default - this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH')); + this.setState({ + editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH') + }); } } else { let contentState = this.state.editorState.getCurrentContent(), @@ -480,7 +502,7 @@ export default class MessageComposerInput extends React.Component { } if (newState != null) { - this.setEditorState(newState); + this.setState({editorState: newState}); return true; } @@ -621,7 +643,7 @@ export default class MessageComposerInput extends React.Component { if (displayedCompletion == null) { if (this.state.originalEditorState) { - this.setEditorState(this.state.originalEditorState); + this.setState({editorState: this.state.originalEditorState}); } return false; } @@ -636,10 +658,7 @@ export default class MessageComposerInput extends React.Component { let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters'); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); - const originalEditorState = activeEditorState; - - await this.setEditorState(editorState); - this.setState({originalEditorState}); + this.setState({editorState, originalEditorState: activeEditorState}); // for some reason, doing this right away does not update the editor :( setTimeout(() => this.refs.editor.focus(), 50); From aaac06c6d3f98473a58ab9a839b754e0787ce30b Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Feb 2017 01:33:06 +0530 Subject: [PATCH 07/37] run eslint --fix over MessageComposerInput --- .../views/rooms/MessageComposerInput.js | 119 +++++++++--------- 1 file changed, 58 insertions(+), 61 deletions(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index b830d52239..b83e5d8dbf 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -159,12 +159,12 @@ export default class MessageComposerInput extends React.Component { // The textarea element to set text to. element: null, - init: function (element, roomId) { + init: function(element, roomId) { this.roomId = roomId; this.element = element; this.position = -1; - var storedData = window.sessionStorage.getItem( - "mx_messagecomposer_history_" + roomId + const storedData = window.sessionStorage.getItem( + "mx_messagecomposer_history_" + roomId, ); if (storedData) { this.data = JSON.parse(storedData); @@ -174,12 +174,12 @@ export default class MessageComposerInput extends React.Component { } }, - push: function (text) { + push: function(text) { // store a message in the sent history this.data.unshift(text); window.sessionStorage.setItem( "mx_messagecomposer_history_" + this.roomId, - JSON.stringify(this.data) + JSON.stringify(this.data), ); // reset history position this.position = -1; @@ -187,12 +187,11 @@ export default class MessageComposerInput extends React.Component { }, // move in the history. Returns true if we managed to move. - next: function (offset) { + next: function(offset) { if (this.position === -1) { // user is going into the history, save the current line. this.originalText = this.element.value; - } - else { + } else { // user may have modified this line in the history; remember it. this.data[this.position] = this.element.value; } @@ -203,7 +202,7 @@ export default class MessageComposerInput extends React.Component { } // retrieve the next item (bounded). - var newPosition = this.position + offset; + let newPosition = this.position + offset; newPosition = Math.max(-1, newPosition); newPosition = Math.min(newPosition, this.data.length - 1); this.position = newPosition; @@ -211,8 +210,7 @@ export default class MessageComposerInput extends React.Component { if (this.position !== -1) { // show the message this.element.value = this.data[this.position]; - } - else if (this.originalText !== undefined) { + } else if (this.originalText !== undefined) { // restore the original text the user was typing. this.element.value = this.originalText; } @@ -220,20 +218,20 @@ export default class MessageComposerInput extends React.Component { return true; }, - saveLastTextEntry: function () { + saveLastTextEntry: function() { // save the currently entered text in order to restore it later. // NB: This isn't 'originalText' because we want to restore // sent history items too! - let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent())); + const contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent())); window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON); }, - setLastTextEntry: function () { - let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); + setLastTextEntry: function() { + const contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); if (contentJSON) { - let content = convertFromRaw(JSON.parse(contentJSON)); + const content = convertFromRaw(JSON.parse(contentJSON)); component.setState({ - editorState: component.createEditorState(component.state.isRichtextEnabled, content) + editorState: component.createEditorState(component.state.isRichtextEnabled, content), }); } }, @@ -244,7 +242,7 @@ export default class MessageComposerInput extends React.Component { this.dispatcherRef = dis.register(this.onAction); this.sentHistory.init( this.refs.editor, - this.props.room.roomId + this.props.room.roomId, ); } @@ -262,8 +260,8 @@ export default class MessageComposerInput extends React.Component { } } - onAction = payload => { - let editor = this.refs.editor; + onAction = (payload) => { + const editor = this.refs.editor; let contentState = this.state.editorState.getCurrentContent(); switch (payload.action) { @@ -277,7 +275,7 @@ export default class MessageComposerInput extends React.Component { contentState = Modifier.replaceText( contentState, this.state.editorState.getSelection(), - `${payload.displayname}: ` + `${payload.displayname}: `, ); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); @@ -306,7 +304,7 @@ export default class MessageComposerInput extends React.Component { if (this.state.isRichtextEnabled) { contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); } - let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); + const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); this.onEditorContentChanged(editorState); editor.focus(); } @@ -333,8 +331,8 @@ export default class MessageComposerInput extends React.Component { startUserTypingTimer() { this.stopUserTypingTimer(); - var self = this; - this.userTypingTimer = setTimeout(function () { + const self = this; + this.userTypingTimer = setTimeout(function() { self.isTyping = false; self.sendTyping(self.isTyping); self.userTypingTimer = null; @@ -350,8 +348,8 @@ export default class MessageComposerInput extends React.Component { startServerTypingTimer() { if (!this.serverTypingTimer) { - var self = this; - this.serverTypingTimer = setTimeout(function () { + const self = this; + this.serverTypingTimer = setTimeout(function() { if (self.isTyping) { self.sendTyping(self.isTyping); self.startServerTypingTimer(); @@ -370,7 +368,7 @@ export default class MessageComposerInput extends React.Component { sendTyping(isTyping) { MatrixClientPeg.get().sendTyping( this.props.room.roomId, - this.isTyping, TYPING_SERVER_TIMEOUT + this.isTyping, TYPING_SERVER_TIMEOUT, ).done(); } @@ -465,34 +463,34 @@ export default class MessageComposerInput extends React.Component { if (blockCommands.includes(command)) { this.setState({ - editorState: RichUtils.toggleBlockType(this.state.editorState, command) + editorState: RichUtils.toggleBlockType(this.state.editorState, command), }); } else if (command === 'strike') { // this is the only inline style not handled by Draft by default this.setState({ - editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH') + editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'), }); } } else { let contentState = this.state.editorState.getCurrentContent(), selection = this.state.editorState.getSelection(); - let modifyFn = { - 'bold': text => `**${text}**`, - 'italic': text => `*${text}*`, - 'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* - 'strike': text => `~~${text}~~`, - 'code': text => `\`${text}\``, - 'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''), - 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), - 'ordered-list-item': text => text.split('\n').map((line, i) => `${i + 1}. ${line}\n`).join(''), + const modifyFn = { + 'bold': (text) => `**${text}**`, + 'italic': (text) => `*${text}*`, + 'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* + 'strike': (text) => `~~${text}~~`, + 'code': (text) => `\`${text}\``, + 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''), + 'unordered-list-item': (text) => text.split('\n').map((line) => `- ${line}\n`).join(''), + 'ordered-list-item': (text) => text.split('\n').map((line, i) => `${i + 1}. ${line}\n`).join(''), }[command]; if (modifyFn) { newState = EditorState.push( this.state.editorState, RichText.modifyText(contentState, selection, modifyFn), - 'insert-characters' + 'insert-characters', ); } } @@ -509,7 +507,7 @@ export default class MessageComposerInput extends React.Component { return false; }; - handleReturn = ev => { + handleReturn = (ev) => { if (ev.shiftKey) { this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); return true; @@ -523,31 +521,30 @@ export default class MessageComposerInput extends React.Component { let contentText = contentState.getPlainText(), contentHTML; - var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); + const cmd = SlashCommands.processInput(this.props.room.roomId, contentText); if (cmd) { if (!cmd.error) { this.setState({ - editorState: this.createEditorState() + editorState: this.createEditorState(), }); } if (cmd.promise) { - cmd.promise.then(function () { + cmd.promise.then(function() { console.log("Command success."); - }, function (err) { + }, function(err) { console.error("Command failure: %s", err); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Server error", - description: err.message + description: err.message, }); }); - } - else if (cmd.error) { + } else if (cmd.error) { console.error(cmd.error); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Command error", - description: cmd.error + description: cmd.error, }); } return true; @@ -555,7 +552,7 @@ export default class MessageComposerInput extends React.Component { if (this.state.isRichtextEnabled) { contentHTML = HtmlUtils.stripParagraphs( - RichText.contentStateToHTML(contentState) + RichText.contentStateToHTML(contentState), ); } else { const md = new Markdown(contentText); @@ -582,7 +579,7 @@ export default class MessageComposerInput extends React.Component { let sendMessagePromise; if (contentHTML) { sendMessagePromise = sendHtmlFn.call( - this.client, this.props.room.roomId, contentText, contentHTML + this.client, this.props.room.roomId, contentText, contentHTML, ); } else { sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText); @@ -603,7 +600,7 @@ export default class MessageComposerInput extends React.Component { return true; }; - onUpArrow = async e => { + onUpArrow = async (e) => { const completion = this.autocomplete.onUpArrow(); if (completion != null) { e.preventDefault(); @@ -611,14 +608,14 @@ export default class MessageComposerInput extends React.Component { return await this.setDisplayedCompletion(completion); }; - onDownArrow = async e => { + onDownArrow = async (e) => { const completion = this.autocomplete.onDownArrow(); e.preventDefault(); return await this.setDisplayedCompletion(completion); }; // tab and shift-tab are mapped to down and up arrow respectively - onTab = async e => { + onTab = async (e) => { e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); if (!didTab && this.autocomplete) { @@ -627,7 +624,7 @@ export default class MessageComposerInput extends React.Component { } }; - onEscape = e => { + onEscape = (e) => { e.preventDefault(); if (this.autocomplete) { this.autocomplete.onEscape(e); @@ -650,10 +647,10 @@ export default class MessageComposerInput extends React.Component { const {range = {}, completion = ''} = displayedCompletion; - let contentState = Modifier.replaceText( + const contentState = Modifier.replaceText( activeEditorState.getCurrentContent(), RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()), - completion + completion, ); let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters'); @@ -688,8 +685,8 @@ export default class MessageComposerInput extends React.Component { const originalStyle = editorState.getCurrentInlineStyle().toArray(); const style = originalStyle - .map(style => styleName[style] || null) - .filter(styleName => !!styleName); + .map((style) => styleName[style] || null) + .filter((styleName) => !!styleName); const blockName = { 'code-block': 'code', @@ -708,7 +705,7 @@ export default class MessageComposerInput extends React.Component { }; } - onMarkdownToggleClicked = e => { + onMarkdownToggleClicked = (e) => { e.preventDefault(); // don't steal focus from the editor! this.handleKeyCommand('toggle-mode'); }; From 46d30c378d647cce7187ae128562170ea9e28726 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Feb 2017 02:06:06 +0530 Subject: [PATCH 08/37] fix tab focus issue in MessageComposerInput onTab was incorrectly implemented causing forceComplete instead of focusing the editor --- src/components/views/rooms/Autocomplete.js | 6 ++++++ .../views/rooms/MessageComposerInput.js | 21 ++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 9be91e068a..9a3a04376d 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -149,6 +149,7 @@ export default class Autocomplete extends React.Component { const done = Q.defer(); this.setState({ forceComplete: true, + hide: false, }, () => { this.complete(this.props.query, this.props.selection).then(() => { done.resolve(); @@ -185,6 +186,11 @@ export default class Autocomplete extends React.Component { } } + setState(state, func) { + super.setState(state, func); + console.log(state); + } + render() { const EmojiText = sdk.getComponent('views.elements.EmojiText'); diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index b83e5d8dbf..c0d19987c7 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -400,7 +400,8 @@ export default class MessageComposerInput extends React.Component { */ setState(state, callback) { if (state.editorState != null) { - state.editorState = RichText.attachImmutableEntitiesToEmoji(state.editorState); + state.editorState = RichText.attachImmutableEntitiesToEmoji( + state.editorState); if (state.editorState.getCurrentContent().hasText()) { this.onTypingActivity(); @@ -413,15 +414,17 @@ export default class MessageComposerInput extends React.Component { } } - super.setState(state, (state, props, context) => { + super.setState(state, () => { if (callback != null) { - callback(state, props, context); + callback(); } if (this.props.onContentChanged) { - const textContent = state.editorState.getCurrentContent().getPlainText(); - const selection = RichText.selectionStateToTextOffsets(state.editorState.getSelection(), - state.editorState.getCurrentContent().getBlocksAsArray()); + const textContent = this.state.editorState + .getCurrentContent().getPlainText(); + const selection = RichText.selectionStateToTextOffsets( + this.state.editorState.getSelection(), + this.state.editorState.getCurrentContent().getBlocksAsArray()); this.props.onContentChanged(textContent, selection); } @@ -616,11 +619,13 @@ export default class MessageComposerInput extends React.Component { // tab and shift-tab are mapped to down and up arrow respectively onTab = async (e) => { + console.log('onTab'); e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes - const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); - if (!didTab && this.autocomplete) { + if (this.autocomplete.state.completionList.length === 0) { await this.autocomplete.forceComplete(); this.onDownArrow(e); + } else { + await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); } }; From 5fbe06ed91497eeff46e395f1e38164d99475d6d Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Feb 2017 03:40:57 +0530 Subject: [PATCH 09/37] force editor rerender when we swap editorStates --- src/components/views/rooms/Autocomplete.js | 23 +++++++++---------- .../views/rooms/MessageComposerInput.js | 19 +++++++++++---- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 9a3a04376d..c06786a80c 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -58,7 +58,7 @@ export default class Autocomplete extends React.Component { return; } - const completionList = flatMap(completions, provider => provider.completions); + const completionList = flatMap(completions, (provider) => provider.completions); // Reset selection when completion list becomes empty. let selectionOffset = COMPOSER_SELECTED; @@ -69,7 +69,7 @@ export default class Autocomplete extends React.Component { const currentSelection = this.state.selectionOffset === 0 ? null : this.state.completionList[this.state.selectionOffset - 1].completion; selectionOffset = completionList.findIndex( - completion => completion.completion === currentSelection); + (completion) => completion.completion === currentSelection); if (selectionOffset === -1) { selectionOffset = COMPOSER_SELECTED; } else { @@ -82,8 +82,8 @@ export default class Autocomplete extends React.Component { let hide = this.state.hide; // These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern - const oldMatches = this.state.completions.map(completion => !!completion.command.command), - newMatches = completions.map(completion => !!completion.command.command); + const oldMatches = this.state.completions.map((completion) => !!completion.command.command), + newMatches = completions.map((completion) => !!completion.command.command); // So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one if (!isEqual(oldMatches, newMatches)) { @@ -170,7 +170,7 @@ export default class Autocomplete extends React.Component { } setSelection(selectionOffset: number) { - this.setState({selectionOffset}); + this.setState({selectionOffset, hide: false}); } componentDidUpdate() { @@ -195,17 +195,16 @@ export default class Autocomplete extends React.Component { const EmojiText = sdk.getComponent('views.elements.EmojiText'); let position = 1; - let renderedCompletions = this.state.completions.map((completionResult, i) => { - let completions = completionResult.completions.map((completion, i) => { - + const renderedCompletions = this.state.completions.map((completionResult, i) => { + const completions = completionResult.completions.map((completion, i) => { const className = classNames('mx_Autocomplete_Completion', { 'selected': position === this.state.selectionOffset, }); - let componentPosition = position; + const componentPosition = position; position++; - let onMouseOver = () => this.setSelection(componentPosition); - let onClick = () => { + const onMouseOver = () => this.setSelection(componentPosition); + const onClick = () => { this.setSelection(componentPosition); this.onCompletionClicked(); }; @@ -226,7 +225,7 @@ export default class Autocomplete extends React.Component { {completionResult.provider.renderCompletions(completions)} ) : null; - }).filter(completion => !!completion); + }).filter((completion) => !!completion); return !this.state.hide && renderedCompletions.length > 0 ? (
this.container = e}> diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index c0d19987c7..7908d7f375 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -414,6 +414,8 @@ export default class MessageComposerInput extends React.Component { } } + console.log(state); + super.setState(state, () => { if (callback != null) { callback(); @@ -425,7 +427,7 @@ export default class MessageComposerInput extends React.Component { const selection = RichText.selectionStateToTextOffsets( this.state.editorState.getSelection(), this.state.editorState.getCurrentContent().getBlocksAsArray()); - + console.log(textContent); this.props.onContentChanged(textContent, selection); } }); @@ -629,12 +631,12 @@ export default class MessageComposerInput extends React.Component { } }; - onEscape = (e) => { + onEscape = async (e) => { e.preventDefault(); if (this.autocomplete) { this.autocomplete.onEscape(e); } - this.setDisplayedCompletion(null); // restore originalEditorState + await this.setDisplayedCompletion(null); // restore originalEditorState }; /* If passed null, restores the original editor content from state.originalEditorState. @@ -645,7 +647,14 @@ export default class MessageComposerInput extends React.Component { if (displayedCompletion == null) { if (this.state.originalEditorState) { - this.setState({editorState: this.state.originalEditorState}); + console.log('setting editorState to originalEditorState'); + let editorState = this.state.originalEditorState; + // This is a workaround from https://github.com/facebook/draft-js/issues/458 + // Due to the way we swap editorStates, Draft does not rerender at times + editorState = EditorState.forceSelection(editorState, + editorState.getSelection()); + this.setState({editorState}); + } return false; } @@ -663,7 +672,7 @@ export default class MessageComposerInput extends React.Component { this.setState({editorState, originalEditorState: activeEditorState}); // for some reason, doing this right away does not update the editor :( - setTimeout(() => this.refs.editor.focus(), 50); + // setTimeout(() => this.refs.editor.focus(), 50); return true; }; From c7d065276222cb5cb6506adccfae9ce249256201 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Feb 2017 04:26:36 +0530 Subject: [PATCH 10/37] actually sort autocomplete results by distance --- src/autocomplete/FuzzyMatcher.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js index c02ee9bbc0..bd19fc53e8 100644 --- a/src/autocomplete/FuzzyMatcher.js +++ b/src/autocomplete/FuzzyMatcher.js @@ -61,14 +61,24 @@ export default class FuzzyMatcher { .algorithm('transposition') .sort_candidates(false) .case_insensitive_sort(true) - .include_distance(false) + .include_distance(true) .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense .build(); } match(query: String): Array { const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE); - return _sortedUniq(_sortBy(_flatMap(candidates, candidate => this.keyMap.objectMap[candidate]), - candidate => this.keyMap.priorityMap[candidate])); + // TODO FIXME This is hideous. Clean up when possible. + const val = _sortedUniq(_sortBy(_flatMap(candidates, candidate => { + return this.keyMap.objectMap[candidate[0]].map(value => { + return { + distance: candidate[1], + ...value, + }; + }); + }), + [candidate => candidate.distance, candidate => this.keyMap.priorityMap[candidate]])); + console.log(val); + return val; } } From 0653343319f72f3e4dff3d0f5fc6f11ad29ee991 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Feb 2017 22:34:52 +0530 Subject: [PATCH 11/37] order User completions by last spoken --- .flowconfig | 6 +++ src/autocomplete/FuzzyMatcher.js | 7 ++- src/autocomplete/QueryMatcher.js | 62 +++++++++++++++++++++++++++ src/autocomplete/UserProvider.js | 35 +++++++++++++-- src/components/structures/RoomView.js | 15 ++----- 5 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 .flowconfig create mode 100644 src/autocomplete/QueryMatcher.js diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000000..81770c6585 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,6 @@ +[include] +src/**/*.js +test/**/*.js + +[ignore] +node_modules/ diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js index bd19fc53e8..c22e2a1101 100644 --- a/src/autocomplete/FuzzyMatcher.js +++ b/src/autocomplete/FuzzyMatcher.js @@ -14,7 +14,12 @@ class KeyMap { const DEFAULT_RESULT_COUNT = 10; const DEFAULT_DISTANCE = 5; -export default class FuzzyMatcher { +// FIXME Until Fuzzy matching works better, we use prefix matching. + +import PrefixMatcher from './QueryMatcher'; +export default PrefixMatcher; + +class FuzzyMatcher { /** * Given an array of objects and keys, returns a KeyMap * Keys can refer to object properties by name and as in JavaScript (for nested properties) diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js new file mode 100644 index 0000000000..b4c27a7179 --- /dev/null +++ b/src/autocomplete/QueryMatcher.js @@ -0,0 +1,62 @@ +//@flow + +import _at from 'lodash/at'; +import _flatMap from 'lodash/flatMap'; +import _sortBy from 'lodash/sortBy'; +import _sortedUniq from 'lodash/sortedUniq'; +import _keys from 'lodash/keys'; + +class KeyMap { + keys: Array; + objectMap: {[String]: Array}; + priorityMap = new Map(); +} + +export default class QueryMatcher { + /** + * Given an array of objects and keys, returns a KeyMap + * Keys can refer to object properties by name and as in JavaScript (for nested properties) + * + * To use, simply presort objects by required criteria, run through this function and create a QueryMatcher with the + * resulting KeyMap. + * + * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it) + */ + static valuesToKeyMap(objects: Array, keys: Array): KeyMap { + const keyMap = new KeyMap(); + const map = {}; + + objects.forEach((object, i) => { + const keyValues = _at(object, keys); + for (const keyValue of keyValues) { + if (!map.hasOwnProperty(keyValue)) { + map[keyValue] = []; + } + map[keyValue].push(object); + } + keyMap.priorityMap.set(object, i); + }); + + keyMap.objectMap = map; + keyMap.keys = _keys(map); + return keyMap; + } + + constructor(objects: Array, options: {[Object]: Object} = {}) { + this.options = options; + this.keys = options.keys; + this.setObjects(objects); + } + + setObjects(objects: Array) { + this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys); + } + + match(query: String): Array { + query = query.toLowerCase().replace(/[^\w]/g, ''); + const results = _sortedUniq(_sortBy(_flatMap(this.keyMap.keys, (key) => { + return key.toLowerCase().replace(/[^\w]/g, '').indexOf(query) >= 0 ? this.keyMap.objectMap[key] : []; + }), (candidate) => this.keyMap.priorityMap.get(candidate))); + return results; + } +} diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index b65439181c..589dfec9fa 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -1,20 +1,27 @@ +//@flow import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import {PillCompletion} from './Components'; import sdk from '../index'; import FuzzyMatcher from './FuzzyMatcher'; +import _pull from 'lodash/pull'; +import _sortBy from 'lodash/sortBy'; +import MatrixClientPeg from '../MatrixClientPeg'; + +import type {Room, RoomMember} from 'matrix-js-sdk'; const USER_REGEX = /@\S*/g; let instance = null; export default class UserProvider extends AutocompleteProvider { + users: Array = []; + constructor() { super(USER_REGEX, { keys: ['name', 'userId'], }); - this.users = []; this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], }); @@ -53,8 +60,30 @@ export default class UserProvider extends AutocompleteProvider { return '👥 Users'; } - setUserList(users) { - this.users = users; + setUserListFromRoom(room: Room) { + const events = room.getLiveTimeline().getEvents(); + const lastSpoken = {}; + + for(const event of events) { + lastSpoken[event.getSender()] = event.getTs(); + } + + const currentUserId = MatrixClientPeg.get().credentials.userId; + this.users = room.getJoinedMembers().filter((member) => { + if (member.userId !== currentUserId) return true; + }); + + this.users = _sortBy(this.users, (user) => 1E20 - lastSpoken[user.userId] || 1E20); + + this.matcher.setObjects(this.users); + } + + onUserSpoke(user: RoomMember) { + if(user.userId === MatrixClientPeg.get().credentials.userId) return; + + // Probably unsafe to compare by reference here? + _pull(this.users, user); + this.users.splice(0, 0, user); this.matcher.setObjects(this.users); } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 696d15f84a..936d88c0ee 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -225,7 +225,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().credentials.userId, 'join' ); - this._updateAutoComplete(); + UserProvider.getInstance().setUserListFromRoom(this.state.room); this.tabComplete.loadEntries(this.state.room); } @@ -479,8 +479,7 @@ module.exports = React.createClass({ // and that has probably just changed if (ev.sender) { this.tabComplete.onMemberSpoke(ev.sender); - // nb. we don't need to update the new autocomplete here since - // its results are currently ordered purely by search score. + UserProvider.getInstance().onUserSpoke(ev.sender); } }, @@ -658,7 +657,7 @@ module.exports = React.createClass({ // refresh the tab complete list this.tabComplete.loadEntries(this.state.room); - this._updateAutoComplete(); + UserProvider.getInstance().setUserListFromRoom(this.state.room); // if we are now a member of the room, where we were not before, that // means we have finished joining a room we were previously peeking @@ -1437,14 +1436,6 @@ module.exports = React.createClass({ } }, - _updateAutoComplete: function() { - const myUserId = MatrixClientPeg.get().credentials.userId; - const members = this.state.room.getJoinedMembers().filter(function(member) { - if (member.userId !== myUserId) return true; - }); - UserProvider.getInstance().setUserList(members); - }, - render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer'); From e65744abdce812b649302f887779c1866f3746c6 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Feb 2017 23:35:13 +0530 Subject: [PATCH 12/37] fix EmojiProvider for new QueryMatcher --- src/autocomplete/EmojiProvider.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 52bc47e7b6..e613f41c52 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -7,14 +7,20 @@ import {PillCompletion} from './Components'; import type {SelectionRange, Completion} from './Autocompleter'; const EMOJI_REGEX = /:\w*:?/g; -const EMOJI_SHORTNAMES = Object.keys(emojioneList); +const EMOJI_SHORTNAMES = Object.keys(emojioneList).map(shortname => { + return { + shortname, + }; +}); let instance = null; export default class EmojiProvider extends AutocompleteProvider { constructor() { super(EMOJI_REGEX); - this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES); + this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, { + keys: 'shortname', + }); } async getCompletions(query: string, selection: SelectionRange) { @@ -24,7 +30,7 @@ export default class EmojiProvider extends AutocompleteProvider { let {command, range} = this.getCurrentCommand(query, selection); if (command) { completions = this.matcher.match(command[0]).map(result => { - const shortname = EMOJI_SHORTNAMES[result]; + const {shortname} = result; const unicode = shortnameToUnicode(shortname); return { completion: unicode, From 2d39b2533487a266ddce7bf2a3e8c80681afc146 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Feb 2017 23:44:04 +0530 Subject: [PATCH 13/37] turn off force complete when editor content changes --- src/components/views/rooms/Autocomplete.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index c06786a80c..bd43b3a85e 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -75,11 +75,11 @@ export default class Autocomplete extends React.Component { } else { selectionOffset++; // selectionOffset is 1-indexed! } - } else { - // If no completions were returned, we should turn off force completion. - forceComplete = false; } + // If no completions were returned, we should turn off force completion. + forceComplete = false; + let hide = this.state.hide; // These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern const oldMatches = this.state.completions.map((completion) => !!completion.command.command), From 32dd89774e78a907215ef3e317faac9e0400206c Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Mon, 20 Feb 2017 19:26:40 +0530 Subject: [PATCH 14/37] add support for autocomplete delay --- src/UserSettingsStore.js | 4 ++-- src/autocomplete/Autocompleter.js | 2 +- src/components/structures/UserSettings.js | 9 +++++++++ src/components/views/rooms/Autocomplete.js | 15 ++++++++++++--- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 66a872958c..0ee78b4f2e 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -139,7 +139,7 @@ module.exports = { getSyncedSetting: function(type, defaultValue = null) { var settings = this.getSyncedSettings(); - return settings.hasOwnProperty(type) ? settings[type] : null; + return settings.hasOwnProperty(type) ? settings[type] : defaultValue; }, setSyncedSetting: function(type, value) { @@ -156,7 +156,7 @@ module.exports = { getLocalSetting: function(type, defaultValue = null) { var settings = this.getLocalSettings(); - return settings.hasOwnProperty(type) ? settings[type] : null; + return settings.hasOwnProperty(type) ? settings[type] : defaultValue; }, setLocalSetting: function(type, value) { diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 1bf1b1dc14..2906a5a0f7 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -43,7 +43,7 @@ export async function getCompletions(query: string, selection: SelectionRange, f PROVIDERS.map(provider => { return Q(provider.getCompletions(query, selection, force)) .timeout(PROVIDER_COMPLETION_TIMEOUT); - }) + }), ); return completionsList diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 10ffbca0d3..5ab69e1a15 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -508,6 +508,15 @@ module.exports = React.createClass({ { this._renderUrlPreviewSelector() } { SETTINGS_LABELS.map( this._renderSyncedSetting ) } { THEMES.map( this._renderThemeSelector ) } + + + + + + + +
Autocomplete Delay (ms): UserSettingsStore.setLocalSetting('autocompleteDelay', +e.target.value)} />
); diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index bd43b3a85e..09b13e8076 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -6,6 +6,7 @@ import isEqual from 'lodash/isEqual'; import sdk from '../../../index'; import type {Completion, SelectionRange} from '../../../autocomplete/Autocompleter'; import Q from 'q'; +import UserSettingsStore from '../../../UserSettingsStore'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -77,9 +78,6 @@ export default class Autocomplete extends React.Component { } } - // If no completions were returned, we should turn off force completion. - forceComplete = false; - let hide = this.state.hide; // These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern const oldMatches = this.state.completions.map((completion) => !!completion.command.command), @@ -90,6 +88,17 @@ export default class Autocomplete extends React.Component { hide = false; } + const autocompleteDelay = UserSettingsStore.getSyncedSetting('autocompleteDelay', 200); + + // We had no completions before, but do now, so we should apply our display delay here + if (this.state.completionList.length === 0 && completionList.length > 0 && + !forceComplete && autocompleteDelay > 0) { + await Q.delay(autocompleteDelay); + } + + // Force complete is turned off each time since we can't edit the query in that case + forceComplete = false; + this.setState({ completions, completionList, From 3a07fc1601ae8b6e35cc45632403650b4e8ece17 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Wed, 22 Feb 2017 02:51:57 +0530 Subject: [PATCH 15/37] fix code-block for markdown mode --- src/components/views/rooms/MessageComposerInput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 7908d7f375..af5627273c 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -485,7 +485,7 @@ export default class MessageComposerInput extends React.Component { 'italic': (text) => `*${text}*`, 'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* 'strike': (text) => `~~${text}~~`, - 'code': (text) => `\`${text}\``, + 'code-block': (text) => `\`\`\`\n${text}\n\`\`\``, 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''), 'unordered-list-item': (text) => text.split('\n').map((line) => `- ${line}\n`).join(''), 'ordered-list-item': (text) => text.split('\n').map((line, i) => `${i + 1}. ${line}\n`).join(''), From feac919c0a527f440d23bfaf25099659eb31e675 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Wed, 22 Feb 2017 03:10:15 +0530 Subject: [PATCH 16/37] fix rendering of UNDERLINE inline style in RTE --- src/RichText.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/RichText.js b/src/RichText.js index e662c22d6a..219af472e8 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -30,7 +30,15 @@ const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); -export const contentStateToHTML = stateToHTML; +export const contentStateToHTML = (contentState: ContentState) => { + return stateToHTML(contentState, { + inlineStyles: { + UNDERLINE: { + element: 'u' + } + } + }); +}; export function HTMLtoContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); From 9946cadc2d3fe62c71959ceb89ed915961cdebb9 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 7 Mar 2017 04:08:06 +0530 Subject: [PATCH 17/37] autocomplete: fix RoomProvider regression --- src/autocomplete/RoomProvider.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 8659b8501f..726d28db88 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -14,7 +14,7 @@ export default class RoomProvider extends AutocompleteProvider { constructor() { super(ROOM_REGEX); this.matcher = new FuzzyMatcher([], { - keys: ['name', 'aliases'], + keys: ['name', 'roomId', 'aliases'], }); } @@ -26,7 +26,7 @@ export default class RoomProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - this.matcher.setObjects(client.getRooms().filter(room => !!room).map(room => { + this.matcher.setObjects(client.getRooms().filter(room => !!room && !!getDisplayAliasForRoom(room)).map(room => { return { room: room, name: room.name, From f5b52fb48844c22d61dff3475b3519bd5dd4acd6 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 7 Mar 2017 04:15:28 +0530 Subject: [PATCH 18/37] rte: change list behaviour in markdown mode --- src/components/views/rooms/MessageComposerInput.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index af5627273c..5d9496e78d 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -487,8 +487,8 @@ export default class MessageComposerInput extends React.Component { 'strike': (text) => `~~${text}~~`, 'code-block': (text) => `\`\`\`\n${text}\n\`\`\``, 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''), - 'unordered-list-item': (text) => text.split('\n').map((line) => `- ${line}\n`).join(''), - 'ordered-list-item': (text) => text.split('\n').map((line, i) => `${i + 1}. ${line}\n`).join(''), + 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''), + 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''), }[command]; if (modifyFn) { From 79f481f81e8b9d1d11535f91f5b8da5d19006d7e Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 7 Mar 2017 04:39:38 +0530 Subject: [PATCH 19/37] rte: special return handling for some block types --- src/components/views/rooms/MessageComposerInput.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 5d9496e78d..e3063babb1 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -513,9 +513,16 @@ export default class MessageComposerInput extends React.Component { }; handleReturn = (ev) => { - if (ev.shiftKey) { - this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); - return true; + const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); + // If we're in any of these three types of blocks, shift enter should insert soft newlines + // And just enter should end the block + if(['blockquote', 'unordered-list-item', 'ordered-list-item'].includes(currentBlockType)) { + if(ev.shiftKey) { + this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); + return true; + } + + return false; } const contentState = this.state.editorState.getCurrentContent(); From b977b559de6e369adbb55fd3de5a929c01c394c6 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 7 Mar 2017 04:46:55 +0530 Subject: [PATCH 20/37] autocomplete: add missing commands to CommandProvider --- src/autocomplete/CommandProvider.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 8f98bf1aa5..a30af5674d 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -9,11 +9,21 @@ const COMMANDS = [ args: '', description: 'Displays action', }, + { + command: '/part', + args: '[#alias:domain]', + description: 'Leave room', + }, { command: '/ban', args: ' [reason]', description: 'Bans user with given id', }, + { + command: '/unban', + args: '', + description: 'Unbans user with given id', + }, { command: '/deop', args: '', @@ -43,6 +53,11 @@ const COMMANDS = [ command: '/ddg', args: '', description: 'Searches DuckDuckGo for results', + }, + { + command: '/op', + args: ' []', + description: 'Define the power level of a user', } ]; From 6004f6d6107dbdafcd70295715040cef6c4a3109 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Mar 2017 20:34:31 +0530 Subject: [PATCH 21/37] rte: fix history --- .eslintrc.js | 2 +- src/ComposerHistoryManager.js | 63 +++++++ src/RichText.js | 10 ++ .../views/rooms/MessageComposerInput.js | 155 ++++-------------- 4 files changed, 108 insertions(+), 122 deletions(-) create mode 100644 src/ComposerHistoryManager.js diff --git a/.eslintrc.js b/.eslintrc.js index 6cd0e1015e..74790a2964 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -64,7 +64,7 @@ module.exports = { // to JSX. ignorePattern: '^\\s*<', ignoreComments: true, - code: 90, + code: 120, }], "valid-jsdoc": ["warn"], "new-cap": ["warn"], diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js new file mode 100644 index 0000000000..5f9cf04e6f --- /dev/null +++ b/src/ComposerHistoryManager.js @@ -0,0 +1,63 @@ +//@flow + +import {ContentState} from 'draft-js'; +import * as RichText from './RichText'; +import Markdown from './Markdown'; +import _flow from 'lodash/flow'; +import _clamp from 'lodash/clamp'; + +type MessageFormat = 'html' | 'markdown'; + +class HistoryItem { + message: string = ''; + format: MessageFormat = 'html'; + + constructor(message: string, format: MessageFormat) { + this.message = message; + this.format = format; + } + + toContentState(format: MessageFormat): ContentState { + let {message} = this; + if (format === 'markdown') { + if (this.format === 'html') { + message = _flow([RichText.HTMLtoContentState, RichText.stateToMarkdown])(message); + } + return ContentState.createFromText(message); + } else { + if (this.format === 'markdown') { + message = new Markdown(message).toHTML(); + } + return RichText.HTMLtoContentState(message); + } + } +} + +export default class ComposerHistoryManager { + history: Array = []; + prefix: string; + lastIndex: number = 0; + currentIndex: number = -1; + + constructor(roomId: string, prefix: string = 'mx_composer_history_') { + this.prefix = prefix + roomId; + + // TODO: Performance issues? + for(; sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`); this.lastIndex++, this.currentIndex++) { + history.push(JSON.parse(sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`))); + } + } + + addItem(message: string, format: MessageFormat) { + const item = new HistoryItem(message, format); + this.history.push(item); + this.currentIndex = this.lastIndex; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item)); + } + + getItem(offset: number, format: MessageFormat): ?ContentState { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1); + const item = this.history[this.currentIndex]; + return item ? item.toContentState(format) : null; + } +} diff --git a/src/RichText.js b/src/RichText.js index 219af472e8..6edde23129 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -16,6 +16,7 @@ import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; import {SelectionRange} from "./autocomplete/Autocompleter"; +import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; const MARKDOWN_REGEX = { LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, @@ -30,6 +31,15 @@ const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); +const ZWS_CODE = 8203; +const ZWS = String.fromCharCode(ZWS_CODE); // zero width space +export function stateToMarkdown(state) { + return __stateToMarkdown(state) + .replace( + ZWS, // draft-js-export-markdown adds these + ''); // this is *not* a zero width space, trust me :) +} + export const contentStateToHTML = (contentState: ContentState) => { return stateToHTML(contentState, { inlineStyles: { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index e3063babb1..33f184c446 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -20,7 +20,6 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, convertFromRaw, convertToRaw, Modifier, EditorChangeType, getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; -import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; import classNames from 'classnames'; import escape from 'lodash/escape'; import Q from 'q'; @@ -40,21 +39,13 @@ import * as HtmlUtils from '../../../HtmlUtils'; import Autocomplete from './Autocomplete'; import {Completion} from "../../../autocomplete/Autocompleter"; import Markdown from '../../../Markdown'; +import ComposerHistoryManager from '../../../ComposerHistoryManager'; import {onSendMessageFailed} from './MessageComposerInputOld'; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const KEY_M = 77; -const ZWS_CODE = 8203; -const ZWS = String.fromCharCode(ZWS_CODE); // zero width space -function stateToMarkdown(state) { - return __stateToMarkdown(state) - .replace( - ZWS, // draft-js-export-markdown adds these - ''); // this is *not* a zero width space, trust me :) -} - /* * The textInput part of the MessageComposer */ @@ -101,6 +92,7 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; + historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); @@ -145,110 +137,13 @@ export default class MessageComposerInput extends React.Component { return EditorState.moveFocusToEnd(editorState); } - componentWillMount() { - const component = this; - this.sentHistory = { - // The list of typed messages. Index 0 is more recent - data: [], - // The position in data currently displayed - position: -1, - // The room the history is for. - roomId: null, - // The original text before they hit UP - originalText: null, - // The textarea element to set text to. - element: null, - - init: function(element, roomId) { - this.roomId = roomId; - this.element = element; - this.position = -1; - const storedData = window.sessionStorage.getItem( - "mx_messagecomposer_history_" + roomId, - ); - if (storedData) { - this.data = JSON.parse(storedData); - } - if (this.roomId) { - this.setLastTextEntry(); - } - }, - - push: function(text) { - // store a message in the sent history - this.data.unshift(text); - window.sessionStorage.setItem( - "mx_messagecomposer_history_" + this.roomId, - JSON.stringify(this.data), - ); - // reset history position - this.position = -1; - this.originalText = null; - }, - - // move in the history. Returns true if we managed to move. - next: function(offset) { - if (this.position === -1) { - // user is going into the history, save the current line. - this.originalText = this.element.value; - } else { - // user may have modified this line in the history; remember it. - this.data[this.position] = this.element.value; - } - - if (offset > 0 && this.position === (this.data.length - 1)) { - // we've run out of history - return false; - } - - // retrieve the next item (bounded). - let newPosition = this.position + offset; - newPosition = Math.max(-1, newPosition); - newPosition = Math.min(newPosition, this.data.length - 1); - this.position = newPosition; - - if (this.position !== -1) { - // show the message - this.element.value = this.data[this.position]; - } else if (this.originalText !== undefined) { - // restore the original text the user was typing. - this.element.value = this.originalText; - } - - return true; - }, - - saveLastTextEntry: function() { - // save the currently entered text in order to restore it later. - // NB: This isn't 'originalText' because we want to restore - // sent history items too! - const contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent())); - window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON); - }, - - setLastTextEntry: function() { - const contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); - if (contentJSON) { - const content = convertFromRaw(JSON.parse(contentJSON)); - component.setState({ - editorState: component.createEditorState(component.state.isRichtextEnabled, content), - }); - } - }, - }; - } - componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - this.sentHistory.init( - this.refs.editor, - this.props.room.roomId, - ); + this.historyManager = new ComposerHistoryManager(this.props.room.roomId); } componentWillUnmount() { dis.unregister(this.dispatcherRef); - this.sentHistory.saveLastTextEntry(); } componentWillUpdate(nextProps, nextState) { @@ -290,7 +185,7 @@ export default class MessageComposerInput extends React.Component { if (formatted_body) { let content = RichText.HTMLtoContentState(`
${formatted_body}
`); if (!this.state.isRichtextEnabled) { - content = ContentState.createFromText(stateToMarkdown(content)); + content = ContentState.createFromText(RichText.stateToMarkdown(content)); } const blockMap = content.getBlockMap(); @@ -414,8 +309,6 @@ export default class MessageComposerInput extends React.Component { } } - console.log(state); - super.setState(state, () => { if (callback != null) { callback(); @@ -434,12 +327,14 @@ export default class MessageComposerInput extends React.Component { } enableRichtext(enabled: boolean) { + if (enabled === this.state.isRichtextEnabled) return; + let contentState = null; if (enabled) { const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); contentState = RichText.HTMLtoContentState(md.toHTML()); } else { - let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); + let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent()); if (markdown[markdown.length - 1] === '\n') { markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) } @@ -513,15 +408,15 @@ export default class MessageComposerInput extends React.Component { }; handleReturn = (ev) => { + if(ev.shiftKey) { + this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); + return true; + } + const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); // If we're in any of these three types of blocks, shift enter should insert soft newlines // And just enter should end the block if(['blockquote', 'unordered-list-item', 'ordered-list-item'].includes(currentBlockType)) { - if(ev.shiftKey) { - this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); - return true; - } - return false; } @@ -586,8 +481,10 @@ export default class MessageComposerInput extends React.Component { sendTextFn = this.client.sendEmoteMessage; } - // XXX: We don't actually seem to use this history? - this.sentHistory.push(contentHTML || contentText); + this.historyManager.addItem( + this.state.isRichtextEnabled ? contentHTML : contentState.getPlainText(), + this.state.isRichtextEnabled ? 'html' : 'markdown'); + let sendMessagePromise; if (contentHTML) { sendMessagePromise = sendHtmlFn.call( @@ -614,14 +511,30 @@ export default class MessageComposerInput extends React.Component { onUpArrow = async (e) => { const completion = this.autocomplete.onUpArrow(); - if (completion != null) { - e.preventDefault(); + if (completion == null) { + const newContent = this.historyManager.getItem(-1, this.state.isRichtextEnabled ? 'html' : 'markdown'); + if (!newContent) return false; + const editorState = EditorState.push(this.state.editorState, + newContent, + 'insert-characters'); + this.setState({editorState}); + return true; } + e.preventDefault(); return await this.setDisplayedCompletion(completion); }; onDownArrow = async (e) => { const completion = this.autocomplete.onDownArrow(); + if (completion == null) { + const newContent = this.historyManager.getItem(+1, this.state.isRichtextEnabled ? 'html' : 'markdown'); + if (!newContent) return false; + const editorState = EditorState.push(this.state.editorState, + newContent, + 'insert-characters'); + this.setState({editorState}); + return true; + } e.preventDefault(); return await this.setDisplayedCompletion(completion); }; From 8dc7f8efe29c2bd796f17c21c41c89a4d6fd858f Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 10 Mar 2017 21:10:27 +0530 Subject: [PATCH 22/37] rte: remove logging and fix new history --- src/ComposerHistoryManager.js | 10 ++++++++-- src/components/views/rooms/Autocomplete.js | 1 - src/components/views/rooms/MessageComposerInput.js | 3 --- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js index 5f9cf04e6f..face75ea8a 100644 --- a/src/ComposerHistoryManager.js +++ b/src/ComposerHistoryManager.js @@ -44,14 +44,20 @@ export default class ComposerHistoryManager { // TODO: Performance issues? for(; sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`); this.lastIndex++, this.currentIndex++) { - history.push(JSON.parse(sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`))); + this.history.push( + Object.assign( + new HistoryItem(), + JSON.parse(sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`)), + ), + ); } + this.currentIndex--; } addItem(message: string, format: MessageFormat) { const item = new HistoryItem(message, format); this.history.push(item); - this.currentIndex = this.lastIndex; + this.currentIndex = this.lastIndex + 1; sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item)); } diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 09b13e8076..5329cde8f2 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -197,7 +197,6 @@ export default class Autocomplete extends React.Component { setState(state, func) { super.setState(state, func); - console.log(state); } render() { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 33f184c446..2a0a62ebf7 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -320,7 +320,6 @@ export default class MessageComposerInput extends React.Component { const selection = RichText.selectionStateToTextOffsets( this.state.editorState.getSelection(), this.state.editorState.getCurrentContent().getBlocksAsArray()); - console.log(textContent); this.props.onContentChanged(textContent, selection); } }); @@ -541,7 +540,6 @@ export default class MessageComposerInput extends React.Component { // tab and shift-tab are mapped to down and up arrow respectively onTab = async (e) => { - console.log('onTab'); e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes if (this.autocomplete.state.completionList.length === 0) { await this.autocomplete.forceComplete(); @@ -567,7 +565,6 @@ export default class MessageComposerInput extends React.Component { if (displayedCompletion == null) { if (this.state.originalEditorState) { - console.log('setting editorState to originalEditorState'); let editorState = this.state.originalEditorState; // This is a workaround from https://github.com/facebook/draft-js/issues/458 // Due to the way we swap editorStates, Draft does not rerender at times From f5a23c14df83c8c8bc91c0daa7ded05c73fbb71c Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Mon, 8 May 2017 17:32:26 +0100 Subject: [PATCH 23/37] Remove redundant bind --- src/components/views/rooms/MessageComposerInput.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 2d16b202d1..7d52d87dbf 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -101,7 +101,6 @@ export default class MessageComposerInput extends React.Component { this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handlePastedFiles = this.handlePastedFiles.bind(this); this.onEditorContentChanged = this.onEditorContentChanged.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); From 8f3eb89f8b0fc7568b4f5c087818fe2beac52748 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 23 Jun 2017 10:48:21 +0100 Subject: [PATCH 24/37] Fix potential race in setting client listeners --- src/components/structures/MatrixChat.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0992d486f6..b618caa1c9 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -548,7 +548,12 @@ module.exports = React.createClass({ this._onLoggedOut(); break; case 'will_start_client': - this._onWillStartClient(); + this.setState({ready: false}, () => { + // if the client is about to start, we are, by definition, not ready. + // Set ready to false now, then it'll be set to true when the sync + // listener we set below fires. + this._onWillStartClient(); + }); break; case 'new_version': this.onVersion( @@ -1012,10 +1017,6 @@ module.exports = React.createClass({ */ _onWillStartClient() { const self = this; - // if the client is about to start, we are, by definition, not ready. - // Set ready to false now, then it'll be set to true when the sync - // listener we set below fires. - this.setState({ready: false}); // reset the 'have completed first sync' flag, // since we're about to start the client and therefore about From f5353fcdc54a09cf94e2fca51a70d2733acf24f3 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 23 Jun 2017 13:43:52 +0100 Subject: [PATCH 25/37] Only submit phone number when phone loginType is selected Otherwise submit a phoneNumber and phoneCountry of `null` (when logging in with email or username). Fixes https://github.com/vector-im/riot-web/issues/4000 --- src/components/views/login/PasswordLogin.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 46a48d14a0..54237bec19 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -69,10 +69,19 @@ class PasswordLogin extends React.Component { onSubmitForm(ev) { ev.preventDefault(); + if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) { + this.props.onSubmit( + this.state.username, + this.state.phoneCountry, + this.state.phoneNumber, + this.state.password, + ); + return; + } this.props.onSubmit( this.state.username, - this.state.phoneCountry, - this.state.phoneNumber, + null, + null, this.state.password, ); } From c51255da40f2cf69ca1a2d9dc54189621a9a4958 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 23 Jun 2017 14:34:19 +0100 Subject: [PATCH 26/37] Submit empty string username when on phone number login --- src/components/views/login/PasswordLogin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 54237bec19..2c125be378 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -71,7 +71,7 @@ class PasswordLogin extends React.Component { ev.preventDefault(); if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) { this.props.onSubmit( - this.state.username, + '', this.state.phoneCountry, this.state.phoneNumber, this.state.password, From ec36a348be008c84fbd13b80d2c2b2eeab983d4c Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 23 Jun 2017 14:48:15 +0100 Subject: [PATCH 27/37] comment why we send the empty string --- src/components/views/login/PasswordLogin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 2c125be378..9f855616fc 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -71,7 +71,7 @@ class PasswordLogin extends React.Component { ev.preventDefault(); if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) { this.props.onSubmit( - '', + '', // XXX: Synapse breaks if you send null here: this.state.phoneCountry, this.state.phoneNumber, this.state.password, From 738f261d5da568c7b2c656a9885f0de3b490da68 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 23 Jun 2017 16:14:22 +0100 Subject: [PATCH 28/37] Add missing translations for RTE ops --- src/i18n/strings/en_EN.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a4dcb2873f..ced98127a2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -240,6 +240,7 @@ "demote": "demote", "Deops user with given id": "Deops user with given id", "Default": "Default", + "Define the power level of a user": "Define the power level of a user", "Device already verified!": "Device already verified!", "Device ID": "Device ID", "Device ID:": "Device ID:", @@ -581,6 +582,7 @@ "Unable to restore previous session": "Unable to restore previous session", "Unable to verify email address.": "Unable to verify email address.", "Unban": "Unban", + "Unbans user with given id": "Unbans user with given id", "%(senderName)s unbanned %(targetName)s.": "%(senderName)s unbanned %(targetName)s.", "Unable to ascertain that the address this invite was sent to matches one associated with your account.": "Unable to ascertain that the address this invite was sent to matches one associated with your account.", "Unable to capture screen": "Unable to capture screen", From 004d4828f883ddbd30ac0c8f9dd52e68ad2a5bb4 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 23 Jun 2017 17:08:37 +0100 Subject: [PATCH 29/37] Make the tests pass sendTextMessage is not called when RTE Markdown is enabled, but rather sendHtmlMessage --- test/components/views/rooms/MessageComposerInput-test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/components/views/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js index 67e788e2eb..e2e2836a50 100644 --- a/test/components/views/rooms/MessageComposerInput-test.js +++ b/test/components/views/rooms/MessageComposerInput-test.js @@ -99,17 +99,18 @@ describe('MessageComposerInput', () => { }); it('should not change content unnecessarily on Markdown -> RTE conversion', () => { - const spy = sinon.spy(client, 'sendTextMessage'); + const spy = sinon.spy(client, 'sendHtmlMessage'); mci.enableRichtext(false); addTextToDraft('a'); mci.handleKeyCommand('toggle-mode'); mci.handleReturn(sinon.stub()); + expect(spy.calledOnce).toEqual(true); expect(spy.args[0][1]).toEqual('a'); }); it('should send emoji messages in rich text', () => { - const spy = sinon.spy(client, 'sendTextMessage'); + const spy = sinon.spy(client, 'sendHtmlMessage'); mci.enableRichtext(true); addTextToDraft('☹'); mci.handleReturn(sinon.stub()); From 89afcfd897775e8a0b664c2e8782ba85be814adf Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 23 Jun 2017 17:35:07 +0100 Subject: [PATCH 30/37] Linting --- src/ComposerHistoryManager.js | 4 ++-- src/RichText.js | 2 +- src/autocomplete/CommandProvider.js | 4 ++-- src/autocomplete/FuzzyMatcher.js | 14 ++++++++------ src/autocomplete/QueryMatcher.js | 4 +++- src/components/structures/UserSettings.js | 8 +++++--- src/components/views/rooms/MessageComposerInput.js | 4 ++-- 7 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js index face75ea8a..ef9232c684 100644 --- a/src/ComposerHistoryManager.js +++ b/src/ComposerHistoryManager.js @@ -21,14 +21,14 @@ class HistoryItem { let {message} = this; if (format === 'markdown') { if (this.format === 'html') { - message = _flow([RichText.HTMLtoContentState, RichText.stateToMarkdown])(message); + message = _flow([RichText.htmlToContentState, RichText.stateToMarkdown])(message); } return ContentState.createFromText(message); } else { if (this.format === 'markdown') { message = new Markdown(message).toHTML(); } - return RichText.HTMLtoContentState(message); + return RichText.htmlToContentState(message); } } } diff --git a/src/RichText.js b/src/RichText.js index 6edde23129..f2f2d533a8 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -50,7 +50,7 @@ export const contentStateToHTML = (contentState: ContentState) => { }); }; -export function HTMLtoContentState(html: string): ContentState { +export function htmlToContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index c54d0fd49e..9ae3a7badb 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -77,7 +77,7 @@ const COMMANDS = [ command: '/op', args: ' []', description: 'Define the power level of a user', - } + }, ]; const COMMAND_RE = /(^\/\w*)/g; @@ -96,7 +96,7 @@ export default class CommandProvider extends AutocompleteProvider { let completions = []; const {command, range} = this.getCurrentCommand(query, selection); if (command) { - completions = this.matcher.match(command[0]).map(result => { + completions = this.matcher.match(command[0]).map((result) => { return { completion: result.command + ' ', component: (, keys: Array): KeyMap { const keyMap = new KeyMap(); @@ -48,7 +50,7 @@ class FuzzyMatcher { keyMap.objectMap = map; keyMap.priorityMap = priorities; - keyMap.keys = _sortBy(_keys(map), [value => priorities[value]]); + keyMap.keys = _sortBy(_keys(map), [(value) => priorities[value]]); return keyMap; } @@ -74,15 +76,15 @@ class FuzzyMatcher { match(query: String): Array { const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE); // TODO FIXME This is hideous. Clean up when possible. - const val = _sortedUniq(_sortBy(_flatMap(candidates, candidate => { - return this.keyMap.objectMap[candidate[0]].map(value => { + const val = _sortedUniq(_sortBy(_flatMap(candidates, (candidate) => { + return this.keyMap.objectMap[candidate[0]].map((value) => { return { distance: candidate[1], ...value, }; }); }), - [candidate => candidate.distance, candidate => this.keyMap.priorityMap[candidate]])); + [(candidate) => candidate.distance, (candidate) => this.keyMap.priorityMap[candidate]])); console.log(val); return val; } diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js index b4c27a7179..ead7ea8047 100644 --- a/src/autocomplete/QueryMatcher.js +++ b/src/autocomplete/QueryMatcher.js @@ -14,13 +14,15 @@ class KeyMap { export default class QueryMatcher { /** - * Given an array of objects and keys, returns a KeyMap + * @param {object[]} objects the objects to perform a match on + * @param {string[]} keys an array of keys within each object to match on * Keys can refer to object properties by name and as in JavaScript (for nested properties) * * To use, simply presort objects by required criteria, run through this function and create a QueryMatcher with the * resulting KeyMap. * * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it) + * @return {KeyMap} */ static valuesToKeyMap(objects: Array, keys: Array): KeyMap { const keyMap = new KeyMap(); diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index d31bd7fc7c..9171b081ab 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -642,6 +642,10 @@ module.exports = React.createClass({ }, _renderUserInterfaceSettings: function() { + // TODO: this ought to be a separate component so that we don't need + // to rebind the onChange each time we render + const onChange = (e) => + UserSettingsStore.setLocalSetting('autocompleteDelay', + e.target.value); return (

{ _t("User Interface") }

@@ -657,9 +661,7 @@ module.exports = React.createClass({ UserSettingsStore.setLocalSetting('autocompleteDelay', + e.target.value) - } + onChange={onChange} /> diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 09183bfab6..4d7c0f7a80 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -201,7 +201,7 @@ export default class MessageComposerInput extends React.Component { let {body, formatted_body} = payload.event.getContent(); formatted_body = formatted_body || escape(body); if (formatted_body) { - let content = RichText.HTMLtoContentState(`
${formatted_body}
`); + let content = RichText.htmlToContentState(`
${formatted_body}
`); if (!this.state.isRichtextEnabled) { content = ContentState.createFromText(RichText.stateToMarkdown(content)); } @@ -350,7 +350,7 @@ export default class MessageComposerInput extends React.Component { let contentState = null; if (enabled) { const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); - contentState = RichText.HTMLtoContentState(md.toHTML()); + contentState = RichText.htmlToContentState(md.toHTML()); } else { let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent()); if (markdown[markdown.length - 1] === '\n') { From ddb84f034e5d435769e5ba11afaa5b145871f4d9 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 23 Jun 2017 17:52:50 +0100 Subject: [PATCH 31/37] Update tab-complete state onRoom received after joining As opposed to doing it when the component mounts. Fixes https://github.com/vector-im/riot-web/issues/3700 (hopefully) --- src/components/structures/RoomView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index da9778cd12..67b523bfaf 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -234,8 +234,6 @@ module.exports = React.createClass({ // making it impossible to indicate a newly joined room. const room = this.state.room; if (room) { - UserProvider.getInstance().setUserListFromRoom(room); - this.tabComplete.loadEntries(room); this.setState({ unsentMessageError: this._getUnsentMessageError(room), }); @@ -523,6 +521,8 @@ module.exports = React.createClass({ this._warnAboutEncryption(room); this._calculatePeekRules(room); this._updatePreviewUrlVisibility(room); + this.tabComplete.loadEntries(room); + UserProvider.getInstance().setUserListFromRoom(room); }, _warnAboutEncryption: function(room) { From c0e48c72fc51e2aea73d4d409bfe04e285610a61 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 23 Jun 2017 18:03:32 +0100 Subject: [PATCH 32/37] Remove dep on liblevenstein While we don't actually use it --- package.json | 1 - src/autocomplete/FuzzyMatcher.js | 170 +++++++++++++++---------------- 2 files changed, 85 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index 76323685ac..8d638a5928 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "glob": "^5.0.14", "highlight.js": "^8.9.1", "isomorphic-fetch": "^2.2.1", - "liblevenshtein": "^2.0.4", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js index 291e63d604..230cb1dbd2 100644 --- a/src/autocomplete/FuzzyMatcher.js +++ b/src/autocomplete/FuzzyMatcher.js @@ -1,91 +1,91 @@ -import Levenshtein from 'liblevenshtein'; -import _at from 'lodash/at'; -import _flatMap from 'lodash/flatMap'; -import _sortBy from 'lodash/sortBy'; -import _sortedUniq from 'lodash/sortedUniq'; -import _keys from 'lodash/keys'; - -class KeyMap { - keys: Array; - objectMap: {[String]: Array}; - priorityMap: {[String]: number} -} - -const DEFAULT_RESULT_COUNT = 10; -const DEFAULT_DISTANCE = 5; +//import Levenshtein from 'liblevenshtein'; +//import _at from 'lodash/at'; +//import _flatMap from 'lodash/flatMap'; +//import _sortBy from 'lodash/sortBy'; +//import _sortedUniq from 'lodash/sortedUniq'; +//import _keys from 'lodash/keys'; +// +//class KeyMap { +// keys: Array; +// objectMap: {[String]: Array}; +// priorityMap: {[String]: number} +//} +// +//const DEFAULT_RESULT_COUNT = 10; +//const DEFAULT_DISTANCE = 5; // FIXME Until Fuzzy matching works better, we use prefix matching. import PrefixMatcher from './QueryMatcher'; export default PrefixMatcher; -class FuzzyMatcher { // eslint-disable-line no-unused-vars - /** - * @param {object[]} objects the objects to perform a match on - * @param {string[]} keys an array of keys within each object to match on - * Keys can refer to object properties by name and as in JavaScript (for nested properties) - * - * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the - * resulting KeyMap. - * - * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it) - * @return {KeyMap} - */ - static valuesToKeyMap(objects: Array, keys: Array): KeyMap { - const keyMap = new KeyMap(); - const map = {}; - const priorities = {}; - - objects.forEach((object, i) => { - const keyValues = _at(object, keys); - console.log(object, keyValues, keys); - for (const keyValue of keyValues) { - if (!map.hasOwnProperty(keyValue)) { - map[keyValue] = []; - } - map[keyValue].push(object); - } - priorities[object] = i; - }); - - keyMap.objectMap = map; - keyMap.priorityMap = priorities; - keyMap.keys = _sortBy(_keys(map), [(value) => priorities[value]]); - return keyMap; - } - - constructor(objects: Array, options: {[Object]: Object} = {}) { - this.options = options; - this.keys = options.keys; - this.setObjects(objects); - } - - setObjects(objects: Array) { - this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys); - console.log(this.keyMap.keys); - this.matcher = new Levenshtein.Builder() - .dictionary(this.keyMap.keys, true) - .algorithm('transposition') - .sort_candidates(false) - .case_insensitive_sort(true) - .include_distance(true) - .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense - .build(); - } - - match(query: String): Array { - const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE); - // TODO FIXME This is hideous. Clean up when possible. - const val = _sortedUniq(_sortBy(_flatMap(candidates, (candidate) => { - return this.keyMap.objectMap[candidate[0]].map((value) => { - return { - distance: candidate[1], - ...value, - }; - }); - }), - [(candidate) => candidate.distance, (candidate) => this.keyMap.priorityMap[candidate]])); - console.log(val); - return val; - } -} +//class FuzzyMatcher { // eslint-disable-line no-unused-vars +// /** +// * @param {object[]} objects the objects to perform a match on +// * @param {string[]} keys an array of keys within each object to match on +// * Keys can refer to object properties by name and as in JavaScript (for nested properties) +// * +// * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the +// * resulting KeyMap. +// * +// * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it) +// * @return {KeyMap} +// */ +// static valuesToKeyMap(objects: Array, keys: Array): KeyMap { +// const keyMap = new KeyMap(); +// const map = {}; +// const priorities = {}; +// +// objects.forEach((object, i) => { +// const keyValues = _at(object, keys); +// console.log(object, keyValues, keys); +// for (const keyValue of keyValues) { +// if (!map.hasOwnProperty(keyValue)) { +// map[keyValue] = []; +// } +// map[keyValue].push(object); +// } +// priorities[object] = i; +// }); +// +// keyMap.objectMap = map; +// keyMap.priorityMap = priorities; +// keyMap.keys = _sortBy(_keys(map), [(value) => priorities[value]]); +// return keyMap; +// } +// +// constructor(objects: Array, options: {[Object]: Object} = {}) { +// this.options = options; +// this.keys = options.keys; +// this.setObjects(objects); +// } +// +// setObjects(objects: Array) { +// this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys); +// console.log(this.keyMap.keys); +// this.matcher = new Levenshtein.Builder() +// .dictionary(this.keyMap.keys, true) +// .algorithm('transposition') +// .sort_candidates(false) +// .case_insensitive_sort(true) +// .include_distance(true) +// .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense +// .build(); +// } +// +// match(query: String): Array { +// const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE); +// // TODO FIXME This is hideous. Clean up when possible. +// const val = _sortedUniq(_sortBy(_flatMap(candidates, (candidate) => { +// return this.keyMap.objectMap[candidate[0]].map((value) => { +// return { +// distance: candidate[1], +// ...value, +// }; +// }); +// }), +// [(candidate) => candidate.distance, (candidate) => this.keyMap.priorityMap[candidate]])); +// console.log(val); +// return val; +// } +//} From 9404dd30c5bd72861488f23260b08028d8a0c8e5 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 23 Jun 2017 18:19:06 +0100 Subject: [PATCH 33/37] Use for strikeout We've swapped to commonmark, which uses instead of ~~ for strikeout, so make the RTE insert when we apply strikeout. Also, when ~~ is inserted, transform them into for simplicity. This means giving an input of ~~test~~ is effectively the same as giving an input of test. --- src/Markdown.js | 3 +++ src/components/views/rooms/MessageComposerInput.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Markdown.js b/src/Markdown.js index 4a46ce4f24..134520b775 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -62,6 +62,9 @@ function is_multi_line(node) { */ export default class Markdown { constructor(input) { + // Support GH-style strikeout + input = input.replace(/~~(.*?)~~/g, '$1'); + this.input = input; const parser = new commonmark.Parser(); diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 4d7c0f7a80..5ea92d18ce 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -397,7 +397,7 @@ export default class MessageComposerInput extends React.Component { 'bold': (text) => `**${text}**`, 'italic': (text) => `*${text}*`, 'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* - 'strike': (text) => `~~${text}~~`, + 'strike': (text) => `${text}`, 'code-block': (text) => `\`\`\`\n${text}\n\`\`\``, 'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''), 'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''), From f0f4a16e979b5c595ae77638a557852e27a0ce43 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 23 Jun 2017 18:28:02 +0100 Subject: [PATCH 34/37] Translate autocomplete delay --- src/components/structures/UserSettings.js | 2 +- src/i18n/strings/en_EN.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 9171b081ab..ef574d2ed6 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -656,7 +656,7 @@ module.exports = React.createClass({ - +
Autocomplete Delay (ms): {_t('Autocomplete Delay (ms):')} Date: Fri, 23 Jun 2017 18:30:16 +0100 Subject: [PATCH 35/37] Add copyright headers --- src/ComposerHistoryManager.js | 15 +++++++++++++++ src/autocomplete/FuzzyMatcher.js | 16 ++++++++++++++++ src/autocomplete/QueryMatcher.js | 15 +++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js index ef9232c684..3e19a78bfe 100644 --- a/src/ComposerHistoryManager.js +++ b/src/ComposerHistoryManager.js @@ -1,4 +1,19 @@ //@flow +/* +Copyright 2017 Aviral Dasgupta + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ import {ContentState} from 'draft-js'; import * as RichText from './RichText'; diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js index 230cb1dbd2..1aa0782c22 100644 --- a/src/autocomplete/FuzzyMatcher.js +++ b/src/autocomplete/FuzzyMatcher.js @@ -1,3 +1,19 @@ +/* +Copyright 2017 Aviral Dasgupta + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + //import Levenshtein from 'liblevenshtein'; //import _at from 'lodash/at'; //import _flatMap from 'lodash/flatMap'; diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js index ead7ea8047..01fc251318 100644 --- a/src/autocomplete/QueryMatcher.js +++ b/src/autocomplete/QueryMatcher.js @@ -1,4 +1,19 @@ //@flow +/* +Copyright 2017 Aviral Dasgupta + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ import _at from 'lodash/at'; import _flatMap from 'lodash/flatMap'; From d4034105a413bd012ada2c7a56a08c8f3544716a Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Mon, 26 Jun 2017 10:10:31 +0100 Subject: [PATCH 36/37] Revert support for ~~gh strikeout~~ --- src/Markdown.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Markdown.js b/src/Markdown.js index 134520b775..4a46ce4f24 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -62,9 +62,6 @@ function is_multi_line(node) { */ export default class Markdown { constructor(input) { - // Support GH-style strikeout - input = input.replace(/~~(.*?)~~/g, '$1'); - this.input = input; const parser = new commonmark.Parser(); From edae66fd3a96d1a77cd68b13e5584c44cb57a570 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 26 Jun 2017 14:28:56 +0100 Subject: [PATCH 37/37] fix one major cause of stuck unread notifications --- src/Unread.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Unread.js b/src/Unread.js index 67166dc24f..8a70291cf2 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -37,7 +37,26 @@ module.exports = { }, doesRoomHaveUnreadMessages: function(room) { - var readUpToId = room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); + var myUserId = MatrixClientPeg.get().credentials.userId; + + // get the most recent read receipt sent by our account. + // N.B. this is NOT a read marker (RM, aka "read up to marker"), + // despite the name of the method :(( + var readUpToId = room.getEventReadUpTo(myUserId); + + // as we don't send RRs for our own messages, make sure we special case that + // if *we* sent the last message into the room, we consider it not unread! + // Should fix: https://github.com/vector-im/riot-web/issues/3263 + // https://github.com/vector-im/riot-web/issues/2427 + // ...and possibly some of the others at + // https://github.com/vector-im/riot-web/issues/3363 + if (room.timeline.length && + room.timeline[room.timeline.length - 1].sender && + room.timeline[room.timeline.length - 1].sender.userId === myUserId) + { + return false; + } + // this just looks at whatever history we have, which if we've only just started // up probably won't be very much, so if the last couple of events are ones that // don't count, we don't know if there are any events that do count between where