diff --git a/src/component-index.js b/src/component-index.js
index 5a37a98913..a0abd33348 100644
--- a/src/component-index.js
+++ b/src/component-index.js
@@ -31,6 +31,7 @@ module.exports.components['structures.login.Login'] = require('./components/stru
module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration');
module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration');
module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat');
+module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel');
module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar');
module.exports.components['structures.RoomView'] = require('./components/structures/RoomView');
module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel');
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
new file mode 100644
index 0000000000..d0c12eac31
--- /dev/null
+++ b/src/components/structures/MessagePanel.js
@@ -0,0 +1,264 @@
+/*
+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 sdk = require('../../index');
+
+/* stateless UI component which builds the event tiles in the room timeline.
+ */
+module.exports = React.createClass({
+ displayName: 'MessagePanel',
+
+ propTypes: {
+ // true to give the component a 'display: hidden' style.
+ hidden: React.PropTypes.bool,
+
+ // the list of MatrixEvents to display
+ events: React.PropTypes.array.isRequired,
+
+ // ID of an event to highlight. If undefined, no event will be highlighted.
+ highlightedEventId: React.PropTypes.string,
+
+ // event after which we should show a read marker
+ readMarkerEventId: React.PropTypes.string,
+
+ // event after which we should show an animating disappearance of a
+ // read marker
+ readMarkerGhostEventId: React.PropTypes.string,
+
+ // the userid of our user. This is used to suppress the read marker
+ // for pending messages.
+ ourUserId: React.PropTypes.string,
+
+ // true to suppress the date at the start of the timeline
+ suppressFirstDateSeparator: React.PropTypes.bool,
+
+ // true if updates to the event list should cause the scroll panel to
+ // scroll down when we are at the bottom of the window. See ScrollPanel
+ // for more details.
+ stickyBottom: React.PropTypes.bool,
+
+ // 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,
+
+ // callback which is called when more content is needed.
+ onFillRequest: React.PropTypes.func,
+ },
+
+ /* get the DOM node representing the given event */
+ getNodeForEventId: function(eventId) {
+ if (!this.eventNodes) {
+ return undefined;
+ }
+
+ return this.eventNodes[eventId];
+ },
+
+ /* return true if the content is fully scrolled down right now; else false.
+ */
+ isAtBottom: function() {
+ return this.refs.scrollPanel
+ && this.refs.scrollPanel.isAtBottom();
+ },
+
+ /* get the current scroll state. See ScrollPanel.getScrollState for
+ * details.
+ *
+ * returns null if we are not mounted.
+ */
+ getScrollState: function() {
+ if (!this.refs.scrollPanel) { return null; }
+ return this.refs.scrollPanel.getScrollState();
+ },
+
+ /* jump to the bottom of the content.
+ */
+ scrollToBottom: function() {
+ if (this.refs.scrollPanel) {
+ this.refs.scrollPanel.scrollToBottom();
+ }
+ },
+
+ /* jump to the given event id.
+ *
+ * pixelOffset gives the number of pixels between the bottom of the node
+ * and the bottom of the container. If undefined, it will put the node
+ * in the middle of the container.
+ */
+ scrollToEvent: function(eventId, pixelOffset) {
+ if (this.refs.scrollPanel) {
+ this.refs.scrollPanel.scrollToToken(eventId, pixelOffset);
+ }
+ },
+
+ /* check the scroll state and send out pagination requests if necessary.
+ */
+ checkFillState: function() {
+ if (this.refs.scrollPanel) {
+ this.refs.scrollPanel.checkFillState();
+ }
+ },
+
+ render: function() {
+ var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
+ return (
+
+ {this._getEventTiles()}
+
+ );
+ },
+
+ _getEventTiles: function() {
+ var DateSeparator = sdk.getComponent('messages.DateSeparator');
+ var EventTile = sdk.getComponent('rooms.EventTile');
+
+ var ret = [];
+
+ var prevEvent = null; // the last event we showed
+ var ghostIndex;
+ var readMarkerIndex;
+ for (var i = 0; i < this.props.events.length; i++) {
+ var mxEv = this.props.events[i];
+
+ if (!EventTile.haveTileForEvent(mxEv)) {
+ continue;
+ }
+
+ if (this.props.isConferenceUser && mxEv.getType() === "m.room.member") {
+ if (this.props.isConferenceUser(mxEv.getSender()) ||
+ this.props.isConferenceUser(mxEv.getStateKey())) {
+ continue; // suppress conf user join/parts
+ }
+ }
+
+ // now we've decided whether or not to show this message,
+ // add the read up to marker if appropriate
+ // doing this here means we implicitly do not show the marker
+ // if it's at the bottom
+ // NB. it would be better to decide where the read marker was going
+ // when the state changed rather than here in the render method, but
+ // this is where we decide what messages we show so it's the only
+ // place we know whether we're at the bottom or not.
+ var mxEvSender = mxEv.sender ? mxEv.sender.userId : null;
+ if (prevEvent && prevEvent.getId() == this.props.readMarkerEventId) {
+ // suppress the read marker if the next event is sent by us; this
+ // is a nonsensical and temporary situation caused by the delay between
+ // us sending a message and receiving the synthesized receipt.
+ if (mxEvSender != this.props.ourUserId) {
+ var hr;
+ hr = (
+
);
+ readMarkerIndex = ret.length;
+ ret.push(
+
+ {hr}
+ );
+ }
+ }
+
+ // is this a continuation of the previous message?
+ var continuation = false;
+ if (prevEvent !== null) {
+ if (mxEvSender &&
+ prevEvent.sender &&
+ (mxEvSender === prevEvent.sender.userId) &&
+ (mxEv.getType() == prevEvent.getType())
+ )
+ {
+ continuation = true;
+ }
+ }
+
+ // do we need a date separator since the last event?
+ var ts1 = mxEv.getTs();
+ if ((prevEvent == null && !this.props.suppressFirstDateSeparator) ||
+ (prevEvent != null &&
+ new Date(prevEvent.getTs()).toDateString()
+ !== new Date(ts1).toDateString())) {
+ var dateSeparator = ;
+ ret.push(dateSeparator);
+ continuation = false;
+ }
+
+ var last = false;
+ if (i == this.props.events.length - 1) {
+ // XXX: we might not show a tile for the last event.
+ last = true;
+ }
+
+ var eventId = mxEv.getId();
+ var highlight = (eventId == this.props.highlightedEventId);
+
+ // we can't use local echoes as scroll tokens, because their event IDs change.
+ // Local echos have a send "status".
+ var scrollToken = mxEv.status ? undefined : eventId;
+
+ ret.push(
+
+
+
+ );
+
+ // A read up to marker has died and returned as a ghost!
+ // Lives in the dom as the ghost of the previous one while it fades away
+ if (eventId == this.props.readMarkerGhostEventId) {
+ ghostIndex = ret.length;
+ }
+
+ prevEvent = mxEv;
+ }
+
+ // splice the read marker ghost in now that we know whether the read receipt
+ // is the last element or not, because we only decide as we're going along.
+ if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) {
+ var hr;
+ hr = (
);
+ ret.splice(ghostIndex, 0, (
+
+ {hr}
+
+ ));
+ }
+
+ return ret;
+ },
+
+ _collectEventNode: function(eventId, node) {
+ if (this.eventNodes == undefined) this.eventNodes = {};
+ this.eventNodes[eventId] = node;
+ },
+});
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index d68ae35dc8..afe3a5112f 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -19,7 +19,6 @@ limitations under the License.
// - Search results component
// - Drag and drop
// - File uploading - uploadFile()
-// - Timeline component (alllll the logic in getEventTiles())
var React = require("react");
var ReactDOM = require("react-dom");
@@ -77,10 +76,6 @@ module.exports = React.createClass({
highlightedEventId: React.PropTypes.string,
},
- /* properties in RoomView objects include:
- *
- * eventNodes: a map from event id to DOM node representing that event
- */
getInitialState: function() {
var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null;
return {
@@ -227,7 +222,7 @@ module.exports = React.createClass({
return;
}
if (eventId) {
- this.refs.messagePanel.scrollToToken(eventId, pixelOffset);
+ this.refs.messagePanel.scrollToEvent(eventId, pixelOffset);
} else {
this.refs.messagePanel.scrollToBottom();
}
@@ -537,10 +532,6 @@ module.exports = React.createClass({
},
componentDidMount: function() {
- if (this.refs.messagePanel) {
- this._initialiseMessagePanel();
- }
-
var call = CallHandler.getCallForRoom(this.props.roomId);
var callState = call ? call.call_state : "ended";
this.setState({
@@ -585,23 +576,6 @@ module.exports = React.createClass({
);
}, 500),
- _initialiseMessagePanel: function() {
- var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
- this.refs.messagePanel.initialised = true;
- this.updateTint();
- },
-
- componentDidUpdate: function() {
- // we need to initialise the messagepanel if we've just joined the
- // room. TODO: we really really ought to factor out messagepanel to a
- // separate component to avoid this ridiculous dance.
- if (!this.refs.messagePanel) return;
-
- if (!this.refs.messagePanel.initialised) {
- this._initialiseMessagePanel();
- }
- },
-
_onTimelineUpdated: function(gotResults) {
// we might have switched rooms since the load started - just bin
// the results if so.
@@ -954,125 +928,6 @@ module.exports = React.createClass({
return ret;
},
- getEventTiles: function() {
- var DateSeparator = sdk.getComponent('messages.DateSeparator');
-
- var ret = [];
- var count = 0;
-
- var EventTile = sdk.getComponent('rooms.EventTile');
-
- var prevEvent = null; // the last event we showed
- var ghostIndex;
- var readMarkerIndex;
- for (var i = 0; i < this.state.events.length; i++) {
- var mxEv = this.state.events[i];
-
- if (!EventTile.haveTileForEvent(mxEv)) {
- continue;
- }
- if (this.props.ConferenceHandler && mxEv.getType() === "m.room.member") {
- if (this.props.ConferenceHandler.isConferenceUser(mxEv.getSender()) ||
- this.props.ConferenceHandler.isConferenceUser(mxEv.getStateKey())) {
- continue; // suppress conf user join/parts
- }
- }
-
- // now we've decided whether or not to show this message,
- // add the read up to marker if appropriate
- // doing this here means we implicitly do not show the marker
- // if it's at the bottom
- // NB. it would be better to decide where the read marker was going
- // when the state changed rather than here in the render method, but
- // this is where we decide what messages we show so it's the only
- // place we know whether we're at the bottom or not.
- var self = this;
- var mxEvSender = mxEv.sender ? mxEv.sender.userId : null;
- if (prevEvent && prevEvent.getId() == this.state.readMarkerEventId && mxEvSender != MatrixClientPeg.get().credentials.userId) {
- var hr;
- hr = (
);
- readMarkerIndex = ret.length;
- ret.push({hr});
- }
-
- // is this a continuation of the previous message?
- var continuation = false;
- if (prevEvent !== null) {
- if (mxEv.sender &&
- prevEvent.sender &&
- (mxEv.sender.userId === prevEvent.sender.userId) &&
- (mxEv.getType() == prevEvent.getType())
- )
- {
- continuation = true;
- }
- }
-
- // do we need a date separator since the last event?
- var ts1 = mxEv.getTs();
- if ((prevEvent == null && !this.state.canBackPaginate) ||
- (prevEvent != null &&
- new Date(prevEvent.getTs()).toDateString() !== new Date(ts1).toDateString())) {
- var dateSeparator = ;
- ret.push(dateSeparator);
- continuation = false;
- }
-
- var last = false;
- if (i == this.state.events.length - 1) {
- // XXX: we might not show a tile for the last event.
- last = true;
- }
-
- var eventId = mxEv.getId();
- var highlight = (eventId == this.props.highlightedEventId);
-
- // we can't use local echoes as scroll tokens, because their event IDs change.
- // Local echos have a send "status".
- var scrollToken = mxEv.status ? undefined : eventId;
-
- ret.push(
-
-
-
- );
-
- // A read up to marker has died and returned as a ghost!
- // Lives in the dom as the ghost of the previous one while it fades away
- if (eventId == this.state.readMarkerGhostEventId) {
- ghostIndex = ret.length;
- }
-
- prevEvent = mxEv;
- }
-
- // splice the read marker ghost in now that we know whether the read receipt
- // is the last element or not, because we only decide as we're going along.
- if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) {
- var hr;
- hr = (
);
- ret.splice(ghostIndex, 0, (
- {hr}
- ));
- }
-
- return ret;
- },
-
- _collectEventNode: function(eventId, node) {
- if (this.eventNodes == undefined) this.eventNodes = {};
- this.eventNodes[eventId] = node;
- },
-
_indexForEventId(evId) {
for (var i = 0; i < this.state.events.length; ++i) {
if (evId == this.state.events[i].getId()) {
@@ -1130,11 +985,10 @@ module.exports = React.createClass({
},
_getLastDisplayedEventIndexIgnoringOwn: function() {
- if (this.eventNodes === undefined) return null;
+ var messagePanel = this.refs.messagePanel;
+ if (messagePanel === undefined) return null;
- var messageWrapper = this.refs.messagePanel;
- if (messageWrapper === undefined) return null;
- var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
+ var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
for (var i = this.state.events.length-1; i >= 0; --i) {
var ev = this.state.events[i];
@@ -1143,7 +997,7 @@ module.exports = React.createClass({
continue;
}
- var node = this.eventNodes[ev.getId()];
+ var node = messagePanel.getNodeForEventId(ev.getId());
if (!node) continue;
var boundingRect = node.getBoundingClientRect();
@@ -1412,10 +1266,10 @@ 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");
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
var Loader = sdk.getComponent("elements.Spinner");
+ var MessagePanel = sdk.getComponent("structures.MessagePanel");
if (!this._timelineWindow) {
if (this.props.roomId) {
@@ -1687,14 +1541,24 @@ module.exports = React.createClass({
var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
messagePanel = (
- {
+ 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 }
- style={ hideMessagePanel ? { display: 'none' } : {} }
- stickyBottom={ stickyBottom }>
-
- {this.getEventTiles()}
-
+ />
);
}