Land refactored roomview branch 'rav/roomview_works'.

This branch has been sitting around a while; it includes a substantial refactor
of RoomView (into separate MessagePanel and TimelinePanel), as well as a number
of fixes.
This commit is contained in:
Richard van der Hoff 2016-02-24 16:52:12 +00:00
commit 1959b03104
9 changed files with 1017 additions and 621 deletions

View file

@ -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.PostRegistration'] = require('./components/structures/login/PostRegistration');
module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration');
module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); 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.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');
@ -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.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.AliasSettings'] = require('./components/views/room_settings/AliasSettings');
module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings'); 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.EntityTile'] = require('./components/views/rooms/EntityTile');
module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile');
module.exports.components['views.rooms.InviteMemberList'] = require('./components/views/rooms/InviteMemberList'); module.exports.components['views.rooms.InviteMemberList'] = require('./components/views/rooms/InviteMemberList');

View file

@ -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(<li key={eventId} data-scroll-token={eventId}/>);
}
if (eventId == this.props.readMarkerEventId && i < lastShownEventIndex) {
// 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.
var nextEvent = this.props.events[i+1];
if (nextEvent.sender && nextEvent.sender.userId != this.props.ourUserId) {
ret.push(this._getReadMarkerTile());
readMarkerVisible = true;
}
} else if (eventId == this.currentReadMarkerEventId && !this.currentGhostEventId) {
// there is currently a read-up-to marker at this point, but no
// more. Show an animation of it disappearing.
ret.push(this._getReadMarkerGhostTile());
this.currentGhostEventId = eventId;
} else if (eventId == this.currentGhostEventId) {
// if we're showing an animation, continue to show it.
ret.push(this._getReadMarkerGhostTile());
}
}
this.currentReadMarkerEventId = readMarkerVisible ? this.props.readMarkerEventId : null;
return ret;
},
_getTilesForEvent: function(prevEvent, mxEv, last) {
var EventTile = sdk.getComponent('rooms.EventTile');
var DateSeparator = sdk.getComponent('messages.DateSeparator');
var ret = [];
// is this a continuation of the previous message?
var continuation = false;
if (prevEvent !== null && prevEvent.sender && mxEv.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.props.suppressFirstDateSeparator) ||
(prevEvent != null &&
new Date(prevEvent.getTs()).toDateString()
!== new Date(ts1).toDateString())) {
var dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>;
ret.push(dateSeparator);
continuation = false;
}
var 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(
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-token={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
last={last} isSelectedEvent={highlight}
onImageLoad={this._onImageLoad} />
</li>
);
return ret;
},
_getReadMarkerTile: function() {
var hr;
hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
/>;
return (
<li key="_readupto"
className="mx_RoomView_myReadMarker_container">
{hr}
</li>
);
},
_getReadMarkerGhostTile: function() {
// reset the ghostEventId when the animation finishes, so that
// we can make a new one (and so that we don't run the
// animation code every time we render)
var completeFunc = () => {
this.currentGhostEventId = null;
};
var hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
ref={function(n) {
Velocity(n, {opacity: '0', width: '10%'},
{duration: 400, easing: 'easeInSine',
delay: 1000, complete: completeFunc});
}}
/>;
// give it a key which depends on the event id. That will ensure that
// we get a new DOM node (restarting the animation) when the ghost
// moves to a different event.
return (
<li key={"_readuptoghost_"+this.currentGhostEventId}
className="mx_RoomView_myReadMarker_container">
{hr}
</li>
);
},
_collectEventNode: function(eventId, node) {
this.eventNodes[eventId] = node;
},
// once images in the events load, make the scrollPanel check the
// scroll offsets.
_onImageLoad: function() {
var scrollPanel = this.refs.messagePanel;
if (scrollPanel) {
scrollPanel.checkScroll();
}
},
render: function() {
var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
return (
<ScrollPanel ref="scrollPanel" className="mx_RoomView_messagePanel"
onScroll={ this.props.onScroll }
onFillRequest={ this.props.onFillRequest }
style={ this.props.hidden ? { display: 'none' } : {} }
stickyBottom={ this.props.stickyBottom }>
{this._getEventTiles()}
</ScrollPanel>
);
},
});

View file

@ -61,11 +61,13 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
syncState: MatrixClientPeg.get().getSyncState(), syncState: MatrixClientPeg.get().getSyncState(),
whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room),
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
}, },
componentDidUpdate: function(prevProps, prevState) { componentDidUpdate: function(prevProps, prevState) {
@ -76,8 +78,10 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
// we may have entirely lost our client as we're logging out before clicking login on the guest bar... // we may have entirely lost our client as we're logging out before clicking login on the guest bar...
if (MatrixClientPeg.get()) { var client = MatrixClientPeg.get();
MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange); if (client) {
client.removeListener("sync", this.onSyncStateChange);
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
} }
}, },
@ -90,6 +94,12 @@ module.exports = React.createClass({
}); });
}, },
onRoomMemberTyping: function(ev, member) {
this.setState({
whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room),
});
},
// determine if we need to call onResize // determine if we need to call onResize
_checkForResize: function(prevProps, prevState) { _checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar. We // figure out the old height and the new height of the status bar. We
@ -235,7 +245,7 @@ module.exports = React.createClass({
); );
} }
var typingString = WhoIsTyping.whoIsTypingString(this.props.room); var typingString = this.state.whoisTypingString;
if (typingString) { if (typingString) {
return ( return (
<div className="mx_RoomStatusBar_typingBar"> <div className="mx_RoomStatusBar_typingBar">

View file

@ -15,18 +15,15 @@ limitations under the License.
*/ */
// TODO: This component is enormous! There's several things which could stand-alone: // TODO: This component is enormous! There's several things which could stand-alone:
// - Aux component
// - Search results component // - Search results component
// - Drag and drop // - Drag and drop
// - File uploading - uploadFile() // - File uploading - uploadFile()
// - Timeline component (alllll the logic in getEventTiles())
var React = require("react"); var React = require("react");
var ReactDOM = require("react-dom"); 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");
@ -42,14 +39,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 {
@ -81,17 +73,11 @@ module.exports = React.createClass({
highlightedEventId: React.PropTypes.string, highlightedEventId: React.PropTypes.string,
}, },
/* properties in RoomView objects include:
*
* eventNodes: a map from event id to DOM node representing that event
*/
getInitialState: function() { getInitialState: function() {
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,
@ -100,29 +86,24 @@ 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
// 'scroll to bottom' knob, among a couple of other things. // 'scroll to bottom' knob, among a couple of other things.
atEndOfLiveTimeline: true, atEndOfLiveTimeline: true,
auxPanelMaxHeight: undefined,
} }
}, },
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.redaction", this.onRoomRedaction);
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("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
// completing at the start of the text // 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: // 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)
@ -153,93 +127,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 {
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 // 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, // here and say if it's got an 'errcode' key with the right value,
// it means we can't peek. // it means we can't peek.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") {
// This is fine: the room just isn't peekable (we assume). // This is fine: the room just isn't peekable (we assume).
this.setState({ this.setState({
timelineLoading: false, roomLoading: false,
}); });
} else { } else {
throw err; throw err;
} }
}).done(); }).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);
} else { } else {
this.refs.messagePanel.scrollToBottom(); this._onRoomLoaded(this.state.room);
} }
this.sendReadReceipt();
});
});
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -264,11 +173,8 @@ module.exports = React.createClass({
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); 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.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("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
} }
@ -322,15 +228,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;
} }
}, },
@ -341,9 +238,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);
} }
}, },
@ -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) { // called when state.room is first initialised (either at initial load,
if (this.unmounted) return; // after a successful peek, or after we join the room).
_onRoomLoaded: function(room) {
// ignore events for other rooms this._calculatePeekRules(room);
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();
}, },
_calculatePeekRules: function(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) // 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) {
// 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) { if (room.roomId == this.props.roomId) {
this.setState({ this.forceUpdate();
room: room
});
} }
}, },
@ -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) { onRoomStateMember: function(ev, state, member) {
if (member.roomId === this.props.roomId) { if (member.roomId === this.props.roomId) {
// a member state changed in this room, refresh the tab complete list // a member state changed in this room, refresh the tab complete list
@ -554,10 +406,6 @@ module.exports = React.createClass({
}, },
componentDidMount: function() { componentDidMount: function() {
if (this.refs.messagePanel) {
this._initialiseMessagePanel();
}
var call = CallHandler.getCallForRoom(this.props.roomId); var call = CallHandler.getCallForRoom(this.props.roomId);
var callState = call ? call.call_state : "ended"; var callState = call ? call.call_state : "ended";
this.setState({ this.setState({
@ -594,18 +442,7 @@ module.exports = React.createClass({
); );
}, 500), }, 500),
_initialiseMessagePanel: function() {
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
this.refs.messagePanel.initialised = true;
this.updateTint();
},
componentDidUpdate: function() { 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) { if (this.refs.roomView) {
var roomView = ReactDOM.findDOMNode(this.refs.roomView); var roomView = ReactDOM.findDOMNode(this.refs.roomView);
if (!roomView.ondrop) { if (!roomView.ondrop) {
@ -615,27 +452,6 @@ module.exports = React.createClass({
roomView.addEventListener('dragend', this.onDragLeaveOrEnd); 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) { 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() { onResendAllClick: function() {
var eventsToResend = this._getUnsentMessages(this.state.room); var eventsToResend = this._getUnsentMessages(this.state.room);
eventsToResend.forEach(function(event) { eventsToResend.forEach(function(event) {
@ -746,19 +545,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,
} });
} }
}, },
@ -976,226 +772,6 @@ module.exports = React.createClass({
return ret; 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(<li key={eventId} data-scroll-token={scrollToken}/>);
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 = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '99%'}} ref={function(n) {
self.readMarkerNode = n;
}} />);
readMarkerIndex = ret.length;
ret.push(<li key="_readupto" className="mx_RoomView_myReadMarker_container">{hr}</li>);
}
// 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 = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>;
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(
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-token={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
last={last} isSelectedEvent={highlight}
onImageLoad={onImageLoad} />
</li>
);
// 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 = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '99%'}} ref={function(n) {
Velocity(n, {opacity: '0', width: '10%'}, {duration: 400, easing: 'easeInSine', delay: 1000, complete: function() {
if (!self.unmounted) self.setState({readMarkerGhostEventId: undefined});
}});
}} />);
ret.splice(ghostIndex, 0, (
<li key="_readuptoghost" className="mx_RoomView_myReadMarker_container">{hr}</li>
));
}
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() { onSettingsClick: function() {
this.showSettings(true); 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 // 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
@ -1390,25 +947,10 @@ module.exports = React.createClass({
// but it's better than the video going missing entirely // but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
if (this.refs.callView) { // we may need to resize the gemini panel after changing the aux panel
var fullscreenElement = // size, so add a callback to onChildResize.
(document.fullscreenElement || this.setState({auxPanelMaxHeight: auxPanelMaxHeight},
document.mozFullScreenElement || this.onChildResize);
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();
}, },
onFullscreenClick: function() { onFullscreenClick: function() {
@ -1442,11 +984,6 @@ module.exports = React.createClass({
}); });
}, },
onCallViewResize: function() {
this.onChildResize();
this.onResize();
},
onChildResize: function() { onChildResize: function() {
// When the video, status bar, or the message composer resizes, the // When the video, status bar, or the message composer resizes, the
// scroll panel also changes size. Work around GeminiScrollBar fail by // scroll panel also changes size. Work around GeminiScrollBar fail by
@ -1469,17 +1006,18 @@ module.exports = React.createClass({
render: function() { render: function() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var MessageComposer = sdk.getComponent('rooms.MessageComposer'); var MessageComposer = sdk.getComponent('rooms.MessageComposer');
var CallView = sdk.getComponent("voip.CallView");
var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var RoomSettings = sdk.getComponent("rooms.RoomSettings");
var AuxPanel = sdk.getComponent("rooms.AuxPanel");
var SearchBar = sdk.getComponent("rooms.SearchBar"); var SearchBar = sdk.getComponent("rooms.SearchBar");
var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); 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 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 />
@ -1553,7 +1091,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;
@ -1613,28 +1150,16 @@ module.exports = React.createClass({
); );
} }
var conferenceCallNotification = null; var auxPanel = (
if (this.state.displayConfCallNotification) { <AuxPanel ref="auxPanel" room={this.state.room}
var supportedText; conferenceHandler={this.props.ConferenceHandler}
if (!MatrixClientPeg.get().supportsVoip()) { draggingFile={this.state.draggingFile}
supportedText = " (unsupported)"; displayConfCallNotification={this.state.displayConfCallNotification}
} maxHeight={this.state.auxPanelMaxHeight}
conferenceCallNotification = ( onCallViewVideoRezize={this.onChildResize} >
<div className="mx_RoomView_ongoingConfCallNotification" onClick={this.onConferenceNotificationClick}> { aux }
Ongoing conference call {supportedText} </AuxPanel>
</div>
); );
}
var fileDropTarget = null;
if (this.state.draggingFile) {
fileDropTarget = <div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel" title="Drop File Here">
<TintableSvg src="img/upload-big.svg" width="45" height="59"/><br/>
Drop file here to upload
</div>
</div>;
}
var messageComposer, searchInfo; var messageComposer, searchInfo;
var canSpeak = ( var canSpeak = (
@ -1709,47 +1234,20 @@ 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;
// if(r) {
// put it in a div of the right class (mx_RoomView_messagePanel) so this.updateTint();
// 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 = (
<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 = (
<ScrollPanel ref="messagePanel" className="mx_RoomView_messagePanel"
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
style={ hideMessagePanel ? { display: 'none' } : {} }
stickyBottom={ stickyBottom }>
<li className={scrollheader_classes}></li>
{this.getEventTiles()}
</ScrollPanel>
);
} }
}}
room={this.state.room}
hidden={hideMessagePanel}
highlightedEventId={this.props.highlightedEventId}
eventId={this.props.eventId}
eventPixelOffset={this.props.eventPixelOffset}
onScroll={ this.onMessageListScroll }
/>);
return ( return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView"> <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">
@ -1765,13 +1263,7 @@ module.exports = React.createClass({
onLeaveClick={ onLeaveClick={
(myMember && myMember.membership === "join") ? this.onLeaveClick : null (myMember && myMember.membership === "join") ? this.onLeaveClick : null
} /> } />
<div className="mx_RoomView_auxPanel" ref="auxPanel"> { auxPanel }
{ fileDropTarget }
<CallView ref="callView" room={this.state.room} ConferenceHandler={this.props.ConferenceHandler}
onResize={this.onCallViewResize} />
{ conferenceCallNotification }
{ aux }
</div>
{ messagePanel } { messagePanel }
{ searchResultsPanel } { searchResultsPanel }
<div className="mx_RoomView_statusArea"> <div className="mx_RoomView_statusArea">

View file

@ -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 (
<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 }
suppressFirstDateSeparator={ this.state.canBackPaginate }
ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom }
onScroll={ this.props.onScroll }
onFillRequest={ this.onMessageListFillRequest }
/>
);
},
});

View file

@ -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 = (
<div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel"
title="Drop File Here">
<TintableSvg src="img/upload-big.svg" width="45" height="59"/>
<br/>
Drop file here to upload
</div>
</div>
);
}
var conferenceCallNotification = null;
if (this.props.displayConfCallNotification) {
var supportedText;
if (!MatrixClientPeg.get().supportsVoip()) {
supportedText = " (unsupported)";
}
conferenceCallNotification = (
<div className="mx_RoomView_ongoingConfCallNotification"
onClick={this.onConferenceNotificationClick}>
Ongoing conference call {supportedText}
</div>
);
}
var callView = (
<CallView ref="callView" room={this.props.room}
ConferenceHandler={this.props.conferenceHandler}
onResize={this.props.onCallViewVideoResize}
maxVideoHeight={this.props.maxHeight}
/>
);
return (
<div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} >
{ fileDropTarget }
{ callView }
{ conferenceCallNotification }
{ this.props.children }
</div>
);
},
});

View file

@ -19,38 +19,32 @@ var CallHandler = require("../../../CallHandler");
var sdk = require('../../../index'); var sdk = require('../../../index');
var MatrixClientPeg = require("../../../MatrixClientPeg"); 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({ module.exports = React.createClass({
displayName: 'CallView', displayName: 'CallView',
propTypes: { propTypes: {
// a callback which is called when the video within the callview // js-sdk room object
// due to a change in video metadata 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, onResize: React.PropTypes.func,
}, },
componentDidMount: function() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
if (this.props.room) {
this.showCall(this.props.room.roomId); 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);
}
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -103,7 +97,10 @@ module.exports = React.createClass({
render: function(){ render: function(){
var VideoView = sdk.getComponent('voip.VideoView'); var VideoView = sdk.getComponent('voip.VideoView');
return ( return (
<VideoView ref="video" onClick={ this.props.onClick } onResize={ this.props.onResize }/> <VideoView ref="video" onClick={ this.props.onClick }
onResize={ this.props.onResize }
maxHeight={ this.props.maxVideoHeight }
/>
); );
} }
}); });

View file

@ -22,6 +22,9 @@ module.exports = React.createClass({
displayName: 'VideoFeed', displayName: 'VideoFeed',
propTypes: { propTypes: {
// maxHeight style attribute for the video element
maxHeight: React.PropTypes.number,
// a callback which is called when the video element is resized // a callback which is called when the video element is resized
// due to a change in video metadata // due to a change in video metadata
onResize: React.PropTypes.func, onResize: React.PropTypes.func,
@ -43,7 +46,7 @@ module.exports = React.createClass({
render: function() { render: function() {
return ( return (
<video ref="vid"> <video ref="vid" style={{maxHeight: this.props.maxHeight}}>
</video> </video>
); );
}, },

View file

@ -25,6 +25,18 @@ var dis = require('../../../dispatcher');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'VideoView', displayName: 'VideoView',
propTypes: {
// maxHeight style attribute for the video element
maxHeight: 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 element is resized due to
// a change in video metadata
onResize: React.PropTypes.func,
},
componentDidMount: function() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
}, },
@ -64,7 +76,6 @@ module.exports = React.createClass({
element.msRequestFullscreen element.msRequestFullscreen
); );
requestMethod.call(element); requestMethod.call(element);
this.getRemoteVideoElement().style.maxHeight = "inherit";
} }
else { else {
var exitMethod = ( var exitMethod = (
@ -83,10 +94,18 @@ module.exports = React.createClass({
render: function() { render: function() {
var VideoFeed = sdk.getComponent('voip.VideoFeed'); var VideoFeed = sdk.getComponent('voip.VideoFeed');
// if we're fullscreen, we don't want to set a maxHeight on the video element.
var fullscreenElement = (document.fullscreenElement ||
document.mozFullScreenElement ||
document.webkitFullscreenElement);
var maxVideoHeight = fullscreenElement ? null : this.props.maxHeight;
return ( return (
<div className="mx_VideoView" ref={this.setContainer} onClick={ this.props.onClick }> <div className="mx_VideoView" ref={this.setContainer} onClick={ this.props.onClick }>
<div className="mx_VideoView_remoteVideoFeed"> <div className="mx_VideoView_remoteVideoFeed">
<VideoFeed ref="remote" onResize={this.props.onResize}/> <VideoFeed ref="remote" onResize={this.props.onResize}
maxHeight={maxVideoHeight} />
<audio ref="remoteAudio"/> <audio ref="remoteAudio"/>
</div> </div>
<div className="mx_VideoView_localVideoFeed"> <div className="mx_VideoView_localVideoFeed">