diff --git a/src/component-index.js b/src/component-index.js
index 5a37a98913..63d2f3c39d 100644
--- a/src/component-index.js
+++ b/src/component-index.js
@@ -31,9 +31,11 @@ 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');
+module.exports.components['structures.TimelinePanel'] = require('./components/structures/TimelinePanel');
module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar');
module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings');
module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar');
@@ -70,6 +72,7 @@ module.exports.components['views.messages.TextualEvent'] = require('./components
module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody');
module.exports.components['views.room_settings.AliasSettings'] = require('./components/views/room_settings/AliasSettings');
module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings');
+module.exports.components['views.rooms.AuxPanel'] = require('./components/views/rooms/AuxPanel');
module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile');
module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile');
module.exports.components['views.rooms.InviteMemberList'] = require('./components/views/rooms/InviteMemberList');
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
new file mode 100644
index 0000000000..cfa4735481
--- /dev/null
+++ b/src/components/structures/MessagePanel.js
@@ -0,0 +1,305 @@
+/*
+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');
+
+/* (almost) 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: none' 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,
+
+ // 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 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,
+ },
+
+ componentWillMount: function() {
+ // the event after which we put a visible unread marker on the last
+ // render cycle; null if readMarkerVisible was false or the RM was
+ // suppressed (eg because it was at the end of the timeline)
+ this.currentReadMarkerEventId = null;
+
+ // the event after which we are showing a disappearing read marker
+ // animation
+ this.currentGhostEventId = null;
+ },
+
+ /* 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();
+ }
+ },
+
+ _getEventTiles: function() {
+ var EventTile = sdk.getComponent('rooms.EventTile');
+
+ this.eventNodes = {};
+
+ var i;
+
+ // first figure out which is the last event in the list which we're
+ // actually going to show; this allows us to behave slightly
+ // differently for the last event in the list.
+ for (i = this.props.events.length-1; i >= 0; i--) {
+ var mxEv = this.props.events[i];
+ if (!EventTile.haveTileForEvent(mxEv)) {
+ continue;
+ }
+
+ break;
+ }
+ var lastShownEventIndex = i;
+
+ var ret = [];
+
+ var prevEvent = null; // the last event we showed
+
+ // assume there is no read marker until proven otherwise
+ var readMarkerVisible = false;
+
+ for (i = 0; i < this.props.events.length; i++) {
+ var mxEv = this.props.events[i];
+ var wantTile = true;
+ var eventId = mxEv.getId();
+
+ if (!EventTile.haveTileForEvent(mxEv)) {
+ wantTile = false;
+ }
+
+ var last = (i == lastShownEventIndex);
+
+ if (wantTile) {
+ ret.push(this._getTilesForEvent(prevEvent, mxEv, last));
+ prevEvent = mxEv;
+ } else if (!mxEv.status) {
+ // if we aren't showing the event, put in a dummy scroll token anyway, so
+ // that we can scroll to the right place.
+ ret.push(
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index b5e1b20ac7..cb9939b56b 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -15,18 +15,15 @@ limitations under the License.
*/
// TODO: This component is enormous! There's several things which could stand-alone:
-// - Aux component
// - 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");
var q = require("q");
var classNames = require("classnames");
var Matrix = require("matrix-js-sdk");
-var EventTimeline = Matrix.EventTimeline;
var MatrixClientPeg = require("../../MatrixClientPeg");
var ContentMessages = require("../../ContentMessages");
@@ -42,14 +39,9 @@ var dis = require("../../dispatcher");
var Tinter = require("../../Tinter");
var rate_limited_func = require('../../ratelimitedfunc');
-var PAGINATE_SIZE = 20;
-var INITIAL_SIZE = 20;
-var SEND_READ_RECEIPT_DELAY = 2000;
-var TIMELINE_CAP = 1000; // the most events to show in a timeline
+var DEBUG = false;
-var DEBUG_SCROLL = false;
-
-if (DEBUG_SCROLL) {
+if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
@@ -81,17 +73,11 @@ 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 {
room: room,
- events: [],
- canBackPaginate: true,
- paginating: room != null,
+ roomLoading: !room,
editingRoomSettings: false,
uploadingRoomSettings: false,
numUnreadMessages: 0,
@@ -100,29 +86,24 @@ module.exports = React.createClass({
searchResults: null,
hasUnsentMessages: this._hasUnsentMessages(room),
callState: null,
- timelineLoading: true, // track whether our room timeline is loading
guestsCanJoin: 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
// the end of the live timeline. It has the effect of hiding the
// 'scroll to bottom' knob, among a couple of other things.
atEndOfLiveTimeline: true,
+
+ auxPanelMaxHeight: undefined,
}
},
componentWillMount: function() {
- this.last_rr_sent_event_id = undefined;
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room", this.onRoom);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
- MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
- MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
- MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
// xchat-style tab complete, add a colon if tab
// completing at the start of the text
@@ -136,13 +117,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:
// - 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)
@@ -153,93 +127,28 @@ module.exports = React.createClass({
if (!this.state.room) {
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({
- room: room
+ room: room,
+ roomLoading: false,
});
- return room;
- });
- } else {
- roomProm = q(this.state.room);
- }
-
- // Next, load the timeline.
- roomProm.then((room) => {
- this._calculatePeekRules(room);
- 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.scrollToToken(eventId, pixelOffset);
+ this._onRoomLoaded(room);
+ }, (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({
+ roomLoading: false,
+ });
} else {
- this.refs.messagePanel.scrollToBottom();
+ throw err;
}
-
- this.sendReadReceipt();
- });
- });
+ }).done();
+ } else {
+ this._onRoomLoaded(this.state.room);
+ }
},
componentWillUnmount: function() {
@@ -264,11 +173,8 @@ module.exports = React.createClass({
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
- MatrixClientPeg.get().removeListener("Room.redaction", this.onRoomRedaction);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
- MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
- MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
}
@@ -322,15 +228,6 @@ module.exports = React.createClass({
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;
}
},
@@ -341,9 +238,8 @@ module.exports = React.createClass({
}
if (newProps.eventId != this.props.eventId) {
- console.log("RoomView switching to eventId " + newProps.eventId +
- " (was " + this.props.eventId + ")");
- return this._initTimeline(newProps);
+ // when we change focussed event id, hide the search results.
+ this.setState({searchResults: null});
}
},
@@ -372,26 +268,12 @@ module.exports = React.createClass({
});
}
}
-
- // 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();
- }
},
- onRoomRedaction: function(ev, room) {
- if (this.unmounted) return;
-
- // ignore events for other rooms
- if (room.roomId != this.props.roomId) return;
-
- // we could skip an update if the event isn't in our timeline,
- // but that's probably an early optimisation.
- this.forceUpdate();
+ // called when state.room is first initialised (either at initial load,
+ // after a successful peek, or after we join the room).
+ _onRoomLoaded: function(room) {
+ this._calculatePeekRules(room);
},
_calculatePeekRules: function(room) {
@@ -416,19 +298,25 @@ module.exports = React.createClass({
// 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,
// 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({
room: room
});
- this._initTimeline(this.props).done();
+ this._onRoomLoaded(room);
}
},
onRoomName: function(room) {
- if (room.roomId == this.props.roomId) {
- this.setState({
- room: room
- });
+ // NB don't set state.room here.
+ //
+ // 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();
}
},
@@ -455,42 +343,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) {
- this.forceUpdate();
- },
-
onRoomStateMember: function(ev, state, member) {
if (member.roomId === this.props.roomId) {
// a member state changed in this room, refresh the tab complete list
@@ -554,10 +406,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({
@@ -594,18 +442,7 @@ 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.roomView) {
var roomView = ReactDOM.findDOMNode(this.refs.roomView);
if (!roomView.ondrop) {
@@ -615,27 +452,6 @@ module.exports = React.createClass({
roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
}
}
-
- 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.
- 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) {
@@ -653,23 +469,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() {
var eventsToResend = this._getUnsentMessages(this.state.room);
eventsToResend.forEach(function(event) {
@@ -746,19 +545,16 @@ module.exports = React.createClass({
},
onMessageListScroll: function(ev) {
- if (this.refs.messagePanel.isAtBottom() &&
- !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
- if (this.state.numUnreadMessages != 0) {
- this.setState({ numUnreadMessages: 0 });
- }
- if (!this.state.atEndOfLiveTimeline) {
- this.setState({ atEndOfLiveTimeline: true });
- }
+ if (this.refs.messagePanel.isAtEndOfLiveTimeline()) {
+ this.setState({
+ numUnreadMessages: 0,
+ atEndOfLiveTimeline: true,
+ });
}
else {
- if (this.state.atEndOfLiveTimeline) {
- this.setState({ atEndOfLiveTimeline: false });
- }
+ this.setState({
+ atEndOfLiveTimeline: false,
+ });
}
},
@@ -976,226 +772,6 @@ module.exports = React.createClass({
return ret;
},
- getEventTiles: function() {
- var DateSeparator = sdk.getComponent('messages.DateSeparator');
- var EventTile = sdk.getComponent('rooms.EventTile');
-
- // once images in the events load, make the scrollPanel check the
- // scroll offsets.
- var onImageLoad = () => {
- var scrollPanel = this.refs.messagePanel;
- if (scrollPanel) {
- scrollPanel.checkScroll();
- }
- }
-
- var ret = [];
- var count = 0;
-
- 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];
- var eventId = mxEv.getId();
-
- // 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;
-
- var wantTile = true;
-
- if (!EventTile.haveTileForEvent(mxEv)) {
- wantTile = false;
- }
- else if (this.props.ConferenceHandler && mxEv.getType() === "m.room.member") {
- if (this.props.ConferenceHandler.isConferenceUser(mxEv.getSender()) ||
- this.props.ConferenceHandler.isConferenceUser(mxEv.getStateKey())) {
- // don't suppress conf user join/parts entirely, as they're useful!
- // wantTile = false;
- }
- }
-
- if (!wantTile) {
- // if we aren't showing the event, put in a dummy scroll token anyway, so
- // that we can scroll to the right place.
- ret.push(
);
- continue;
- }
-
- // 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 highlight = (eventId == this.props.highlightedEventId);
-
- 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()) {
- 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, true);
- 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() {
- if (this.eventNodes === undefined) return null;
-
- var messageWrapper = this.refs.messagePanel;
- if (messageWrapper === undefined) return null;
- var wrapperRect = ReactDOM.findDOMNode(messageWrapper).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 = this.eventNodes[ev.getId()];
- if (!node) continue;
-
- var boundingRect = node.getBoundingClientRect();
-
- if (boundingRect.bottom < wrapperRect.bottom) {
- return i;
- }
- }
- return null;
- },
-
onSettingsClick: function() {
this.showSettings(true);
},
@@ -1298,28 +874,9 @@ module.exports = React.createClass({
});
},
- onConferenceNotificationClick: function() {
- dis.dispatch({
- action: 'place_call',
- type: "video",
- room_id: this.props.roomId
- });
- },
-
// 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();
- }
- }
+ this.refs.messagePanel.jumpToLiveTimeline();
},
// get the current scroll position of the room, so that it can be
@@ -1390,25 +947,10 @@ module.exports = React.createClass({
// but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
- if (this.refs.callView) {
- var fullscreenElement =
- (document.fullscreenElement ||
- document.mozFullScreenElement ||
- document.webkitFullscreenElement);
- if (!fullscreenElement) {
- var video = this.refs.callView.getVideoView().getRemoteVideoElement();
- video.style.maxHeight = auxPanelMaxHeight + "px";
- }
- }
-
- // we need to do this for general auxPanels too
- if (this.refs.auxPanel) {
- this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px";
- }
-
- // the above might have made the aux panel resize itself, so now
- // we need to tell the gemini panel to adapt.
- this.onChildResize();
+ // we may need to resize the gemini panel after changing the aux panel
+ // size, so add a callback to onChildResize.
+ this.setState({auxPanelMaxHeight: auxPanelMaxHeight},
+ this.onChildResize);
},
onFullscreenClick: function() {
@@ -1442,11 +984,6 @@ module.exports = React.createClass({
});
},
- onCallViewResize: function() {
- this.onChildResize();
- this.onResize();
- },
-
onChildResize: function() {
// When the video, status bar, or the message composer resizes, the
// scroll panel also changes size. Work around GeminiScrollBar fail by
@@ -1469,17 +1006,18 @@ module.exports = React.createClass({
render: function() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var MessageComposer = sdk.getComponent('rooms.MessageComposer');
- var CallView = sdk.getComponent("voip.CallView");
var RoomSettings = sdk.getComponent("rooms.RoomSettings");
+ var AuxPanel = sdk.getComponent("rooms.AuxPanel");
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 TimelinePanel = sdk.getComponent("structures.TimelinePanel");
- if (!this._timelineWindow) {
+ if (!this.state.room) {
if (this.props.roomId) {
- if (this.state.timelineLoading) {
+ if (this.state.roomLoading) {
return (
@@ -1553,7 +1091,6 @@ module.exports = React.createClass({
var scrollheader_classes = classNames({
mx_RoomView_scrollheader: true,
- loading: this.state.paginating
});
var statusBar;
@@ -1613,28 +1150,16 @@ module.exports = React.createClass({
);
}
- var conferenceCallNotification = null;
- if (this.state.displayConfCallNotification) {
- var supportedText;
- if (!MatrixClientPeg.get().supportsVoip()) {
- supportedText = " (unsupported)";
- }
- conferenceCallNotification = (
-
- Ongoing conference call {supportedText}
-
- );
- }
-
- var fileDropTarget = null;
- if (this.state.draggingFile) {
- fileDropTarget =
-
-
- Drop file here to upload
-
-
;
- }
+ var auxPanel = (
+
+ { aux }
+
+ );
var messageComposer, searchInfo;
var canSpeak = (
@@ -1709,47 +1234,20 @@ module.exports = React.createClass({
hideMessagePanel = true;
}
- var messagePanel;
-
- // 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) {
- messagePanel = (
-
-
-
- );
- } 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 = (
-
-
- {this.getEventTiles()}
-
- );
- }
+ var messagePanel = (
+
{
+ this.refs.messagePanel = r;
+ if(r) {
+ this.updateTint();
+ }
+ }}
+ room={this.state.room}
+ hidden={hideMessagePanel}
+ highlightedEventId={this.props.highlightedEventId}
+ eventId={this.props.eventId}
+ eventPixelOffset={this.props.eventPixelOffset}
+ onScroll={ this.onMessageListScroll }
+ />);
return (
@@ -1765,13 +1263,7 @@ module.exports = React.createClass({
onLeaveClick={
(myMember && myMember.membership === "join") ? this.onLeaveClick : null
} />
-
- { fileDropTarget }
-
- { conferenceCallNotification }
- { aux }
-
+ { auxPanel }
{ messagePanel }
{ searchResultsPanel }
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
new file mode 100644
index 0000000000..adad8bc060
--- /dev/null
+++ b/src/components/structures/TimelinePanel.js
@@ -0,0 +1,463 @@
+/*
+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: none' 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 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(),
+ };
+ },
+
+ 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);
+ MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
+
+ 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);
+
+ var client = MatrixClientPeg.get();
+ if (client) {
+ client.removeListener("Room.timeline", this.onRoomTimeline);
+ client.removeListener("Room.receipt", this.onRoomReceipt);
+ client.removeListener("Room.redaction", this.onRoomRedaction);
+ }
+ },
+
+ // 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,
+ });
+ },
+
+ onRoomRedaction: function(ev, room) {
+ if (this.unmounted) return;
+
+ // ignore events for other rooms
+ if (room !== this.props.room) return;
+
+ // we could skip an update if the event isn't in our timeline,
+ // but that's probably an early optimisation.
+ this.forceUpdate();
+ },
+
+ sendReadReceipt: function() {
+ if (!this.refs.messagePanel) return;
+
+ var currentReadUpToEventId = this._getCurrentReadReceipt(true);
+ 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();
+ },
+
+ _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.
+ *
+ * @param {Boolean} ignoreSynthesized If true, return only receipts that
+ * have been sent by the server, not
+ * implicit ones generated by the JS
+ * SDK.
+ */
+ _getCurrentReadReceipt: function(ignoreSynthesized) {
+ 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, ignoreSynthesized);
+ },
+
+
+ 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 (
+
+
+
+ );
+ }
+
+ // 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 (
+
+ );
+ },
+});
diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js
new file mode 100644
index 0000000000..49c7dfc6e5
--- /dev/null
+++ b/src/components/views/rooms/AuxPanel.js
@@ -0,0 +1,104 @@
+/*
+Copyright 2015, 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 MatrixClientPeg = require("../../../MatrixClientPeg");
+var sdk = require('../../../index');
+var dis = require("../../../dispatcher");
+
+module.exports = React.createClass({
+ displayName: 'AuxPanel',
+
+ propTypes: {
+ // js-sdk room object
+ room: React.PropTypes.object.isRequired,
+
+ // Conference Handler implementation
+ conferenceHandler: React.PropTypes.object,
+
+ // set to true to show the file drop target
+ draggingFile: React.PropTypes.bool,
+
+ // set to true to show the 'active conf call' banner
+ displayConfCallNotification: React.PropTypes.bool,
+
+ // maxHeight attribute for the aux panel and the video
+ // therein
+ maxHeight: React.PropTypes.number,
+
+ // a callback which is called when the video element in a voip call is
+ // resized due to a change in video metadata
+ onCallViewVideoResize: React.PropTypes.func,
+ },
+
+ onConferenceNotificationClick: function() {
+ dis.dispatch({
+ action: 'place_call',
+ type: "video",
+ room_id: this.props.room.roomId,
+ });
+ },
+
+ render: function() {
+ var CallView = sdk.getComponent("voip.CallView");
+ var TintableSvg = sdk.getComponent("elements.TintableSvg");
+
+ var fileDropTarget = null;
+ if (this.props.draggingFile) {
+ fileDropTarget = (
+
+
+
+
+ Drop file here to upload
+
+
+ );
+ }
+
+ var conferenceCallNotification = null;
+ if (this.props.displayConfCallNotification) {
+ var supportedText;
+ if (!MatrixClientPeg.get().supportsVoip()) {
+ supportedText = " (unsupported)";
+ }
+ conferenceCallNotification = (
+
+ Ongoing conference call {supportedText}
+
+ );
+ }
+
+ var callView = (
+
+ );
+
+ return (
+
+ { fileDropTarget }
+ { callView }
+ { conferenceCallNotification }
+ { this.props.children }
+
+ );
+ },
+});
diff --git a/src/components/views/voip/CallView.js b/src/components/views/voip/CallView.js
index 5958c2b278..13abcb7f9d 100644
--- a/src/components/views/voip/CallView.js
+++ b/src/components/views/voip/CallView.js
@@ -19,38 +19,32 @@ var CallHandler = require("../../../CallHandler");
var sdk = require('../../../index');
var MatrixClientPeg = require("../../../MatrixClientPeg");
-/*
- * State vars:
- * this.state.call = MatrixCall|null
- *
- * Props:
- * this.props.room = Room (JS SDK)
- * this.props.ConferenceHandler = A Conference Handler implementation
- * Must have a function signature:
- * getConferenceCallForRoom(roomId: string): MatrixCall
- */
-
module.exports = React.createClass({
displayName: 'CallView',
propTypes: {
- // a callback which is called when the video within the callview
- // due to a change in video metadata
+ // js-sdk room object
+ room: React.PropTypes.object.isRequired,
+
+ // A Conference Handler implementation
+ // Must have a function signature:
+ // getConferenceCallForRoom(roomId: string): MatrixCall
+ ConferenceHandler: React.PropTypes.object,
+
+ // maxHeight style attribute for the video panel
+ maxVideoHeight: React.PropTypes.number,
+
+ // a callback which is called when the user clicks on the video div
+ onClick: React.PropTypes.func,
+
+ // a callback which is called when the video within the callview is
+ // resized due to a change in video metadata
onResize: React.PropTypes.func,
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
- if (this.props.room) {
- this.showCall(this.props.room.roomId);
- }
- else {
- // XXX: why would we ever not have a this.props.room?
- var call = CallHandler.getAnyActiveCall();
- if (call) {
- this.showCall(call.roomId);
- }
- }
+ this.showCall(this.props.room.roomId);
},
componentWillUnmount: function() {
@@ -103,7 +97,10 @@ module.exports = React.createClass({
render: function(){
var VideoView = sdk.getComponent('voip.VideoView');
return (
-
+
);
}
});
diff --git a/src/components/views/voip/VideoFeed.js b/src/components/views/voip/VideoFeed.js
index c4a65d1145..0b8d0b20fc 100644
--- a/src/components/views/voip/VideoFeed.js
+++ b/src/components/views/voip/VideoFeed.js
@@ -22,6 +22,9 @@ module.exports = React.createClass({
displayName: 'VideoFeed',
propTypes: {
+ // maxHeight style attribute for the video element
+ maxHeight: React.PropTypes.number,
+
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize: React.PropTypes.func,
@@ -43,7 +46,7 @@ module.exports = React.createClass({
render: function() {
return (
-