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/package.json b/package.json index cbc06c9771..8d638a5928 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "classnames": "^2.1.2", "commonmark": "^0.27.0", "counterpart": "^0.18.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", @@ -64,7 +64,7 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "0.7.13", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "prop-types": "^15.5.8", "q": "^1.4.1", diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js new file mode 100644 index 0000000000..3e19a78bfe --- /dev/null +++ b/src/ComposerHistoryManager.js @@ -0,0 +1,84 @@ +//@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'; +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++) { + 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 + 1; + 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 b1793d0ddf..f2f2d533a8 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,9 +31,26 @@ const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); -export const contentStateToHTML = stateToHTML; +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 function HTMLtoContentState(html: string): ContentState { +export const contentStateToHTML = (contentState: ContentState) => { + return stateToHTML(contentState, { + inlineStyles: { + UNDERLINE: { + element: 'u' + } + } + }); +}; + +export function htmlToContentState(html: string): ContentState { return ContentState.createFromBlockArray(convertFromHTML(html)); } @@ -146,9 +164,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]; } /** 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 diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index cbdb839ce3..4c7d039da4 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -19,7 +19,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/Autocompleter.js b/src/autocomplete/Autocompleter.js index f8564a43a0..62b5a870f3 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -59,7 +59,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/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 205a3737dc..9ae3a7badb 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -18,7 +18,7 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import Fuse from 'fuse.js'; +import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; // Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file @@ -28,11 +28,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: '', @@ -63,6 +73,11 @@ const COMMANDS = [ args: '', description: 'Searches DuckDuckGo for results', }, + { + command: '/op', + args: ' []', + description: 'Define the power level of a user', + }, ]; const COMMAND_RE = /(^\/\w*)/g; @@ -72,7 +87,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'], }); } @@ -81,7 +96,7 @@ export default class CommandProvider extends AutocompleteProvider { let completions = []; const {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: ( { + return { + shortname, + }; +}); let instance = null; export default class EmojiProvider extends AutocompleteProvider { constructor() { super(EMOJI_REGEX); - this.fuse = new Fuse(EMOJI_SHORTNAMES, {}); + this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, { + keys: 'shortname', + }); } async getCompletions(query: string, selection: SelectionRange) { @@ -41,8 +47,8 @@ export default class EmojiProvider extends AutocompleteProvider { let completions = []; let {command, range} = this.getCurrentCommand(query, selection); if (command) { - completions = this.fuse.search(command[0]).map(result => { - const shortname = EMOJI_SHORTNAMES[result]; + completions = this.matcher.match(command[0]).map(result => { + const {shortname} = result; const unicode = shortnameToUnicode(shortname); return { completion: unicode, diff --git a/src/autocomplete/FuzzyMatcher.js b/src/autocomplete/FuzzyMatcher.js new file mode 100644 index 0000000000..1aa0782c22 --- /dev/null +++ b/src/autocomplete/FuzzyMatcher.js @@ -0,0 +1,107 @@ +/* +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'; +//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; +// } +//} diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js new file mode 100644 index 0000000000..01fc251318 --- /dev/null +++ b/src/autocomplete/QueryMatcher.js @@ -0,0 +1,79 @@ +//@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'; +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 { + /** + * @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(); + 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/RoomProvider.js b/src/autocomplete/RoomProvider.js index be35c53e5d..a001f381ee 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -19,7 +19,7 @@ import React from 'react'; import { _t } from '../languageHandler'; 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'; @@ -30,11 +30,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', 'roomId', 'aliases'], }); } @@ -46,17 +44,17 @@ 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 && !!getDisplayAliasForRoom(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, + completion: displayAlias + ' ', component: ( } title={room.name} description={displayAlias} /> ), @@ -84,8 +82,4 @@ export default class RoomProvider extends AutocompleteProvider { {completions} ; } - - shouldForceComplete(): boolean { - return true; - } } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 809fec94be..4e0c0f5ea7 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -1,3 +1,4 @@ +//@flow /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd @@ -18,21 +19,27 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import Fuse from 'fuse.js'; 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.fuse = new Fuse([], { + this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], }); } @@ -43,8 +50,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) { @@ -71,8 +77,31 @@ export default class UserProvider extends AutocompleteProvider { return '👥 ' + _t('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); } static getInstance(): UserProvider { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0deb236e86..f4da0e6a44 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -557,7 +557,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( @@ -1021,10 +1026,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 diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index b29b3579f0..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) { - this._updateAutoComplete(room); - this.tabComplete.loadEntries(room); this.setState({ unsentMessageError: this._getUnsentMessageError(room), }); @@ -500,8 +498,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); } }, @@ -524,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) { @@ -700,7 +699,7 @@ module.exports = React.createClass({ // refresh the tab complete list this.tabComplete.loadEntries(this.state.room); - this._updateAutoComplete(this.state.room); + 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 @@ -1425,14 +1424,6 @@ module.exports = React.createClass({ } }, - _updateAutoComplete: function(room) { - const myUserId = MatrixClientPeg.get().credentials.userId; - const members = room.getJoinedMembers().filter(function(member) { - if (member.userId !== myUserId) return true; - }); - UserProvider.getInstance().setUserList(members); - }, - render: function() { const RoomHeader = sdk.getComponent('rooms.RoomHeader'); const MessageComposer = sdk.getComponent('rooms.MessageComposer'); diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 9a5eb07cde..ef574d2ed6 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") }

@@ -649,8 +653,21 @@ module.exports = React.createClass({ { this._renderUrlPreviewSelector() } { SETTINGS_LABELS.map( this._renderSyncedSetting ) } { THEMES.map( this._renderThemeSelector ) } + + + + + + + +
{_t('Autocomplete Delay (ms):')} + +
{ this._renderLanguageSetting() } -
); diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 46a48d14a0..9f855616fc 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( + '', // XXX: Synapse breaks if you send null here: + 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, ); } diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 5f6bac0007..d591e4f6c2 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} from '../../../autocomplete/Autocompleter'; import Q from 'q'; +import UserSettingsStore from '../../../UserSettingsStore'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -58,7 +59,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,27 +70,35 @@ 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 { selectionOffset++; // selectionOffset is 1-indexed! } - } else { - // 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), - 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)) { 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, @@ -149,6 +158,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(); @@ -169,7 +179,7 @@ export default class Autocomplete extends React.Component { } setSelection(selectionOffset: number) { - this.setState({selectionOffset}); + this.setState({selectionOffset, hide: false}); } componentDidUpdate() { @@ -185,21 +195,24 @@ export default class Autocomplete extends React.Component { } } + setState(state, func) { + super.setState(state, func); + } + render() { 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(); }; @@ -220,7 +233,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 b7ef9fa184..5ea92d18ce 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'; @@ -41,6 +40,7 @@ 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; @@ -58,6 +58,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 === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { @@ -77,6 +100,7 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; + historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); @@ -84,7 +108,6 @@ export default class MessageComposerInput extends React.Component { 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); @@ -119,7 +142,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; @@ -132,110 +155,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; - var 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). - var 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! - let 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); - if (contentJSON) { - let content = convertFromRaw(JSON.parse(contentJSON)); - component.setEditorState(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) { @@ -247,8 +173,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) { @@ -262,22 +188,22 @@ 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()); this.onEditorContentChanged(editorState); editor.focus(); } - break; + break; case 'quote': { 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(stateToMarkdown(content)); + content = ContentState.createFromText(RichText.stateToMarkdown(content)); } const blockMap = content.getBlockMap(); @@ -291,14 +217,14 @@ 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(); } } - break; + break; } - } + }; onTypingActivity() { this.isTyping = true; @@ -318,7 +244,7 @@ export default class MessageComposerInput extends React.Component { startUserTypingTimer() { this.stopUserTypingTimer(); - var self = this; + const self = this; this.userTypingTimer = setTimeout(function() { self.isTyping = false; self.sendTyping(self.isTyping); @@ -335,7 +261,7 @@ export default class MessageComposerInput extends React.Component { startServerTypingTimer() { if (!this.serverTypingTimer) { - var self = this; + const self = this; this.serverTypingTimer = setTimeout(function() { if (self.isTyping) { self.sendTyping(self.isTyping); @@ -356,7 +282,7 @@ export default class MessageComposerInput extends React.Component { if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return; MatrixClientPeg.get().sendTyping( this.props.room.roomId, - this.isTyping, TYPING_SERVER_TIMEOUT + this.isTyping, TYPING_SERVER_TIMEOUT, ).done(); } @@ -367,60 +293,80 @@ 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()); + originalEditorState: null, + }); + }; - if (editorState.getCurrentContent().hasText()) { - this.onTypingActivity(); - } else { - this.onFinishedTyping(); + /** + * 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; + } } - if (this.props.onContentChanged) { - const textContent = editorState.getCurrentContent().getPlainText(); - const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), - editorState.getCurrentContent().getBlocksAsArray()); + super.setState(state, () => { + if (callback != null) { + callback(); + } - this.props.onContentChanged(textContent, selection); - } - return contentChanged.promise; - } - - setEditorState(editorState: EditorState) { - return this.onEditorContentChanged(editorState, false); + if (this.props.onContentChanged) { + 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); + } + }); } 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()); + 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 (?!?) } 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 { + handleKeyCommand = (command: string): boolean => { if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichtextEnabled); return true; @@ -434,31 +380,35 @@ 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(), 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-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(''), + 'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''), }[command]; if (modifyFn) { newState = EditorState.push( this.state.editorState, RichText.modifyText(contentState, selection, modifyFn), - 'insert-characters' + 'insert-characters', ); } } @@ -468,7 +418,7 @@ export default class MessageComposerInput extends React.Component { } if (newState != null) { - this.setEditorState(newState); + this.setState({editorState: newState}); return true; } @@ -481,6 +431,13 @@ export default class MessageComposerInput extends React.Component { 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)) { + return false; + } + const contentState = this.state.editorState.getCurrentContent(); if (!contentState.hasText()) { return true; @@ -489,11 +446,11 @@ 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) { @@ -501,16 +458,15 @@ export default class MessageComposerInput extends React.Component { console.log("Command success."); }, function(err) { console.error("Command failure: %s", err); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: _t("Server error"), description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")), }); }); - } - 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: _t("Command error"), description: cmd.error, @@ -521,7 +477,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); @@ -543,12 +499,14 @@ 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( - 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); @@ -567,87 +525,106 @@ 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(); + 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; } - return await this.setDisplayedCompletion(completion); - } - - async onDownArrow(e) { - const completion = this.autocomplete.onDownArrow(); 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); + }; // 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); - }); + if (this.autocomplete.state.completionList.length === 0) { + await this.autocomplete.forceComplete(); + this.onDownArrow(e); + } else { + await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e); } - } + }; - 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. * 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) { if (this.state.originalEditorState) { - this.setEditorState(this.state.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; } 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'); 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); + // 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 +635,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 +655,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 +675,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,7 +690,7 @@ export default class MessageComposerInput extends React.Component { ref={(e) => this.autocomplete = e} onConfirm={this.setDisplayedCompletion} query={contentText} - selection={selection} /> + selection={selection}/>
+ spellCheck={true}/>
); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c6bd304428..27a377e8b7 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", @@ -921,6 +923,7 @@ "You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.", "Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys.", "Encryption key request": "Encryption key request", + "Autocomplete Delay (ms):": "Autocomplete Delay (ms):", "This Home server does not support groups": "This Home server does not support groups", "Loading device info...": "Loading device info..." } 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());