Merge branch 'develop' into matthew/settings

This commit is contained in:
Kegan Dougal 2015-12-23 14:19:42 +00:00
commit 6295cf2ec9
7 changed files with 977 additions and 401 deletions

300
src/TabComplete.js Normal file
View file

@ -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;

101
src/TabCompleteEntries.js Normal file
View file

@ -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;

View file

@ -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');

View file

@ -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>

View file

@ -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>
);
},
});

View file

@ -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) {

View file

@ -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>
);
}
});