diff --git a/res/css/_components.scss b/res/css/_components.scss index f29e30dcb4..ec8476ee63 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -19,6 +19,7 @@ @import "./structures/_RoomStatusBar.scss"; @import "./structures/_RoomSubList.scss"; @import "./structures/_RoomView.scss"; +@import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_TagPanel.scss"; diff --git a/res/css/structures/_MainSplit.scss b/res/css/structures/_MainSplit.scss index 28c89fe7ca..4d73953cd7 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -19,3 +19,9 @@ limitations under the License. flex-direction: row; min-width: 0; } + +// move hit area 5px to the right so it doesn't overlap with the timeline scrollbar +.mx_MainSplit > .mx_ResizeHandle.mx_ResizeHandle_horizontal { + margin: 0 -10px 0 0; + padding: 0 10px 0 0; +} diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index e914869fd1..018176146c 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -91,6 +91,7 @@ limitations under the License. display: flex; flex-direction: column; flex: 1; + min-width: 0; } .mx_RoomView_body .mx_RoomView_timeline { @@ -118,6 +119,8 @@ limitations under the License. .mx_RoomView_messagePanel { width: 100%; overflow-y: auto; + flex: 1 1 0; + overflow-anchor: none; } .mx_RoomView_messagePanelSearchSpinner { diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss new file mode 100644 index 0000000000..699224949b --- /dev/null +++ b/res/css/structures/_ScrollPanel.scss @@ -0,0 +1,26 @@ +/* +Copyright 2019 New Vector 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. +*/ + +.mx_ScrollPanel { + + .mx_RoomView_MessageList { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + overflow-y: hidden; + } +} diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 9f2b5da930..cac97cb60d 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -20,6 +20,7 @@ limitations under the License. flex: 1; display: flex; flex-direction: column; + min-height: 0; .mx_Spinner { flex: 1 0 auto; @@ -35,6 +36,10 @@ limitations under the License. margin-top: 8px; margin-bottom: 4px; } + + .mx_AutoHideScrollbar { + flex: 1 1 0; + } } .mx_MemberList_chevron { diff --git a/src/Notifier.js b/src/Notifier.js index 80e8be1084..6a4f9827f7 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -220,7 +220,17 @@ const Notifier = { } }, - isToolbarHidden: function() { + shouldShowToolbar: function() { + const client = MatrixClientPeg.get(); + if (!client) { + return false; + } + const isGuest = client.isGuest(); + return !isGuest && this.supportsDesktopNotifications() && + !this.isEnabled() && !this._isToolbarHidden(); + }, + + _isToolbarHidden: function() { // Check localStorage for any such meta data if (global.localStorage) { return global.localStorage.getItem("notifications_hidden") === "true"; diff --git a/src/components/structures/AutoHideScrollbar.js b/src/components/structures/AutoHideScrollbar.js index 0f93f20407..72d48a2084 100644 --- a/src/components/structures/AutoHideScrollbar.js +++ b/src/components/structures/AutoHideScrollbar.js @@ -121,6 +121,7 @@ export default class AutoHideScrollbar extends React.Component { render() { return (
diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 927449750c..e35a39a107 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -123,6 +123,7 @@ const FilePanel = React.createClass({ timelineSet={this.state.timelineSet} showUrlPreview = {false} tileShape="file_grid" + resizeNotifier={this.props.resizeNotifier} empty={_t('There are no visible files in this room')} /> ); diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 21438c597c..95b57a0ca5 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -234,7 +234,7 @@ const LeftPanel = React.createClass({ diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index c6c1be67ec..4771c6f487 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -22,7 +22,6 @@ import PropTypes from 'prop-types'; import { DragDropContext } from 'react-beautiful-dnd'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; -import Notifier from '../../Notifier'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; import sdk from '../../index'; @@ -121,6 +120,18 @@ const LoggedInView = React.createClass({ this._matrixClient.on("RoomState.events", this.onRoomStateEvents); }, + componentDidUpdate(prevProps) { + // attempt to guess when a banner was opened or closed + if ( + (prevProps.showCookieBar !== this.props.showCookieBar) || + (prevProps.hasNewVersion !== this.props.hasNewVersion) || + (prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) || + (prevProps.showNotifierToolbar !== this.props.showNotifierToolbar) + ) { + this.props.resizeNotifier.notifyBannersChanged(); + } + }, + componentWillUnmount: function() { document.removeEventListener('keydown', this._onKeyDown); this._matrixClient.removeListener("accountData", this.onAccountData); @@ -173,6 +184,7 @@ const LoggedInView = React.createClass({ }, onResized: (size) => { window.localStorage.setItem("mx_lhs_size", '' + size); + this.props.resizeNotifier.notifyLeftHandleResized(); }, }; const resizer = new Resizer( @@ -448,6 +460,7 @@ const LoggedInView = React.createClass({ disabled={this.props.middleDisabled} collapsedRhs={this.props.collapsedRhs} ConferenceHandler={this.props.ConferenceHandler} + resizeNotifier={this.props.resizeNotifier} />; break; @@ -489,7 +502,6 @@ const LoggedInView = React.createClass({ }); let topBar; - const isGuest = this.props.matrixClient.isGuest(); if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { topBar = ; } else if (this.state.userHasGeneratedPassword) { topBar = ; - } else if ( - !isGuest && Notifier.supportsDesktopNotifications() && - !Notifier.isEnabled() && !Notifier.isToolbarHidden() - ) { + } else if (this.props.showNotifierToolbar) { topBar = ; } @@ -534,7 +543,7 @@ const LoggedInView = React.createClass({
diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 0427130eea..64f841da97 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -27,6 +27,9 @@ export default class MainSplit extends React.Component { _onResized(size) { window.localStorage.setItem("mx_rhs_size", size); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.notifyRightHandleResized(); + } } _createResizer() { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index e810a01928..f64d546bbb 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -29,6 +29,7 @@ import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; import * as RoomListSorter from "../../RoomListSorter"; import dis from "../../dispatcher"; +import Notifier from '../../Notifier'; import Modal from "../../Modal"; import Tinter from "../../Tinter"; @@ -48,6 +49,7 @@ import { _t, getCurrentLanguage } from '../../languageHandler'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import { startAnyRegistrationFlow } from "../../Registration.js"; import { messageForSyncError } from '../../utils/ErrorUtils'; +import ResizeNotifier from "../../utils/ResizeNotifier"; import TimelineExplosionDialog from "../views/dialogs/TimelineExplosionDialog"; const AutoDiscovery = Matrix.AutoDiscovery; @@ -195,6 +197,8 @@ export default React.createClass({ hideToSRUsers: false, syncError: null, // If the current syncing status is ERROR, the error object, otherwise null. + resizeNotifier: new ResizeNotifier(), + showNotifierToolbar: false, }; return s; }, @@ -317,6 +321,9 @@ export default React.createClass({ // N.B. we don't call the whole of setTheme() here as we may be // racing with the theme CSS download finishing from index.js Tinter.tint(); + + // For PersistentElement + this.state.resizeNotifier.on("middlePanelResized", this._dispatchTimelineResize); }, componentDidMount: function() { @@ -399,6 +406,7 @@ export default React.createClass({ dis.unregister(this.dispatcherRef); window.removeEventListener("focus", this.onFocus); window.removeEventListener('resize', this.handleResize); + this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize); }, componentWillUpdate: function(props, state) { @@ -639,8 +647,9 @@ export default React.createClass({ case 'view_invite': showRoomInviteDialog(payload.roomId); break; - case 'notifier_enabled': - this.forceUpdate(); + case 'notifier_enabled': { + this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()}); + } break; case 'hide_left_panel': this.setState({ @@ -1188,7 +1197,7 @@ export default React.createClass({ * Called when a new logged in session has started */ _onLoggedIn: async function() { - this.setStateForNewView({view: VIEWS.LOGGED_IN}); + this.setStateForNewView({ view: VIEWS.LOGGED_IN }); if (this._is_registered) { this._is_registered = false; @@ -1332,7 +1341,10 @@ export default React.createClass({ self.firstSyncPromise.resolve(); dis.dispatch({action: 'focus_composer'}); - self.setState({ready: true}); + self.setState({ + ready: true, + showNotifierToolbar: Notifier.shouldShowToolbar(), + }); }); cli.on('Call.incoming', function(call) { // we dispatch this synchronously to make sure that the event @@ -1696,9 +1708,14 @@ export default React.createClass({ dis.dispatch({ action: 'show_right_panel' }); } + this.state.resizeNotifier.notifyWindowResized(); this._windowWidth = window.innerWidth; }, + _dispatchTimelineResize() { + dis.dispatch({ action: 'timeline_resize' }); + }, + onRoomCreated: function(roomId) { dis.dispatch({ action: "view_room", diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index b1f88a6221..b57b659136 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -21,7 +21,6 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import shouldHideEvent from '../../shouldHideEvent'; import {wantsDateSeparator} from '../../DateUtils'; -import dis from "../../dispatcher"; import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; @@ -628,16 +627,29 @@ module.exports = React.createClass({ _onHeightChanged: function() { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { - scrollPanel.forceUpdate(); + scrollPanel.checkScroll(); } }, - _onTypingVisible: function() { + _onTypingShown: function() { const scrollPanel = this.refs.scrollPanel; + // this will make the timeline grow, so checkScroll + scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { - // scroll down if at bottom + scrollPanel.preventShrinking(); + } + }, + + _onTypingHidden: function() { + const scrollPanel = this.refs.scrollPanel; + if (scrollPanel) { + // as hiding the typing notifications doesn't + // update the scrollPanel, we tell it to apply + // the shrinking prevention once the typing notifs are hidden + scrollPanel.updatePreventShrinking(); + // order is important here as checkScroll will scroll down to + // reveal added padding to balance the notifs disappearing. scrollPanel.checkScroll(); - scrollPanel.blockShrinking(); } }, @@ -653,22 +665,18 @@ module.exports = React.createClass({ // update the min-height, so once the last // person stops typing, no jumping occurs if (isAtBottom && isTypingVisible) { - scrollPanel.blockShrinking(); + scrollPanel.preventShrinking(); } } }, - clearTimelineHeight: function() { + onTimelineReset: function() { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { - scrollPanel.clearBlockShrinking(); + scrollPanel.clearPreventShrinking(); } }, - onResize: function() { - dis.dispatch({ action: 'timeline_resize' }, true); - }, - render: function() { const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile"); @@ -693,7 +701,12 @@ module.exports = React.createClass({ let whoIsTyping; if (this.props.room) { - whoIsTyping = (); + whoIsTyping = ( + ); } return ( @@ -703,7 +716,8 @@ module.exports = React.createClass({ onFillRequest={this.props.onFillRequest} onUnfillRequest={this.props.onUnfillRequest} style={style} - stickyBottom={this.props.stickyBottom}> + stickyBottom={this.props.stickyBottom} + resizeNotifier={this.props.resizeNotifier}> { topSpinner } { this._getEventTiles() } { whoIsTyping } diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 74820c804a..a1e0af3606 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -198,7 +198,7 @@ export default class RightPanel extends React.Component { } else if (this.state.phase === RightPanel.Phase.NotificationPanel) { panel = ; } else if (this.state.phase === RightPanel.Phase.FilePanel) { - panel = ; + panel = ; } const classes = classNames("mx_RightPanel", "mx_fadable", { diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index a7211f6f46..5342276e63 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -549,7 +549,6 @@ module.exports = React.createClass({ onFillRequest={ this.onFillRequest } stickyBottom={false} startAtBottom={false} - onResize={function() {}} > { scrollpanel_content } ; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 80841d0abe..73b7545c26 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -394,7 +394,9 @@ module.exports = React.createClass({ this._updateConfCallNotification(); window.addEventListener('beforeunload', this.onPageUnload); - window.addEventListener('resize', this.onResize); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.on("middlePanelResized", this.onResize); + } this.onResize(); document.addEventListener("keydown", this.onKeyDown); @@ -486,7 +488,9 @@ module.exports = React.createClass({ } window.removeEventListener('beforeunload', this.onPageUnload); - window.removeEventListener('resize', this.onResize); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); + } document.removeEventListener("keydown", this.onKeyDown); @@ -879,10 +883,6 @@ module.exports = React.createClass({ } }, - onSearchResultsResize: function() { - dis.dispatch({ action: 'timeline_resize' }, true); - }, - onSearchResultsFillRequest: function(backwards) { if (!backwards) { return Promise.resolve(false); @@ -1378,8 +1378,7 @@ module.exports = React.createClass({ const showBar = this.refs.messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { - this.setState({showTopUnreadMessagesBar: showBar}, - this.onChildResize); + this.setState({showTopUnreadMessagesBar: showBar}); } }, @@ -1422,7 +1421,7 @@ module.exports = React.createClass({ }; }, - onResize: function(e) { + onResize: function() { // It seems flexbox doesn't give us a way to constrain the auxPanel height to have // a minimum of the height of the video element, whilst also capping it from pushing out the page // so we have to do it via JS instead. In this implementation we cap the height by putting @@ -1440,9 +1439,6 @@ module.exports = React.createClass({ if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); - - // changing the maxHeight on the auxpanel will trigger a callback go - // onChildResize, so no need to worry about that here. }, onFullscreenClick: function() { @@ -1472,10 +1468,6 @@ module.exports = React.createClass({ this.forceUpdate(); // TODO: just update the voip buttons }, - onChildResize: function() { - // no longer anything to do here - }, - onStatusBarVisible: function() { if (this.unmounted) return; this.setState({ @@ -1687,7 +1679,6 @@ module.exports = React.createClass({ isPeeking={myMembership !== "join"} onInviteClick={this.onInviteButtonClick} onStopWarningClick={this.onStopAloneWarningClick} - onResize={this.onChildResize} onVisible={this.onStatusBarVisible} onHidden={this.onStatusBarHidden} />; @@ -1768,7 +1759,6 @@ module.exports = React.createClass({ draggingFile={this.state.draggingFile} displayConfCallNotification={this.state.displayConfCallNotification} maxHeight={this.state.auxPanelMaxHeight} - onResize={this.onChildResize} showApps={this.state.showApps} hideAppsDrawer={false} > { aux } @@ -1784,7 +1774,6 @@ module.exports = React.createClass({ messageComposer =
  • { this.getSearchResultTiles() } @@ -1894,6 +1883,7 @@ module.exports = React.createClass({ className="mx_RoomView_messagePanel" membersLoaded={this.state.membersLoaded} permalinkCreator={this.state.permalinkCreator} + resizeNotifier={this.props.resizeNotifier} />); let topUnreadMessagesBar = null; @@ -1926,7 +1916,7 @@ module.exports = React.createClass({ }, ); - const rightPanel = this.state.room ? : undefined; + const rightPanel = this.state.room ? : undefined; return (
    @@ -1942,7 +1932,11 @@ module.exports = React.createClass({ onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} /> - +
    { auxPanel }
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index b88bd6d98e..cbbca3c468 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -15,14 +15,13 @@ limitations under the License. */ const React = require("react"); -const ReactDOM = require("react-dom"); import PropTypes from 'prop-types'; import Promise from 'bluebird'; import { KeyCode } from '../../Keyboard'; -import sdk from '../../index.js'; +import Timer from '../../utils/Timer'; +import AutoHideScrollbar from "./AutoHideScrollbar"; const DEBUG_SCROLL = false; -// var DEBUG_SCROLL = true; // The amount of extra scroll distance to allow prior to unfilling. // See _getExcessHeight. @@ -31,11 +30,14 @@ const UNPAGINATION_PADDING = 6000; // many scroll events causing many unfilling requests. const UNFILL_REQUEST_DEBOUNCE_MS = 200; +const PAGE_SIZE = 200; + +let debuglog; if (DEBUG_SCROLL) { // using bind means that we get to keep useful line numbers in the console - var debuglog = console.log.bind(console); + debuglog = console.log.bind(console, "ScrollPanel debuglog:"); } else { - var debuglog = function() {}; + debuglog = function() {}; } /* This component implements an intelligent scrolling list. @@ -129,11 +131,6 @@ module.exports = React.createClass({ */ onScroll: PropTypes.func, - /* onResize: a callback which is called whenever the Gemini scroll - * panel is resized - */ - onResize: PropTypes.func, - /* className: classnames to add to the top-level div */ className: PropTypes.string, @@ -141,6 +138,9 @@ module.exports = React.createClass({ /* style: styles to add to the top-level div */ style: PropTypes.object, + /* resizeNotifier: ResizeNotifier to know when middle column has changed size + */ + resizeNotifier: PropTypes.object, }, getDefaultProps: function() { @@ -150,12 +150,18 @@ module.exports = React.createClass({ onFillRequest: function(backwards) { return Promise.resolve(false); }, onUnfillRequest: function(backwards, scrollToken) {}, onScroll: function() {}, - onResize: function() {}, }; }, componentWillMount: function() { + this._fillRequestWhileRunning = false; + this._isFilling = false; this._pendingFillRequests = {b: null, f: null}; + + if (this.props.resizeNotifier) { + this.props.resizeNotifier.on("middlePanelResized", this.onResize); + } + this.resetScrollState(); }, @@ -170,6 +176,7 @@ module.exports = React.createClass({ // // This will also re-check the fill state, in case the paginate was inadequate this.checkScroll(); + this.updatePreventShrinking(); }, componentWillUnmount: function() { @@ -178,54 +185,27 @@ module.exports = React.createClass({ // // (We could use isMounted(), but facebook have deprecated that.) this.unmounted = true; + + if (this.props.resizeNotifier) { + this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); + } }, onScroll: function(ev) { - const sn = this._getScrollNode(); - debuglog("Scroll event: offset now:", sn.scrollTop, - "_lastSetScroll:", this._lastSetScroll); - - // Sometimes we see attempts to write to scrollTop essentially being - // ignored. (Or rather, it is successfully written, but on the next - // scroll event, it's been reset again). - // - // This was observed on Chrome 47, when scrolling using the trackpad in OS - // X Yosemite. Can't reproduce on El Capitan. Our theory is that this is - // due to Chrome not being able to cope with the scroll offset being reset - // while a two-finger drag is in progress. - // - // By way of a workaround, we detect this situation and just keep - // resetting scrollTop until we see the scroll node have the right - // value. - if (this._lastSetScroll !== undefined && sn.scrollTop < this._lastSetScroll-200) { - console.log("Working around vector-im/vector-web#528"); - this._restoreSavedScrollState(); - return; - } - - // If there weren't enough children to fill the viewport, the scroll we - // got might be different to the scroll we wanted; we don't want to - // forget what we wanted, so don't overwrite the saved state unless - // this appears to be a user-initiated scroll. - if (sn.scrollTop != this._lastSetScroll) { - this._saveScrollState(); - } else { - debuglog("Ignoring scroll echo"); - // only ignore the echo once, otherwise we'll get confused when the - // user scrolls away from, and back to, the autoscroll point. - this._lastSetScroll = undefined; - } - + debuglog("onScroll", this._getScrollNode().scrollTop); + this._scrollTimeout.restart(); + this._saveScrollState(); + this.updatePreventShrinking(); this.props.onScroll(ev); - this.checkFillState(); }, onResize: function() { - this.clearBlockShrinking(); - this.props.onResize(); this.checkScroll(); - if (this._gemScroll) this._gemScroll.forceUpdate(); + // update preventShrinkingState if present + if (this.preventShrinkingState) { + this.preventShrinking(); + } }, // after an update to the contents of the panel, check that the scroll is @@ -238,18 +218,14 @@ module.exports = React.createClass({ // return true if the content is fully scrolled down right now; else false. // // note that this is independent of the 'stuckAtBottom' state - it is simply - // about whether the the content is scrolled down right now, irrespective of + // about whether the content is scrolled down right now, irrespective of // whether it will stay that way when the children update. isAtBottom: function() { const sn = this._getScrollNode(); - - // there seems to be some bug with flexbox/gemini/chrome/richvdh's - // understanding of the box model, wherein the scrollNode ends up 2 - // pixels higher than the available space, even when there are less - // than a screenful of messages. + 3 is a fudge factor to pretend - // that we're at the bottom when we're still a few pixels off. - - return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3; + // fractional values for scrollTop happen on certain browsers/platforms + // when scrolled all the way down. E.g. Chrome 72 on debian. + // so ceil everything upwards to make sure it aligns. + return Math.ceil(sn.scrollTop) === Math.ceil(sn.scrollHeight - sn.clientHeight); }, // returns the vertical height in the given direction that can be removed from @@ -285,19 +261,25 @@ module.exports = React.createClass({ // `---------' - _getExcessHeight: function(backwards) { const sn = this._getScrollNode(); + const contentHeight = this._getMessagesHeight(); + const listHeight = this._getListHeight(); + const clippedHeight = contentHeight - listHeight; + const unclippedScrollTop = sn.scrollTop + clippedHeight; + if (backwards) { - return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING; + return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING; } else { - return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; + return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; } }, // check the scroll state and send out backfill requests if necessary. - checkFillState: function() { + checkFillState: async function(depth=0) { if (this.unmounted) { return; } + const isFirstCall = depth === 0; const sn = this._getScrollNode(); // if there is less than a screenful of messages above or below the @@ -324,13 +306,53 @@ module.exports = React.createClass({ // `---------' - // - if (sn.scrollTop < sn.clientHeight) { - // need to back-fill - this._maybeFill(true); + // as filling is async and recursive, + // don't allow more than 1 chain of calls concurrently + // do make a note when a new request comes in while already running one, + // so we can trigger a new chain of calls once done. + if (isFirstCall) { + if (this._isFilling) { + debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request"); + this._fillRequestWhileRunning = true; + return; + } + debuglog("_isFilling: setting"); + this._isFilling = true; } - if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) { + + const itemlist = this.refs.itemlist; + const firstTile = itemlist && itemlist.firstElementChild; + const contentTop = firstTile && firstTile.offsetTop; + const fillPromises = []; + + // if scrollTop gets to 1 screen from the top of the first tile, + // try backward filling + if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) { + // need to back-fill + fillPromises.push(this._maybeFill(depth, true)); + } + // if scrollTop gets to 2 screens from the end (so 1 screen below viewport), + // try forward filling + if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) { // need to forward-fill - this._maybeFill(false); + fillPromises.push(this._maybeFill(depth, false)); + } + + if (fillPromises.length) { + try { + await Promise.all(fillPromises); + } catch (err) { + console.error(err); + } + } + if (isFirstCall) { + debuglog("_isFilling: clearing"); + this._isFilling = false; + } + + if (this._fillRequestWhileRunning) { + this._fillRequestWhileRunning = false; + this.checkFillState(); } }, @@ -340,6 +362,9 @@ module.exports = React.createClass({ if (excessHeight <= 0) { return; } + + const origExcessHeight = excessHeight; + const tiles = this.refs.itemlist.children; // The scroll token of the first/last tile to be unpaginated @@ -351,8 +376,9 @@ module.exports = React.createClass({ // pagination. // // If backwards is true, we unpaginate (remove) tiles from the back (top). + let tile; for (let i = 0; i < tiles.length; i++) { - const tile = tiles[backwards ? i : tiles.length - 1 - i]; + tile = tiles[backwards ? i : tiles.length - 1 - i]; // Subtract height of tile as if it were unpaginated excessHeight -= tile.clientHeight; //If removing the tile would lead to future pagination, break before setting scroll token @@ -373,26 +399,31 @@ module.exports = React.createClass({ } this._unfillDebouncer = setTimeout(() => { this._unfillDebouncer = null; + debuglog("unfilling now", backwards, origExcessHeight); this.props.onUnfillRequest(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); } }, // check if there is already a pending fill request. If not, set one off. - _maybeFill: function(backwards) { + _maybeFill: function(depth, backwards) { const dir = backwards ? 'b' : 'f'; if (this._pendingFillRequests[dir]) { - debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another"); + debuglog("Already a "+dir+" fill in progress - not starting another"); return; } - debuglog("ScrollPanel: starting "+dir+" fill"); + debuglog("starting "+dir+" fill"); // onFillRequest can end up calling us recursively (via onScroll // events) so make sure we set this before firing off the call. this._pendingFillRequests[dir] = true; - Promise.try(() => { + // wait 1ms before paginating, because otherwise + // this will block the scroll event handler for +700ms + // if messages are already cached in memory, + // This would cause jumping to happen on Chrome/macOS. + return new Promise(resolve => setTimeout(resolve, 1)).then(() => { return this.props.onFillRequest(backwards); }).finally(() => { this._pendingFillRequests[dir] = false; @@ -403,14 +434,14 @@ module.exports = React.createClass({ // Unpaginate once filling is complete this._checkUnfillState(!backwards); - debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults); + debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults); if (hasMoreResults) { // further pagination requests have been disabled until now, so // it's time to check the fill state again in case the pagination // was insufficient. - this.checkFillState(); + return this.checkFillState(depth + 1); } - }).done(); + }); }, /* get the current scroll state. This returns an object with the following @@ -423,7 +454,7 @@ module.exports = React.createClass({ * false, the first token in data-scroll-tokens of the child which we are * tracking. * - * number pixelOffset: undefined if stuckAtBottom is true; if it is false, + * number bottomOffset: undefined if stuckAtBottom is true; if it is false, * the number of pixels the bottom of the tracked child is above the * bottom of the scroll panel. */ @@ -444,14 +475,20 @@ module.exports = React.createClass({ * child list.) */ resetScrollState: function() { - this.scrollState = {stuckAtBottom: this.props.startAtBottom}; + this.scrollState = { + stuckAtBottom: this.props.startAtBottom, + }; + this._bottomGrowth = 0; + this._pages = 0; + this._scrollTimeout = new Timer(100); + this._heightUpdateInProgress = false; }, /** * jump to the top of the content. */ scrollToTop: function() { - this._setScrollTop(0); + this._getScrollNode().scrollTop = 0; this._saveScrollState(); }, @@ -463,24 +500,26 @@ module.exports = React.createClass({ // saved is to do the scroll, then save the updated state. (Calculating // it ourselves is hard, and we can't rely on an onScroll callback // happening, since there may be no user-visible change here). - this._setScrollTop(Number.MAX_VALUE); + const sn = this._getScrollNode(); + sn.scrollTop = sn.scrollHeight; this._saveScrollState(); }, /** * Page up/down. * - * mult: -1 to page up, +1 to page down + * @param {number} mult: -1 to page up, +1 to page down */ scrollRelative: function(mult) { const scrollNode = this._getScrollNode(); const delta = mult * scrollNode.clientHeight * 0.5; - this._setScrollTop(scrollNode.scrollTop + delta); + scrollNode.scrollTop = scrollNode.scrollTop + delta; this._saveScrollState(); }, /** * Scroll up/down in response to a scroll key + * @param {object} ev the keyboard event */ handleScrollKey: function(ev) { switch (ev.keyCode) { @@ -525,77 +564,41 @@ module.exports = React.createClass({ pixelOffset = pixelOffset || 0; offsetBase = offsetBase || 0; - // convert pixelOffset so that it is based on the bottom of the - // container. - pixelOffset += this._getScrollNode().clientHeight * (1-offsetBase); - - // save the desired scroll state. It's important we do this here rather - // than as a result of the scroll event, because (a) we might not *get* - // a scroll event, and (b) it might not currently be possible to set - // the requested scroll state (eg, because we hit the end of the - // timeline and need to do more pagination); we want to save the - // *desired* scroll state rather than what we end up achieving. + // set the trackedScrollToken so we can get the node through _getTrackedNode this.scrollState = { stuckAtBottom: false, trackedScrollToken: scrollToken, - pixelOffset: pixelOffset, }; - - // ... then make it so. - this._restoreSavedScrollState(); - }, - - // set the scrollTop attribute appropriately to position the given child at the - // given offset in the window. A helper for _restoreSavedScrollState. - _scrollToToken: function(scrollToken, pixelOffset) { - /* find the dom node with the right scrolltoken */ - let node; - const messages = this.refs.itemlist.children; - for (let i = messages.length-1; i >= 0; --i) { - const m = messages[i]; - // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens - // There might only be one scroll token - if (m.dataset.scrollTokens && - m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) { - node = m; - break; - } - } - - if (!node) { - debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'"); - return; - } - + const trackedNode = this._getTrackedNode(); const scrollNode = this._getScrollNode(); - const scrollTop = scrollNode.scrollTop; - const viewportBottom = scrollTop + scrollNode.clientHeight; - const nodeBottom = node.offsetTop + node.clientHeight; - const intendedViewportBottom = nodeBottom + pixelOffset; - const scrollDelta = intendedViewportBottom - viewportBottom; - - debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" + - pixelOffset + " (delta: "+scrollDelta+")"); - - if (scrollDelta !== 0) { - this._setScrollTop(scrollTop + scrollDelta); + if (trackedNode) { + // set the scrollTop to the position we want. + // note though, that this might not succeed if the combination of offsetBase and pixelOffset + // would position the trackedNode towards the top of the viewport. + // This because when setting the scrollTop only 10 or so events might be loaded, + // not giving enough content below the trackedNode to scroll downwards + // enough so it ends up in the top of the viewport. + debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop}); + scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset; + this._saveScrollState(); } }, _saveScrollState: function() { if (this.props.stickyBottom && this.isAtBottom()) { this.scrollState = { stuckAtBottom: true }; - debuglog("ScrollPanel: Saved scroll state", this.scrollState); + debuglog("saved stuckAtBottom state"); return; } const scrollNode = this._getScrollNode(); - const viewportBottom = scrollNode.scrollTop + scrollNode.clientHeight; + const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight); const itemlist = this.refs.itemlist; const messages = itemlist.children; let node = null; + // TODO: do a binary search here, as items are sorted by offsetTop // loop backwards, from bottom-most message (as that is the most common case) for (let i = messages.length-1; i >= 0; --i) { if (!messages[i].dataset.scrollTokens) { @@ -604,59 +607,150 @@ module.exports = React.createClass({ node = messages[i]; // break at the first message (coming from the bottom) // that has it's offsetTop above the bottom of the viewport. - if (node.offsetTop < viewportBottom) { + if (this._topFromBottom(node) > viewportBottom) { // Use this node as the scrollToken break; } } if (!node) { - debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); + debuglog("unable to save scroll state: found no children in the viewport"); return; } - - const nodeBottom = node.offsetTop + node.clientHeight; - debuglog("ScrollPanel: saved scroll state", this.scrollState); + const scrollToken = node.dataset.scrollTokens.split(',')[0]; + debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken); + const bottomOffset = this._topFromBottom(node); this.scrollState = { stuckAtBottom: false, - trackedScrollToken: node.dataset.scrollTokens.split(',')[0], - pixelOffset: viewportBottom - nodeBottom, + trackedNode: node, + trackedScrollToken: scrollToken, + bottomOffset: bottomOffset, + pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room }; }, - _restoreSavedScrollState: function() { + _restoreSavedScrollState: async function() { const scrollState = this.scrollState; if (scrollState.stuckAtBottom) { - this._setScrollTop(Number.MAX_VALUE); + const sn = this._getScrollNode(); + sn.scrollTop = sn.scrollHeight; } else if (scrollState.trackedScrollToken) { - this._scrollToToken(scrollState.trackedScrollToken, - scrollState.pixelOffset); + const itemlist = this.refs.itemlist; + const trackedNode = this._getTrackedNode(); + if (trackedNode) { + const newBottomOffset = this._topFromBottom(trackedNode); + const bottomDiff = newBottomOffset - scrollState.bottomOffset; + this._bottomGrowth += bottomDiff; + scrollState.bottomOffset = newBottomOffset; + itemlist.style.height = `${this._getListHeight()}px`; + debuglog("balancing height because messages below viewport grew by", bottomDiff); + } + } + if (!this._heightUpdateInProgress) { + this._heightUpdateInProgress = true; + try { + await this._updateHeight(); + } finally { + this._heightUpdateInProgress = false; + } + } else { + debuglog("not updating height because request already in progress"); + } + }, + // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content? + async _updateHeight() { + // wait until user has stopped scrolling + if (this._scrollTimeout.isRunning()) { + debuglog("updateHeight waiting for scrolling to end ... "); + await this._scrollTimeout.finished(); + } else { + debuglog("updateHeight getting straight to business, no scrolling going on."); + } + + const sn = this._getScrollNode(); + const itemlist = this.refs.itemlist; + const contentHeight = this._getMessagesHeight(); + const minHeight = sn.clientHeight; + const height = Math.max(minHeight, contentHeight); + this._pages = Math.ceil(height / PAGE_SIZE); + this._bottomGrowth = 0; + const newHeight = this._getListHeight(); + + const scrollState = this.scrollState; + if (scrollState.stuckAtBottom) { + itemlist.style.height = `${newHeight}px`; + sn.scrollTop = sn.scrollHeight; + debuglog("updateHeight to", newHeight); + } else if (scrollState.trackedScrollToken) { + const trackedNode = this._getTrackedNode(); + // if the timeline has been reloaded + // this can be called before scrollToBottom or whatever has been called + // so don't do anything if the node has disappeared from + // the currently filled piece of the timeline + if (trackedNode) { + const oldTop = trackedNode.offsetTop; + // changing the height might change the scrollTop + // if the new height is smaller than the scrollTop. + // We calculate the diff that needs to be applied + // ourselves, so be sure to measure the + // scrollTop before changing the height. + const preexistingScrollTop = sn.scrollTop; + itemlist.style.height = `${newHeight}px`; + const newTop = trackedNode.offsetTop; + const topDiff = newTop - oldTop; + sn.scrollTop = preexistingScrollTop + topDiff; + debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop}); + } } }, - _setScrollTop: function(scrollTop) { - const scrollNode = this._getScrollNode(); + _getTrackedNode() { + const scrollState = this.scrollState; + const trackedNode = scrollState.trackedNode; - const prevScroll = scrollNode.scrollTop; + if (!trackedNode || !trackedNode.parentElement) { + let node; + const messages = this.refs.itemlist.children; + const scrollToken = scrollState.trackedScrollToken; - // FF ignores attempts to set scrollTop to very large numbers - scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight); - - // If this change generates a scroll event, we should not update the - // saved scroll state on it. See the comments in onScroll. - // - // If we *don't* expect a scroll event, we need to leave _lastSetScroll - // alone, otherwise we'll end up ignoring a future scroll event which is - // nothing to do with this change. - - if (scrollNode.scrollTop != prevScroll) { - this._lastSetScroll = scrollNode.scrollTop; + for (let i = messages.length-1; i >= 0; --i) { + const m = messages[i]; + // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens + // There might only be one scroll token + if (m.dataset.scrollTokens && + m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) { + node = m; + break; + } + } + if (node) { + debuglog("had to find tracked node again for " + scrollState.trackedScrollToken); + } + scrollState.trackedNode = node; } - debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop, - "requested:", scrollTop, - "_lastSetScroll:", this._lastSetScroll); + if (!scrollState.trackedNode) { + debuglog("No node with ; '"+scrollState.trackedScrollToken+"'"); + return; + } + + return scrollState.trackedNode; + }, + + _getListHeight() { + return this._bottomGrowth + (this._pages * PAGE_SIZE); + }, + + _getMessagesHeight() { + const itemlist = this.refs.itemlist; + const lastNode = itemlist.lastElementChild; + // 18 is itemlist padding + return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2); + }, + + _topFromBottom(node) { + return this.refs.itemlist.clientHeight - node.offsetTop; }, /* get the DOM node which has the scrollTop property we care about for our @@ -669,49 +763,112 @@ module.exports = React.createClass({ throw new Error("ScrollPanel._getScrollNode called when unmounted"); } - if (!this._gemScroll) { + if (!this._divScroll) { // Likewise, we should have the ref by this point, but if not // turn the NPE into something meaningful. throw new Error("ScrollPanel._getScrollNode called before gemini ref collected"); } - return this._gemScroll.scrollbar.getViewElement(); + return this._divScroll; }, - _collectGeminiScroll: function(gemScroll) { - this._gemScroll = gemScroll; + _collectScroll: function(divScroll) { + this._divScroll = divScroll; }, /** - * Set the current height as the min height for the message list - * so the timeline cannot shrink. This is used to avoid - * jumping when the typing indicator gets replaced by a smaller message. - */ - blockShrinking: function() { - // Disabled for now because of https://github.com/vector-im/riot-web/issues/9205 + Mark the bottom offset of the last tile so we can balance it out when + anything below it changes, by calling updatePreventShrinking, to keep + the same minimum bottom offset, effectively preventing the timeline to shrink. + */ + preventShrinking: function() { + const messageList = this.refs.itemlist; + const tiles = messageList && messageList.children; + if (!messageList) { + return; + } + let lastTileNode; + for (let i = tiles.length - 1; i >= 0; i--) { + const node = tiles[i]; + if (node.dataset.scrollTokens) { + lastTileNode = node; + break; + } + } + if (!lastTileNode) { + return; + } + this.clearPreventShrinking(); + const offsetFromBottom = messageList.clientHeight - (lastTileNode.offsetTop + lastTileNode.clientHeight); + this.preventShrinkingState = { + offsetFromBottom: offsetFromBottom, + offsetNode: lastTileNode, + }; + debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom"); + }, + + /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ + clearPreventShrinking: function() { + const messageList = this.refs.itemlist; + const balanceElement = messageList && messageList.parentElement; + if (balanceElement) balanceElement.style.paddingBottom = null; + this.preventShrinkingState = null; + debuglog("prevent shrinking cleared"); }, /** - * Clear the previously set min height - */ - clearBlockShrinking: function() { - // Disabled for now because of https://github.com/vector-im/riot-web/issues/9205 + update the container padding to balance + the bottom offset of the last tile since + preventShrinking was called. + Clears the prevent-shrinking state ones the offset + from the bottom of the marked tile grows larger than + what it was when marking. + */ + updatePreventShrinking: function() { + if (this.preventShrinkingState) { + const sn = this._getScrollNode(); + const scrollState = this.scrollState; + const messageList = this.refs.itemlist; + const {offsetNode, offsetFromBottom} = this.preventShrinkingState; + // element used to set paddingBottom to balance the typing notifs disappearing + const balanceElement = messageList.parentElement; + // if the offsetNode got unmounted, clear + let shouldClear = !offsetNode.parentElement; + // also if 200px from bottom + if (!shouldClear && !scrollState.stuckAtBottom) { + const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight); + shouldClear = spaceBelowViewport >= 200; + } + // try updating if not clearing + if (!shouldClear) { + const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight); + const offsetDiff = offsetFromBottom - currentOffset; + if (offsetDiff > 0) { + balanceElement.style.paddingBottom = `${offsetDiff}px`; + debuglog("update prevent shrinking ", offsetDiff, "px from bottom"); + } else if (offsetDiff < 0) { + shouldClear = true; + } + } + if (shouldClear) { + this.clearPreventShrinking(); + } + } }, render: function() { - const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); // TODO: the classnames on the div and ol could do with being updated to // reflect the fact that we don't necessarily contain a list of messages. // it's not obvious why we have a separate div and ol anyway. - return ( + return (
      { this.props.children }
    -
    - ); + + ); }, }); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index f0feaf94c5..aba7964a15 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -939,7 +939,7 @@ var TimelinePanel = React.createClass({ // clear the timeline min-height when // (re)loading the timeline if (this.refs.messagePanel) { - this.refs.messagePanel.clearTimelineHeight(); + this.refs.messagePanel.onTimelineReset(); } this._reloadEvents(); @@ -1228,6 +1228,7 @@ var TimelinePanel = React.createClass({ alwaysShowTimestamps={this.state.alwaysShowTimestamps} className={this.props.className} tileShape={this.props.tileShape} + resizeNotifier={this.props.resizeNotifier} /> ); }, diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 3ada730ec8..35161dedf7 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -44,6 +44,7 @@ import SdkConfig from '../../../SdkConfig'; import MultiInviter from "../../../utils/MultiInviter"; import SettingsStore from "../../../settings/SettingsStore"; import E2EIcon from "./E2EIcon"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; module.exports = withMatrixClient(React.createClass({ displayName: 'MemberInfo', @@ -1003,7 +1004,7 @@ module.exports = withMatrixClient(React.createClass({ { roomMemberDetails }
    - +
    { this._renderUserOptions() } @@ -1015,7 +1016,7 @@ module.exports = withMatrixClient(React.createClass({ { spinner }
    -
    +
    ); }, diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index e79f2f21d4..8350001d01 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -20,6 +20,7 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import dis from '../../../dispatcher'; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import {isValid3pidInvite} from "../../../RoomInvite"; const MatrixClientPeg = require("../../../MatrixClientPeg"); const sdk = require('../../../index'); @@ -444,7 +445,6 @@ module.exports = React.createClass({ const SearchBox = sdk.getComponent('structures.SearchBox'); const TruncatedList = sdk.getComponent("elements.TruncatedList"); - const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.roomId); @@ -471,7 +471,7 @@ module.exports = React.createClass({ return (
    { inviteButton } - +
    - + this.messageComposerInput = c} key="controls_input" - onResize={this.props.onResize} room={this.props.room} placeholder={placeholderText} onFilesPasted={this.uploadFiles} @@ -505,10 +504,6 @@ export default class MessageComposer extends React.Component { } MessageComposer.propTypes = { - // a callback which is called when the height of the composer is - // changed due to a change in content. - onResize: PropTypes.func, - // js-sdk Room object room: PropTypes.object.isRequired, diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 6b80902c8f..cbea2bccb9 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -135,10 +135,6 @@ function rangeEquals(a: Range, b: Range): boolean { */ export default class MessageComposerInput extends React.Component { static propTypes = { - // a callback which is called when the height of the composer is - // changed due to a change in content. - onResize: PropTypes.func, - // js-sdk Room object room: PropTypes.object.isRequired, diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 227dd318ed..33b97964f6 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -212,7 +212,9 @@ module.exports = React.createClass({ this._checkSubListsOverflow(); this.resizer.attach(); - window.addEventListener("resize", this.onWindowResize); + if (this.props.resizeNotifier) { + this.props.resizeNotifier.on("leftPanelResized", this.onResize); + } this.mounted = true; }, @@ -260,7 +262,6 @@ module.exports = React.createClass({ componentWillUnmount: function() { this.mounted = false; - window.removeEventListener("resize", this.onWindowResize); dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room", this.onRoom); @@ -273,6 +274,11 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); } + if (this.props.resizeNotifier) { + this.props.resizeNotifier.removeListener("leftPanelResized", this.onResize); + } + + if (this._tagStoreToken) { this._tagStoreToken.remove(); } @@ -293,13 +299,14 @@ module.exports = React.createClass({ this._delayedRefreshRoomList.cancelPendingCall(); }, - onWindowResize: function() { + + onResize: function() { if (this.mounted && this._layout && this.resizeContainer && Array.isArray(this._layoutSections) ) { this._layout.update( this._layoutSections, - this.resizeContainer.offsetHeight + this.resizeContainer.offsetHeight, ); } }, diff --git a/src/components/views/rooms/WhoIsTypingTile.js b/src/components/views/rooms/WhoIsTypingTile.js index 9dd690f6e5..eb5e14876d 100644 --- a/src/components/views/rooms/WhoIsTypingTile.js +++ b/src/components/views/rooms/WhoIsTypingTile.js @@ -29,7 +29,8 @@ module.exports = React.createClass({ propTypes: { // the room this statusbar is representing. room: PropTypes.object.isRequired, - onVisible: PropTypes.func, + onShown: PropTypes.func, + onHidden: PropTypes.func, // Number of names to display in typing indication. E.g. set to 3, will // result in "X, Y, Z and 100 others are typing." whoIsTypingLimit: PropTypes.number, @@ -59,11 +60,12 @@ module.exports = React.createClass({ }, componentDidUpdate: function(_, prevState) { - if (this.props.onVisible && - !prevState.usersTyping.length && - this.state.usersTyping.length - ) { - this.props.onVisible(); + const wasVisible = this._isVisible(prevState); + const isVisible = this._isVisible(this.state); + if (this.props.onShown && !wasVisible && isVisible) { + this.props.onShown(); + } else if (this.props.onHidden && wasVisible && !isVisible) { + this.props.onHidden(); } }, @@ -77,8 +79,12 @@ module.exports = React.createClass({ Object.values(this.state.delayedStopTypingTimers).forEach((t) => t.abort()); }, + _isVisible: function(state) { + return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0; + }, + isVisible: function() { - return this.state.usersTyping.length !== 0 || Object.keys(this.state.delayedStopTypingTimers).length !== 0; + return this._isVisible(this.state); }, onRoomTimeline: function(event, room) { diff --git a/src/utils/ResizeNotifier.js b/src/utils/ResizeNotifier.js new file mode 100644 index 0000000000..35ec1a0269 --- /dev/null +++ b/src/utils/ResizeNotifier.js @@ -0,0 +1,59 @@ +/* +Copyright 2019 New Vector 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. +*/ + +/** + * Fires when the middle panel has been resized. + * @event module:utils~ResizeNotifier#"middlePanelResized" + */ +import { EventEmitter } from "events"; +import { throttle } from "lodash"; + +export default class ResizeNotifier extends EventEmitter { + constructor() { + super(); + // with default options, will call fn once at first call, and then every x ms + // if there was another call in that timespan + this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200); + } + + notifyBannersChanged() { + this.emit("leftPanelResized"); + this.emit("middlePanelResized"); + } + + // can be called in quick succession + notifyLeftHandleResized() { + // don't emit event for own region + this._throttledMiddlePanel(); + } + + // can be called in quick succession + notifyRightHandleResized() { + this._throttledMiddlePanel(); + } + + // can be called in quick succession + notifyWindowResized() { + // no need to throttle this one, + // also it could make scrollbars appear for + // a split second when the room list manual layout is now + // taller than the available space + this.emit("leftPanelResized"); + + this._throttledMiddlePanel(); + } +} + diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 96d898972d..6e311de0fb 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -21,6 +21,7 @@ const ReactDOM = require("react-dom"); const TestUtils = require('react-addons-test-utils'); const expect = require('expect'); import sinon from 'sinon'; +import { EventEmitter } from "events"; const sdk = require('matrix-react-sdk'); @@ -48,8 +49,14 @@ const WrappedMessagePanel = React.createClass({ }; }, + getInitialState: function() { + return { + resizeNotifier: new EventEmitter(), + }; + }, + render: function() { - return ; + return ; }, }); diff --git a/test/components/structures/ScrollPanel-test.js b/test/components/structures/ScrollPanel-test.js deleted file mode 100644 index 0e091cdddf..0000000000 --- a/test/components/structures/ScrollPanel-test.js +++ /dev/null @@ -1,280 +0,0 @@ -/* -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. -*/ - -const React = require('react'); -const ReactDOM = require("react-dom"); -const ReactTestUtils = require('react-addons-test-utils'); -const expect = require('expect'); -import Promise from 'bluebird'; - -const sdk = require('matrix-react-sdk'); - -const ScrollPanel = sdk.getComponent('structures.ScrollPanel'); -const test_utils = require('test-utils'); - -const Tester = React.createClass({ - getInitialState: function() { - return { - tileKeys: [], - }; - }, - - componentWillMount: function() { - this.fillCounts = {'b': 0, 'f': 0}; - this._fillHandlers = {'b': null, 'f': null}; - this._fillDefers = {'b': null, 'f': null}; - this._scrollDefer = null; - - // scrollTop at the last scroll event - this.lastScrollEvent = null; - }, - - _onFillRequest: function(back) { - const dir = back ? 'b': 'f'; - console.log("FillRequest: " + dir); - this.fillCounts[dir]++; - - const handler = this._fillHandlers[dir]; - const defer = this._fillDefers[dir]; - - // don't use the same handler twice - this._fillHandlers[dir] = null; - this._fillDefers[dir] = null; - - let res; - if (handler) { - res = handler(); - } else { - res = Promise.resolve(false); - } - - if (defer) { - defer.resolve(); - } - return res; - }, - - addFillHandler: function(dir, handler) { - this._fillHandlers[dir] = handler; - }, - - /* returns a promise which will resolve when the fill happens */ - awaitFill: function(dir) { - console.log("ScrollPanel Tester: awaiting " + dir + " fill"); - const defer = Promise.defer(); - this._fillDefers[dir] = defer; - return defer.promise; - }, - - _onScroll: function(ev) { - const st = ev.target.scrollTop; - console.log("ScrollPanel Tester: scroll event; scrollTop: " + st); - this.lastScrollEvent = st; - - const d = this._scrollDefer; - if (d) { - this._scrollDefer = null; - d.resolve(); - } - }, - - /* returns a promise which will resolve when a scroll event happens */ - awaitScroll: function() { - console.log("Awaiting scroll"); - this._scrollDefer = Promise.defer(); - return this._scrollDefer.promise; - }, - - setTileKeys: function(keys) { - console.log("Updating keys: len=" + keys.length); - this.setState({tileKeys: keys.slice()}); - }, - - scrollPanel: function() { - return this.refs.sp; - }, - - _mkTile: function(key) { - // each tile is 150 pixels high: - // 98 pixels of body - // 2 pixels of border - // 50 pixels of margin - // - // there is an extra 50 pixels of margin at the bottom. - return ( -
  • -
    - { key } -
    -
  • - ); - }, - - render: function() { - const tiles = this.state.tileKeys.map(this._mkTile); - console.log("rendering with " + tiles.length + " tiles"); - return ( - - { tiles } - - ); - }, -}); - -describe('ScrollPanel', function() { - let parentDiv; - let tester; - let scrollingDiv; - - beforeEach(function(done) { - test_utils.beforeEach(this); - - // create a div of a useful size to put our panel in, and attach it to - // the document so that we can interact with it properly. - parentDiv = document.createElement('div'); - parentDiv.style.width = '800px'; - parentDiv.style.height = '600px'; - parentDiv.style.overflow = 'hidden'; - document.body.appendChild(parentDiv); - - tester = ReactDOM.render(, parentDiv); - expect(tester.fillCounts.b).toEqual(1); - expect(tester.fillCounts.f).toEqual(1); - - scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( - tester, "gm-scroll-view"); - - // we need to make sure we don't call done() until q has finished - // running the completion handlers from the fill requests. We can't - // just use .done(), because that will end up ahead of those handlers - // in the queue. We can't use window.setTimeout(0), because that also might - // run ahead of those handlers. - const sp = tester.scrollPanel(); - let retriesRemaining = 1; - const awaitReady = function() { - return Promise.resolve().then(() => { - if (sp._pendingFillRequests.b === false && - sp._pendingFillRequests.f === false - ) { - return; - } - - if (retriesRemaining == 0) { - throw new Error("fillRequests did not complete"); - } - retriesRemaining--; - return awaitReady(); - }); - }; - awaitReady().done(done); - }); - - afterEach(function() { - if (parentDiv) { - document.body.removeChild(parentDiv); - parentDiv = null; - } - }); - - it('should handle scrollEvent strangeness', function() { - const events = []; - - return Promise.resolve().then(() => { - // initialise with a load of events - for (let i = 0; i < 20; i++) { - events.push(i+80); - } - tester.setTileKeys(events); - expect(scrollingDiv.scrollHeight).toEqual(3050); // 20*150 + 50 - expect(scrollingDiv.scrollTop).toEqual(3050 - 600); - return tester.awaitScroll(); - }).then(() => { - expect(tester.lastScrollEvent).toBe(3050 - 600); - - tester.scrollPanel().scrollToToken("92", 0); - - // at this point, ScrollPanel will have updated scrollTop, but - // the event hasn't fired. - expect(tester.lastScrollEvent).toEqual(3050 - 600); - expect(scrollingDiv.scrollTop).toEqual(1950); - - // now stamp over the scrollTop. - console.log('faking #528'); - scrollingDiv.scrollTop = 500; - - return tester.awaitScroll(); - }).then(() => { - expect(tester.lastScrollEvent).toBe(1950); - expect(scrollingDiv.scrollTop).toEqual(1950); - }); - }); - - it('should not get stuck in #528 workaround', function(done) { - let events = []; - Promise.resolve().then(() => { - // initialise with a bunch of events - for (let i = 0; i < 40; i++) { - events.push(i); - } - tester.setTileKeys(events); - expect(tester.fillCounts.b).toEqual(1); - expect(tester.fillCounts.f).toEqual(2); - expect(scrollingDiv.scrollHeight).toEqual(6050); // 40*150 + 50 - expect(scrollingDiv.scrollTop).toEqual(6050 - 600); - - // try to scroll up, to a non-integer offset. - tester.scrollPanel().scrollToToken("30", -101/3); - - expect(scrollingDiv.scrollTop).toEqual(4616); // 31*150 - 34 - - // wait for the scroll event to land - return tester.awaitScroll(); // fails - }).then(() => { - expect(tester.lastScrollEvent).toEqual(4616); - - // Now one more event; this will make it reset the scroll, but - // because the delta will be less than 1, will not trigger a - // scroll event, this leaving recentEventScroll defined. - console.log("Adding event 50"); - events.push(50); - tester.setTileKeys(events); - - // wait for the scrollpanel to stop trying to paginate - }).then(() => { - // Now, simulate hitting "scroll to bottom". - events = []; - for (let i = 100; i < 120; i++) { - events.push(i); - } - tester.setTileKeys(events); - tester.scrollPanel().scrollToBottom(); - - // wait for the scroll event to land - return tester.awaitScroll(); // fails - }).then(() => { - expect(scrollingDiv.scrollTop).toEqual(20*150 + 50 - 600); - - // simulate a user-initiated scroll on the div - scrollingDiv.scrollTop = 1200; - return tester.awaitScroll(); - }).then(() => { - expect(scrollingDiv.scrollTop).toEqual(1200); - }).done(done); - }); -}); diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js deleted file mode 100644 index 01ea6d8421..0000000000 --- a/test/components/structures/TimelinePanel-test.js +++ /dev/null @@ -1,372 +0,0 @@ -/* -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. -*/ - -const React = require('react'); -const ReactDOM = require('react-dom'); -const ReactTestUtils = require('react-addons-test-utils'); -const expect = require('expect'); -import Promise from 'bluebird'; -const sinon = require('sinon'); - -const jssdk = require('matrix-js-sdk'); -const EventTimeline = jssdk.EventTimeline; - -const sdk = require('matrix-react-sdk'); -const TimelinePanel = sdk.getComponent('structures.TimelinePanel'); -const peg = require('../../../src/MatrixClientPeg'); - -const test_utils = require('test-utils'); - -const ROOM_ID = '!room:localhost'; -const USER_ID = '@me:localhost'; - -// wrap TimelinePanel with a component which provides the MatrixClient in the context. -const WrappedTimelinePanel = React.createClass({ - childContextTypes: { - matrixClient: React.PropTypes.object, - }, - - getChildContext: function() { - return { - matrixClient: peg.get(), - }; - }, - - render: function() { - return ; - }, -}); - - -describe('TimelinePanel', function() { - let sandbox; - let timelineSet; - let room; - let client; - let timeline; - let parentDiv; - - // make a dummy message. eventNum is put in the message text to help - // identification during debugging, and also in the timestamp so that we - // don't get lots of events with the same timestamp. - function mkMessage(eventNum, opts) { - return test_utils.mkMessage( - { - event: true, room: ROOM_ID, user: USER_ID, - ts: Date.now() + eventNum, - msg: "Event " + eventNum, - ...opts, - }); - } - - function scryEventTiles(panel) { - return ReactTestUtils.scryRenderedComponentsWithType( - panel, sdk.getComponent('rooms.EventTile')); - } - - beforeEach(function() { - test_utils.beforeEach(this); - sandbox = test_utils.stubClient(sandbox); - - room = sinon.createStubInstance(jssdk.Room); - room.currentState = sinon.createStubInstance(jssdk.RoomState); - room.currentState.members = {}; - room.roomId = ROOM_ID; - - timelineSet = sinon.createStubInstance(jssdk.EventTimelineSet); - timelineSet.getPendingEvents.returns([]); - timelineSet.room = room; - - timeline = new jssdk.EventTimeline(timelineSet); - - timelineSet.getLiveTimeline.returns(timeline); - - client = peg.get(); - client.credentials = {userId: USER_ID}; - - // create a div of a useful size to put our panel in, and attach it to - // the document so that we can interact with it properly. - parentDiv = document.createElement('div'); - parentDiv.style.width = '800px'; - - // This has to be slightly carefully chosen. We expect to have to do - // exactly one pagination to fill it. - parentDiv.style.height = '500px'; - - parentDiv.style.overflow = 'hidden'; - document.body.appendChild(parentDiv); - }); - - afterEach(function() { - if (parentDiv) { - ReactDOM.unmountComponentAtNode(parentDiv); - parentDiv.remove(); - parentDiv = null; - } - sandbox.restore(); - }); - - it('should load new events even if you are scrolled up', function(done) { - // this is https://github.com/vector-im/vector-web/issues/1367 - - // enough events to allow us to scroll back - const N_EVENTS = 30; - for (let i = 0; i < N_EVENTS; i++) { - timeline.addEvent(mkMessage(i)); - } - - let scrollDefer; - const onScroll = (e) => { - console.log(`TimelinePanel called onScroll: ${e.target.scrollTop}`); - if (scrollDefer) { - scrollDefer.resolve(); - } - }; - const rendered = ReactDOM.render( - , - parentDiv, - ); - const panel = rendered.refs.panel; - const scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( - panel, "gm-scroll-view"); - - // helper function which will return a promise which resolves when the - // panel isn't paginating - var awaitPaginationCompletion = function() { - if(!panel.state.forwardPaginating) {return Promise.resolve();} else {return Promise.delay(0).then(awaitPaginationCompletion);} - }; - - // helper function which will return a promise which resolves when - // the TimelinePanel fires a scroll event - const awaitScroll = function() { - scrollDefer = Promise.defer(); - return scrollDefer.promise; - }; - - // let the first round of pagination finish off - Promise.delay(5).then(() => { - expect(panel.state.canBackPaginate).toBe(false); - expect(scryEventTiles(panel).length).toEqual(N_EVENTS); - - // scroll up - console.log("setting scrollTop = 0"); - scrollingDiv.scrollTop = 0; - - // wait for the scroll event to land - }).then(awaitScroll).then(() => { - expect(scrollingDiv.scrollTop).toEqual(0); - - // there should be no pagination going on now - expect(panel.state.backPaginating).toBe(false); - expect(panel.state.forwardPaginating).toBe(false); - expect(panel.state.canBackPaginate).toBe(false); - expect(panel.state.canForwardPaginate).toBe(false); - expect(panel.isAtEndOfLiveTimeline()).toBe(false); - expect(scrollingDiv.scrollTop).toEqual(0); - - console.log("adding event"); - - // a new event! - const ev = mkMessage(N_EVENTS+1); - timeline.addEvent(ev); - panel.onRoomTimeline(ev, room, false, false, { - liveEvent: true, - timeline: timeline, - }); - - // that won't make much difference, because we don't paginate - // unless we're at the bottom of the timeline, but a scroll event - // should be enough to set off a pagination. - expect(scryEventTiles(panel).length).toEqual(N_EVENTS); - - scrollingDiv.scrollTop = 10; - - return awaitScroll(); - }).then(awaitPaginationCompletion).then(() => { - expect(scryEventTiles(panel).length).toEqual(N_EVENTS+1); - }).done(done, done); - }); - - it('should not paginate forever if there are no events', function(done) { - // start with a handful of events in the timeline, as would happen when - // joining a room - const d = Date.now(); - for (let i = 0; i < 3; i++) { - timeline.addEvent(mkMessage(i)); - } - timeline.setPaginationToken('tok', EventTimeline.BACKWARDS); - - // back-pagination returns a promise for true, but adds no events - client.paginateEventTimeline = sinon.spy((tl, opts) => { - console.log("paginate:", opts); - expect(opts.backwards).toBe(true); - return Promise.resolve(true); - }); - - const rendered = ReactDOM.render( - , - parentDiv, - ); - const panel = rendered.refs.panel; - - const messagePanel = ReactTestUtils.findRenderedComponentWithType( - panel, sdk.getComponent('structures.MessagePanel')); - - expect(messagePanel.props.backPaginating).toBe(true); - - // let the first round of pagination finish off - setTimeout(() => { - // at this point, the timeline window should have tried to paginate - // 5 times, and we should have given up paginating - expect(client.paginateEventTimeline.callCount).toEqual(5); - expect(messagePanel.props.backPaginating).toBe(false); - expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); - - // now, if we update the events, there shouldn't be any - // more requests. - client.paginateEventTimeline.resetHistory(); - panel.forceUpdate(); - expect(messagePanel.props.backPaginating).toBe(false); - setTimeout(() => { - expect(client.paginateEventTimeline.callCount).toEqual(0); - done(); - }, 0); - }, 10); - }); - - it("should let you scroll down to the bottom after you've scrolled up", function(done) { - const N_EVENTS = 120; // the number of events to simulate being added to the timeline - - // sadly, loading all those events takes a while - this.timeout(N_EVENTS * 50); - - // client.getRoom is called a /lot/ in this test, so replace - // sinon's spy with a fast noop. - client.getRoom = function(id) { return null; }; - - // fill the timeline with lots of events - for (let i = 0; i < N_EVENTS; i++) { - timeline.addEvent(mkMessage(i)); - } - console.log("added events to timeline"); - - let scrollDefer; - const rendered = ReactDOM.render( - {scrollDefer.resolve();}} />, - parentDiv, - ); - console.log("TimelinePanel rendered"); - const panel = rendered.refs.panel; - const messagePanel = ReactTestUtils.findRenderedComponentWithType( - panel, sdk.getComponent('structures.MessagePanel')); - const scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( - panel, "gm-scroll-view"); - - // helper function which will return a promise which resolves when - // the TimelinePanel fires a scroll event - const awaitScroll = function() { - scrollDefer = Promise.defer(); - - return scrollDefer.promise.then(() => { - console.log("got scroll event; scrollTop now " + - scrollingDiv.scrollTop); - }); - }; - - function setScrollTop(scrollTop) { - const before = scrollingDiv.scrollTop; - scrollingDiv.scrollTop = scrollTop; - console.log("setScrollTop: before update: " + before + - "; assigned: " + scrollTop + - "; after update: " + scrollingDiv.scrollTop); - } - - function backPaginate() { - console.log("back paginating..."); - setScrollTop(0); - return awaitScroll().then(() => { - const eventTiles = scryEventTiles(panel); - const firstEvent = eventTiles[0].props.mxEvent; - - console.log("TimelinePanel contains " + eventTiles.length + - " events; first is " + - firstEvent.getContent().body); - - if(scrollingDiv.scrollTop > 0) { - // need to go further - return backPaginate(); - } - console.log("paginated to start."); - }); - } - - function scrollDown() { - // Scroll the bottom of the viewport to the bottom of the panel - setScrollTop(scrollingDiv.scrollHeight - scrollingDiv.clientHeight); - console.log("scrolling down... " + scrollingDiv.scrollTop); - return awaitScroll().delay(0).then(() => { - const eventTiles = scryEventTiles(panel); - const events = timeline.getEvents(); - - const lastEventInPanel = eventTiles[eventTiles.length - 1].props.mxEvent; - const lastEventInTimeline = events[events.length - 1]; - - // Scroll until the last event in the panel = the last event in the timeline - if(lastEventInPanel.getId() !== lastEventInTimeline.getId()) { - // need to go further - return scrollDown(); - } - console.log("paginated to end."); - }); - } - - // let the first round of pagination finish off - awaitScroll().then(() => { - // we should now have loaded the first few events - expect(messagePanel.props.backPaginating).toBe(false); - expect(messagePanel.props.suppressFirstDateSeparator).toBe(true); - - // back-paginate until we hit the start - return backPaginate(); - }).then(() => { - // hopefully, we got to the start of the timeline - expect(messagePanel.props.backPaginating).toBe(false); - - expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); - const events = scryEventTiles(panel); - expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]); - - // At this point, we make no assumption that unpagination has happened. This doesn't - // mean that we shouldn't be able to scroll all the way down to the bottom to see the - // most recent event in the timeline. - - // scroll all the way to the bottom - return scrollDown(); - }).then(() => { - expect(messagePanel.props.backPaginating).toBe(false); - expect(messagePanel.props.forwardPaginating).toBe(false); - - const events = scryEventTiles(panel); - - // Expect to be able to see the most recent event - const lastEventInPanel = events[events.length - 1].props.mxEvent; - const lastEventInTimeline = timeline.getEvents()[timeline.getEvents().length - 1]; - expect(lastEventInPanel.getContent()).toBe(lastEventInTimeline.getContent()); - - console.log("done"); - }).done(done, done); - }); -});