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>&nbsp;
-                    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>
+            );
+        }
     },
 });