Factor out a TimelinePanel from RoomView

This commit is contained in:
Richard van der Hoff 2016-02-10 18:27:40 +00:00
parent c3692378fa
commit f2377fa9fe
3 changed files with 518 additions and 358 deletions

View file

@ -35,6 +35,7 @@ module.exports.components['structures.MessagePanel'] = require('./components/str
module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar'); module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar');
module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); module.exports.components['structures.RoomView'] = require('./components/structures/RoomView');
module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel');
module.exports.components['structures.TimelinePanel'] = require('./components/structures/TimelinePanel');
module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar');
module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings');
module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar'); module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar');

View file

@ -25,7 +25,6 @@ var ReactDOM = require("react-dom");
var q = require("q"); var q = require("q");
var classNames = require("classnames"); var classNames = require("classnames");
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
var EventTimeline = Matrix.EventTimeline;
var MatrixClientPeg = require("../../MatrixClientPeg"); var MatrixClientPeg = require("../../MatrixClientPeg");
var ContentMessages = require("../../ContentMessages"); var ContentMessages = require("../../ContentMessages");
@ -41,14 +40,9 @@ var dis = require("../../dispatcher");
var Tinter = require("../../Tinter"); var Tinter = require("../../Tinter");
var rate_limited_func = require('../../ratelimitedfunc'); var rate_limited_func = require('../../ratelimitedfunc');
var PAGINATE_SIZE = 20; var DEBUG = false;
var INITIAL_SIZE = 20;
var SEND_READ_RECEIPT_DELAY = 2000;
var TIMELINE_CAP = 1000; // the most events to show in a timeline
var DEBUG_SCROLL = false; if (DEBUG) {
if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console); var debuglog = console.log.bind(console);
} else { } else {
@ -80,9 +74,7 @@ module.exports = React.createClass({
var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null;
return { return {
room: room, room: room,
events: [], roomLoading: !room,
canBackPaginate: true,
paginating: room != null,
editingRoomSettings: false, editingRoomSettings: false,
uploadingRoomSettings: false, uploadingRoomSettings: false,
numUnreadMessages: 0, numUnreadMessages: 0,
@ -91,11 +83,8 @@ module.exports = React.createClass({
searchResults: null, searchResults: null,
hasUnsentMessages: this._hasUnsentMessages(room), hasUnsentMessages: this._hasUnsentMessages(room),
callState: null, callState: null,
timelineLoading: true, // track whether our room timeline is loading
guestsCanJoin: false, guestsCanJoin: false,
canPeek: false, canPeek: false,
readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null,
readMarkerGhostEventId: undefined,
// this is true if we are fully scrolled-down, and are looking at // this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline. It has the effect of hiding the // the end of the live timeline. It has the effect of hiding the
@ -105,13 +94,11 @@ module.exports = React.createClass({
}, },
componentWillMount: function() { componentWillMount: function() {
this.last_rr_sent_event_id = undefined;
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room", this.onRoom); MatrixClientPeg.get().on("Room", this.onRoom);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
// xchat-style tab complete, add a colon if tab // xchat-style tab complete, add a colon if tab
@ -126,13 +113,6 @@ module.exports = React.createClass({
}); });
// to make the timeline load work correctly, build up a chain of promises which
// take us through the necessary steps.
// First of all, we may need to load the room. Construct a promise
// which resolves to the Room object.
var roomProm;
// if this is an unknown room then we're in one of three states: // if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can peek into (search engine) (we can /peek)
// - This is a room we can publicly join or were invited to. (we can /join) // - This is a room we can publicly join or were invited to. (we can /join)
@ -143,93 +123,28 @@ module.exports = React.createClass({
if (!this.state.room) { if (!this.state.room) {
console.log("Attempting to peek into room %s", this.props.roomId); console.log("Attempting to peek into room %s", this.props.roomId);
roomProm = MatrixClientPeg.get().peekInRoom(this.props.roomId).then((room) => { MatrixClientPeg.get().peekInRoom(this.props.roomId).then((room) => {
this.setState({ this.setState({
room: room room: room,
roomLoading: false,
}); });
return room; this._onRoomLoaded(room);
}); }, (err) => {
} else { // This won't necessarily be a MatrixError, but we duck-type
roomProm = q(this.state.room); // here and say if it's got an 'errcode' key with the right value,
} // it means we can't peek.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") {
// Next, load the timeline. // This is fine: the room just isn't peekable (we assume).
roomProm.then((room) => { this.setState({
this._calculatePeekRules(room); roomLoading: false,
return this._initTimeline(this.props); });
}).catch((err) => {
// This won't necessarily be a MatrixError, but we duck-type
// here and say if it's got an 'errcode' key with the right value,
// it means we can't peek.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") {
// This is fine: the room just isn't peekable (we assume).
this.setState({
timelineLoading: false,
});
} else {
throw err;
}
}).done();
},
_initTimeline: function(props) {
var initialEvent = props.eventId;
var pixelOffset = props.eventPixelOffset;
return this._loadTimeline(initialEvent, pixelOffset);
},
/**
* (re)-load the event timeline, and initialise the scroll state, centered
* around the given event.
*
* @param {string?} eventId the event to focus on. If undefined, will
* scroll to the bottom of the room.
*
* @param {number?} pixelOffset offset to position the given event at
* (pixels from the bottom of the view). If undefined, will put the
* event in the middle of the view.
*
* returns a promise which will resolve when the load completes.
*/
_loadTimeline: function(eventId, pixelOffset) {
// TODO: we could optimise this, by not resetting the window if the
// event is in the current window (though it's not obvious how we can
// tell if the current window is on the live event stream)
this.setState({
events: [],
searchResults: null, // we may have arrived here by clicking on a
// search result. Hide the results.
timelineLoading: true,
});
this._timelineWindow = new Matrix.TimelineWindow(
MatrixClientPeg.get(), this.state.room,
{windowLimit: TIMELINE_CAP});
return this._timelineWindow.load(eventId, INITIAL_SIZE).then(() => {
debuglog("RoomView: timeline loaded");
this._onTimelineUpdated(true);
}).finally(() => {
this.setState({
timelineLoading: false,
}, () => {
// initialise the scroll state of the message panel
if (!this.refs.messagePanel) {
// this shouldn't happen.
console.log("can't initialise scroll state because " +
"messagePanel didn't load");
return;
}
if (eventId) {
this.refs.messagePanel.scrollToEvent(eventId, pixelOffset);
} else { } else {
this.refs.messagePanel.scrollToBottom(); throw err;
} }
}).done();
this.sendReadReceipt(); } else {
}); this._onRoomLoaded(this.state.room);
}); }
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -256,7 +171,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
} }
@ -311,15 +225,6 @@ module.exports = React.createClass({
callState: callState callState: callState
}); });
break;
case 'user_activity':
case 'user_activity_end':
// we could treat user_activity_end differently and not
// send receipts for messages that have arrived between
// the actual user activity and the time they stopped
// being active, but let's see if this is actually
// necessary.
this.sendReadReceipt();
break; break;
} }
}, },
@ -330,9 +235,8 @@ module.exports = React.createClass({
} }
if (newProps.eventId != this.props.eventId) { if (newProps.eventId != this.props.eventId) {
console.log("RoomView switching to eventId " + newProps.eventId + // when we change focussed event id, hide the search results.
" (was " + this.props.eventId + ")"); this.setState({searchResults: null});
return this._initTimeline(newProps);
} }
}, },
@ -361,15 +265,12 @@ module.exports = React.createClass({
}); });
} }
} }
},
// tell the messagepanel to go paginate itself. This in turn will cause // called when state.room is first initialised (either at initial load,
// onMessageListFillRequest to be called, which will call // after a successful peek, or after we join the room).
// _onTimelineUpdated, which will update the state with the new event - _onRoomLoaded: function(room) {
// so there is no need update the state here. this._calculatePeekRules(room);
//
if (this.refs.messagePanel) {
this.refs.messagePanel.checkFillState();
}
}, },
_calculatePeekRules: function(room) { _calculatePeekRules: function(room) {
@ -394,19 +295,25 @@ module.exports = React.createClass({
// set it in our state and start using it (ie. init the timeline) // set it in our state and start using it (ie. init the timeline)
// This will happen if we start off viewing a room we're not joined, // This will happen if we start off viewing a room we're not joined,
// then join it whilst RoomView is looking at that room. // then join it whilst RoomView is looking at that room.
if (room.roomId == this.props.roomId) { if (room.roomId == this.props.roomId && !this.state.room) {
this.setState({ this.setState({
room: room room: room
}); });
this._initTimeline(this.props).done(); this._onRoomLoaded(room);
} }
}, },
onRoomName: function(room) { onRoomName: function(room) {
if (room.roomId == this.props.roomId) { // NB don't set state.room here.
this.setState({ //
room: room // When peeking, this event lands *before* the timeline is correctly
}); // synced; if we set state.room here, the TimelinePanel will be
// instantiated, and it will initialise its scroll state, with *no
// events*. In short, the scroll state will be all messed up.
//
// There's no need to set state.room here anyway.
if (room.roomId == this.props.roomId) {
this.forceUpdate();
} }
}, },
@ -433,38 +340,6 @@ module.exports = React.createClass({
} }
}, },
onRoomReceipt: function(receiptEvent, room) {
if (room.roomId == this.props.roomId) {
var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
var readMarkerGhostEventId = this.state.readMarkerGhostEventId;
if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) {
readMarkerGhostEventId = this.state.readMarkerEventId;
}
// if the event after the one referenced in the read receipt if sent by us, do nothing since
// this is a temporary period before the synthesized receipt for our own message arrives
var readMarkerGhostEventIndex;
for (var i = 0; i < this.state.events.length; ++i) {
if (this.state.events[i].getId() == readMarkerGhostEventId) {
readMarkerGhostEventIndex = i;
break;
}
}
if (readMarkerGhostEventIndex + 1 < this.state.events.length) {
var nextEvent = this.state.events[readMarkerGhostEventIndex + 1];
if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) {
readMarkerGhostEventId = undefined;
}
}
this.setState({
readMarkerEventId: readMarkerEventId,
readMarkerGhostEventId: readMarkerGhostEventId,
});
}
},
onRoomMemberTyping: function(ev, member) { onRoomMemberTyping: function(ev, member) {
this.forceUpdate(); this.forceUpdate();
}, },
@ -576,23 +451,6 @@ module.exports = React.createClass({
); );
}, 500), }, 500),
_onTimelineUpdated: function(gotResults) {
// we might have switched rooms since the load started - just bin
// the results if so.
if (this.unmounted) return;
this.setState({
paginating: false,
});
if (gotResults) {
this.setState({
events: this._timelineWindow.getEvents(),
canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS),
});
}
},
onSearchResultsFillRequest: function(backwards) { onSearchResultsFillRequest: function(backwards) {
if (!backwards) if (!backwards)
return q(false); return q(false);
@ -608,23 +466,6 @@ module.exports = React.createClass({
} }
}, },
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
if(!this._timelineWindow.canPaginate(dir)) {
debuglog("RoomView: can't paginate at this time; backwards:"+backwards);
return q(false);
}
this.setState({paginating: true});
debuglog("RoomView: Initiating paginate; backwards:"+backwards);
return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => {
debuglog("RoomView: paginate complete backwards:"+backwards+"; success:"+r);
this._onTimelineUpdated(r);
return r;
});
},
onResendAllClick: function() { onResendAllClick: function() {
var eventsToResend = this._getUnsentMessages(this.state.room); var eventsToResend = this._getUnsentMessages(this.state.room);
eventsToResend.forEach(function(event) { eventsToResend.forEach(function(event) {
@ -701,19 +542,16 @@ module.exports = React.createClass({
}, },
onMessageListScroll: function(ev) { onMessageListScroll: function(ev) {
if (this.refs.messagePanel.isAtBottom() && if (this.refs.messagePanel.isAtEndOfLiveTimeline()) {
!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { this.setState({
if (this.state.numUnreadMessages != 0) { numUnreadMessages: 0,
this.setState({ numUnreadMessages: 0 }); atEndOfLiveTimeline: true,
} });
if (!this.state.atEndOfLiveTimeline) {
this.setState({ atEndOfLiveTimeline: true });
}
} }
else { else {
if (this.state.atEndOfLiveTimeline) { this.setState({
this.setState({ atEndOfLiveTimeline: false }); atEndOfLiveTimeline: false,
} });
} }
}, },
@ -928,87 +766,6 @@ module.exports = React.createClass({
return ret; return ret;
}, },
_indexForEventId(evId) {
for (var i = 0; i < this.state.events.length; ++i) {
if (evId == this.state.events[i].getId()) {
return i;
}
}
return null;
},
sendReadReceipt: function() {
if (!this.state.room) return;
if (!this.refs.messagePanel) return;
// we don't want to see our RR marker dropping down as we scroll
// through old history. For now, do this just by leaving the RR where
// it is until we hit the bottom of the room, though ultimately we
// probably want to keep sending RR, but hide the RR until we reach
// the bottom of the room again, or something.
if (!this.state.atEndOfLiveTimeline) return;
var currentReadUpToEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
// We want to avoid sending out read receipts when we are looking at
// events in the past which are before the latest RR.
//
// For now, let's apply a heuristic: if (a) the event corresponding to
// the latest RR (either from the server, or sent by ourselves) doesn't
// appear in our timeline, and (b) we could forward-paginate the event
// timeline, then don't send any more RRs.
//
// This isn't watertight, as we could be looking at a section of
// timeline which is *after* the latest RR (so we should actually send
// RRs) - but that is a bit of a niche case. It will sort itself out when
// the user eventually hits the live timeline.
//
if (currentReadUpToEventId && currentReadUpToEventIndex === null &&
this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
return;
}
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
if (lastReadEventIndex === null) return;
var lastReadEvent = this.state.events[lastReadEventIndex];
// we also remember the last read receipt we sent to avoid spamming the same one at the server repeatedly
if (lastReadEventIndex > currentReadUpToEventIndex && this.last_rr_sent_event_id != lastReadEvent.getId()) {
this.last_rr_sent_event_id = lastReadEvent.getId();
MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => {
// it failed, so allow retries next time the user is active
this.last_rr_sent_event_id = undefined;
});
}
},
_getLastDisplayedEventIndexIgnoringOwn: function() {
var messagePanel = this.refs.messagePanel;
if (messagePanel === undefined) return null;
var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
for (var i = this.state.events.length-1; i >= 0; --i) {
var ev = this.state.events[i];
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
continue;
}
var node = messagePanel.getNodeForEventId(ev.getId());
if (!node) continue;
var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
return i;
}
}
return null;
},
onSettingsClick: function() { onSettingsClick: function() {
this.showSettings(true); this.showSettings(true);
}, },
@ -1113,18 +870,7 @@ module.exports = React.createClass({
// jump down to the bottom of this room, where new events are arriving // jump down to the bottom of this room, where new events are arriving
jumpToLiveTimeline: function() { jumpToLiveTimeline: function() {
// if we can't forward-paginate the existing timeline, then there this.refs.messagePanel.jumpToLiveTimeline();
// is no point reloading it - just jump straight to the bottom.
//
// Otherwise, reload the timeline rather than trying to paginate
// through all of space-time.
if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
this._loadTimeline();
} else {
if (this.refs.messagePanel) {
this.refs.messagePanel.scrollToBottom();
}
}
}, },
// get the current scroll position of the room, so that it can be // get the current scroll position of the room, so that it can be
@ -1266,14 +1012,15 @@ module.exports = React.createClass({
var CallView = sdk.getComponent("voip.CallView"); var CallView = sdk.getComponent("voip.CallView");
var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var RoomSettings = sdk.getComponent("rooms.RoomSettings");
var SearchBar = sdk.getComponent("rooms.SearchBar"); var SearchBar = sdk.getComponent("rooms.SearchBar");
var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); var TintableSvg = sdk.getComponent("elements.TintableSvg");
var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
var MessagePanel = sdk.getComponent("structures.MessagePanel"); var TimelinePanel = sdk.getComponent("structures.TimelinePanel");
if (!this._timelineWindow) { if (!this.state.room) {
if (this.props.roomId) { if (this.props.roomId) {
if (this.state.timelineLoading) { if (this.state.roomLoading) {
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
<Loader /> <Loader />
@ -1355,7 +1102,6 @@ module.exports = React.createClass({
var scrollheader_classes = classNames({ var scrollheader_classes = classNames({
mx_RoomView_scrollheader: true, mx_RoomView_scrollheader: true,
loading: this.state.paginating
}); });
var statusBar; var statusBar;
@ -1510,57 +1256,21 @@ module.exports = React.createClass({
hideMessagePanel = true; hideMessagePanel = true;
} }
var messagePanel; var messagePanel = (
<TimelinePanel ref={(r) => {
// just show a spinner while the timeline loads. this.refs.messagePanel = r;
// this.updateTint();
// put it in a div of the right class (mx_RoomView_messagePanel) so }}
// that the order in the roomview flexbox is correct, and room={this.state.room}
// mx_RoomView_messageListWrapper to position the inner div in the hidden={hideMessagePanel}
// right place. highlightedEventId={this.props.highlightedEventId}
// eventId={this.props.eventId}
// Note that the click-on-search-result functionality relies on the eventPixelOffset={this.props.eventPixelOffset}
// fact that the messagePanel is hidden while the timeline reloads, isConferenceUser={this.props.ConferenceHandler ?
// but that the RoomHeader (complete with search term) continues to this.props.ConferenceHandler.isConferenceUser :
// exist. null }
if (this.state.timelineLoading) { onScroll={ this.onMessageListScroll }
messagePanel = ( />);
<div className="mx_RoomView_messagePanel mx_RoomView_messageListWrapper">
<Loader />
</div>
);
} else {
// give the messagepanel a stickybottom if we're at the end of the
// live timeline, so that the arrival of new events triggers a
// scroll.
//
// Make sure that stickyBottom is *false* if we can paginate
// forwards, otherwise if somebody hits the bottom of the loaded
// events when viewing historical messages, we get stuck in a loop
// of paginating our way through the entire history of the room.
var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
messagePanel = (
<MessagePanel ref={(r) => {
this.refs.messagePanel = r;
this.updateTint();
}}
hidden={ hideMessagePanel }
events={ this.state.events }
highlightedEventId={ this.props.highlightedEventId }
readMarkerEventId={ this.state.readMarkerEventId }
readMarkerGhostEventId={ this.state.readMarkerGhostEventId }
ourUserId={ MatrixClientPeg.get().credentials.userId }
suppressFirstDateSeparator={ this.state.canBackPaginate }
stickyBottom={ stickyBottom }
isConferenceUser={this.props.ConferenceHandler ?
this.props.ConferenceHandler.isConferenceUser :
null }
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
/>
);
}
return ( return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView"> <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">

View file

@ -0,0 +1,449 @@
/*
Copyright 2016 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 q = require("q");
var Matrix = require("matrix-js-sdk");
var EventTimeline = Matrix.EventTimeline;
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher");
var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20;
var TIMELINE_CAP = 1000; // the most events to show in a timeline
var DEBUG = false;
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
}
/*
* Component which shows the event timeline in a room view.
*
* Also responsible for handling and sending read receipts.
*/
module.exports = React.createClass({
displayName: 'TimelinePanel',
propTypes: {
// The js-sdk Room object for the room whose timeline we are
// representing.
room: React.PropTypes.object.isRequired,
// true to give the component a 'display: hidden' style.
hidden: React.PropTypes.bool,
// ID of an event to highlight. If undefined, no event will be highlighted.
// typically this will be either 'eventId' or undefined.
highlightedEventId: React.PropTypes.string,
// id of an event to jump to. If not given, will go to the end of the
// live timeline.
eventId: React.PropTypes.string,
// where to position the event given by eventId, in pixels from the
// bottom of the viewport. If not given, will try to put the event in the
// middle of the viewprt.
eventPixelOffset: React.PropTypes.number,
// callback to determine if a user is the magic freeswitch conference
// user. Takes one parameter, which is a user id. Should return true if
// the user is the conference user.
isConferenceUser: React.PropTypes.func,
// callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func,
},
getInitialState: function() {
return {
events: [],
timelineLoading: true, // track whether our room timeline is loading
canBackPaginate: true,
readMarkerEventId: this._getCurrentReadReceipt(),
readMarkerGhostEventId: null,
};
},
componentWillMount: function() {
debuglog("TimelinePanel: mounting");
this.last_rr_sent_event_id = undefined;
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
this._initTimeline(this.props);
},
componentWillReceiveProps: function(newProps) {
if (newProps.room !== this.props.room) {
throw new Error("changing room on a TimelinePanel is not supported");
}
if (newProps.eventId != this.props.eventId) {
console.log("TimelinePanel switching to eventId " + newProps.eventId +
" (was " + this.props.eventId + ")");
return this._initTimeline(newProps);
}
},
componentWillUnmount: function() {
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
//
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
dis.unregister(this.dispatcherRef);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
},
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
if(!this._timelineWindow.canPaginate(dir)) {
debuglog("TimelinePanel: can't paginate at this time; backwards:"+backwards);
return q(false);
}
debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => {
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
this._onTimelineUpdated(r);
return r;
});
},
onAction: function(payload) {
switch (payload.action) {
case 'user_activity':
case 'user_activity_end':
// we could treat user_activity_end differently and not
// send receipts for messages that have arrived between
// the actual user activity and the time they stopped
// being active, but let's see if this is actually
// necessary.
this.sendReadReceipt();
break;
}
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
// ignore events for other rooms
if (room !== this.props.room) return;
// ignore anything but real-time updates at the end of the room:
// updates from pagination will happen when the paginate completes.
if (toStartOfTimeline || !data || !data.liveEvent) return;
// tell the messagepanel to go paginate itself. This in turn will cause
// onMessageListFillRequest to be called, which will call
// _onTimelineUpdated, which will update the state with the new event -
// so there is no need update the state here.
//
if (this.refs.messagePanel) {
this.refs.messagePanel.checkFillState();
}
},
onRoomReceipt: function(receiptEvent, room) {
if (room !== this.props.room)
return;
// the received event may or may not be for our user; but it turns out
// to be easier to do the processing anyway than to figure out if it
// is.
var oldReadMarker = this.state.readMarkerEventId;
var newReadMarker = this._getCurrentReadReceipt();
if (newReadMarker == oldReadMarker) {
return;
}
// suppress the animation when moving forward over an event which was sent
// by us; the original RM will have been suppressed so we don't want to show
// the animation either.
var oldReadMarkerIndex = this._indexForEventId(oldReadMarker);
if (oldReadMarkerIndex + 1 < this.state.events.length) {
var myUserId = MatrixClientPeg.get().credentials.userId;
var nextEvent = this.state.events[oldReadMarkerIndex + 1];
if (nextEvent.sender && nextEvent.sender.userId == myUserId) {
oldReadMarker = undefined;
}
}
this.setState({
readMarkerEventId: newReadMarker,
readMarkerGhostEventId: oldReadMarker,
});
},
sendReadReceipt: function() {
if (!this.refs.messagePanel) return;
var currentReadUpToEventId = this._getCurrentReadReceipt();
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
// We want to avoid sending out read receipts when we are looking at
// events in the past which are before the latest RR.
//
// For now, let's apply a heuristic: if (a) the event corresponding to
// the latest RR (either from the server, or sent by ourselves) doesn't
// appear in our timeline, and (b) we could forward-paginate the event
// timeline, then don't send any more RRs.
//
// This isn't watertight, as we could be looking at a section of
// timeline which is *after* the latest RR (so we should actually send
// RRs) - but that is a bit of a niche case. It will sort itself out when
// the user eventually hits the live timeline.
//
if (currentReadUpToEventId && currentReadUpToEventIndex === null &&
this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
return;
}
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
if (lastReadEventIndex === null) return;
var lastReadEvent = this.state.events[lastReadEventIndex];
// we also remember the last read receipt we sent to avoid spamming the
// same one at the server repeatedly
if (lastReadEventIndex > currentReadUpToEventIndex
&& this.last_rr_sent_event_id != lastReadEvent.getId()) {
this.last_rr_sent_event_id = lastReadEvent.getId();
MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => {
// it failed, so allow retries next time the user is active
this.last_rr_sent_event_id = undefined;
});
}
},
/* jump down to the bottom of this room, where new events are arriving
*/
jumpToLiveTimeline: function() {
// if we can't forward-paginate the existing timeline, then there
// is no point reloading it - just jump straight to the bottom.
//
// Otherwise, reload the timeline rather than trying to paginate
// through all of space-time.
if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
this._loadTimeline();
} else {
if (this.refs.messagePanel) {
this.refs.messagePanel.scrollToBottom();
}
}
},
/* return true if the content is fully scrolled down and we are
* at the end of the live timeline.
*/
isAtEndOfLiveTimeline: function() {
return this.refs.messagePanel
&& this.refs.messagePanel.isAtBottom()
&& this._timelineWindow
&& !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
},
/* get the current scroll state. See ScrollPanel.getScrollState for
* details.
*
* returns null if we are not mounted.
*/
getScrollState: function() {
if (!this.refs.messagePanel) { return null; }
return this.refs.messagePanel.getScrollState();
},
render: function() {
var MessagePanel = sdk.getComponent("structures.MessagePanel");
var Loader = sdk.getComponent("elements.Spinner");
// just show a spinner while the timeline loads.
//
// put it in a div of the right class (mx_RoomView_messagePanel) so
// that the order in the roomview flexbox is correct, and
// mx_RoomView_messageListWrapper to position the inner div in the
// right place.
//
// Note that the click-on-search-result functionality relies on the
// fact that the messagePanel is hidden while the timeline reloads,
// but that the RoomHeader (complete with search term) continues to
// exist.
if (this.state.timelineLoading) {
return (
<div className="mx_RoomView_messagePanel mx_RoomView_messageListWrapper">
<Loader />
</div>
);
}
// give the messagepanel a stickybottom if we're at the end of the
// live timeline, so that the arrival of new events triggers a
// scroll.
//
// Make sure that stickyBottom is *false* if we can paginate
// forwards, otherwise if somebody hits the bottom of the loaded
// events when viewing historical messages, we get stuck in a loop
// of paginating our way through the entire history of the room.
var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
return (
<MessagePanel ref="messagePanel"
hidden={ this.props.hidden }
events={ this.state.events }
highlightedEventId={ this.props.highlightedEventId }
readMarkerEventId={ this.state.readMarkerEventId }
readMarkerGhostEventId={ this.state.readMarkerGhostEventId }
suppressFirstDateSeparator={ this.state.canBackPaginate }
ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom }
isConferenceUser={ this.props.isConferenceUser }
onScroll={ this.props.onScroll }
onFillRequest={ this.onMessageListFillRequest }
/>
);
},
_initTimeline: function(props) {
var initialEvent = props.eventId;
var pixelOffset = props.eventPixelOffset;
return this._loadTimeline(initialEvent, pixelOffset);
},
/**
* (re)-load the event timeline, and initialise the scroll state, centered
* around the given event.
*
* @param {string?} eventId the event to focus on. If undefined, will
* scroll to the bottom of the room.
*
* @param {number?} pixelOffset offset to position the given event at
* (pixels from the bottom of the view). If undefined, will put the
* event in the middle of the view.
*
* returns a promise which will resolve when the load completes.
*/
_loadTimeline: function(eventId, pixelOffset) {
// TODO: we could optimise this, by not resetting the window if the
// event is in the current window (though it's not obvious how we can
// tell if the current window is on the live event stream)
this.setState({
events: [],
timelineLoading: true,
});
this._timelineWindow = new Matrix.TimelineWindow(
MatrixClientPeg.get(), this.props.room,
{windowLimit: TIMELINE_CAP});
return this._timelineWindow.load(eventId, INITIAL_SIZE).then(() => {
debuglog("TimelinePanel: timeline loaded");
this._onTimelineUpdated(true);
}).finally(() => {
this.setState({
timelineLoading: false,
}, () => {
// initialise the scroll state of the message panel
if (!this.refs.messagePanel) {
// this shouldn't happen.
console.log("can't initialise scroll state because " +
"messagePanel didn't load");
return;
}
if (eventId) {
this.refs.messagePanel.scrollToEvent(eventId, pixelOffset);
} else {
this.refs.messagePanel.scrollToBottom();
}
this.sendReadReceipt();
});
});
},
_onTimelineUpdated: function(gotResults) {
// we might have switched rooms since the load started - just bin
// the results if so.
if (this.unmounted) return;
if (gotResults) {
this.setState({
events: this._timelineWindow.getEvents(),
canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS),
});
}
},
_indexForEventId: function(evId) {
for (var i = 0; i < this.state.events.length; ++i) {
if (evId == this.state.events[i].getId()) {
return i;
}
}
return null;
},
_getLastDisplayedEventIndexIgnoringOwn: function() {
var messagePanel = this.refs.messagePanel;
if (messagePanel === undefined) return null;
var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
for (var i = this.state.events.length-1; i >= 0; --i) {
var ev = this.state.events[i];
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
continue;
}
var node = messagePanel.getNodeForEventId(ev.getId());
if (!node) continue;
var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
return i;
}
}
return null;
},
/**
* get the id of the event corresponding to our user's latest read-receipt.
*/
_getCurrentReadReceipt: function() {
var client = MatrixClientPeg.get();
// the client can be null on logout
if (client == null)
return null;
var myUserId = client.credentials.userId;
return this.props.room.getEventReadUpTo(myUserId);
},
});