Merge remote-tracking branch 'origin/develop' into travis/cancel-3pid
						commit
						86e4d29582
					
				|  | @ -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"; | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
| } | ||||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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"; | ||||
|  |  | |||
|  | @ -121,6 +121,7 @@ export default class AutoHideScrollbar extends React.Component { | |||
|     render() { | ||||
|         return (<div | ||||
|                     ref={this._collectContainerRef} | ||||
|                     style={this.props.style} | ||||
|                     className={["mx_AutoHideScrollbar", this.props.className].join(" ")} | ||||
|                     onScroll={this.props.onScroll} | ||||
|                 > | ||||
|  |  | |||
|  | @ -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')} | ||||
|                 /> | ||||
|             ); | ||||
|  |  | |||
|  | @ -234,7 +234,7 @@ const LeftPanel = React.createClass({ | |||
|                     <CallPreview ConferenceHandler={VectorConferenceHandler} /> | ||||
|                     <RoomList | ||||
|                         ref={this.collectRoomList} | ||||
|                         toolbarShown={this.props.toolbarShown} | ||||
|                         resizeNotifier={this.props.resizeNotifier} | ||||
|                         collapsed={this.props.collapsed} | ||||
|                         searchFilter={this.state.searchFilter} | ||||
|                         ConferenceHandler={VectorConferenceHandler} /> | ||||
|  |  | |||
|  | @ -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 = <ServerLimitBar kind='hard' | ||||
|                 adminContact={this.state.syncErrorData.error.data.admin_contact} | ||||
|  | @ -513,10 +525,7 @@ const LoggedInView = React.createClass({ | |||
|             topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />; | ||||
|         } else if (this.state.userHasGeneratedPassword) { | ||||
|             topBar = <PasswordNagBar />; | ||||
|         } else if ( | ||||
|             !isGuest && Notifier.supportsDesktopNotifications() && | ||||
|             !Notifier.isEnabled() && !Notifier.isToolbarHidden() | ||||
|         ) { | ||||
|         } else if (this.props.showNotifierToolbar) { | ||||
|             topBar = <MatrixToolbar />; | ||||
|         } | ||||
| 
 | ||||
|  | @ -534,7 +543,7 @@ const LoggedInView = React.createClass({ | |||
|                 <DragDropContext onDragEnd={this._onDragEnd}> | ||||
|                     <div ref={this._setResizeContainerRef} className={bodyClasses}> | ||||
|                         <LeftPanel | ||||
|                             toolbarShown={!!topBar} | ||||
|                             resizeNotifier={this.props.resizeNotifier} | ||||
|                             collapsed={this.props.collapseLhs || false} | ||||
|                             disabled={this.props.leftDisabled} | ||||
|                         /> | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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 = (<WhoIsTypingTile room={this.props.room} onVisible={this._onTypingVisible} ref="whoIsTyping" />); | ||||
|             whoIsTyping = (<WhoIsTypingTile | ||||
|                 room={this.props.room} | ||||
|                 onShown={this._onTypingShown} | ||||
|                 onHidden={this._onTypingHidden} | ||||
|                 ref="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 } | ||||
|  |  | |||
|  | @ -198,7 +198,7 @@ export default class RightPanel extends React.Component { | |||
|         } else if (this.state.phase === RightPanel.Phase.NotificationPanel) { | ||||
|             panel = <NotificationPanel />; | ||||
|         } else if (this.state.phase === RightPanel.Phase.FilePanel) { | ||||
|             panel = <FilePanel roomId={this.props.roomId} />; | ||||
|             panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />; | ||||
|         } | ||||
| 
 | ||||
|         const classes = classNames("mx_RightPanel", "mx_fadable", { | ||||
|  |  | |||
|  | @ -549,7 +549,6 @@ module.exports = React.createClass({ | |||
|                 onFillRequest={ this.onFillRequest } | ||||
|                 stickyBottom={false} | ||||
|                 startAtBottom={false} | ||||
|                 onResize={function() {}} | ||||
|             > | ||||
|                 { scrollpanel_content } | ||||
|             </ScrollPanel>; | ||||
|  |  | |||
|  | @ -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 = | ||||
|                 <MessageComposer | ||||
|                     room={this.state.room} | ||||
|                     onResize={this.onChildResize} | ||||
|                     uploadFile={this.uploadFile} | ||||
|                     callState={this.state.callState} | ||||
|                     disabled={this.props.disabled} | ||||
|  | @ -1859,7 +1848,7 @@ module.exports = React.createClass({ | |||
|                     <ScrollPanel ref="searchResultsPanel" | ||||
|                         className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel" | ||||
|                         onFillRequest={this.onSearchResultsFillRequest} | ||||
|                         onResize={this.onSearchResultsResize} | ||||
|                         resizeNotifier={this.props.resizeNotifier} | ||||
|                     > | ||||
|                         <li className={scrollheader_classes}></li> | ||||
|                         { 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 ? <RightPanel roomId={this.state.room.roomId} /> : undefined; | ||||
|         const rightPanel = this.state.room ? <RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} /> : undefined; | ||||
| 
 | ||||
|         return ( | ||||
|             <main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView"> | ||||
|  | @ -1942,7 +1932,11 @@ module.exports = React.createClass({ | |||
|                     onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} | ||||
|                     e2eStatus={this.state.e2eStatus} | ||||
|                 /> | ||||
|                 <MainSplit panel={rightPanel} collapsedRhs={this.props.collapsedRhs}> | ||||
|                 <MainSplit | ||||
|                     panel={rightPanel} | ||||
|                     collapsedRhs={this.props.collapsedRhs} | ||||
|                     resizeNotifier={this.props.resizeNotifier} | ||||
|                 > | ||||
|                     <div className={fadableSectionClasses}> | ||||
|                         { auxPanel } | ||||
|                         <div className="mx_RoomView_timeline"> | ||||
|  |  | |||
|  | @ -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 (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll} | ||||
|                 onScroll={this.onScroll} onResize={this.onResize} | ||||
|                 className={this.props.className} style={this.props.style}> | ||||
|         return (<AutoHideScrollbar wrappedRef={this._collectScroll} | ||||
|                 onScroll={this.onScroll} | ||||
|                 className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}> | ||||
|                     <div className="mx_RoomView_messageListWrapper"> | ||||
|                         <ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite"> | ||||
|                             { this.props.children } | ||||
|                         </ol> | ||||
|                     </div> | ||||
|                 </GeminiScrollbarWrapper> | ||||
|                ); | ||||
|                 </AutoHideScrollbar> | ||||
|             ); | ||||
|     }, | ||||
| }); | ||||
|  |  | |||
|  | @ -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} | ||||
|             /> | ||||
|         ); | ||||
|     }, | ||||
|  |  | |||
|  | @ -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 } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <GeminiScrollbarWrapper autoshow={true} className="mx_MemberInfo_scrollContainer"> | ||||
|                     <AutoHideScrollbar className="mx_MemberInfo_scrollContainer"> | ||||
|                         <div className="mx_MemberInfo_container"> | ||||
|                             { this._renderUserOptions() } | ||||
| 
 | ||||
|  | @ -1015,7 +1016,7 @@ module.exports = withMatrixClient(React.createClass({ | |||
| 
 | ||||
|                             { spinner } | ||||
|                         </div> | ||||
|                     </GeminiScrollbarWrapper> | ||||
|                     </AutoHideScrollbar> | ||||
|             </div> | ||||
|         ); | ||||
|     }, | ||||
|  |  | |||
|  | @ -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 ( | ||||
|             <div className="mx_MemberList"> | ||||
|                 { inviteButton } | ||||
|                 <GeminiScrollbarWrapper autoshow={true}> | ||||
|                 <AutoHideScrollbar> | ||||
|                     <div className="mx_MemberList_wrapper"> | ||||
|                         <TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined} | ||||
|                             createOverflowElement={this._createOverflowTileJoined} | ||||
|  | @ -480,7 +480,7 @@ module.exports = React.createClass({ | |||
|                         { invitedHeader } | ||||
|                         { invitedSection } | ||||
|                     </div> | ||||
|                 </GeminiScrollbarWrapper> | ||||
|                 </AutoHideScrollbar> | ||||
| 
 | ||||
|                 <SearchBox className="mx_MemberList_query mx_textinput_icon mx_textinput_search" | ||||
|                            placeholder={ _t('Filter room members') } | ||||
|  |  | |||
|  | @ -412,7 +412,6 @@ export default class MessageComposer extends React.Component { | |||
|                 <MessageComposerInput | ||||
|                     ref={(c) => 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, | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
|             ); | ||||
|         } | ||||
|     }, | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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(); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -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 <MessagePanel room={room} {...this.props} />; | ||||
|         return <MessagePanel room={room} {...this.props} resizeNotifier={this.state.resizeNotifier} />; | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 ( | ||||
|             <li key={key} data-scroll-tokens={key}> | ||||
|                 <div style={{height: '98px', margin: '50px', border: '1px solid black', | ||||
|                              backgroundColor: '#fff8dc' }}> | ||||
|                    { key } | ||||
|                 </div> | ||||
|              </li> | ||||
|          ); | ||||
|     }, | ||||
| 
 | ||||
|     render: function() { | ||||
|         const tiles = this.state.tileKeys.map(this._mkTile); | ||||
|         console.log("rendering with " + tiles.length + " tiles"); | ||||
|         return ( | ||||
|             <ScrollPanel ref="sp" | ||||
|                 onScroll={this._onScroll} | ||||
|                 onFillRequest={this._onFillRequest}> | ||||
|                     { tiles } | ||||
|             </ScrollPanel> | ||||
|         ); | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
| 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(<Tester />, 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); | ||||
|     }); | ||||
| }); | ||||
|  | @ -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 <TimelinePanel ref="panel" {...this.props} />; | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| 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( | ||||
|                 <WrappedTimelinePanel timelineSet={timelineSet} onScroll={onScroll} />, | ||||
|                 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( | ||||
|             <WrappedTimelinePanel timelineSet={timelineSet} />, | ||||
|             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( | ||||
|             <WrappedTimelinePanel timelineSet={timelineSet} onScroll={() => {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); | ||||
|     }); | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue
	
	 Travis Ralston
						Travis Ralston