Merge branch 'develop' into matthew/settings
						commit
						6295cf2ec9
					
				|  | @ -0,0 +1,300 @@ | |||
| /* | ||||
| 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. | ||||
| */ | ||||
| var Entry = require("./TabCompleteEntries").Entry; | ||||
| 
 | ||||
| const DELAY_TIME_MS = 1000; | ||||
| const KEY_TAB = 9; | ||||
| const KEY_SHIFT = 16; | ||||
| const KEY_WINDOWS = 91; | ||||
| 
 | ||||
| // NB: DO NOT USE \b its "words" are roman alphabet only!
 | ||||
| //
 | ||||
| // Capturing group containing the start
 | ||||
| // of line or a whitespace char
 | ||||
| //     \_______________       __________Capturing group of 1 or more non-whitespace chars
 | ||||
| //                    _|__  _|_         followed by the end of line
 | ||||
| //                   /    \/   \
 | ||||
| const MATCH_REGEX = /(^|\s)(\S+)$/; | ||||
| 
 | ||||
| class TabComplete { | ||||
| 
 | ||||
|     constructor(opts) { | ||||
|         opts.startingWordSuffix = opts.startingWordSuffix || ""; | ||||
|         opts.wordSuffix = opts.wordSuffix || ""; | ||||
|         opts.allowLooping = opts.allowLooping || false; | ||||
|         opts.autoEnterTabComplete = opts.autoEnterTabComplete || false; | ||||
|         opts.onClickCompletes = opts.onClickCompletes || false; | ||||
|         this.opts = opts; | ||||
|         this.completing = false; | ||||
|         this.list = []; // full set of tab-completable things
 | ||||
|         this.matchedList = []; // subset of completable things to loop over
 | ||||
|         this.currentIndex = 0; // index in matchedList currently
 | ||||
|         this.originalText = null; // original input text when tab was first hit
 | ||||
|         this.textArea = opts.textArea; // DOMElement
 | ||||
|         this.isFirstWord = false; // true if you tab-complete on the first word
 | ||||
|         this.enterTabCompleteTimerId = null; | ||||
|         this.inPassiveMode = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param {Entry[]} completeList | ||||
|      */ | ||||
|     setCompletionList(completeList) { | ||||
|         this.list = completeList; | ||||
|         if (this.opts.onClickCompletes) { | ||||
|             // assign onClick listeners for each entry to complete the text
 | ||||
|             this.list.forEach((l) => { | ||||
|                 l.onClick = () => { | ||||
|                     this.completeTo(l.getText()); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param {DOMElement} | ||||
|      */ | ||||
|     setTextArea(textArea) { | ||||
|         this.textArea = textArea; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return {Boolean} | ||||
|      */ | ||||
|     isTabCompleting() { | ||||
|         // actually have things to tab over
 | ||||
|         return this.completing && this.matchedList.length > 1; | ||||
|     } | ||||
| 
 | ||||
|     stopTabCompleting() { | ||||
|         this.completing = false; | ||||
|         this.currentIndex = 0; | ||||
|         this._notifyStateChange(); | ||||
|     } | ||||
| 
 | ||||
|     startTabCompleting() { | ||||
|         this.completing = true; | ||||
|         this.currentIndex = 0; | ||||
|         this._calculateCompletions(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Do an auto-complete with the given word. This terminates the tab-complete. | ||||
|      * @param {string} someVal | ||||
|      */ | ||||
|     completeTo(someVal) { | ||||
|         this.textArea.value = this._replaceWith(someVal, true); | ||||
|         this.stopTabCompleting(); | ||||
|         // keep focus on the text area
 | ||||
|         this.textArea.focus(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param {Number} numAheadToPeek Return *up to* this many elements. | ||||
|      * @return {Entry[]} | ||||
|      */ | ||||
|     peek(numAheadToPeek) { | ||||
|         if (this.matchedList.length === 0) { | ||||
|             return []; | ||||
|         } | ||||
|         var peekList = []; | ||||
| 
 | ||||
|         // return the current match item and then one with an index higher, and
 | ||||
|         // so on until we've reached the requested limit. If we hit the end of
 | ||||
|         // the list of options we're done.
 | ||||
|         for (var i = 0; i < numAheadToPeek; i++) { | ||||
|             var nextIndex; | ||||
|             if (this.opts.allowLooping) { | ||||
|                 nextIndex = (this.currentIndex + i) % this.matchedList.length; | ||||
|             } | ||||
|             else { | ||||
|                 nextIndex = this.currentIndex + i; | ||||
|                 if (nextIndex === this.matchedList.length) { | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|             peekList.push(this.matchedList[nextIndex]); | ||||
|         } | ||||
|         // console.log("Peek list(%s): %s", numAheadToPeek, JSON.stringify(peekList));
 | ||||
|         return peekList; | ||||
|     } | ||||
| 
 | ||||
|     handleTabPress(passive, shiftKey) { | ||||
|         var wasInPassiveMode = this.inPassiveMode && !passive; | ||||
|         this.inPassiveMode = passive; | ||||
| 
 | ||||
|         if (!this.completing) { | ||||
|             this.startTabCompleting(); | ||||
|         } | ||||
| 
 | ||||
|         if (shiftKey) { | ||||
|             this.nextMatchedEntry(-1); | ||||
|         } | ||||
|         else { | ||||
|             // if we were in passive mode we got out of sync by incrementing the
 | ||||
|             // index to show the peek view but not set the text area. Therefore,
 | ||||
|             // we want to set the *current* index rather than the *next* index.
 | ||||
|             this.nextMatchedEntry(wasInPassiveMode ? 0 : 1); | ||||
|         } | ||||
|         this._notifyStateChange(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param {DOMEvent} e | ||||
|      */ | ||||
|     onKeyDown(ev) { | ||||
|         if (!this.textArea) { | ||||
|             console.error("onKeyDown called before a <textarea> was set!"); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (ev.keyCode !== KEY_TAB) { | ||||
|             // pressing any key (except shift, windows, cmd (OSX) and ctrl/alt combinations)
 | ||||
|             // aborts the current tab completion
 | ||||
|             if (this.completing && ev.keyCode !== KEY_SHIFT && | ||||
|                     !ev.metaKey && !ev.ctrlKey && !ev.altKey && ev.keyCode !== KEY_WINDOWS) { | ||||
|                 // they're resuming typing; reset tab complete state vars.
 | ||||
|                 this.stopTabCompleting(); | ||||
|             } | ||||
| 
 | ||||
|             // pressing any key at all (except tab) restarts the automatic tab-complete timer
 | ||||
|             if (this.opts.autoEnterTabComplete) { | ||||
|                 clearTimeout(this.enterTabCompleteTimerId); | ||||
|                 this.enterTabCompleteTimerId = setTimeout(() => { | ||||
|                     if (!this.completing) { | ||||
|                         this.handleTabPress(true, false); | ||||
|                     } | ||||
|                 }, DELAY_TIME_MS); | ||||
|             } | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // tab key has been pressed at this point
 | ||||
|         this.handleTabPress(false, ev.shiftKey) | ||||
| 
 | ||||
|         // prevent the default TAB operation (typically focus shifting)
 | ||||
|         ev.preventDefault(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set the textarea to the next value in the matched list. | ||||
|      * @param {Number} offset Offset to apply *before* setting the next value. | ||||
|      */ | ||||
|     nextMatchedEntry(offset) { | ||||
|         if (this.matchedList.length === 0) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // work out the new index, wrapping if necessary.
 | ||||
|         this.currentIndex += offset; | ||||
|         if (this.currentIndex >= this.matchedList.length) { | ||||
|             this.currentIndex = 0; | ||||
|         } | ||||
|         else if (this.currentIndex < 0) { | ||||
|             this.currentIndex = this.matchedList.length - 1; | ||||
|         } | ||||
|         var isTransitioningToOriginalText = ( | ||||
|             // impossible to transition if they've never hit tab
 | ||||
|             !this.inPassiveMode && this.currentIndex === 0 | ||||
|         ); | ||||
| 
 | ||||
|         if (!this.inPassiveMode) { | ||||
|             // set textarea to this new value
 | ||||
|             this.textArea.value = this._replaceWith( | ||||
|                 this.matchedList[this.currentIndex].text, | ||||
|                 this.currentIndex !== 0 // don't suffix the original text!
 | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         // visual display to the user that we looped - TODO: This should be configurable
 | ||||
|         if (isTransitioningToOriginalText) { | ||||
|             this.textArea.style["background-color"] = "#faa"; | ||||
|             setTimeout(() => { // yay for lexical 'this'!
 | ||||
|                  this.textArea.style["background-color"] = ""; | ||||
|             }, 150); | ||||
| 
 | ||||
|             if (!this.opts.allowLooping) { | ||||
|                 this.stopTabCompleting(); | ||||
|             } | ||||
|         } | ||||
|         else { | ||||
|             this.textArea.style["background-color"] = ""; // cancel blinks TODO: required?
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     _replaceWith(newVal, includeSuffix) { | ||||
|         // The regex to replace the input matches a character of whitespace AND
 | ||||
|         // the partial word. If we just use string.replace() with the regex it will
 | ||||
|         // replace the partial word AND the character of whitespace. We want to
 | ||||
|         // preserve whatever that character is (\n, \t, etc) so find out what it is now.
 | ||||
|         var boundaryChar; | ||||
|         var res = MATCH_REGEX.exec(this.originalText); | ||||
|         if (res) { | ||||
|             boundaryChar = res[1]; // the first captured group
 | ||||
|         } | ||||
|         if (boundaryChar === undefined) { | ||||
|             console.warn("Failed to find boundary char on text: '%s'", this.originalText); | ||||
|             boundaryChar = ""; | ||||
|         } | ||||
| 
 | ||||
|         var replacementText = ( | ||||
|             boundaryChar + newVal + ( | ||||
|                 includeSuffix ? | ||||
|                     (this.isFirstWord ? this.opts.startingWordSuffix : this.opts.wordSuffix) : | ||||
|                     "" | ||||
|             ) | ||||
|         ); | ||||
|         return this.originalText.replace(MATCH_REGEX, function() { | ||||
|             return replacementText; // function form to avoid `$` special-casing
 | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     _calculateCompletions() { | ||||
|         this.originalText = this.textArea.value; // cache starting text
 | ||||
| 
 | ||||
|         // grab the partial word from the text which we'll be tab-completing
 | ||||
|         var res = MATCH_REGEX.exec(this.originalText); | ||||
|         if (!res) { | ||||
|             this.matchedList = []; | ||||
|             return; | ||||
|         } | ||||
|         // ES6 destructuring; ignore first element (the complete match)
 | ||||
|         var [ , boundaryGroup, partialGroup] = res; | ||||
|         this.isFirstWord = partialGroup.length === this.originalText.length; | ||||
| 
 | ||||
|         this.matchedList = [ | ||||
|             new Entry(partialGroup) // first entry is always the original partial
 | ||||
|         ]; | ||||
| 
 | ||||
|         // find matching entries in the set of entries given to us
 | ||||
|         this.list.forEach((entry) => { | ||||
|             if (entry.text.toLowerCase().indexOf(partialGroup.toLowerCase()) === 0) { | ||||
|                 this.matchedList.push(entry); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         // console.log("_calculateCompletions => %s", JSON.stringify(this.matchedList));
 | ||||
|     } | ||||
| 
 | ||||
|     _notifyStateChange() { | ||||
|         if (this.opts.onStateChange) { | ||||
|             this.opts.onStateChange(this.completing); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| 
 | ||||
| module.exports = TabComplete; | ||||
|  | @ -0,0 +1,101 @@ | |||
| /* | ||||
| 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. | ||||
| */ | ||||
| var React = require("react"); | ||||
| var sdk = require("./index"); | ||||
| 
 | ||||
| class Entry { | ||||
|     constructor(text) { | ||||
|         this.text = text; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return {string} The text to display in this entry. | ||||
|      */ | ||||
|     getText() { | ||||
|         return this.text; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return {ReactClass} Raw JSX | ||||
|      */ | ||||
|     getImageJsx() { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return {?string} The unique key= prop for React dedupe | ||||
|      */ | ||||
|     getKey() { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Called when this entry is clicked. | ||||
|      */ | ||||
|     onClick() { | ||||
|         // NOP
 | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class MemberEntry extends Entry { | ||||
|     constructor(member) { | ||||
|         super(member.name || member.userId); | ||||
|         this.member = member; | ||||
|     } | ||||
| 
 | ||||
|     getImageJsx() { | ||||
|         var MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); | ||||
|         return ( | ||||
|             <MemberAvatar member={this.member} width={24} height={24} /> | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     getKey() { | ||||
|         return this.member.userId; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| MemberEntry.fromMemberList = function(members) { | ||||
|     return members.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 new MemberEntry(m); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| module.exports.Entry = Entry; | ||||
| module.exports.MemberEntry = MemberEntry; | ||||
|  | @ -28,6 +28,7 @@ module.exports.components['structures.login.PostRegistration'] = require('./comp | |||
| module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); | ||||
| module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); | ||||
| module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); | ||||
| module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); | ||||
| module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); | ||||
| module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); | ||||
| module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); | ||||
|  | @ -65,6 +66,7 @@ module.exports.components['views.rooms.RoomHeader'] = require('./components/view | |||
| module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList'); | ||||
| module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings'); | ||||
| module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile'); | ||||
| module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar'); | ||||
| module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar'); | ||||
| module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName'); | ||||
| module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword'); | ||||
|  |  | |||
|  | @ -23,7 +23,6 @@ limitations under the License. | |||
| 
 | ||||
| var React = require("react"); | ||||
| var ReactDOM = require("react-dom"); | ||||
| var GeminiScrollbar = require('react-gemini-scrollbar'); | ||||
| var q = require("q"); | ||||
| var classNames = require("classnames"); | ||||
| var Matrix = require("matrix-js-sdk"); | ||||
|  | @ -34,6 +33,8 @@ var WhoIsTyping = require("../../WhoIsTyping"); | |||
| var Modal = require("../../Modal"); | ||||
| var sdk = require('../../index'); | ||||
| var CallHandler = require('../../CallHandler'); | ||||
| var TabComplete = require("../../TabComplete"); | ||||
| var MemberEntry = require("../../TabCompleteEntries").MemberEntry; | ||||
| var Resend = require("../../Resend"); | ||||
| var dis = require("../../dispatcher"); | ||||
| 
 | ||||
|  | @ -49,13 +50,6 @@ module.exports = React.createClass({ | |||
|     }, | ||||
| 
 | ||||
|     /* properties in RoomView objects include: | ||||
|      * | ||||
|      * savedScrollState: the current scroll position in the backlog. Response | ||||
|      *     from _calculateScrollState. Updated on scroll events. | ||||
|      * | ||||
|      * savedSearchScrollState: similar to savedScrollState, but specific to the | ||||
|      *     search results (we need to preserve savedScrollState when search | ||||
|      *     results are visible) | ||||
|      * | ||||
|      * eventNodes: a map from event id to DOM node representing that event | ||||
|      */ | ||||
|  | @ -84,7 +78,18 @@ module.exports = React.createClass({ | |||
|         MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); | ||||
|         MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); | ||||
|         MatrixClientPeg.get().on("sync", this.onSyncStateChange); | ||||
|         this.savedScrollState = {atBottom: true}; | ||||
|         // xchat-style tab complete, add a colon if tab
 | ||||
|         // completing at the start of the text
 | ||||
|         this.tabComplete = new TabComplete({ | ||||
|             startingWordSuffix: ": ", | ||||
|             wordSuffix: " ", | ||||
|             allowLooping: false, | ||||
|             autoEnterTabComplete: true, | ||||
|             onClickCompletes: true, | ||||
|             onStateChange: (isCompleting) => { | ||||
|                 this.forceUpdate(); | ||||
|             } | ||||
|         }); | ||||
|     }, | ||||
| 
 | ||||
|     componentWillUnmount: function() { | ||||
|  | @ -168,23 +173,6 @@ module.exports = React.createClass({ | |||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     // get the DOM node which has the scrollTop property we care about for our
 | ||||
|     // message panel.
 | ||||
|     //
 | ||||
|     // If the gemini scrollbar is doing its thing, this will be a div within
 | ||||
|     // the message panel (ie, the gemini container); otherwise it will be the
 | ||||
|     // message panel itself.
 | ||||
|     _getScrollNode: function() { | ||||
|         var panel = ReactDOM.findDOMNode(this.refs.messagePanel); | ||||
|         if (!panel) return null; | ||||
| 
 | ||||
|         if (panel.classList.contains('gm-prevented')) { | ||||
|             return panel; | ||||
|         } else { | ||||
|             return panel.children[2]; // XXX: Fragile!
 | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     onSyncStateChange: function(state, prevState) { | ||||
|         if (state === "SYNCING" && prevState === "SYNCING") { | ||||
|             return; | ||||
|  | @ -218,7 +206,7 @@ module.exports = React.createClass({ | |||
|         if (!toStartOfTimeline && | ||||
|                 (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { | ||||
|             // update unread count when scrolled up
 | ||||
|             if (!this.state.searchResults && this.savedScrollState.atBottom) { | ||||
|             if (!this.state.searchResults && this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { | ||||
|                 currentUnread = 0; | ||||
|             } | ||||
|             else { | ||||
|  | @ -251,6 +239,11 @@ module.exports = React.createClass({ | |||
|     }, | ||||
| 
 | ||||
|     onRoomStateMember: function(ev, state, member) { | ||||
|         if (member.roomId === this.props.roomId) { | ||||
|             // a member state changed in this room, refresh the tab complete list
 | ||||
|             this._updateTabCompleteList(this.state.room); | ||||
|         } | ||||
| 
 | ||||
|         if (!this.props.ConferenceHandler) { | ||||
|             return; | ||||
|         } | ||||
|  | @ -313,6 +306,17 @@ module.exports = React.createClass({ | |||
| 
 | ||||
|         window.addEventListener('resize', this.onResize); | ||||
|         this.onResize(); | ||||
| 
 | ||||
|         this._updateTabCompleteList(this.state.room); | ||||
|     }, | ||||
| 
 | ||||
|     _updateTabCompleteList: function(room) { | ||||
|         if (!room || !this.tabComplete) { | ||||
|             return; | ||||
|         } | ||||
|         this.tabComplete.setCompletionList( | ||||
|             MemberEntry.fromMemberList(room.getJoinedMembers()) | ||||
|         ); | ||||
|     }, | ||||
| 
 | ||||
|     _initialiseMessagePanel: function() { | ||||
|  | @ -326,7 +330,8 @@ module.exports = React.createClass({ | |||
| 
 | ||||
|         this.scrollToBottom(); | ||||
|         this.sendReadReceipt(); | ||||
|         this.fillSpace(); | ||||
| 
 | ||||
|         this.refs.messagePanel.checkFillState(); | ||||
|     }, | ||||
| 
 | ||||
|     componentDidUpdate: function() { | ||||
|  | @ -338,11 +343,6 @@ module.exports = React.createClass({ | |||
|         if (!this.refs.messagePanel.initialised) { | ||||
|             this._initialiseMessagePanel(); | ||||
|         } | ||||
| 
 | ||||
|         // after adding event tiles, we may need to tweak the scroll (either to
 | ||||
|         // keep at the bottom of the timeline, or to maintain the view after
 | ||||
|         // adding events to the top).
 | ||||
|         this._restoreSavedScrollState(); | ||||
|     }, | ||||
| 
 | ||||
|     _paginateCompleted: function() { | ||||
|  | @ -352,37 +352,32 @@ module.exports = React.createClass({ | |||
|             room: MatrixClientPeg.get().getRoom(this.props.roomId) | ||||
|         }); | ||||
| 
 | ||||
|         // we might not have got enough results from the pagination
 | ||||
|         // request, so give fillSpace() a chance to set off another.
 | ||||
|         this.setState({paginating: false}); | ||||
| 
 | ||||
|         if (!this.state.searchResults) { | ||||
|             this.fillSpace(); | ||||
|         // we might not have got enough (or, indeed, any) results from the
 | ||||
|         // pagination request, so give the messagePanel a chance to set off
 | ||||
|         // another.
 | ||||
| 
 | ||||
|         this.refs.messagePanel.checkFillState(); | ||||
|     }, | ||||
| 
 | ||||
|     onSearchResultsFillRequest: function(backwards) { | ||||
|         if (!backwards || this.state.searchInProgress) | ||||
|             return; | ||||
| 
 | ||||
|         if (this.nextSearchBatch) { | ||||
|             if (DEBUG_SCROLL) console.log("requesting more search results"); | ||||
|             this._getSearchBatch(this.state.searchTerm, | ||||
|                                  this.state.searchScope); | ||||
|         } else { | ||||
|             if (DEBUG_SCROLL) console.log("no more search results"); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     // check the scroll position, and if we need to, set off a pagination
 | ||||
|     // request.
 | ||||
|     fillSpace: function() { | ||||
|         if (!this.refs.messagePanel) return; | ||||
|         var messageWrapperScroll = this._getScrollNode(); | ||||
|         if (messageWrapperScroll.scrollTop > messageWrapperScroll.clientHeight) { | ||||
|     // set off a pagination request.
 | ||||
|     onMessageListFillRequest: function(backwards) { | ||||
|         if (!backwards || this.state.paginating) | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // there's less than a screenful of messages left - try to get some
 | ||||
|         // more messages.
 | ||||
| 
 | ||||
|         if (this.state.searchResults) { | ||||
|             if (this.nextSearchBatch) { | ||||
|                 if (DEBUG_SCROLL) console.log("requesting more search results"); | ||||
|                 this._getSearchBatch(this.state.searchTerm, | ||||
|                                      this.state.searchScope); | ||||
|             } else { | ||||
|                 if (DEBUG_SCROLL) console.log("no more search results"); | ||||
|             } | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Either wind back the message cap (if there are enough events in the
 | ||||
|         // timeline to do so), or fire off a pagination request.
 | ||||
|  | @ -399,6 +394,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) { | ||||
|  | @ -425,44 +426,9 @@ module.exports = React.createClass({ | |||
|     }, | ||||
| 
 | ||||
|     onMessageListScroll: function(ev) { | ||||
|         var sn = this._getScrollNode(); | ||||
|         if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); | ||||
| 
 | ||||
|         // Sometimes we see attempts to write to scrollTop essentially being
 | ||||
|         // ignored. (Or rather, it is successfully written, but on the next
 | ||||
|         // scroll event, it's been reset again).
 | ||||
|         //
 | ||||
|         // This was observed on Chrome 47, when scrolling using the trackpad in OS
 | ||||
|         // X Yosemite.  Can't reproduce on El Capitan. Our theory is that this is
 | ||||
|         // due to Chrome not being able to cope with the scroll offset being reset
 | ||||
|         // while a two-finger drag is in progress.
 | ||||
|         //
 | ||||
|         // By way of a workaround, we detect this situation and just keep
 | ||||
|         // resetting scrollTop until we see the scroll node have the right
 | ||||
|         // value.
 | ||||
|         if (this.recentEventScroll !== undefined) { | ||||
|             if(sn.scrollTop < this.recentEventScroll-200) { | ||||
|                 console.log("Working around vector-im/vector-web#528"); | ||||
|                 this._restoreSavedScrollState(); | ||||
|                 return; | ||||
|             } | ||||
|             this.recentEventScroll = undefined; | ||||
|         } | ||||
| 
 | ||||
|         if (this.refs.messagePanel) { | ||||
|             if (this.state.searchResults) { | ||||
|                 this.savedSearchScrollState = this._calculateScrollState(); | ||||
|                 if (DEBUG_SCROLL) console.log("Saved search scroll state", this.savedSearchScrollState); | ||||
|             } else { | ||||
|                 this.savedScrollState = this._calculateScrollState(); | ||||
|                 if (DEBUG_SCROLL) console.log("Saved scroll state", this.savedScrollState); | ||||
|                 if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) { | ||||
|                     this.setState({numUnreadMessages: 0}); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         if (!this.state.paginating && !this.state.searchInProgress) { | ||||
|             this.fillSpace(); | ||||
|         if (this.state.numUnreadMessages != 0 && | ||||
|                 this.refs.messagePanel.isAtBottom()) { | ||||
|             this.setState({numUnreadMessages: 0}); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|  | @ -521,9 +487,15 @@ module.exports = React.createClass({ | |||
|             searchResults: [], | ||||
|             searchHighlights: [], | ||||
|             searchCount: null, | ||||
|             searchCanPaginate: null, | ||||
|         }); | ||||
| 
 | ||||
|         this.savedSearchScrollState = {atBottom: true}; | ||||
|         // if we already have a search panel, we need to tell it to forget
 | ||||
|         // about its scroll state.
 | ||||
|         if (this.refs.searchResultsPanel) { | ||||
|             this.refs.searchResultsPanel.resetScrollState(); | ||||
|         } | ||||
| 
 | ||||
|         this.nextSearchBatch = null; | ||||
|         this._getSearchBatch(term, scope); | ||||
|     }, | ||||
|  | @ -579,6 +551,7 @@ module.exports = React.createClass({ | |||
|                 searchHighlights: highlights, | ||||
|                 searchResults: events, | ||||
|                 searchCount: results.count, | ||||
|                 searchCanPaginate: !!(results.next_batch), | ||||
|             }); | ||||
|             self.nextSearchBatch = results.next_batch; | ||||
|         }, function(error) { | ||||
|  | @ -622,66 +595,88 @@ module.exports = React.createClass({ | |||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     getEventTiles: function() { | ||||
|     getSearchResultTiles: function() { | ||||
|         var DateSeparator = sdk.getComponent('messages.DateSeparator'); | ||||
|         var cli = MatrixClientPeg.get(); | ||||
| 
 | ||||
|         var ret = []; | ||||
| 
 | ||||
|         var EventTile = sdk.getComponent('rooms.EventTile'); | ||||
| 
 | ||||
|         // XXX: todo: merge overlapping results somehow?
 | ||||
|         // XXX: why doesn't searching on name work?
 | ||||
| 
 | ||||
| 
 | ||||
|         if (this.state.searchCanPaginate === false) { | ||||
|             if (this.state.searchResults.length == 0) { | ||||
|                 ret.push(<li key="search-top-marker"> | ||||
|                          <h2 className="mx_RoomView_topMarker">No results</h2> | ||||
|                          </li> | ||||
|                         ); | ||||
|             } else { | ||||
|                 ret.push(<li key="search-top-marker"> | ||||
|                          <h2 className="mx_RoomView_topMarker">No more results</h2> | ||||
|                          </li> | ||||
|                         ); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         var lastRoomId; | ||||
| 
 | ||||
|         for (var i = this.state.searchResults.length - 1; i >= 0; i--) { | ||||
|             var result = this.state.searchResults[i]; | ||||
|             var mxEv = new Matrix.MatrixEvent(result.result); | ||||
| 
 | ||||
|             if (!EventTile.haveTileForEvent(mxEv)) { | ||||
|                 // XXX: can this ever happen? It will make the result count
 | ||||
|                 // not match the displayed count.
 | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
|             var eventId = mxEv.getId(); | ||||
| 
 | ||||
|             if (this.state.searchScope === 'All') { | ||||
|                 var roomId = result.result.room_id; | ||||
|                 if(roomId != lastRoomId) { | ||||
|                     ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>); | ||||
|                     lastRoomId = roomId; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             var ts1 = result.result.origin_server_ts; | ||||
|             ret.push(<li key={ts1 + "-search"}><DateSeparator ts={ts1}/></li>); // Rank: {resultList[i].rank} | ||||
| 
 | ||||
|             if (result.context.events_before[0]) { | ||||
|                 var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]); | ||||
|                 if (EventTile.haveTileForEvent(mxEv2)) { | ||||
|                     ret.push(<li key={eventId+"-1"} data-scroll-token={eventId+"-1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={this.state.searchHighlights}/></li>); | ||||
| 
 | ||||
|             if (result.context.events_after[0]) { | ||||
|                 var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]); | ||||
|                 if (EventTile.haveTileForEvent(mxEv2)) { | ||||
|                     ret.push(<li key={eventId+"+1"} data-scroll-token={eventId+"+1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return ret; | ||||
|     }, | ||||
| 
 | ||||
|     getEventTiles: function() { | ||||
|         var DateSeparator = sdk.getComponent('messages.DateSeparator'); | ||||
| 
 | ||||
|         var ret = []; | ||||
|         var count = 0; | ||||
| 
 | ||||
|         var EventTile = sdk.getComponent('rooms.EventTile'); | ||||
|         var self = this; | ||||
| 
 | ||||
|         if (this.state.searchResults) | ||||
|         { | ||||
|             // XXX: todo: merge overlapping results somehow?
 | ||||
|             // XXX: why doesn't searching on name work?
 | ||||
| 
 | ||||
|             var lastRoomId; | ||||
| 
 | ||||
|             for (var i = this.state.searchResults.length - 1; i >= 0; i--) { | ||||
|                 var result = this.state.searchResults[i]; | ||||
|                 var mxEv = new Matrix.MatrixEvent(result.result); | ||||
| 
 | ||||
|                 if (!EventTile.haveTileForEvent(mxEv)) { | ||||
|                     // XXX: can this ever happen? It will make the result count
 | ||||
|                     // not match the displayed count.
 | ||||
|                     continue; | ||||
|                 } | ||||
| 
 | ||||
|                 var eventId = mxEv.getId(); | ||||
| 
 | ||||
|                 if (self.state.searchScope === 'All') { | ||||
|                     var roomId = result.result.room_id; | ||||
|                     if(roomId != lastRoomId) { | ||||
|                         ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>); | ||||
|                         lastRoomId = roomId; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 var ts1 = result.result.origin_server_ts; | ||||
|                 ret.push(<li key={ts1 + "-search"}><DateSeparator ts={ts1}/></li>); // Rank: {resultList[i].rank} | ||||
| 
 | ||||
|                 if (result.context.events_before[0]) { | ||||
|                     var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]); | ||||
|                     if (EventTile.haveTileForEvent(mxEv2)) { | ||||
|                         ret.push(<li key={eventId+"-1"} data-scroll-token={eventId+"-1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>); | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={self.state.searchHighlights}/></li>); | ||||
| 
 | ||||
|                 if (result.context.events_after[0]) { | ||||
|                     var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]); | ||||
|                     if (EventTile.haveTileForEvent(mxEv2)) { | ||||
|                         ret.push(<li key={eventId+"+1"} data-scroll-token={eventId+"+1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             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 +689,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 = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>; | ||||
|                     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 = <li key={ts1}><DateSeparator ts={ts1}/></li>; | ||||
|             // 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 = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>; | ||||
|                 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( | ||||
|                 <li key={eventId} ref={this._collectEventNode.bind(this, eventId)} data-scroll-token={eventId}> | ||||
|                     <EventTile mxEvent={mxEv} continuation={continuation} last={last}/> | ||||
|                 </li> | ||||
|             ); | ||||
|             if (dateSeparator) { | ||||
|                 ret.unshift(dateSeparator); | ||||
|             } | ||||
|             ++count; | ||||
| 
 | ||||
|             prevEvent = mxEv; | ||||
|         } | ||||
| 
 | ||||
|         return ret; | ||||
|     }, | ||||
| 
 | ||||
|  | @ -975,10 +966,9 @@ module.exports = React.createClass({ | |||
|     }, | ||||
| 
 | ||||
|     scrollToBottom: function() { | ||||
|         var scrollNode = this._getScrollNode(); | ||||
|         if (!scrollNode) return; | ||||
|         scrollNode.scrollTop = scrollNode.scrollHeight; | ||||
|         if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop); | ||||
|         var messagePanel = this.refs.messagePanel; | ||||
|         if (!messagePanel) return; | ||||
|         messagePanel.scrollToBottom(); | ||||
|     }, | ||||
| 
 | ||||
|     // scroll the event view to put the given event at the bottom.
 | ||||
|  | @ -986,6 +976,9 @@ module.exports = React.createClass({ | |||
|     // pixel_offset gives the number of pixels between the bottom of the event
 | ||||
|     // and the bottom of the container.
 | ||||
|     scrollToEvent: function(eventId, pixelOffset) { | ||||
|         var messagePanel = this.refs.messagePanel; | ||||
|         if (!messagePanel) return; | ||||
| 
 | ||||
|         var idx = this._indexForEventId(eventId); | ||||
|         if (idx === null) { | ||||
|             // we don't seem to have this event in our timeline. Presumably
 | ||||
|  | @ -995,7 +988,7 @@ module.exports = React.createClass({ | |||
|             //
 | ||||
|             // for now, just scroll to the top of the buffer.
 | ||||
|             console.log("Refusing to scroll to unknown event "+eventId); | ||||
|             this._getScrollNode().scrollTop = 0; | ||||
|             messagePanel.scrollToTop(); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|  | @ -1015,117 +1008,30 @@ module.exports = React.createClass({ | |||
| 
 | ||||
|         // the scrollTokens on our DOM nodes are the event IDs, so we can pass
 | ||||
|         // eventId directly into _scrollToToken.
 | ||||
|         this._scrollToToken(eventId, pixelOffset); | ||||
|     }, | ||||
| 
 | ||||
|     _restoreSavedScrollState: function() { | ||||
|         var scrollState = this.state.searchResults ? this.savedSearchScrollState : this.savedScrollState; | ||||
|         if (!scrollState || scrollState.atBottom) { | ||||
|             this.scrollToBottom(); | ||||
|         } else if (scrollState.lastDisplayedScrollToken) { | ||||
|             this._scrollToToken(scrollState.lastDisplayedScrollToken, | ||||
|                                 scrollState.pixelOffset); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     _calculateScrollState: function() { | ||||
|         // we don't save the absolute scroll offset, because that
 | ||||
|         // would be affected by window width, zoom level, amount of scrollback,
 | ||||
|         // etc.
 | ||||
|         //
 | ||||
|         // instead we save an identifier for the last fully-visible message,
 | ||||
|         // and the number of pixels the window was scrolled below it - which
 | ||||
|         // will hopefully be near enough.
 | ||||
|         //
 | ||||
|         // Our scroll implementation is agnostic of the precise contents of the
 | ||||
|         // message list (since it needs to work with both search results and
 | ||||
|         // timelines). 'refs.messageList' is expected to be a DOM node with a
 | ||||
|         // number of children, each of which may have a 'data-scroll-token'
 | ||||
|         // attribute. It is this token which is stored as the
 | ||||
|         // 'lastDisplayedScrollToken'.
 | ||||
| 
 | ||||
|         var messageWrapperScroll = this._getScrollNode(); | ||||
|         // + 1 here to avoid fractional pixel rounding errors
 | ||||
|         var atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1; | ||||
| 
 | ||||
|         var messageWrapper = this.refs.messagePanel; | ||||
|         var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); | ||||
|         var messages = this.refs.messageList.children; | ||||
| 
 | ||||
|         for (var i = messages.length-1; i >= 0; --i) { | ||||
|             var node = messages[i]; | ||||
|             if (!node.dataset.scrollToken) continue; | ||||
| 
 | ||||
|             var boundingRect = node.getBoundingClientRect(); | ||||
|             if (boundingRect.bottom < wrapperRect.bottom) { | ||||
|                 return { | ||||
|                     atBottom: atBottom, | ||||
|                     lastDisplayedScrollToken: node.dataset.scrollToken, | ||||
|                     pixelOffset: wrapperRect.bottom - boundingRect.bottom, | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // apparently the entire timeline is below the viewport. Give up.
 | ||||
|         return { atBottom: true }; | ||||
|     }, | ||||
| 
 | ||||
|     // scroll the message list to the node with the given scrollToken. See
 | ||||
|     // notes in _calculateScrollState on how this works.
 | ||||
|     //
 | ||||
|     // pixel_offset gives the number of pixels between the bottom of the node
 | ||||
|     // and the bottom of the container.
 | ||||
|     _scrollToToken: function(scrollToken, pixelOffset) { | ||||
|         /* find the dom node with the right scrolltoken */ | ||||
|         var node; | ||||
|         var messages = this.refs.messageList.children; | ||||
|         for (var i = messages.length-1; i >= 0; --i) { | ||||
|             var m = messages[i]; | ||||
|             if (!m.dataset.scrollToken) continue; | ||||
|             if (m.dataset.scrollToken == scrollToken) { | ||||
|                 node = m; | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (!node) { | ||||
|             console.error("No node with scrollToken '"+scrollToken+"'"); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         var scrollNode = this._getScrollNode(); | ||||
|         var messageWrapper = this.refs.messagePanel; | ||||
|         var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); | ||||
|         var boundingRect = node.getBoundingClientRect(); | ||||
|         var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; | ||||
|         if(scrollDelta != 0) { | ||||
|             scrollNode.scrollTop += scrollDelta; | ||||
| 
 | ||||
|             // see the comments in onMessageListScroll regarding recentEventScroll
 | ||||
|             this.recentEventScroll = scrollNode.scrollTop; | ||||
|         } | ||||
| 
 | ||||
|         if (DEBUG_SCROLL) { | ||||
|             console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")"); | ||||
|             console.log("recentEventScroll now "+this.recentEventScroll); | ||||
|         } | ||||
|         messagePanel.scrollToToken(eventId, pixelOffset); | ||||
|     }, | ||||
| 
 | ||||
|     // get the current scroll position of the room, so that it can be
 | ||||
|     // restored when we switch back to it
 | ||||
|     getScrollState: function() { | ||||
|         return this.savedScrollState; | ||||
|         var messagePanel = this.refs.messagePanel; | ||||
|         if (!messagePanel) return null; | ||||
| 
 | ||||
|         return messagePanel.getScrollState(); | ||||
|     }, | ||||
| 
 | ||||
|     restoreScrollState: function(scrollState) { | ||||
|         if (!this.refs.messagePanel) return; | ||||
|         var messagePanel = this.refs.messagePanel; | ||||
|         if (!messagePanel) return null; | ||||
| 
 | ||||
|         if(scrollState.atBottom) { | ||||
|             // we were at the bottom before. Ideally we'd scroll to the
 | ||||
|             // 'read-up-to' mark here.
 | ||||
|             messagePanel.scrollToBottom(); | ||||
| 
 | ||||
|         } else if (scrollState.lastDisplayedScrollToken) { | ||||
|             // we might need to backfill, so we call scrollToEvent rather than
 | ||||
|             // _scrollToToken here. The scrollTokens on our DOM nodes are the
 | ||||
|             // scrollToToken here. The scrollTokens on our DOM nodes are the
 | ||||
|             // event IDs, so lastDisplayedScrollToken will be the event ID we need,
 | ||||
|             // and we can pass it directly into scrollToEvent.
 | ||||
|             this.scrollToEvent(scrollState.lastDisplayedScrollToken, | ||||
|  | @ -1191,6 +1097,7 @@ module.exports = React.createClass({ | |||
|         var CallView = sdk.getComponent("voip.CallView"); | ||||
|         var RoomSettings = sdk.getComponent("rooms.RoomSettings"); | ||||
|         var SearchBar = sdk.getComponent("rooms.SearchBar"); | ||||
|         var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); | ||||
| 
 | ||||
|         if (!this.state.room) { | ||||
|             if (this.props.roomId) { | ||||
|  | @ -1277,6 +1184,21 @@ module.exports = React.createClass({ | |||
|                         </div> | ||||
|                     ); | ||||
|                 } | ||||
|                 else if (this.tabComplete.isTabCompleting()) { | ||||
|                     var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar'); | ||||
|                     statusBar = ( | ||||
|                         <div className="mx_RoomView_tabCompleteBar"> | ||||
|                             <div className="mx_RoomView_tabCompleteImage">...</div> | ||||
|                             <div className="mx_RoomView_tabCompleteWrapper"> | ||||
|                                 <TabCompleteBar entries={this.tabComplete.peek(6)} /> | ||||
|                                 <div className="mx_RoomView_tabCompleteEol"> | ||||
|                                     <img src="img/eol.svg" width="22" height="16" alt="->|"/> | ||||
|                                     Auto-complete | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     ); | ||||
|                 } | ||||
|                 else if (this.state.hasUnsentMessages) { | ||||
|                     statusBar = ( | ||||
|                         <div className="mx_RoomView_connectionLostBar"> | ||||
|  | @ -1357,7 +1279,9 @@ module.exports = React.createClass({ | |||
|             ); | ||||
|             if (canSpeak) { | ||||
|                 messageComposer = | ||||
|                     <MessageComposer room={this.state.room} roomView={this} uploadFile={this.uploadFile} callState={this.state.callState} /> | ||||
|                     <MessageComposer | ||||
|                         room={this.state.room} roomView={this} uploadFile={this.uploadFile} | ||||
|                         callState={this.state.callState} tabComplete={this.tabComplete} /> | ||||
|             } | ||||
| 
 | ||||
|             // TODO: Why aren't we storing the term/scope/count in this format
 | ||||
|  | @ -1412,6 +1336,33 @@ module.exports = React.createClass({ | |||
|                     </div> | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|             // if we have search results, we keep the messagepanel (so that it preserves its
 | ||||
|             // scroll state), but hide it.
 | ||||
|             var searchResultsPanel; | ||||
|             var hideMessagePanel = false; | ||||
| 
 | ||||
|             if (this.state.searchResults) { | ||||
|                 searchResultsPanel = ( | ||||
|                     <ScrollPanel ref="searchResultsPanel" className="mx_RoomView_messagePanel" | ||||
|                             onFillRequest={ this.onSearchResultsFillRequest }> | ||||
|                         <li className={scrollheader_classes}></li> | ||||
|                         {this.getSearchResultTiles()} | ||||
|                     </ScrollPanel> | ||||
|                 ); | ||||
|                 hideMessagePanel = true; | ||||
|             } | ||||
| 
 | ||||
|             var messagePanel = ( | ||||
|                     <ScrollPanel ref="messagePanel" className="mx_RoomView_messagePanel" | ||||
|                             onScroll={ this.onMessageListScroll }  | ||||
|                             onFillRequest={ this.onMessageListFillRequest } | ||||
|                             style={ hideMessagePanel ? { display: 'none' } : {} } > | ||||
|                         <li className={scrollheader_classes}></li> | ||||
|                         {this.getEventTiles()} | ||||
|                     </ScrollPanel> | ||||
|             ); | ||||
| 
 | ||||
|             return ( | ||||
|                 <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }> | ||||
|                     <RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo} | ||||
|  | @ -1432,15 +1383,8 @@ module.exports = React.createClass({ | |||
|                         { conferenceCallNotification } | ||||
|                         { aux } | ||||
|                     </div> | ||||
|                     <GeminiScrollbar autoshow={true} ref="messagePanel" className="mx_RoomView_messagePanel" onScroll={ this.onMessageListScroll }> | ||||
|                         <div className="mx_RoomView_messageListWrapper"> | ||||
|                             <ol ref="messageList" className="mx_RoomView_MessageList" aria-live="polite"> | ||||
|                                 <li className={scrollheader_classes}> | ||||
|                                 </li> | ||||
|                                 {this.getEventTiles()} | ||||
|                             </ol> | ||||
|                         </div> | ||||
|                     </GeminiScrollbar> | ||||
|                     { messagePanel } | ||||
|                     { searchResultsPanel } | ||||
|                     <div className="mx_RoomView_statusArea"> | ||||
|                         <div className="mx_RoomView_statusAreaBox"> | ||||
|                             <div className="mx_RoomView_statusAreaBox_line"></div> | ||||
|  |  | |||
|  | @ -0,0 +1,283 @@ | |||
| /* | ||||
| 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. | ||||
| */ | ||||
| 
 | ||||
| var React = require("react"); | ||||
| var ReactDOM = require("react-dom"); | ||||
| var GeminiScrollbar = require('react-gemini-scrollbar'); | ||||
| 
 | ||||
| var DEBUG_SCROLL = false; | ||||
| 
 | ||||
| /* This component implements an intelligent scrolling list. | ||||
|  * | ||||
|  * It wraps a list of <li> children; when items are added to the start or end | ||||
|  * of the list, the scroll position is updated so that the user still sees the | ||||
|  * same position in the list. | ||||
|  * | ||||
|  * It also provides a hook which allows parents to provide more list elements | ||||
|  * when we get close to the start or end of the list. | ||||
|  * | ||||
|  * We don't save the absolute scroll offset, because that would be affected by | ||||
|  * window width, zoom level, amount of scrollback, etc. Instead we save an | ||||
|  * identifier for the last fully-visible message, and the number of pixels the | ||||
|  * window was scrolled below it - which is hopefully be near enough. | ||||
|  * | ||||
|  * Each child element should have a 'data-scroll-token'. This token is used to | ||||
|  * serialise the scroll state, and returned as the 'lastDisplayedScrollToken' | ||||
|  * attribute by getScrollState(). | ||||
|  */ | ||||
| module.exports = React.createClass({ | ||||
|     displayName: 'ScrollPanel', | ||||
| 
 | ||||
|     propTypes: { | ||||
|         /* stickyBottom: if set to true, then once the user hits the bottom of | ||||
|          * the list, any new children added to the list will cause the list to | ||||
|          * scroll down to show the new element, rather than preserving the | ||||
|          * existing view. | ||||
|          */ | ||||
|         stickyBottom: React.PropTypes.bool, | ||||
| 
 | ||||
|         /* onFillRequest(backwards): a callback which is called on scroll when | ||||
|          * the user nears the start (backwards = true) or end (backwards = | ||||
|          * false) of the list | ||||
|          */ | ||||
|         onFillRequest: React.PropTypes.func, | ||||
| 
 | ||||
|         /* onScroll: a callback which is called whenever any scroll happens. | ||||
|          */ | ||||
|         onScroll: React.PropTypes.func, | ||||
| 
 | ||||
|         /* className: classnames to add to the top-level div | ||||
|          */ | ||||
|         className: React.PropTypes.string, | ||||
| 
 | ||||
|         /* style: styles to add to the top-level div | ||||
|          */ | ||||
|         style: React.PropTypes.object, | ||||
|     }, | ||||
| 
 | ||||
|     getDefaultProps: function() { | ||||
|         return { | ||||
|             stickyBottom: true, | ||||
|             onFillRequest: function(backwards) {}, | ||||
|             onScroll: function() {}, | ||||
|         }; | ||||
|     }, | ||||
| 
 | ||||
|     componentWillMount: function() { | ||||
|         this.resetScrollState(); | ||||
|     }, | ||||
| 
 | ||||
|     componentDidUpdate: function() { | ||||
|         // after adding event tiles, we may need to tweak the scroll (either to
 | ||||
|         // keep at the bottom of the timeline, or to maintain the view after
 | ||||
|         // adding events to the top).
 | ||||
|         this._restoreSavedScrollState(); | ||||
|     }, | ||||
| 
 | ||||
|     onScroll: function(ev) { | ||||
|         var sn = this._getScrollNode(); | ||||
|         if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); | ||||
| 
 | ||||
|         // Sometimes we see attempts to write to scrollTop essentially being
 | ||||
|         // ignored. (Or rather, it is successfully written, but on the next
 | ||||
|         // scroll event, it's been reset again).
 | ||||
|         //
 | ||||
|         // This was observed on Chrome 47, when scrolling using the trackpad in OS
 | ||||
|         // X Yosemite.  Can't reproduce on El Capitan. Our theory is that this is
 | ||||
|         // due to Chrome not being able to cope with the scroll offset being reset
 | ||||
|         // while a two-finger drag is in progress.
 | ||||
|         //
 | ||||
|         // By way of a workaround, we detect this situation and just keep
 | ||||
|         // resetting scrollTop until we see the scroll node have the right
 | ||||
|         // value.
 | ||||
|         if (this.recentEventScroll !== undefined) { | ||||
|             if(sn.scrollTop < this.recentEventScroll-200) { | ||||
|                 console.log("Working around vector-im/vector-web#528"); | ||||
|                 this._restoreSavedScrollState(); | ||||
|                 return; | ||||
|             } | ||||
|             this.recentEventScroll = undefined; | ||||
|         } | ||||
| 
 | ||||
|         this.scrollState = this._calculateScrollState(); | ||||
|         if (DEBUG_SCROLL) console.log("Saved scroll state", this.scrollState); | ||||
| 
 | ||||
|         this.props.onScroll(ev); | ||||
| 
 | ||||
|         this.checkFillState(); | ||||
|     }, | ||||
| 
 | ||||
|     isAtBottom: function() { | ||||
|         return this.scrollState && this.scrollState.atBottom; | ||||
|     }, | ||||
| 
 | ||||
|     // check the scroll state and send out backfill requests if necessary.
 | ||||
|     checkFillState: function() { | ||||
|         var sn = this._getScrollNode(); | ||||
| 
 | ||||
|         if (sn.scrollTop < sn.clientHeight) { | ||||
|             // there's less than a screenful of messages left - try to get some
 | ||||
|             // more messages.
 | ||||
|             this.props.onFillRequest(true); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     // get the current scroll position of the room, so that it can be
 | ||||
|     // restored later
 | ||||
|     getScrollState: function() { | ||||
|         return this.scrollState; | ||||
|     }, | ||||
| 
 | ||||
|     /* reset the saved scroll state. | ||||
|      * | ||||
|      * This will cause the scroll to be reinitialised on the next update of the | ||||
|      * child list. | ||||
|      * | ||||
|      * This is useful if the list is being replaced, and you don't want to | ||||
|      * preserve scroll even if new children happen to have the same scroll | ||||
|      * tokens as old ones. | ||||
|      */ | ||||
|     resetScrollState: function() { | ||||
|         this.scrollState = null; | ||||
|     }, | ||||
| 
 | ||||
|     scrollToTop: function() { | ||||
|         this._getScrollNode().scrollTop = 0; | ||||
|         if (DEBUG_SCROLL) console.log("Scrolled to top"); | ||||
|     }, | ||||
| 
 | ||||
|     scrollToBottom: function() { | ||||
|         var scrollNode = this._getScrollNode(); | ||||
|         scrollNode.scrollTop = scrollNode.scrollHeight; | ||||
|         if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop); | ||||
|     }, | ||||
| 
 | ||||
|     // scroll the message list to the node with the given scrollToken. See
 | ||||
|     // notes in _calculateScrollState on how this works.
 | ||||
|     //
 | ||||
|     // pixel_offset gives the number of pixels between the bottom of the node
 | ||||
|     // and the bottom of the container.
 | ||||
|     scrollToToken: function(scrollToken, pixelOffset) { | ||||
|         /* find the dom node with the right scrolltoken */ | ||||
|         var node; | ||||
|         var messages = this.refs.itemlist.children; | ||||
|         for (var i = messages.length-1; i >= 0; --i) { | ||||
|             var m = messages[i]; | ||||
|             if (!m.dataset.scrollToken) continue; | ||||
|             if (m.dataset.scrollToken == scrollToken) { | ||||
|                 node = m; | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (!node) { | ||||
|             console.error("No node with scrollToken '"+scrollToken+"'"); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         var scrollNode = this._getScrollNode(); | ||||
|         var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); | ||||
|         var boundingRect = node.getBoundingClientRect(); | ||||
|         var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; | ||||
|         if(scrollDelta != 0) { | ||||
|             scrollNode.scrollTop += scrollDelta; | ||||
| 
 | ||||
|             // see the comments in onMessageListScroll regarding recentEventScroll
 | ||||
|             this.recentEventScroll = scrollNode.scrollTop; | ||||
|         } | ||||
| 
 | ||||
|         if (DEBUG_SCROLL) { | ||||
|             console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")"); | ||||
|             console.log("recentEventScroll now "+this.recentEventScroll); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     _calculateScrollState: function() { | ||||
|         // Our scroll implementation is agnostic of the precise contents of the
 | ||||
|         // message list (since it needs to work with both search results and
 | ||||
|         // timelines). 'refs.messageList' is expected to be a DOM node with a
 | ||||
|         // number of children, each of which may have a 'data-scroll-token'
 | ||||
|         // attribute. It is this token which is stored as the
 | ||||
|         // 'lastDisplayedScrollToken'.
 | ||||
| 
 | ||||
|         var sn = this._getScrollNode(); | ||||
|         // + 1 here to avoid fractional pixel rounding errors
 | ||||
|         var atBottom = sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1; | ||||
| 
 | ||||
|         var itemlist = this.refs.itemlist; | ||||
|         var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); | ||||
|         var messages = itemlist.children; | ||||
| 
 | ||||
|         for (var i = messages.length-1; i >= 0; --i) { | ||||
|             var node = messages[i]; | ||||
|             if (!node.dataset.scrollToken) continue; | ||||
| 
 | ||||
|             var boundingRect = node.getBoundingClientRect(); | ||||
|             if (boundingRect.bottom < wrapperRect.bottom) { | ||||
|                 return { | ||||
|                     atBottom: atBottom, | ||||
|                     lastDisplayedScrollToken: node.dataset.scrollToken, | ||||
|                     pixelOffset: wrapperRect.bottom - boundingRect.bottom, | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // apparently the entire timeline is below the viewport. Give up.
 | ||||
|         return { atBottom: true }; | ||||
|     }, | ||||
| 
 | ||||
|     _restoreSavedScrollState: function() { | ||||
|         var scrollState = this.scrollState; | ||||
|         if (!scrollState || (this.props.stickyBottom && scrollState.atBottom)) { | ||||
|             this.scrollToBottom(); | ||||
|         } else if (scrollState.lastDisplayedScrollToken) { | ||||
|             this.scrollToToken(scrollState.lastDisplayedScrollToken, | ||||
|                                scrollState.pixelOffset); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     /* get the DOM node which has the scrollTop property we care about for our | ||||
|      * message panel. | ||||
|      */ | ||||
|     _getScrollNode: function() { | ||||
|         var panel = ReactDOM.findDOMNode(this.refs.geminiPanel); | ||||
| 
 | ||||
|         // If the gemini scrollbar is doing its thing, this will be a div within
 | ||||
|         // the message panel (ie, the gemini container); otherwise it will be the
 | ||||
|         // message panel itself.
 | ||||
| 
 | ||||
|         if (panel.classList.contains('gm-prevented')) { | ||||
|             return panel; | ||||
|         } else { | ||||
|             return panel.children[2]; // XXX: Fragile!
 | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     render: function() { | ||||
|         // TODO: the classnames on the div and ol could do with being updated to
 | ||||
|         // reflect the fact that we don't necessarily contain a list of messages.
 | ||||
|         // it's not obvious why we have a separate div and ol anyway.
 | ||||
|         return (<GeminiScrollbar autoshow={true} ref="geminiPanel" onScroll={ this.onScroll } | ||||
|                 className={this.props.className} style={this.props.style}> | ||||
|                     <div className="mx_RoomView_messageListWrapper"> | ||||
|                         <ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite"> | ||||
|                             {this.props.children} | ||||
|                         </ol> | ||||
|                     </div> | ||||
|                 </GeminiScrollbar> | ||||
|                ); | ||||
|     }, | ||||
| }); | ||||
|  | @ -31,6 +31,7 @@ var MatrixClientPeg = require("../../../MatrixClientPeg"); | |||
| var SlashCommands = require("../../../SlashCommands"); | ||||
| var Modal = require("../../../Modal"); | ||||
| var CallHandler = require('../../../CallHandler'); | ||||
| var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; | ||||
| var sdk = require('../../../index'); | ||||
| 
 | ||||
| var dis = require("../../../dispatcher"); | ||||
|  | @ -64,14 +65,13 @@ function mdownToHtml(mdown) { | |||
| module.exports = React.createClass({ | ||||
|     displayName: 'MessageComposer', | ||||
| 
 | ||||
|     propTypes: { | ||||
|         tabComplete: React.PropTypes.any | ||||
|     }, | ||||
| 
 | ||||
|     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 +172,9 @@ module.exports = React.createClass({ | |||
|             this.props.room.roomId | ||||
|         ); | ||||
|         this.resizeInput(); | ||||
|         if (this.props.tabComplete) { | ||||
|             this.props.tabComplete.setTextArea(this.refs.textarea); | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     componentWillUnmount: function() { | ||||
|  | @ -197,13 +200,6 @@ module.exports = React.createClass({ | |||
|             this.sentHistory.push(input); | ||||
|             this.onEnter(ev); | ||||
|         } | ||||
|         else if (ev.keyCode === KeyCode.TAB) { | ||||
|             var members = []; | ||||
|             if (this.props.room) { | ||||
|                 members = this.props.room.getJoinedMembers(); | ||||
|             } | ||||
|             this.onTab(ev, members); | ||||
|         } | ||||
|         else if (ev.keyCode === KeyCode.UP) { | ||||
|             var input = this.refs.textarea.value; | ||||
|             var offset = this.refs.textarea.selectionStart || 0; | ||||
|  | @ -222,10 +218,9 @@ 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; | ||||
| 
 | ||||
|         if (this.props.tabComplete) { | ||||
|             this.props.tabComplete.onKeyDown(ev); | ||||
|         } | ||||
| 
 | ||||
|         var self = this; | ||||
|  | @ -319,6 +314,9 @@ module.exports = React.createClass({ | |||
|         if (isEmote) { | ||||
|             contentText = contentText.substring(4); | ||||
|         } | ||||
|         else if (contentText[0] === '/') { | ||||
|             contentText = contentText.substring(1);    | ||||
|         } | ||||
| 
 | ||||
|         var htmlText; | ||||
|         if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) { | ||||
|  | @ -346,104 +344,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<sortedMembers.length; i++) { | ||||
|                 var member = sortedMembers[i]; | ||||
|                 if (member.name && searchIndex < targetIndex) { | ||||
|                     if (member.name.toLowerCase().indexOf(search[1].toLowerCase()) === 0) { | ||||
|                         expansion = member.name; | ||||
|                         searchIndex++; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (searchIndex < targetIndex) { // then search raw mxids
 | ||||
|                 for (var i=0; i<sortedMembers.length; i++) { | ||||
|                     if (searchIndex >= 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) { | ||||
|  |  | |||
|  | @ -0,0 +1,46 @@ | |||
| /* | ||||
| 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. | ||||
| */ | ||||
| 
 | ||||
| 'use strict'; | ||||
| 
 | ||||
| var React = require('react'); | ||||
| var MatrixClientPeg = require("../../../MatrixClientPeg"); | ||||
| 
 | ||||
| module.exports = React.createClass({ | ||||
|     displayName: 'TabCompleteBar', | ||||
| 
 | ||||
|     propTypes: { | ||||
|         entries: React.PropTypes.array.isRequired | ||||
|     }, | ||||
| 
 | ||||
|     render: function() { | ||||
|         return ( | ||||
|             <div className="mx_TabCompleteBar"> | ||||
|             {this.props.entries.map(function(entry, i) { | ||||
|                 return ( | ||||
|                     <div key={entry.getKey() || i + ""} className="mx_TabCompleteBar_item" | ||||
|                             onClick={entry.onClick.bind(entry)} > | ||||
|                         {entry.getImageJsx()} | ||||
|                         <span className="mx_TabCompleteBar_text"> | ||||
|                             {entry.getText()} | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 ); | ||||
|             })} | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue
	
	 Kegan Dougal
						Kegan Dougal