From d9ffe30a0d368b3c205a9dd2e9d6984b5173c326 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Tue, 23 Aug 2016 14:30:15 +0100 Subject: [PATCH 01/26] 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 ( + <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; From ae34f2ed5c567680d2b9a675475e4e8b160649c9 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Tue, 30 Aug 2016 01:11:26 +0100 Subject: [PATCH 02/26] placeholder NotificationPanel --- src/component-index.js | 1 + .../structures/NotificationPanel.js | 702 +----------------- 2 files changed, 4 insertions(+), 699 deletions(-) diff --git a/src/component-index.js b/src/component-index.js index 3871f60e15..6dbda67943 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -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.MatrixChat'] = require('./components/structures/MatrixChat'); 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.RoomView'] = require('./components/structures/RoomView'); module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index e16a95ecc4..12bf9750a7 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -16,716 +16,20 @@ 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 + * Component which shows the notification timeline using a 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 ( - <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 } - /> - ); + // wrap a TimelinePanel with the jump-to-event bits turned off. }, }); -module.exports = TimelinePanel; +module.exports = NotificationPanel; From c3de0359643c6610c4cd52133c72571872c8ce8f Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Wed, 31 Aug 2016 11:58:06 +0100 Subject: [PATCH 03/26] filepanel --- src/components/structures/FilePanel.js | 62 ++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/components/structures/FilePanel.js diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js new file mode 100644 index 0000000000..6e1f5e4633 --- /dev/null +++ b/src/components/structures/FilePanel.js @@ -0,0 +1,62 @@ +/* +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 sdk = require('../../index'); +var MatrixClientPeg = require("../../MatrixClientPeg"); +var dis = require("../../dispatcher"); + +/* + * Component which shows the filtered file using a TimelinePanel + */ +var FilePanel = React.createClass({ + displayName: 'FilePanel', + + // this has to be a proper method rather than an unnamed function, + // otherwise react calls it with null on each update. + _gatherTimelinePanelRef: function(r) { + this.refs.messagePanel = r; + }, + + render: function() { + // wrap a TimelinePanel with the jump-to-event bits turned off. + + var room = MatrixClientPeg.get().getRoom(this.props.roomId); + + // <TimelinePanel ref={this._gatherTimelinePanelRef} + // room={this.state.room} + // hidden={hideMessagePanel} + // highlightedEventId={this.props.highlightedEventId} + // eventId={this.props.eventId} + // eventPixelOffset={this.props.eventPixelOffset} + // onScroll={ this.onMessageListScroll } + // onReadMarkerUpdated={ this._updateTopUnreadMessagesBar } + // showUrlPreview = { this.state.showUrlPreview } + // opacity={ this.props.opacity } + + return ( + <TimelinePanel ref={this._gatherTimelinePanelRef} + room={this.state.room} + showUrlPreview = { false } + opacity={ this.props.opacity } + /> + ); + }, +}); + +module.exports = NotificationPanel; From 820cd579d8d06aaaae069c4cd095546b64d8b03a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Mon, 5 Sep 2016 11:48:22 +0100 Subject: [PATCH 04/26] fix stupid double-spacing issue --- src/components/views/rooms/AuxPanel.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index 4eaa19193e..f7c3052ea8 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -93,8 +93,9 @@ module.exports = React.createClass({ } else { joinText = (<span> - Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice')}} href="#">voice</a> - or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video') }} href="#">video</a>. + Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice')}} + href="#">voice</a> or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video') }} + href="#">video</a>. </span>); } From e22d0a53b6924c24dc6d5fab61a6e138a57a16c5 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Tue, 6 Sep 2016 00:59:17 +0100 Subject: [PATCH 05/26] WIP switch-over of TimePanel from taking Rooms to taking EventTimelineSets --- src/components/structures/FilePanel.js | 46 +++++- src/components/structures/RoomView.js | 4 +- src/components/structures/TimelinePanel.js | 140 ++++++++++-------- .../structures/TimelinePanel-test.js | 17 ++- 4 files changed, 139 insertions(+), 68 deletions(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 6e1f5e4633..f43a848568 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -27,6 +27,46 @@ var dis = require("../../dispatcher"); var FilePanel = React.createClass({ displayName: 'FilePanel', + propTypes: { + roomId: React.PropTypes.string.isRequired, + }, + + getInitialState: function() { + return { + room: MatrixClientPeg.get().getRoom(this.props.roomId), + timelineSet: null, + } + }, + + componentWillMount: function() { + if (this.state.room) { + var client = MatrixClientPeg.get(); + var filter = new Matrix.Filter(client.credentials.userId); + filter.setDefinition( + { + "room": { + "timeline": { + "contains_url": true + }, + } + } + ); + + client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then( + (filterId)=>{ + var timelineSet = this.state.room.getOrCreateFilteredTimelineSet(filter); + this.setState({ timelineSet: timelineSet }); + }, + (error)=>{ + console.error("Failed to get or create file panel filter", error); + } + ); + } + else { + console.error("Failed to add filtered timelineSet for FilePanel as no room!"); + } + }, + // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. _gatherTimelinePanelRef: function(r) { @@ -36,8 +76,6 @@ var FilePanel = React.createClass({ render: function() { // wrap a TimelinePanel with the jump-to-event bits turned off. - var room = MatrixClientPeg.get().getRoom(this.props.roomId); - // <TimelinePanel ref={this._gatherTimelinePanelRef} // room={this.state.room} // hidden={hideMessagePanel} @@ -51,7 +89,9 @@ var FilePanel = React.createClass({ return ( <TimelinePanel ref={this._gatherTimelinePanelRef} - room={this.state.room} + manageReadReceipts={false} + manageReadMarkers={false} + timelineSet={this.state.timelineSet} showUrlPreview = { false } opacity={ this.props.opacity } /> diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 041493d420..73d1b8582b 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1570,7 +1570,9 @@ module.exports = React.createClass({ var messagePanel = ( <TimelinePanel ref={this._gatherTimelinePanelRef} - room={this.state.room} + timelineSet={this.state.room.getTimelineSets()[0]} + manageReadReceipts={true} + manageReadMarkers={true} hidden={hideMessagePanel} highlightedEventId={this.props.highlightedEventId} eventId={this.props.eventId} diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index c80a8c89d7..6e337eb6ab 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -50,9 +50,15 @@ var TimelinePanel = React.createClass({ displayName: 'TimelinePanel', propTypes: { - // The js-sdk Room object for the room whose timeline we are - // representing. - room: React.PropTypes.object.isRequired, + // The js-sdk EventTimelineSet object for the timeline sequence we are + // representing. This may or may not have a room, depending on what it's + // a timeline representing. If it has a room, we maintain RRs etc for + // that room. + timelineSet: React.PropTypes.object.isRequired, + + // Enable managing RRs and RMs. These require the timelineSet to have a room. + manageReadReceipts: React.PropTypes.bool, + manageReadMarkers: React.PropTypes.bool, // true to give the component a 'display: none' style. hidden: React.PropTypes.bool, @@ -101,9 +107,13 @@ var TimelinePanel = React.createClass({ }, getInitialState: function() { - var initialReadMarker = - TimelinePanel.roomReadMarkerMap[this.props.room.roomId] - || this._getCurrentReadReceipt(); + // XXX: we could track RM per TimelineSet rather than per Room. + // but for now we just do it per room for simplicity. + if (this.props.manageReadMarkers) { + var initialReadMarker = + TimelinePanel.roomReadMarkerMap[this.props.timelineSet.room.roomId] + || this._getCurrentReadReceipt(); + } return { events: [], @@ -137,7 +147,7 @@ var TimelinePanel = React.createClass({ canForwardPaginate: false, // start with the read-marker visible, so that we see its animated - // disappearance when swtitching into the room. + // disappearance when switching into the room. readMarkerVisible: true, readMarkerEventId: initialReadMarker, @@ -163,8 +173,8 @@ var TimelinePanel = React.createClass({ }, componentWillReceiveProps: function(newProps) { - if (newProps.room !== this.props.room) { - // throw new Error("changing room on a TimelinePanel is not supported"); + if (newProps.timelineSet !== this.props.timelineSet) { + // throw new Error("changing timelineSet on a TimelinePanel is not supported"); // regrettably, this does happen; in particular, when joining a // room with /join. In that case, there are two Rooms in @@ -175,7 +185,7 @@ var TimelinePanel = React.createClass({ // // for now, just warn about this. But we're going to end up paginating // both rooms separately, and it's all bad. - console.warn("Replacing room on a TimelinePanel - confusion may ensue"); + console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue"); } if (newProps.eventId != this.props.eventId) { @@ -280,11 +290,13 @@ var TimelinePanel = React.createClass({ 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}); + if (this.props.manageReadMarkers) { + // 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}); + } } }, @@ -305,7 +317,7 @@ var TimelinePanel = React.createClass({ onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { // ignore events for other rooms - if (room !== this.props.room) return; + if (data.timelineSet !== this.props.timelineSet) return; // ignore anything but real-time updates at the end of the room: // updates from pagination will happen when the paginate completes. @@ -337,32 +349,34 @@ var TimelinePanel = React.createClass({ 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()); + if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + events.push(... this.props.timelineSet.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; + if (this.props.manageReadMarkers) { + // 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); @@ -370,7 +384,7 @@ var TimelinePanel = React.createClass({ }, onRoomTimelineReset: function(room) { - if (room !== this.props.room) return; + if (room !== this.props.timelineSet.room) return; if (this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { this._loadTimeline(); @@ -381,7 +395,7 @@ var TimelinePanel = React.createClass({ if (this.unmounted) return; // ignore events for other rooms - if (room !== this.props.room) return; + if (room !== this.props.timelineSet.room) return; // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. @@ -392,7 +406,7 @@ var TimelinePanel = React.createClass({ if (this.unmounted) return; // ignore events for other rooms - if (room !== this.props.room) return; + if (room !== this.props.timelineSet.room) return; this.forceUpdate(); }, @@ -401,7 +415,7 @@ var TimelinePanel = React.createClass({ if (this.unmounted) return; // ignore events for other rooms - if (room !== this.props.room) return; + if (room !== this.props.timelineSet.room) return; this._reloadEvents(); }, @@ -409,12 +423,13 @@ var TimelinePanel = React.createClass({ sendReadReceipt: function() { if (!this.refs.messagePanel) return; + if (!this.props.manageReadReceipts) return; // if we are scrolled to the bottom, do a quick-reset of our unreadNotificationCount // to avoid having to wait from the remote echo from the homeserver. if (this.isAtEndOfLiveTimeline()) { - this.props.room.setUnreadNotificationCount('total', 0); - this.props.room.setUnreadNotificationCount('highlight', 0); + this.props.timelineSet.room.setUnreadNotificationCount('total', 0); + this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); // XXX: i'm a bit surprised we don't have to emit an event or dispatch to get this picked up } @@ -461,6 +476,7 @@ var TimelinePanel = React.createClass({ // if the read marker is on the screen, we can now assume we've caught up to the end // of the screen, so move the marker down to the bottom of the screen. updateReadMarker: function() { + if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() !== 0) { return; } @@ -498,6 +514,8 @@ var TimelinePanel = React.createClass({ // advance the read marker past any events we sent ourselves. _advanceReadMarkerPastMyEvents: function() { + if (!this.props.manageReadMarkers) return; + // we call _timelineWindow.getEvents() rather than using // this.state.events, because react batches the update to the latter, so it // may not have been updated yet. @@ -548,11 +566,9 @@ var TimelinePanel = React.createClass({ * the container. */ jumpToReadMarker: function() { - if (!this.refs.messagePanel) - return; - - if (!this.state.readMarkerEventId) - return; + if (!this.props.manageReadMarkers) return; + 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 @@ -579,10 +595,12 @@ var TimelinePanel = React.createClass({ /* update the read-up-to marker to match the read receipt */ forgetReadMarker: function() { + if (!this.props.manageReadMarkers) return; + var rmId = this._getCurrentReadReceipt(); // see if we know the timestamp for the rr event - var tl = this.props.room.getTimelineForEvent(rmId); + var tl = this.props.timelineSet.getTimelineForEvent(rmId); var rmTs; if (tl) { var event = tl.getEvents().find((e) => { return e.getId() == rmId }); @@ -622,7 +640,9 @@ var TimelinePanel = React.createClass({ // 0: read marker is visible // +1: read marker is below the window getReadMarkerPosition: function() { - if (!this.refs.messagePanel) { return null; } + if (!this.props.manageReadMarkers) return null; + if (!this.refs.messagePanel) return null; + var ret = this.refs.messagePanel.getReadMarkerPosition(); if (ret !== null) { return ret; @@ -630,7 +650,7 @@ var TimelinePanel = React.createClass({ // 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]; + var rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.timelineSet.roomId]; if (rmTs && this.state.events.length > 0) { if (rmTs < this.state.events[0].getTs()) { return -1; @@ -691,7 +711,7 @@ var TimelinePanel = React.createClass({ */ _loadTimeline: function(eventId, pixelOffset, offsetBase) { this._timelineWindow = new Matrix.TimelineWindow( - MatrixClientPeg.get(), this.props.room, + MatrixClientPeg.get(), this.props.timelineSet, {windowLimit: this.props.timelineCap}); var onLoaded = () => { @@ -745,7 +765,7 @@ var TimelinePanel = React.createClass({ // go via the dispatcher so that the URL is updated dis.dispatch({ action: 'view_room', - room_id: this.props.room.roomId, + room_id: this.props.timelineSet.roomId, }); }; } @@ -807,7 +827,7 @@ var TimelinePanel = React.createClass({ // 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()); + events.push(... this.props.timelineSet.room.getPendingEvents()); } return events; @@ -873,11 +893,13 @@ var TimelinePanel = React.createClass({ return null; var myUserId = client.credentials.userId; - return this.props.room.getEventReadUpTo(myUserId, ignoreSynthesized); + return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); }, _setReadMarker: function(eventId, eventTs, inhibitSetState) { - if (TimelinePanel.roomReadMarkerMap[this.props.room.roomId] == eventId) { + var roomId = this.props.timelineSet.room.roomId; + + if (TimelinePanel.roomReadMarkerMap[roomId] == eventId) { // don't update the state (and cause a re-render) if there is // no change to the RM. return; @@ -885,11 +907,11 @@ var TimelinePanel = React.createClass({ // ideally we'd sync these via the server, but for now just stash them // in a map. - TimelinePanel.roomReadMarkerMap[this.props.room.roomId] = eventId; + TimelinePanel.roomReadMarkerMap[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; + TimelinePanel.roomReadMarkerTsMap[roomId] = eventTs; if (inhibitSetState) { return; diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index 027d888d2d..4683637a1b 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -35,6 +35,7 @@ var USER_ID = '@me:localhost'; describe('TimelinePanel', function() { var sandbox; + var timelineSet; var room; var client; var timeline; @@ -60,9 +61,12 @@ describe('TimelinePanel', function() { timeline = new jssdk.EventTimeline(ROOM_ID); room = sinon.createStubInstance(jssdk.Room); - room.getLiveTimeline.returns(timeline); room.getPendingEvents.returns([]); + timelineSet = sinon.createStubInstance(jssdk.EventTimelineSet); + timelineSet.getLiveTimeline.returns(timeline); + timelineSet.room = room; + client = peg.get(); client.credentials = {userId: USER_ID}; @@ -95,7 +99,7 @@ describe('TimelinePanel', function() { var scrollDefer; var panel = ReactDOM.render( - <TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}} + <TimelinePanel timelineSet={timelineSet} onScroll={() => {scrollDefer.resolve()}} />, parentDiv, ); @@ -143,7 +147,10 @@ describe('TimelinePanel', function() { // a new event! var ev = mkMessage(); timeline.addEvent(ev); - panel.onRoomTimeline(ev, room, false, false, {liveEvent: true}); + panel.onRoomTimeline(ev, room, false, false, { + liveEvent: true, + timelineSet: timelineSet, + }); // that won't make much difference, because we don't paginate // unless we're at the bottom of the timeline, but a scroll event @@ -178,7 +185,7 @@ describe('TimelinePanel', function() { }); var panel = ReactDOM.render( - <TimelinePanel room={room}/>, + <TimelinePanel timelineSet={timelineSet}/>, parentDiv ); @@ -226,7 +233,7 @@ describe('TimelinePanel', function() { var scrollDefer; var panel = ReactDOM.render( - <TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}} + <TimelinePanel timelineSet={timelineSet} onScroll={() => {scrollDefer.resolve()}} timelineCap={TIMELINE_CAP} />, parentDiv From 850578a973bf8224e5ce37dd56d9c1951ee24117 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Tue, 6 Sep 2016 01:33:06 +0100 Subject: [PATCH 06/26] FilePanel --- src/component-index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/component-index.js b/src/component-index.js index 6dbda67943..762412e2c2 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -27,6 +27,7 @@ limitations under the License. module.exports.components = {}; module.exports.components['structures.ContextualMenu'] = require('./components/structures/ContextualMenu'); module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); +module.exports.components['structures.FilePanel'] = require('./components/structures/FilePanel'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel'); module.exports.components['structures.NotificationPanel'] = require('./components/structures/NotificationPanel'); From b4c1eca1c5452783bdad9388a986901f250b35bf Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Tue, 6 Sep 2016 01:44:42 +0100 Subject: [PATCH 07/26] fix key warning --- src/components/views/rooms/MessageComposer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 7b84d394e0..d5e7bf3abd 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -67,7 +67,7 @@ export default class MessageComposer extends React.Component { let fileList = []; for (let i=0; i<files.length; i++) { - fileList.push(<li> + fileList.push(<li key={i}> <TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name} </li>); } From e5dd2e0b463366d8c316c6d392fe4dc97f7b4d19 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Tue, 6 Sep 2016 01:44:55 +0100 Subject: [PATCH 08/26] make FilePanel work, superficially at least --- src/components/structures/FilePanel.js | 30 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index f43a848568..9b8c197889 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -17,6 +17,7 @@ limitations under the License. var React = require('react'); var ReactDOM = require("react-dom"); +var Matrix = require("matrix-js-sdk"); var sdk = require('../../index'); var MatrixClientPeg = require("../../MatrixClientPeg"); var dis = require("../../dispatcher"); @@ -70,11 +71,13 @@ var FilePanel = React.createClass({ // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. _gatherTimelinePanelRef: function(r) { - this.refs.messagePanel = r; + //this.refs.messagePanel = r; }, render: function() { // wrap a TimelinePanel with the jump-to-event bits turned off. + var TimelinePanel = sdk.getComponent("structures.TimelinePanel"); + var Loader = sdk.getComponent("elements.Spinner"); // <TimelinePanel ref={this._gatherTimelinePanelRef} // room={this.state.room} @@ -87,16 +90,21 @@ var FilePanel = React.createClass({ // showUrlPreview = { this.state.showUrlPreview } // opacity={ this.props.opacity } - return ( - <TimelinePanel ref={this._gatherTimelinePanelRef} - manageReadReceipts={false} - manageReadMarkers={false} - timelineSet={this.state.timelineSet} - showUrlPreview = { false } - opacity={ this.props.opacity } - /> - ); + if (this.state.timelineSet) { + return ( + <TimelinePanel ref={this._gatherTimelinePanelRef} + manageReadReceipts={false} + manageReadMarkers={false} + timelineSet={this.state.timelineSet} + showUrlPreview = { false } + opacity={ this.props.opacity } + /> + ); + } + else { + return <Loader/> + } }, }); -module.exports = NotificationPanel; +module.exports = FilePanel; From c2f2fb38168101bf565b4d3b4cfd7b1e0571f721 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Tue, 6 Sep 2016 12:10:43 +0100 Subject: [PATCH 09/26] use passive event listener for mousewheel --- src/UserActivity.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/UserActivity.js b/src/UserActivity.js index 5c80f4743e..e7338e17e9 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -37,7 +37,8 @@ class UserActivity { // itself being scrolled. Need to use addEventListener's useCapture. // also this needs to be the wheel event, not scroll, as scroll is // fired when the view scrolls down for a new message. - window.addEventListener('wheel', this._onUserActivity.bind(this), true); + window.addEventListener('wheel', this._onUserActivity.bind(this), + { passive: true, capture: true }); this.lastActivityAtTs = new Date().getTime(); this.lastDispatchAtTs = 0; this.activityEndTimer = undefined; @@ -50,7 +51,8 @@ class UserActivity { document.onmousedown = undefined; document.onmousemove = undefined; document.onkeypress = undefined; - window.removeEventListener('wheel', this._onUserActivity.bind(this), true); + window.removeEventListener('wheel', this._onUserActivity.bind(this), + { passive: true, capture: true }); } /** From e7074ef9af15f22ec8f1b80d891e80162c897432 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Wed, 7 Sep 2016 02:16:29 +0100 Subject: [PATCH 10/26] apply FilePanel css and fix filtering --- src/components/structures/FilePanel.js | 2 ++ src/components/structures/MessagePanel.js | 5 ++++- src/components/structures/RoomView.js | 1 + src/components/structures/TimelinePanel.js | 8 ++++++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 9b8c197889..58888d39b3 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -55,6 +55,7 @@ var FilePanel = React.createClass({ client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then( (filterId)=>{ + filter.filterId = filterId; var timelineSet = this.state.room.getOrCreateFilteredTimelineSet(filter); this.setState({ timelineSet: timelineSet }); }, @@ -93,6 +94,7 @@ var FilePanel = React.createClass({ if (this.state.timelineSet) { return ( <TimelinePanel ref={this._gatherTimelinePanelRef} + className="mx_FilePanel" manageReadReceipts={false} manageReadMarkers={false} timelineSet={this.state.timelineSet} diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 73ea2fd1a0..54ed501289 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -73,6 +73,9 @@ module.exports = React.createClass({ // opacity for dynamic UI fading effects opacity: React.PropTypes.number, + + // className for the panel + className: React.PropTypes.string.isRequired, }, componentWillMount: function() { @@ -503,7 +506,7 @@ module.exports = React.createClass({ style.opacity = this.props.opacity; return ( - <ScrollPanel ref="scrollPanel" className="mx_RoomView_messagePanel mx_fadable" + <ScrollPanel ref="scrollPanel" className={ this.props.className + " mx_fadable" } onScroll={ this.props.onScroll } onResize={ this.onResize } onFillRequest={ this.props.onFillRequest } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 73d1b8582b..8d2d2631fe 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1581,6 +1581,7 @@ module.exports = React.createClass({ onReadMarkerUpdated={ this._updateTopUnreadMessagesBar } showUrlPreview = { this.state.showUrlPreview } opacity={ this.props.opacity } + className="mx_RoomView_messagePanel" />); var topUnreadMessagesBar = null; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 6e337eb6ab..122737ed52 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -90,6 +90,9 @@ var TimelinePanel = React.createClass({ // maximum number of events to show in a timeline timelineCap: React.PropTypes.number, + + // classname to use for the messagepanel + className: React.PropTypes.string.isRequired, }, statics: { @@ -827,7 +830,7 @@ var TimelinePanel = React.createClass({ // if we're at the end of the live timeline, append the pending events if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - events.push(... this.props.timelineSet.room.getPendingEvents()); + events.push(... this.props.timelineSet.getPendingEvents()); } return events; @@ -941,7 +944,7 @@ var TimelinePanel = React.createClass({ // exist. if (this.state.timelineLoading) { return ( - <div className="mx_RoomView_messagePanel mx_RoomView_messageListWrapper"> + <div className={ this.props.className + " mx_RoomView_messageListWrapper" }> <Loader /> </div> ); @@ -973,6 +976,7 @@ var TimelinePanel = React.createClass({ onScroll={ this.onMessageListScroll } onFillRequest={ this.onMessageListFillRequest } opacity={ this.props.opacity } + className={ this.props.className } /> ); }, From 990d84df776ed6e57cc8f700e64e1eb03c55d2e4 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Wed, 7 Sep 2016 15:43:14 +0100 Subject: [PATCH 11/26] make FilePanel refresh when we switch rooms --- src/components/structures/FilePanel.js | 42 +++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 58888d39b3..b8752da619 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -34,14 +34,27 @@ var FilePanel = React.createClass({ getInitialState: function() { return { - room: MatrixClientPeg.get().getRoom(this.props.roomId), timelineSet: null, } }, componentWillMount: function() { - if (this.state.room) { - var client = MatrixClientPeg.get(); + this.updateTimelineSet(this.props.roomId); + }, + + componentWillReceiveProps: function(nextProps) { + if (nextProps.roomId !== this.props.roomId) { + // otherwise we race between updating the TimelinePanel and determining the new timelineSet + this.setState({ timelineSet: null }); + this.updateTimelineSet(nextProps.roomId); + } + }, + + updateTimelineSet: function(roomId) { + var client = MatrixClientPeg.get(); + var room = client.getRoom(roomId); + + if (room) { var filter = new Matrix.Filter(client.credentials.userId); filter.setDefinition( { @@ -56,7 +69,7 @@ var FilePanel = React.createClass({ client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then( (filterId)=>{ filter.filterId = filterId; - var timelineSet = this.state.room.getOrCreateFilteredTimelineSet(filter); + var timelineSet = room.getOrCreateFilteredTimelineSet(filter); this.setState({ timelineSet: timelineSet }); }, (error)=>{ @@ -80,20 +93,11 @@ var FilePanel = React.createClass({ var TimelinePanel = sdk.getComponent("structures.TimelinePanel"); var Loader = sdk.getComponent("elements.Spinner"); - // <TimelinePanel ref={this._gatherTimelinePanelRef} - // room={this.state.room} - // hidden={hideMessagePanel} - // highlightedEventId={this.props.highlightedEventId} - // eventId={this.props.eventId} - // eventPixelOffset={this.props.eventPixelOffset} - // onScroll={ this.onMessageListScroll } - // onReadMarkerUpdated={ this._updateTopUnreadMessagesBar } - // showUrlPreview = { this.state.showUrlPreview } - // opacity={ this.props.opacity } - if (this.state.timelineSet) { + console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.roomId + " " + + "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); return ( - <TimelinePanel ref={this._gatherTimelinePanelRef} + <TimelinePanel key={"filepanel_" + this.props.roomId} ref={this._gatherTimelinePanelRef} className="mx_FilePanel" manageReadReceipts={false} manageReadMarkers={false} @@ -104,7 +108,11 @@ var FilePanel = React.createClass({ ); } else { - return <Loader/> + return ( + <div className="mx_FilePanel"> + <Loader/> + </div> + ); } }, }); From 8f75bce29ee3be7ce8a6792e40fa8c6229d610e4 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Wed, 7 Sep 2016 16:09:44 +0100 Subject: [PATCH 12/26] add fixmes --- src/components/structures/FilePanel.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index b8752da619..95fd978f59 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -44,7 +44,12 @@ var FilePanel = React.createClass({ componentWillReceiveProps: function(nextProps) { if (nextProps.roomId !== this.props.roomId) { - // otherwise we race between updating the TimelinePanel and determining the new timelineSet + // otherwise we race between re-rendering the TimelinePanel and setting the new timelineSet. + // + // FIXME: this race only happens because of the promise returned by getOrCreateFilter(). + // We should only need to create the containsUrl filter once per login session, so in practice + // it shouldn't be being done here at all. Then we could just update the timelineSet directly + // without resetting it first, and speed up room-change. this.setState({ timelineSet: null }); this.updateTimelineSet(nextProps.roomId); } @@ -66,6 +71,7 @@ var FilePanel = React.createClass({ } ); + // FIXME: we shouldn't be doing this every time we change room - see comment above. client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter).then( (filterId)=>{ filter.filterId = filterId; From 334564c814cdd3621400e6c2297d7d54ba20ccd1 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Wed, 7 Sep 2016 21:10:31 +0100 Subject: [PATCH 13/26] unbreak tests --- src/components/structures/TimelinePanel.js | 3 ++- test/components/structures/TimelinePanel-test.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 122737ed52..47ad89c75b 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -92,7 +92,7 @@ var TimelinePanel = React.createClass({ timelineCap: React.PropTypes.number, // classname to use for the messagepanel - className: React.PropTypes.string.isRequired, + className: React.PropTypes.string, }, statics: { @@ -106,6 +106,7 @@ var TimelinePanel = React.createClass({ getDefaultProps: function() { return { timelineCap: 250, + className: 'mx_RoomView_messagePanel', }; }, diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index 4683637a1b..c2bb616b0d 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -61,10 +61,10 @@ describe('TimelinePanel', function() { timeline = new jssdk.EventTimeline(ROOM_ID); room = sinon.createStubInstance(jssdk.Room); - room.getPendingEvents.returns([]); timelineSet = sinon.createStubInstance(jssdk.EventTimelineSet); timelineSet.getLiveTimeline.returns(timeline); + timelineSet.getPendingEvents.returns([]); timelineSet.room = room; client = peg.get(); From 7174648f94cedc174499e1bba113ff9eb9d7755a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Thu, 8 Sep 2016 03:01:38 +0100 Subject: [PATCH 14/26] don't show RRs if manageRRs is false --- src/components/structures/MessagePanel.js | 8 +++++++- src/components/structures/TimelinePanel.js | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 54ed501289..4dd4a488f9 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -60,6 +60,9 @@ module.exports = React.createClass({ // true to suppress the date at the start of the timeline suppressFirstDateSeparator: React.PropTypes.bool, + // whether to show read receipts + manageReadReceipts: 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. @@ -373,7 +376,10 @@ module.exports = React.createClass({ // Local echos have a send "status". var scrollToken = mxEv.status ? undefined : eventId; - var readReceipts = this._getReadReceiptsForEvent(mxEv); + var readReceipts; + if (this.props.manageReadReceipts) { + readReceipts = this._getReadReceiptsForEvent(mxEv); + } ret.push( <li key={eventId} diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 47ad89c75b..2a8a684c33 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -972,6 +972,7 @@ var TimelinePanel = React.createClass({ readMarkerVisible={ this.state.readMarkerVisible } suppressFirstDateSeparator={ this.state.canBackPaginate } showUrlPreview = { this.props.showUrlPreview } + manageReadReceipts = { this.props.manageReadReceipts } ourUserId={ MatrixClientPeg.get().credentials.userId } stickyBottom={ stickyBottom } onScroll={ this.onMessageListScroll } From 27b3f5f6b1fc544fad043bd8623c38c973e826a8 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Thu, 8 Sep 2016 03:02:26 +0100 Subject: [PATCH 15/26] create a global notif timeline set for each client --- src/MatrixClientPeg.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 190181c875..3ba50769a1 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -18,6 +18,8 @@ limitations under the License. import Matrix from 'matrix-js-sdk'; import utils from 'matrix-js-sdk/lib/utils'; +import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; +import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; const localStorage = window.localStorage; @@ -104,6 +106,13 @@ class MatrixClientPeg { this.matrixClient.setMaxListeners(500); this.matrixClient.setGuest(Boolean(creds.guest)); + + var notifTimelineSet = new EventTimelineSet(null, null, { + timelineSupport: true + }); + // XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync. + notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); + this.matrixClient.setNotifTimelineSet(notifTimelineSet); } } From c882d66b0dd60c5cc289d5cb83116984da76aa22 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Thu, 8 Sep 2016 03:02:31 +0100 Subject: [PATCH 16/26] make NotificationPanel work --- .../structures/NotificationPanel.js | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index 12bf9750a7..338ca6c1c5 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -17,18 +17,53 @@ limitations under the License. var React = require('react'); var ReactDOM = require("react-dom"); +var Matrix = require("matrix-js-sdk"); var sdk = require('../../index'); var MatrixClientPeg = require("../../MatrixClientPeg"); var dis = require("../../dispatcher"); /* - * Component which shows the notification timeline using a TimelinePanel + * Component which shows the filtered file using a TimelinePanel */ var NotificationPanel = React.createClass({ displayName: 'NotificationPanel', + propTypes: { + }, + + // this has to be a proper method rather than an unnamed function, + // otherwise react calls it with null on each update. + _gatherTimelinePanelRef: function(r) { + //this.refs.messagePanel = r; + }, + render: function() { // wrap a TimelinePanel with the jump-to-event bits turned off. + var TimelinePanel = sdk.getComponent("structures.TimelinePanel"); + var Loader = sdk.getComponent("elements.Spinner"); + + var timelineSet = MatrixClientPeg.get().getNotifTimelineSet(); + + if (timelineSet) { + return ( + <TimelinePanel key={"NotificationPanel_" + this.props.roomId} ref={this._gatherTimelinePanelRef} + className="mx_NotificationPanel" + manageReadReceipts={false} + manageReadMarkers={false} + timelineSet={timelineSet} + showUrlPreview = { false } + opacity={ this.props.opacity } + /> + ); + } + else { + console.error("No notifTimelineSet available!"); + return ( + <div className="mx_NotificationPanel"> + <Loader/> + </div> + ); + } }, }); From f88ba9df110fc21c16da49a7cc49cb0b13419267 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Thu, 8 Sep 2016 14:38:34 +0100 Subject: [PATCH 17/26] fix comment & use room.getUnfilteredTimelineSet() --- src/components/structures/NotificationPanel.js | 2 +- src/components/structures/RoomView.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index 338ca6c1c5..51a1081615 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -23,7 +23,7 @@ var MatrixClientPeg = require("../../MatrixClientPeg"); var dis = require("../../dispatcher"); /* - * Component which shows the filtered file using a TimelinePanel + * Component which shows the global notification list using a TimelinePanel */ var NotificationPanel = React.createClass({ displayName: 'NotificationPanel', diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 8d2d2631fe..37af891db0 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1570,7 +1570,7 @@ module.exports = React.createClass({ var messagePanel = ( <TimelinePanel ref={this._gatherTimelinePanelRef} - timelineSet={this.state.room.getTimelineSets()[0]} + timelineSet={this.state.room.getUnfilteredTimelineSet()} manageReadReceipts={true} manageReadMarkers={true} hidden={hideMessagePanel} From 3c7864a7ad0de95554143c851591afbd2d4f2deb Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Thu, 8 Sep 2016 14:52:08 +0100 Subject: [PATCH 18/26] remove unused _gatherTimelinePanelRef --- src/components/structures/FilePanel.js | 8 +------- src/components/structures/NotificationPanel.js | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 95fd978f59..f19d0cdc2b 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -88,12 +88,6 @@ var FilePanel = React.createClass({ } }, - // this has to be a proper method rather than an unnamed function, - // otherwise react calls it with null on each update. - _gatherTimelinePanelRef: function(r) { - //this.refs.messagePanel = r; - }, - render: function() { // wrap a TimelinePanel with the jump-to-event bits turned off. var TimelinePanel = sdk.getComponent("structures.TimelinePanel"); @@ -103,7 +97,7 @@ var FilePanel = React.createClass({ console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.roomId + " " + "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); return ( - <TimelinePanel key={"filepanel_" + this.props.roomId} ref={this._gatherTimelinePanelRef} + <TimelinePanel key={"filepanel_" + this.props.roomId} className="mx_FilePanel" manageReadReceipts={false} manageReadMarkers={false} diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index 51a1081615..e1b7d72002 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -31,12 +31,6 @@ var NotificationPanel = React.createClass({ propTypes: { }, - // this has to be a proper method rather than an unnamed function, - // otherwise react calls it with null on each update. - _gatherTimelinePanelRef: function(r) { - //this.refs.messagePanel = r; - }, - render: function() { // wrap a TimelinePanel with the jump-to-event bits turned off. var TimelinePanel = sdk.getComponent("structures.TimelinePanel"); @@ -46,7 +40,7 @@ var NotificationPanel = React.createClass({ if (timelineSet) { return ( - <TimelinePanel key={"NotificationPanel_" + this.props.roomId} ref={this._gatherTimelinePanelRef} + <TimelinePanel key={"NotificationPanel_" + this.props.roomId} className="mx_NotificationPanel" manageReadReceipts={false} manageReadMarkers={false} From 4aef352a5ca606a89b30279859e20283b21e2402 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Thu, 8 Sep 2016 22:48:44 +0100 Subject: [PATCH 19/26] update to use new API based on js-sdk PR input --- src/MatrixClientPeg.js | 2 +- src/Notifier.js | 3 ++- src/components/structures/RoomView.js | 3 +++ src/components/structures/TimelinePanel.js | 4 ++-- src/components/views/rooms/RoomList.js | 3 ++- test/components/structures/TimelinePanel-test.js | 9 ++++++--- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 3ba50769a1..98b698ca3e 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -107,7 +107,7 @@ class MatrixClientPeg { this.matrixClient.setGuest(Boolean(creds.guest)); - var notifTimelineSet = new EventTimelineSet(null, null, { + var notifTimelineSet = new EventTimelineSet(null, { timelineSupport: true }); // XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync. diff --git a/src/Notifier.js b/src/Notifier.js index 99fef9d671..c3ece7bbc1 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -224,10 +224,11 @@ var Notifier = { } }, - onRoomTimeline: function(ev, room, toStartOfTimeline) { + onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (toStartOfTimeline) return; if (!this.isPrepared) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.notify) { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 37af891db0..9cb113e25c 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -342,6 +342,9 @@ module.exports = React.createClass({ // ignore events for other rooms if (!this.state.room || room.roomId != this.state.room.roomId) return; + // ignore events from filtered timelines + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + if (ev.getType() === "org.matrix.room.preview_urls") { this._updatePreviewUrlVisibility(room); } diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 2a8a684c33..9d559ed4a4 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -320,8 +320,8 @@ var TimelinePanel = React.createClass({ }, onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { - // ignore events for other rooms - if (data.timelineSet !== this.props.timelineSet) return; + // ignore events for other timeline sets + if (data.timeline.getTimelineSet() !== this.props.timelineSet) return; // ignore anything but real-time updates at the end of the room: // updates from pagination will happen when the paginate completes. diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index d679a1f4c6..4a4f0dce2f 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -146,8 +146,9 @@ module.exports = React.createClass({ this._updateStickyHeaders(true, scrollToPosition); }, - onRoomTimeline: function(ev, room, toStartOfTimeline) { + onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (toStartOfTimeline) return; + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; this._delayedRefreshRoomList(); }, diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index c2bb616b0d..993973cb1d 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -59,14 +59,17 @@ describe('TimelinePanel', function() { test_utils.beforeEach(this); sandbox = test_utils.stubClient(sandbox); - timeline = new jssdk.EventTimeline(ROOM_ID); room = sinon.createStubInstance(jssdk.Room); + room.roomId = ROOM_ID; timelineSet = sinon.createStubInstance(jssdk.EventTimelineSet); - timelineSet.getLiveTimeline.returns(timeline); timelineSet.getPendingEvents.returns([]); timelineSet.room = room; + timeline = new jssdk.EventTimeline(timelineSet); + + timelineSet.getLiveTimeline.returns(timeline); + client = peg.get(); client.credentials = {userId: USER_ID}; @@ -149,7 +152,7 @@ describe('TimelinePanel', function() { timeline.addEvent(ev); panel.onRoomTimeline(ev, room, false, false, { liveEvent: true, - timelineSet: timelineSet, + timeline: timeline, }); // that won't make much difference, because we don't paginate From f4d939da8123193c47ec899cd92b288c9a2ce053 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Fri, 9 Sep 2016 02:09:12 +0100 Subject: [PATCH 20/26] null checks on room in onRoomTimeline --- src/Notifier.js | 3 ++- src/components/structures/RoomView.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Notifier.js b/src/Notifier.js index c3ece7bbc1..4390083129 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -190,7 +190,7 @@ var Notifier = { setToolbarHidden: function(hidden, persistent = true) { this.toolbarHidden = hidden; - + // XXX: why are we dispatching this here? // this is nothing to do with notifier_enabled dis.dispatch({ @@ -226,6 +226,7 @@ var Notifier = { onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (toStartOfTimeline) return; + if (!room) return; if (!this.isPrepared) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 9cb113e25c..49d171f631 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -340,6 +340,7 @@ module.exports = React.createClass({ if (this.unmounted) return; // ignore events for other rooms + if (!room) return; if (!this.state.room || room.roomId != this.state.room.roomId) return; // ignore events from filtered timelines From db61d8e8ad9693ea613aec84d6f87f40ebef9871 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Fri, 9 Sep 2016 02:28:14 +0100 Subject: [PATCH 21/26] another null check --- src/components/views/rooms/RoomList.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 4a4f0dce2f..fd3f491ad9 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -148,6 +148,7 @@ module.exports = React.createClass({ onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (toStartOfTimeline) return; + if (!room) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; this._delayedRefreshRoomList(); }, From 1380bf705a1a44f9bb0c06f0f36c6dab46ba1e12 Mon Sep 17 00:00:00 2001 From: Shell Turner <cam.turn@gmail.com> Date: Fri, 9 Sep 2016 18:21:31 +0100 Subject: [PATCH 22/26] Fix CAS support by using a temporary Matrix client Signed-off-by: Shell Turner <cam.turn@gmail.com> --- src/Signup.js | 10 ++++++++++ src/components/structures/login/Login.js | 6 +++++- src/components/views/login/CasLogin.js | 13 +++---------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Signup.js b/src/Signup.js index 1ac92f3218..eaf1906059 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -6,6 +6,7 @@ var MatrixClientPeg = require("./MatrixClientPeg"); var SignupStages = require("./SignupStages"); var dis = require("./dispatcher"); var q = require("q"); +var url = require("url"); const EMAIL_STAGE_TYPE = "m.login.email.identity"; @@ -413,6 +414,15 @@ class Login extends Signup { throw error; }); } + + redirectToCas() { + var client = this._createTemporaryClient(); + var parsedUrl = url.parse(window.location.href, true); + parsedUrl.query["homeserver"] = client.getHomeserverUrl(); + parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); + var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); + window.location.href = casUrl; + } } module.exports.Register = Register; diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 8025504857..3139d020a6 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -90,6 +90,10 @@ module.exports = React.createClass({ }).done(); }, + onCasLogin: function() { + this._loginLogic.redirectToCas(); + }, + _onLoginAsGuestClick: function() { var self = this; self.setState({ @@ -225,7 +229,7 @@ module.exports = React.createClass({ ); case 'm.login.cas': return ( - <CasLogin /> + <CasLogin onSubmit={this.onCasLogin} /> ); default: if (!step) { diff --git a/src/components/views/login/CasLogin.js b/src/components/views/login/CasLogin.js index 5c89fd3706..c818586d52 100644 --- a/src/components/views/login/CasLogin.js +++ b/src/components/views/login/CasLogin.js @@ -16,26 +16,19 @@ limitations under the License. 'use strict'; -var MatrixClientPeg = require("../../../MatrixClientPeg"); var React = require('react'); -var url = require("url"); module.exports = React.createClass({ displayName: 'CasLogin', - onCasClicked: function(ev) { - var cli = MatrixClientPeg.get(); - var parsedUrl = url.parse(window.location.href, true); - parsedUrl.query["homeserver"] = cli.getHomeserverUrl(); - parsedUrl.query["identityServer"] = cli.getIdentityServerUrl(); - var casUrl = MatrixClientPeg.get().getCasLoginUrl(url.format(parsedUrl)); - window.location.href = casUrl; + propTypes: { + onSubmit: React.PropTypes.func, // fn() }, render: function() { return ( <div> - <button onClick={this.onCasClicked}>Sign in with CAS</button> + <button onClick={this.props.onSubmit}>Sign in with CAS</button> </div> ); } From 6f270fd621468d8d67f62b8ff038d7c6a9420593 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Sat, 10 Sep 2016 01:39:19 +0100 Subject: [PATCH 23/26] remove debug --- src/components/structures/FilePanel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index f19d0cdc2b..6c4a63c24b 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -94,8 +94,8 @@ var FilePanel = React.createClass({ var Loader = sdk.getComponent("elements.Spinner"); if (this.state.timelineSet) { - console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.roomId + " " + - "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); + // console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " + + // "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); return ( <TimelinePanel key={"filepanel_" + this.props.roomId} className="mx_FilePanel" From be33c358763ea04a662bf4135bc7d5008202b07d Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Sat, 10 Sep 2016 10:46:30 +0100 Subject: [PATCH 24/26] use new onRoomTimelineReset API to let notif timeline reset itself --- src/components/structures/TimelinePanel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 9d559ed4a4..8fbe4334c8 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -387,8 +387,8 @@ var TimelinePanel = React.createClass({ }); }, - onRoomTimelineReset: function(room) { - if (room !== this.props.timelineSet.room) return; + onRoomTimelineReset: function(room, timelineSet) { + if (timelineSet !== this.props.timelineSet) return; if (this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { this._loadTimeline(); From bae6409edb546fdb0dc29910ebac281b39594098 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Sun, 11 Sep 2016 00:30:43 +0100 Subject: [PATCH 25/26] show badge menu on RoomTile hoverover --- src/components/views/rooms/RoomTile.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 79698e475d..2128b68488 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -92,14 +92,16 @@ module.exports = React.createClass({ onMouseEnter: function() { this.setState( { hover : true }); + this.badgeOnMouseEnter(); }, onMouseLeave: function() { this.setState( { hover : false }); + this.badgeOnMouseLeave(); }, badgeOnMouseEnter: function() { - // Only allow none guests to access the context menu + // Only allow non-guests to access the context menu // and only change it if it needs to change if (!MatrixClientPeg.get().isGuest() && !this.state.badgeHover) { this.setState( { badgeHover : true } ); @@ -227,7 +229,7 @@ module.exports = React.createClass({ badgeContent = '\u200B'; } - badge = <div className={ badgeClasses } onClick={this.onBadgeClicked} onMouseEnter={this.badgeOnMouseEnter} onMouseLeave={this.badgeOnMouseLeave}>{ badgeContent }</div>; + badge = <div className={ badgeClasses } onClick={this.onBadgeClicked}>{ badgeContent }</div>; const EmojiText = sdk.getComponent('elements.EmojiText'); var label; From 34bb37aaba4c4401b6096d46b61dff00a088c077 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson <matthew@matrix.org> Date: Sun, 11 Sep 2016 02:14:27 +0100 Subject: [PATCH 26/26] layout for file & notif panel --- src/components/structures/FilePanel.js | 1 + src/components/structures/MessagePanel.js | 4 + .../structures/NotificationPanel.js | 1 + src/components/structures/TimelinePanel.js | 4 + src/components/views/messages/MFileBody.js | 38 ++++-- src/components/views/messages/MImageBody.js | 31 ++++- src/components/views/messages/MVideoBody.js | 25 ++++ src/components/views/messages/MessageEvent.js | 4 + src/components/views/rooms/EventTile.js | 122 ++++++++++++++---- 9 files changed, 186 insertions(+), 44 deletions(-) diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 6c4a63c24b..0dd16a7e99 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -103,6 +103,7 @@ var FilePanel = React.createClass({ manageReadMarkers={false} timelineSet={this.state.timelineSet} showUrlPreview = { false } + tileShape="file_grid" opacity={ this.props.opacity } /> ); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 4dd4a488f9..6f2d8f038b 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -79,6 +79,9 @@ module.exports = React.createClass({ // className for the panel className: React.PropTypes.string.isRequired, + + // shape parameter to be passed to EventTiles + tileShape: React.PropTypes.string, }, componentWillMount: function() { @@ -392,6 +395,7 @@ module.exports = React.createClass({ showUrlPreview={this.props.showUrlPreview} checkUnmounting={this._isUnmounting} eventSendStatus={mxEv.status} + tileShape={this.props.tileShape} last={last} isSelectedEvent={highlight}/> </li> ); diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index e1b7d72002..7d9e752657 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -47,6 +47,7 @@ var NotificationPanel = React.createClass({ timelineSet={timelineSet} showUrlPreview = { false } opacity={ this.props.opacity } + tileShape="notif" /> ); } diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 8fbe4334c8..be35e9cbdd 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -93,6 +93,9 @@ var TimelinePanel = React.createClass({ // classname to use for the messagepanel className: React.PropTypes.string, + + // shape property to be passed to EventTiles + tileShape: React.PropTypes.string, }, statics: { @@ -979,6 +982,7 @@ var TimelinePanel = React.createClass({ onFillRequest={ this.onMessageListFillRequest } opacity={ this.props.opacity } className={ this.props.className } + tileShape={ this.props.tileShape } /> ); }, diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index dbad084024..c37cd32c4e 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -57,18 +57,34 @@ module.exports = React.createClass({ var TintableSvg = sdk.getComponent("elements.TintableSvg"); if (httpUrl) { - return ( - <span className="mx_MFileBody"> - <div className="mx_MImageBody_download"> - <a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> - <TintableSvg src="img/download.svg" width="12" height="14"/> - Download {text} - </a> - </div> - </span> - ); + if (this.props.tileShape === "file_grid") { + return ( + <span className="mx_MFileBody"> + <div className="mx_MImageBody_download"> + <a className="mx_ImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> + { content.body && content.body.length > 0 ? content.body : "Attachment" } + </a> + <div className="mx_MImageBody_size"> + { content.info && content.info.size ? filesize(content.info.size) : "" } + </div> + </div> + </span> + ); + } + else { + return ( + <span className="mx_MFileBody"> + <div className="mx_MImageBody_download"> + <a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> + <TintableSvg src="img/download.svg" width="12" height="14"/> + Download {text} + </a> + </div> + </span> + ); + } } else { - var extra = text ? ': '+text : ''; + var extra = text ? (': ' + text) : ''; return <span className="mx_MFileBody"> Invalid file{extra} </span> diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index ec594af2ce..526fc6a3a5 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -123,6 +123,30 @@ module.exports = React.createClass({ var content = this.props.mxEvent.getContent(); var cli = MatrixClientPeg.get(); + var download; + if (this.props.tileShape === "file_grid") { + download = ( + <div className="mx_MImageBody_download"> + <a className="mx_MImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> + {content.body} + </a> + <div className="mx_MImageBody_size"> + { content.info && content.info.size ? filesize(content.info.size) : "" } + </div> + </div> + ); + } + else { + download = ( + <div className="mx_MImageBody_download"> + <a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> + <TintableSvg src="img/download.svg" width="12" height="14"/> + Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" }) + </a> + </div> + ); + } + var thumbUrl = this._getThumbUrl(); if (thumbUrl) { return ( @@ -133,12 +157,7 @@ module.exports = React.createClass({ onMouseEnter={this.onImageEnter} onMouseLeave={this.onImageLeave} /> </a> - <div className="mx_MImageBody_download"> - <a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> - <TintableSvg src="img/download.svg" width="12" height="14"/> - Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" }) - </a> - </div> + { download } </span> ); } else if (content.body) { diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index c8327a71ae..2494ab9499 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -69,12 +69,37 @@ module.exports = React.createClass({ } } + var download; + if (this.props.tileShape === "file_grid") { + download = ( + <div className="mx_MImageBody_download"> + <a className="mx_MImageBody_downloadLink" href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> + {content.body} + </a> + <div className="mx_MImageBody_size"> + { content.info && content.info.size ? filesize(content.info.size) : "" } + </div> + </div> + ); + } + else { + download = ( + <div className="mx_MImageBody_download"> + <a href={cli.mxcUrlToHttp(content.url)} target="_blank" rel="noopener"> + <TintableSvg src="img/download.svg" width="12" height="14"/> + Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" }) + </a> + </div> + ); + } + return ( <span className="mx_MVideoBody"> <video className="mx_MVideoBody" src={cli.mxcUrlToHttp(content.url)} alt={content.body} controls preload={preload} autoPlay={false} height={height} width={width} poster={poster}> </video> + { download } </span> ); }, diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 26658c3005..4ac0c0dabb 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -37,6 +37,9 @@ module.exports = React.createClass({ /* callback called when dynamic content in events are loaded */ onWidgetLoad: React.PropTypes.func, + + /* the shsape of the tile, used */ + tileShape: React.PropTypes.string, }, getEventTileOps: function() { @@ -69,6 +72,7 @@ module.exports = React.createClass({ return <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights} highlightLink={this.props.highlightLink} showUrlPreview={this.props.showUrlPreview} + tileShape={this.props.tileShape} onWidgetLoad={this.props.onWidgetLoad} />; }, }); diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index b1df3f3267..f3ae3dff7f 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -128,6 +128,15 @@ module.exports = React.createClass({ /* the status of this event - ie, mxEvent.status. Denormalised to here so * that we can tell when it changes. */ eventSendStatus: React.PropTypes.string, + + /* the shape of the tile. by default, the layout is intended for the + * normal room timeline. alternative values are: "file_list", "file_grid" + * and "notif". This could be done by CSS, but it'd be horribly inefficient. + * It could also be done by subclassing EventTile, but that'd be quite + * boiilerplatey. So just make the necessary render decisions conditional + * for now. + */ + tileShape: React.PropTypes.string, }, getInitialState: function() { @@ -382,18 +391,16 @@ module.exports = React.createClass({ this.props.eventSendStatus ) !== -1, mx_EventTile_notSent: this.props.eventSendStatus == 'not_sent', - mx_EventTile_highlight: this.shouldHighlight(), + mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), mx_EventTile_selected: this.props.isSelectedEvent, - mx_EventTile_continuation: this.props.continuation, + mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation, mx_EventTile_last: this.props.last, mx_EventTile_contextual: this.props.contextual, menu: this.state.menu, mx_EventTile_verified: this.state.verified == true, mx_EventTile_unverified: this.state.verified == false, }); - var timestamp = <a href={ "#/room/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId() }> - <MessageTimestamp ts={this.props.mxEvent.getTs()} /> - </a> + var permalink = "#/room/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId(); var readAvatars = this.getReadAvatars(); @@ -401,7 +408,10 @@ module.exports = React.createClass({ let avatarSize; let needsSenderProfile; - if (isInfoMessage) { + if (this.props.tileShape === "notif") { + avatarSize = 24; + needsSenderProfile = true; + } else if (isInfoMessage) { // a small avatar, with no sender profile, for emotes and // joins/parts/etc avatarSize = 14; @@ -428,35 +438,93 @@ module.exports = React.createClass({ if (needsSenderProfile) { let aux = null; - if (msgtype === 'm.image') aux = "sent an image"; - else if (msgtype === 'm.video') aux = "sent a video"; - else if (msgtype === 'm.file') aux = "uploaded a file"; + if (!this.props.tileShape) { + if (msgtype === 'm.image') aux = "sent an image"; + else if (msgtype === 'm.video') aux = "sent a video"; + else if (msgtype === 'm.file') aux = "uploaded a file"; + sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} aux={aux} />; + } + else { + sender = <SenderProfile mxEvent={this.props.mxEvent} />; + } - sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} aux={aux} />; } var editButton = ( <img className="mx_EventTile_editButton" src="img/icon_context_message.svg" width="19" height="19" alt="Options" title="Options" onClick={this.onEditClicked} /> ); - return ( - <div className={classes}> - <div className="mx_EventTile_msgOption"> - { readAvatars } + if (this.props.tileShape === "notif") { + var room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + + return ( + <div className={classes}> + <div className="mx_EventTile_roomName"> + <a href={ permalink }> + { room.name } + </a> + </div> + <div className="mx_EventTile_senderDetails"> + { avatar } + <a href={ permalink }> + { sender } + <MessageTimestamp ts={this.props.mxEvent.getTs()} /> + </a> + </div> + <div className="mx_EventTile_line" > + <EventTileType ref="tile" + mxEvent={this.props.mxEvent} + highlights={this.props.highlights} + highlightLink={this.props.highlightLink} + showUrlPreview={this.props.showUrlPreview} + onWidgetLoad={this.props.onWidgetLoad} /> + </div> </div> - { avatar } - { sender } - <div className="mx_EventTile_line"> - { timestamp } - <EventTileType ref="tile" - mxEvent={this.props.mxEvent} - highlights={this.props.highlights} - highlightLink={this.props.highlightLink} - showUrlPreview={this.props.showUrlPreview} - onWidgetLoad={this.props.onWidgetLoad} /> - { editButton } + ); + } + else if (this.props.tileShape === "file_grid") { + return ( + <div className={classes}> + <div className="mx_EventTile_line" > + <EventTileType ref="tile" + mxEvent={this.props.mxEvent} + highlights={this.props.highlights} + highlightLink={this.props.highlightLink} + showUrlPreview={this.props.showUrlPreview} + tileShape={this.props.tileShape} + onWidgetLoad={this.props.onWidgetLoad} /> + </div> + <a className="mx_EventTile_senderDetailsLink" href={ permalink }> + <div className="mx_EventTile_senderDetails"> + { sender } + <MessageTimestamp ts={this.props.mxEvent.getTs()} /> + </div> + </a> </div> - </div> - ); + ); + } + else { + return ( + <div className={classes}> + <div className="mx_EventTile_msgOption"> + { readAvatars } + </div> + { avatar } + { sender } + <div className="mx_EventTile_line"> + <a href={ permalink }> + <MessageTimestamp ts={this.props.mxEvent.getTs()} /> + </a> + <EventTileType ref="tile" + mxEvent={this.props.mxEvent} + highlights={this.props.highlights} + highlightLink={this.props.highlightLink} + showUrlPreview={this.props.showUrlPreview} + onWidgetLoad={this.props.onWidgetLoad} /> + { editButton } + </div> + </div> + ); + } }, });