From c6d02b2c261361b7c9db5eea9c11c2c43b5000ea Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 21 Dec 2015 10:38:37 +0000 Subject: [PATCH 01/26] Move tab-complete logic out from MessageComposer Moved to a `TabComplete` class. Make it more generic (list of strings rather than RoomMembers) and sort the member list by last_active_ago. Everything still seems to work. --- src/TabComplete.js | 151 ++++++++++++++++++ src/components/views/rooms/MessageComposer.js | 151 +++++------------- 2 files changed, 191 insertions(+), 111 deletions(-) create mode 100644 src/TabComplete.js diff --git a/src/TabComplete.js b/src/TabComplete.js new file mode 100644 index 0000000000..5a6bfa8343 --- /dev/null +++ b/src/TabComplete.js @@ -0,0 +1,151 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const DELAY_TIME_MS = 500; +const KEY_TAB = 9; +const KEY_SHIFT = 16; + +class TabComplete { + + constructor(opts) { + opts.startingWordSuffix = opts.startingWordSuffix || ""; + opts.wordSuffix = opts.wordSuffix || ""; + this.opts = opts; + + this.tabStruct = { + completing: false, + original: null, + index: 0 + }; + this.list = []; + this.textArea = opts.textArea; + } + + /** + * @param {String[]} completeList + */ + setCompletionList(completeList) { + this.list = completeList; + } + + setTextArea(textArea) { + this.textArea = textArea; + } + + next() { + this.tabStruct.index++; + this.setCompletionOption(); + } + + prev() { + this.tabStruct.index --; + if (this.tabStruct.index < 0) { + // wrap to the last search match, and fix up to a real index + // value after we've matched. + this.tabStruct.index = Number.MAX_VALUE; + } + this.setCompletionOption(); + } + + setCompletionOption() { + var searchIndex = 0; + var targetIndex = this.tabStruct.index; + var text = this.tabStruct.original; + + var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text); + // console.log("Searched in '%s' - got %s", text, search); + if (targetIndex === 0) { // 0 is always the original text + this.textArea.value = text; + } + else if (search && search[1]) { + // console.log("search found: " + search+" from "+text); + var expansion; + + // FIXME: could do better than linear search here + for (var i=0; i < this.list.length; i++) { + if (searchIndex < targetIndex) { + if (this.list[i].toLowerCase().indexOf(search[1].toLowerCase()) === 0) { + expansion = this.list[i]; + searchIndex++; + } + } + } + + if (searchIndex === targetIndex || targetIndex === Number.MAX_VALUE) { + if (search[0].length === text.length) { + expansion += this.opts.startingWordSuffix; + } + else { + expansion += this.opts.wordSuffix; + } + this.textArea.value = text.replace( + /@?([a-zA-Z0-9_\-:\.]+)$/, expansion + ); + // cancel blink + this.textArea.style["background-color"] = ""; + if (targetIndex === Number.MAX_VALUE) { + // wrap the index around to the last index found + this.tabStruct.index = searchIndex; + targetIndex = searchIndex; + } + } + else { + // console.log("wrapped!"); + this.textArea.style["background-color"] = "#faa"; + setTimeout(() => { // yay for lexical 'this'! + this.textArea.style["background-color"] = ""; + }, 150); + this.textArea.value = text; + this.tabStruct.index = 0; + } + } + else { + this.tabStruct.index = 0; + } + } + + onKeyDown(ev) { + if (ev.keyCode !== KEY_TAB) { + if (ev.keyCode !== KEY_SHIFT && this.tabStruct.completing) { + // they're resuming typing; reset tab complete state vars. + this.tabStruct.completing = false; + this.tabStruct.index = 0; + } + return false; + } + // init struct if necessary + if (!this.tabStruct.completing) { + this.tabStruct.completing = true; + this.tabStruct.index = 0; + // cache starting text + this.tabStruct.original = this.textArea.value; + } + + if (ev.shiftKey) { + this.prev(); + } + else { + this.next(); + } + // prevent the default TAB operation (typically focus shifting) + ev.preventDefault(); + return true; + } + +}; + + +module.exports = TabComplete; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 7c228b5c9d..3978ef2154 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -31,6 +31,7 @@ var MatrixClientPeg = require("../../../MatrixClientPeg"); var SlashCommands = require("../../../SlashCommands"); var Modal = require("../../../Modal"); var CallHandler = require('../../../CallHandler'); +var TabComplete = require("../../../TabComplete"); var sdk = require('../../../index'); var dis = require("../../../dispatcher"); @@ -67,11 +68,6 @@ module.exports = React.createClass({ componentWillMount: function() { this.oldScrollHeight = 0; this.markdownEnabled = MARKDOWN_ENABLED; - this.tabStruct = { - completing: false, - original: null, - index: 0 - }; var self = this; this.sentHistory = { // The list of typed messages. Index 0 is more recent @@ -172,6 +168,14 @@ module.exports = React.createClass({ this.props.room.roomId ); this.resizeInput(); + + // xchat-style tab complete, add a colon if tab + // completing at the start of the text + this._tabComplete = new TabComplete({ + textArea: this.refs.textarea, + startingWordSuffix: ": ", + wordSuffix: " " + }); }, componentWillUnmount: function() { @@ -198,11 +202,38 @@ module.exports = React.createClass({ this.onEnter(ev); } else if (ev.keyCode === KeyCode.TAB) { - var members = []; + var memberList = []; if (this.props.room) { - members = this.props.room.getJoinedMembers(); + // TODO: We should cache this list and only update it when the + // member list changes + memberList = this.props.room.getJoinedMembers().sort(function(a, b) { + var userA = a.user; + var userB = b.user; + if (userA && !userB) { + return -1; // a comes first + } + else if (!userA && userB) { + return 1; // b comes first + } + else if (!userA && !userB) { + return 0; // don't care + } + else { // both User objects exist + if (userA.lastActiveAgo < userB.lastActiveAgo) { + return -1; // a comes first + } + else if (userA.lastActiveAgo > userB.lastActiveAgo) { + return 1; // b comes first + } + else { + return 0; // same last active ago + } + } + }).map(function(m) { + return m.name || m.userId; + }); } - this.onTab(ev, members); + this._tabComplete.setCompletionList(memberList); } else if (ev.keyCode === KeyCode.UP) { var input = this.refs.textarea.value; @@ -222,11 +253,7 @@ module.exports = React.createClass({ this.resizeInput(); } } - else if (ev.keyCode !== KeyCode.SHIFT && this.tabStruct.completing) { - // they're resuming typing; reset tab complete state vars. - this.tabStruct.completing = false; - this.tabStruct.index = 0; - } + this._tabComplete.onKeyDown(ev); var self = this; setTimeout(function() { @@ -346,104 +373,6 @@ module.exports = React.createClass({ ev.preventDefault(); }, - onTab: function(ev, sortedMembers) { - var textArea = this.refs.textarea; - if (!this.tabStruct.completing) { - this.tabStruct.completing = true; - this.tabStruct.index = 0; - // cache starting text - this.tabStruct.original = textArea.value; - } - - // loop in the right direction - if (ev.shiftKey) { - this.tabStruct.index --; - if (this.tabStruct.index < 0) { - // wrap to the last search match, and fix up to a real index - // value after we've matched. - this.tabStruct.index = Number.MAX_VALUE; - } - } - else { - this.tabStruct.index++; - } - - var searchIndex = 0; - var targetIndex = this.tabStruct.index; - var text = this.tabStruct.original; - - var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text); - // console.log("Searched in '%s' - got %s", text, search); - if (targetIndex === 0) { // 0 is always the original text - textArea.value = text; - } - else if (search && search[1]) { - // console.log("search found: " + search+" from "+text); - var expansion; - - // FIXME: could do better than linear search here - for (var i=0; i= targetIndex) { - break; - } - var userId = sortedMembers[i].userId; - // === 1 because mxids are @username - if (userId.toLowerCase().indexOf(search[1].toLowerCase()) === 1) { - expansion = userId; - searchIndex++; - } - } - } - - if (searchIndex === targetIndex || - targetIndex === Number.MAX_VALUE) { - // xchat-style tab complete, add a colon if tab - // completing at the start of the text - if (search[0].length === text.length) { - expansion += ": "; - } - else { - expansion += " "; - } - textArea.value = text.replace( - /@?([a-zA-Z0-9_\-:\.]+)$/, expansion - ); - // cancel blink - textArea.style["background-color"] = ""; - if (targetIndex === Number.MAX_VALUE) { - // wrap the index around to the last index found - this.tabStruct.index = searchIndex; - targetIndex = searchIndex; - } - } - else { - // console.log("wrapped!"); - textArea.style["background-color"] = "#faa"; - setTimeout(function() { - textArea.style["background-color"] = ""; - }, 150); - textArea.value = text; - this.tabStruct.index = 0; - } - } - else { - this.tabStruct.index = 0; - } - // prevent the default TAB operation (typically focus shifting) - ev.preventDefault(); - }, - onTypingActivity: function() { this.isTyping = true; if (!this.userTypingTimer) { From afadb23f89e3dcaf3ce35de2619172c741704542 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 21 Dec 2015 10:46:32 +0000 Subject: [PATCH 02/26] Fix bug with date separator flashing up on scrollback Refactor the event-tile generation loop to go forwards rather than backwards, which makes it easier to figure out whether we are displaying a continuation of the previous event, and whether we need a date separator. Also only display the date separator at the top of the room if there's no more scrollback to be shown. This fixes vector-im/vector-web#431 --- src/components/structures/RoomView.js | 61 +++++++++++++++------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e7b97021df..aafc9b8906 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -399,6 +399,12 @@ module.exports = React.createClass({ } }, + // return true if there's more messages in the backlog which we aren't displaying + _canPaginate: function() { + return (this.state.messageCap < this.state.room.timeline.length) || + this.state.room.oldState.paginationToken; + }, + onResendAllClick: function() { var eventsToResend = this._getUnsentMessages(this.state.room); eventsToResend.forEach(function(event) { @@ -681,7 +687,10 @@ module.exports = React.createClass({ return ret; } - for (var i = this.state.room.timeline.length-1; i >= 0 && count < this.state.messageCap; --i) { + + var prevEvent = null; // the last event we showed + var startIdx = Math.max(0, this.state.room.timeline.length-this.state.messageCap); + for (var i = startIdx; i < this.state.room.timeline.length; i++) { var mxEv = this.state.room.timeline[i]; if (!EventTile.haveTileForEvent(mxEv)) { @@ -694,49 +703,45 @@ module.exports = React.createClass({ } } + // is this a continuation of the previous message? var continuation = false; - var last = false; - var dateSeparator = null; - if (i == this.state.room.timeline.length - 1) { - last = true; - } - if (i > 0 && count < this.state.messageCap - 1) { - if (this.state.room.timeline[i].sender && - this.state.room.timeline[i - 1].sender && - (this.state.room.timeline[i].sender.userId === - this.state.room.timeline[i - 1].sender.userId) && - (this.state.room.timeline[i].getType() == - this.state.room.timeline[i - 1].getType()) + if (prevEvent !== null) { + if (mxEv.sender && + prevEvent.sender && + (mxEv.sender.userId === prevEvent.sender.userId) && + (mxEv.getType() == prevEvent.getType()) ) { continuation = true; } - - var ts0 = this.state.room.timeline[i - 1].getTs(); - var ts1 = this.state.room.timeline[i].getTs(); - if (new Date(ts0).toDateString() !== new Date(ts1).toDateString()) { - dateSeparator =
  • ; - continuation = false; - } } - if (i === 1) { // n.b. 1, not 0, as the 0th event is an m.room.create and so doesn't show on the timeline - var ts1 = this.state.room.timeline[i].getTs(); - dateSeparator =
  • ; + // do we need a date separator since the last event? + var ts1 = mxEv.getTs(); + if ((prevEvent == null && !this._canPaginate()) || + (prevEvent != null && + new Date(prevEvent.getTs()).toDateString() !== new Date(ts1).toDateString())) { + var dateSeparator =
  • ; + ret.push(dateSeparator); continuation = false; } + var last = false; + if (i == this.state.room.timeline.length - 1) { + // XXX: we might not show a tile for the last event. + last = true; + } + var eventId = mxEv.getId(); - ret.unshift( + ret.push(
  • ); - if (dateSeparator) { - ret.unshift(dateSeparator); - } - ++count; + + prevEvent = mxEv; } + return ret; }, From 26dc3cc553e49ee889e5579c32ca022cc7126961 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 21 Dec 2015 10:59:10 +0000 Subject: [PATCH 03/26] Push up instantiation of TabComplete to RoomView RoomView is the parent component which creates MessageComposer AND the status bar. By making RoomView instantiate TabComplete we can scope instances correctly rather than relying on singleton behaviour through dispatches. This also makes communication between status bar and the MessageComposer infinitely easier since they are now sharing the same TabComplete object. --- src/TabComplete.js | 16 ++++++++++++ src/components/structures/RoomView.js | 11 +++++++- src/components/views/rooms/MessageComposer.js | 25 +++++++++++-------- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/TabComplete.js b/src/TabComplete.js index 5a6bfa8343..c49ef19e7f 100644 --- a/src/TabComplete.js +++ b/src/TabComplete.js @@ -45,6 +45,10 @@ class TabComplete { this.textArea = textArea; } + isTabCompleting() { + return this.tabStruct.completing; + } + next() { this.tabStruct.index++; this.setCompletionOption(); @@ -117,15 +121,27 @@ class TabComplete { } } + /** + * @param {DOMEvent} e + * @return {Boolean} True if the tab complete state changed as a result of + * this event. + */ onKeyDown(ev) { if (ev.keyCode !== KEY_TAB) { if (ev.keyCode !== KEY_SHIFT && this.tabStruct.completing) { // they're resuming typing; reset tab complete state vars. this.tabStruct.completing = false; this.tabStruct.index = 0; + return true; } return false; } + + if (!this.textArea) { + console.error("onKeyDown called before a