From d9ffe30a0d368b3c205a9dd2e9d6984b5173c326 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 23 Aug 2016 14:30:15 +0100 Subject: [PATCH 01/25] WIP notif panel --- .../structures/NotificationPanel.js | 731 ++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 src/components/structures/NotificationPanel.js diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js new file mode 100644 index 0000000000..e16a95ecc4 --- /dev/null +++ b/src/components/structures/NotificationPanel.js @@ -0,0 +1,731 @@ +/* +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 sdk = require('../../index'); +var MatrixClientPeg = require("../../MatrixClientPeg"); +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 + */ +var NotificationPanel = React.createClass({ + 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() { + var MessagePanel = sdk.getComponent("structures.MessagePanel"); + var Loader = sdk.getComponent("elements.Spinner"); + + // just show a spinner while the timeline loads. + // + // put it in a div of the right class (mx_RoomView_messagePanel) so + // that the order in the roomview flexbox is correct, and + // mx_RoomView_messageListWrapper to position the inner div in the + // right place. + // + // Note that the click-on-search-result functionality relies on the + // fact that the messagePanel is hidden while the timeline reloads, + // but that the RoomHeader (complete with search term) continues to + // exist. + if (this.state.timelineLoading) { + return ( +
+ +
+ ); + } + + // give the messagepanel a stickybottom if we're at the end of the + // live timeline, so that the arrival of new events triggers a + // scroll. + // + // Make sure that stickyBottom is *false* if we can paginate + // forwards, otherwise if somebody hits the bottom of the loaded + // events when viewing historical messages, we get stuck in a loop + // of paginating our way through the entire history of the room. + var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); + + return ( +