placeholder NotificationPanel

This commit is contained in:
Matthew Hodgson 2016-08-30 01:11:26 +01:00
parent d9ffe30a0d
commit ae34f2ed5c
2 changed files with 4 additions and 699 deletions

View File

@ -29,6 +29,7 @@ module.exports.components['structures.ContextualMenu'] = require('./components/s
module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom');
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.MessagePanel'] = require('./components/structures/MessagePanel');
module.exports.components['structures.NotificationPanel'] = require('./components/structures/NotificationPanel');
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');

View File

@ -16,716 +16,20 @@ limitations under the License.
var React = require('react'); var React = require('react');
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var q = require("q");
var Matrix = require("matrix-js-sdk");
var sdk = require('../../index'); var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg"); var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var ObjectUtils = require('../../ObjectUtils');
var Modal = require("../../Modal");
var UserActivity = require("../../UserActivity");
var KeyCode = require('../../KeyCode');
var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20;
var TIMELINE_CAP = 250; // 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 notification timeline, based on TimelinePanel * Component which shows the notification timeline using a TimelinePanel
*/ */
var NotificationPanel = React.createClass({ var NotificationPanel = React.createClass({
displayName: 'NotificationPanel', displayName: 'NotificationPanel',
propTypes: {
// 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
// half way down the viewport.
eventPixelOffset: React.PropTypes.number,
// callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
},
getInitialState: function() {
return {
events: [],
notificationsLoading: true, // track whether our room timeline is loading
// canBackPaginate == false may mean:
//
// * we haven't (successfully) loaded the notifications yet, or:
//
// * the server indicated that there were no more visible events
// (normally implying we got to the start of time), or:
//
// * we gave up asking the server for more events
canBackPaginate: false,
// canForwardPaginate == false may mean:
//
// * we haven't (successfully) loaded the notifications yet
//
// * we have got to the end of time and are now tracking live
// notifications
//
// * we are looking at some historical point, but gave up asking
// the server for more events
canForwardPaginate: false,
backPaginating: false,
forwardPaginating: false,
};
},
componentWillMount: function() {
debuglog("NotificationPanel: mounting");
MatrixClientPeg.get().on("Notification.timeline", this.onNotificationTimeline);
this._initNotification(this.props);
},
shouldComponentUpdate: function(nextProps, nextState) {
if (!ObjectUtils.shallowEqual(this.props, nextProps)) {
if (DEBUG) {
console.group("NotificationPanel.shouldComponentUpdate: props change");
console.log("props before:", this.props);
console.log("props after:", nextProps);
console.groupEnd();
}
return true;
}
if (!ObjectUtils.shallowEqual(this.state, nextState)) {
if (DEBUG) {
console.group("NotificationPanel.shouldComponentUpdate: state change");
console.log("state before:", this.state);
console.log("state after:", nextState);
console.groupEnd();
}
return true;
}
return false;
},
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;
var client = MatrixClientPeg.get();
if (client) {
client.removeListener("Notification.timeline", this.onNotificationTimeline);
}
},
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
var canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate';
var paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating';
if (!this.state[canPaginateKey]) {
debuglog("NotificationPanel: have given up", dir, "paginating notifications");
return q(false);
}
if(!this._timelineWindow.canPaginate(dir)) {
debuglog("TimelinePanel: can't", dir, "paginate any further");
this.setState({[canPaginateKey]: false});
return q(false);
}
debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards);
this.setState({[paginatingKey]: true});
return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => {
if (this.unmounted) { return; }
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
var newState = {
[paginatingKey]: false,
[canPaginateKey]: r,
events: this._getEvents(),
};
// moving the window in this direction may mean that we can now
// paginate in the other where we previously could not.
var otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
var canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate';
if (!this.state[canPaginateOtherWayKey] &&
this._timelineWindow.canPaginate(otherDirection)) {
debuglog('TimelinePanel: can now', otherDirection, 'paginate again');
newState[canPaginateOtherWayKey] = true;
}
this.setState(newState);
return r;
});
},
onMessageListScroll: function () {
if (this.props.onScroll) {
this.props.onScroll();
}
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (this.getReadMarkerPosition() < 0) {
this.setState({readMarkerVisible: true});
}
},
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;
if (!this.refs.messagePanel) return;
if (!this.refs.messagePanel.getScrollState().stuckAtBottom) {
// we won't load this event now, because we don't want to push any
// events off the other end of the timeline. But we need to note
// that we can now paginate.
this.setState({canForwardPaginate: true});
return;
}
// tell the timeline window to try to advance itself, but not to make
// an http request to do so.
//
// we deliberately avoid going via the ScrollPanel for this call - the
// ScrollPanel might already have an active pagination promise, which
// will fail, but would stop us passing the pagination request to the
// timeline window.
//
// see https://github.com/vector-im/vector-web/issues/1035
this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).done(() => {
if (this.unmounted) { return; }
var events = this._timelineWindow.getEvents();
var lastEv = events[events.length-1];
// if we're at the end of the live timeline, append the pending events
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.room.getPendingEvents());
}
var updatedState = {events: events};
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userCurrentlyActive.
//
var myUserId = MatrixClientPeg.get().credentials.userId;
var sender = ev.sender ? ev.sender.userId : null;
var callback = null;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
updatedState.readMarkerVisible = true;
} else if(lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this._setReadMarker(lastEv.getId(), lastEv.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastEv.getId();
callback = this.props.onReadMarkerUpdated;
}
this.setState(updatedState, callback);
});
},
onRoomTimelineReset: function(room) {
if (room !== this.props.room) return;
if (this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) {
this._loadTimeline();
}
},
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();
},
onRoomReceipt: function(ev, room) {
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.room) return;
this.forceUpdate();
},
onLocalEchoUpdated: function(ev, room, oldEventId) {
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.room) return;
this._reloadEvents();
},
/* 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();
}
}
},
/* scroll to show the read-up-to marker. We put it 1/3 of the way down
* the container.
*/
jumpToReadMarker: function() {
if (!this.refs.messagePanel)
return;
if (!this.state.readMarkerEventId)
return;
// we may not have loaded the event corresponding to the read-marker
// into the _timelineWindow. In that case, attempts to scroll to it
// will fail.
//
// a quick way to figure out if we've loaded the relevant event is
// simply to check if the messagepanel knows where the read-marker is.
var ret = this.refs.messagePanel.getReadMarkerPosition();
if (ret !== null) {
// The messagepanel knows where the RM is, so we must have loaded
// the relevant event.
this.refs.messagePanel.scrollToEvent(this.state.readMarkerEventId,
0, 1/3);
return;
}
// Looks like we haven't loaded the event corresponding to the read-marker.
// As with jumpToLiveTimeline, we want to reload the timeline around the
// read-marker.
this._loadTimeline(this.state.readMarkerEventId, 0, 1/3);
},
/* update the read-up-to marker to match the read receipt
*/
forgetReadMarker: function() {
var rmId = this._getCurrentReadReceipt();
// see if we know the timestamp for the rr event
var tl = this.props.room.getTimelineForEvent(rmId);
var rmTs;
if (tl) {
var event = tl.getEvents().find((e) => { return e.getId() == rmId });
if (event) {
rmTs = event.getTs();
}
}
this._setReadMarker(rmId, rmTs);
},
/* 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();
},
// returns one of:
//
// null: there is no read marker
// -1: read marker is above the window
// 0: read marker is visible
// +1: read marker is below the window
getReadMarkerPosition: function() {
if (!this.refs.messagePanel) { return null; }
var ret = this.refs.messagePanel.getReadMarkerPosition();
if (ret !== null) {
return ret;
}
// the messagePanel doesn't know where the read marker is.
// if we know the timestamp of the read marker, make a guess based on that.
var rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.room.roomId];
if (rmTs && this.state.events.length > 0) {
if (rmTs < this.state.events[0].getTs()) {
return -1;
} else {
return 1;
}
}
return null;
},
/**
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
*/
handleScrollKey: function(ev) {
if (!this.refs.messagePanel) { return; }
// jump to the live timeline on ctrl-end, rather than the end of the
// timeline window.
if (ev.ctrlKey && ev.keyCode == KeyCode.END) {
this.jumpToLiveTimeline();
} else {
this.refs.messagePanel.handleScrollKey(ev);
}
},
_initTimeline: function(props) {
var initialEvent = props.eventId;
var pixelOffset = props.eventPixelOffset;
// if a pixelOffset is given, it is relative to the bottom of the
// container. If not, put the event in the middle of the container.
var offsetBase = 1;
if (pixelOffset == null) {
offsetBase = 0.5;
}
return this._loadTimeline(initialEvent, pixelOffset, offsetBase);
},
/**
* (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 offsetBase). If omitted, defaults to 0.
*
* @param {number?} offsetBase the reference point for the pixelOffset. 0
* means the top of the container, 1 means the bottom, and fractional
* values mean somewhere in the middle. If omitted, it defaults to 0.
*
* returns a promise which will resolve when the load completes.
*/
_loadTimeline: function(eventId, pixelOffset, offsetBase) {
this._timelineWindow = new Matrix.TimelineWindow(
MatrixClientPeg.get(), this.props.room,
{windowLimit: TIMELINE_CAP});
var onLoaded = () => {
this._reloadEvents();
// If we switched away from the room while there were pending
// outgoing events, the read-marker will be before those events.
// We need to skip over any which have subsequently been sent.
this._advanceReadMarkerPastMyEvents();
this.setState({
canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS),
canForwardPaginate: this._timelineWindow.canPaginate(EventTimeline.FORWARDS),
timelineLoading: false,
}, () => {
// initialise the scroll state of the message panel
if (!this.refs.messagePanel) {
// this shouldn't happen - we know we're mounted because
// we're in a setState callback, and we know
// timelineLoading is now false, so render() should have
// mounted the message panel.
console.log("can't initialise scroll state because " +
"messagePanel didn't load");
return;
}
if (eventId) {
this.refs.messagePanel.scrollToEvent(eventId, pixelOffset,
offsetBase);
} else {
this.refs.messagePanel.scrollToBottom();
}
this.sendReadReceipt();
this.updateReadMarker();
});
};
var onError = (error) => {
this.setState({timelineLoading: false});
var msg = error.message ? error.message : JSON.stringify(error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var onFinished;
// if we were given an event ID, then when the user closes the
// dialog, let's jump to the end of the timeline. If we weren't,
// something has gone badly wrong and rather than causing a loop of
// undismissable dialogs, let's just give up.
if (eventId) {
onFinished = () => {
// go via the dispatcher so that the URL is updated
dis.dispatch({
action: 'view_room',
room_id: this.props.room.roomId,
});
};
}
var message = "Vector was trying to load a specific point in this room's timeline but ";
if (error.errcode == 'M_FORBIDDEN') {
message += "you do not have permission to view the message in question.";
} else {
message += "was unable to find it.";
}
Modal.createDialog(ErrorDialog, {
title: "Failed to load timeline position",
description: message,
onFinished: onFinished,
});
}
var prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
// if we already have the event in question, TimelineWindow.load
// returns a resolved promise.
//
// In this situation, we don't really want to defer the update of the
// state to the next event loop, because it makes room-switching feel
// quite slow. So we detect that situation and shortcut straight to
// calling _reloadEvents and updating the state.
if (prom.isFulfilled()) {
onLoaded();
} else {
this.setState({
events: [],
canBackPaginate: false,
canForwardPaginate: false,
timelineLoading: true,
});
prom = prom.then(onLoaded, onError)
}
prom.done();
},
// handle the completion of a timeline load or localEchoUpdate, by
// reloading the events from the timelinewindow and pending event list into
// the state.
_reloadEvents: function() {
// we might have switched rooms since the load started - just bin
// the results if so.
if (this.unmounted) return;
this.setState({
events: this._getEvents(),
});
},
// get the list of events from the timeline window and the pending event list
_getEvents: function() {
var events = this._timelineWindow.getEvents();
// if we're at the end of the live timeline, append the pending events
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.room.getPendingEvents());
}
return events;
},
_indexForEventId: function(evId) {
for (var i = 0; i < this.state.events.length; ++i) {
if (evId == this.state.events[i].getId()) {
return i;
}
}
return null;
},
_getLastDisplayedEventIndex: function(opts) {
opts = opts || {};
var ignoreOwn = opts.ignoreOwn || false;
var ignoreEchoes = opts.ignoreEchoes || false;
var allowPartial = opts.allowPartial || false;
var messagePanel = this.refs.messagePanel;
if (messagePanel === undefined) return null;
var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
var myUserId = MatrixClientPeg.get().credentials.userId;
for (var i = this.state.events.length-1; i >= 0; --i) {
var ev = this.state.events[i];
if (ignoreOwn && ev.sender && ev.sender.userId == myUserId) {
continue;
}
// local echoes have a fake event ID
if (ignoreEchoes && ev.status) {
continue;
}
var node = messagePanel.getNodeForEventId(ev.getId());
if (!node) continue;
var boundingRect = node.getBoundingClientRect();
if ((allowPartial && boundingRect.top < wrapperRect.bottom) ||
(!allowPartial && 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);
},
_setReadMarker: function(eventId, eventTs, inhibitSetState) {
if (TimelinePanel.roomReadMarkerMap[this.props.room.roomId] == eventId) {
// don't update the state (and cause a re-render) if there is
// no change to the RM.
return;
}
// ideally we'd sync these via the server, but for now just stash them
// in a map.
TimelinePanel.roomReadMarkerMap[this.props.room.roomId] = eventId;
// in order to later figure out if the read marker is
// above or below the visible timeline, we stash the timestamp.
TimelinePanel.roomReadMarkerTsMap[this.props.room.roomId] = eventTs;
if (inhibitSetState) {
return;
}
// run the render cycle before calling the callback, so that
// getReadMarkerPosition() returns the right thing.
this.setState({
readMarkerEventId: eventId,
}, this.props.onReadMarkerUpdated);
},
render: function() { render: function() {
var MessagePanel = sdk.getComponent("structures.MessagePanel"); // wrap a TimelinePanel with the jump-to-event bits turned off.
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 }
backPaginating={ this.state.backPaginating }
forwardPaginating={ this.state.forwardPaginating }
events={ this.state.events }
highlightedEventId={ this.props.highlightedEventId }
readMarkerEventId={ this.state.readMarkerEventId }
readMarkerVisible={ this.state.readMarkerVisible }
suppressFirstDateSeparator={ this.state.canBackPaginate }
showUrlPreview = { this.props.showUrlPreview }
ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
opacity={ this.props.opacity }
/>
);
}, },
}); });
module.exports = TimelinePanel; module.exports = NotificationPanel;