Merge remote-tracking branch 'origin/develop' into travis/cancel-3pid

This commit is contained in:
Travis Ralston 2019-03-29 11:47:46 -06:00
commit 86e4d29582
28 changed files with 577 additions and 911 deletions

View file

@ -19,6 +19,7 @@
@import "./structures/_RoomStatusBar.scss"; @import "./structures/_RoomStatusBar.scss";
@import "./structures/_RoomSubList.scss"; @import "./structures/_RoomSubList.scss";
@import "./structures/_RoomView.scss"; @import "./structures/_RoomView.scss";
@import "./structures/_ScrollPanel.scss";
@import "./structures/_SearchBox.scss"; @import "./structures/_SearchBox.scss";
@import "./structures/_TabbedView.scss"; @import "./structures/_TabbedView.scss";
@import "./structures/_TagPanel.scss"; @import "./structures/_TagPanel.scss";

View file

@ -19,3 +19,9 @@ limitations under the License.
flex-direction: row; flex-direction: row;
min-width: 0; 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;
}

View file

@ -91,6 +91,7 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-width: 0;
} }
.mx_RoomView_body .mx_RoomView_timeline { .mx_RoomView_body .mx_RoomView_timeline {
@ -118,6 +119,8 @@ limitations under the License.
.mx_RoomView_messagePanel { .mx_RoomView_messagePanel {
width: 100%; width: 100%;
overflow-y: auto; overflow-y: auto;
flex: 1 1 0;
overflow-anchor: none;
} }
.mx_RoomView_messagePanelSearchSpinner { .mx_RoomView_messagePanelSearchSpinner {

View file

@ -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;
}
}

View file

@ -20,6 +20,7 @@ limitations under the License.
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
.mx_Spinner { .mx_Spinner {
flex: 1 0 auto; flex: 1 0 auto;
@ -35,6 +36,10 @@ limitations under the License.
margin-top: 8px; margin-top: 8px;
margin-bottom: 4px; margin-bottom: 4px;
} }
.mx_AutoHideScrollbar {
flex: 1 1 0;
}
} }
.mx_MemberList_chevron { .mx_MemberList_chevron {

View file

@ -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 // Check localStorage for any such meta data
if (global.localStorage) { if (global.localStorage) {
return global.localStorage.getItem("notifications_hidden") === "true"; return global.localStorage.getItem("notifications_hidden") === "true";

View file

@ -121,6 +121,7 @@ export default class AutoHideScrollbar extends React.Component {
render() { render() {
return (<div return (<div
ref={this._collectContainerRef} ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")} className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll} onScroll={this.props.onScroll}
> >

View file

@ -123,6 +123,7 @@ const FilePanel = React.createClass({
timelineSet={this.state.timelineSet} timelineSet={this.state.timelineSet}
showUrlPreview = {false} showUrlPreview = {false}
tileShape="file_grid" tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')} empty={_t('There are no visible files in this room')}
/> />
); );

View file

@ -234,7 +234,7 @@ const LeftPanel = React.createClass({
<CallPreview ConferenceHandler={VectorConferenceHandler} /> <CallPreview ConferenceHandler={VectorConferenceHandler} />
<RoomList <RoomList
ref={this.collectRoomList} ref={this.collectRoomList}
toolbarShown={this.props.toolbarShown} resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed} collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter} searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} /> ConferenceHandler={VectorConferenceHandler} />

View file

@ -22,7 +22,6 @@ import PropTypes from 'prop-types';
import { DragDropContext } from 'react-beautiful-dnd'; import { DragDropContext } from 'react-beautiful-dnd';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier';
import PageTypes from '../../PageTypes'; import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler'; import CallMediaHandler from '../../CallMediaHandler';
import sdk from '../../index'; import sdk from '../../index';
@ -121,6 +120,18 @@ const LoggedInView = React.createClass({
this._matrixClient.on("RoomState.events", this.onRoomStateEvents); 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() { componentWillUnmount: function() {
document.removeEventListener('keydown', this._onKeyDown); document.removeEventListener('keydown', this._onKeyDown);
this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("accountData", this.onAccountData);
@ -173,6 +184,7 @@ const LoggedInView = React.createClass({
}, },
onResized: (size) => { onResized: (size) => {
window.localStorage.setItem("mx_lhs_size", '' + size); window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.notifyLeftHandleResized();
}, },
}; };
const resizer = new Resizer( const resizer = new Resizer(
@ -448,6 +460,7 @@ const LoggedInView = React.createClass({
disabled={this.props.middleDisabled} disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapsedRhs} collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler} ConferenceHandler={this.props.ConferenceHandler}
resizeNotifier={this.props.resizeNotifier}
/>; />;
break; break;
@ -489,7 +502,6 @@ const LoggedInView = React.createClass({
}); });
let topBar; let topBar;
const isGuest = this.props.matrixClient.isGuest();
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
topBar = <ServerLimitBar kind='hard' topBar = <ServerLimitBar kind='hard'
adminContact={this.state.syncErrorData.error.data.admin_contact} adminContact={this.state.syncErrorData.error.data.admin_contact}
@ -513,10 +525,7 @@ const LoggedInView = React.createClass({
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />; topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
} else if (this.state.userHasGeneratedPassword) { } else if (this.state.userHasGeneratedPassword) {
topBar = <PasswordNagBar />; topBar = <PasswordNagBar />;
} else if ( } else if (this.props.showNotifierToolbar) {
!isGuest && Notifier.supportsDesktopNotifications() &&
!Notifier.isEnabled() && !Notifier.isToolbarHidden()
) {
topBar = <MatrixToolbar />; topBar = <MatrixToolbar />;
} }
@ -534,7 +543,7 @@ const LoggedInView = React.createClass({
<DragDropContext onDragEnd={this._onDragEnd}> <DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}> <div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel <LeftPanel
toolbarShown={!!topBar} resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false} collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled} disabled={this.props.leftDisabled}
/> />

View file

@ -27,6 +27,9 @@ export default class MainSplit extends React.Component {
_onResized(size) { _onResized(size) {
window.localStorage.setItem("mx_rhs_size", size); window.localStorage.setItem("mx_rhs_size", size);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.notifyRightHandleResized();
}
} }
_createResizer() { _createResizer() {

View file

@ -29,6 +29,7 @@ import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter"; import * as RoomListSorter from "../../RoomListSorter";
import dis from "../../dispatcher"; import dis from "../../dispatcher";
import Notifier from '../../Notifier';
import Modal from "../../Modal"; import Modal from "../../Modal";
import Tinter from "../../Tinter"; import Tinter from "../../Tinter";
@ -48,6 +49,7 @@ import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js"; import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils'; import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import TimelineExplosionDialog from "../views/dialogs/TimelineExplosionDialog"; import TimelineExplosionDialog from "../views/dialogs/TimelineExplosionDialog";
const AutoDiscovery = Matrix.AutoDiscovery; const AutoDiscovery = Matrix.AutoDiscovery;
@ -195,6 +197,8 @@ export default React.createClass({
hideToSRUsers: false, hideToSRUsers: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null. syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
resizeNotifier: new ResizeNotifier(),
showNotifierToolbar: false,
}; };
return s; 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 // 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 // racing with the theme CSS download finishing from index.js
Tinter.tint(); Tinter.tint();
// For PersistentElement
this.state.resizeNotifier.on("middlePanelResized", this._dispatchTimelineResize);
}, },
componentDidMount: function() { componentDidMount: function() {
@ -399,6 +406,7 @@ export default React.createClass({
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
window.removeEventListener("focus", this.onFocus); window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
}, },
componentWillUpdate: function(props, state) { componentWillUpdate: function(props, state) {
@ -639,8 +647,9 @@ export default React.createClass({
case 'view_invite': case 'view_invite':
showRoomInviteDialog(payload.roomId); showRoomInviteDialog(payload.roomId);
break; break;
case 'notifier_enabled': case 'notifier_enabled': {
this.forceUpdate(); this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()});
}
break; break;
case 'hide_left_panel': case 'hide_left_panel':
this.setState({ this.setState({
@ -1188,7 +1197,7 @@ export default React.createClass({
* Called when a new logged in session has started * Called when a new logged in session has started
*/ */
_onLoggedIn: async function() { _onLoggedIn: async function() {
this.setStateForNewView({view: VIEWS.LOGGED_IN}); this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) { if (this._is_registered) {
this._is_registered = false; this._is_registered = false;
@ -1332,7 +1341,10 @@ export default React.createClass({
self.firstSyncPromise.resolve(); self.firstSyncPromise.resolve();
dis.dispatch({action: 'focus_composer'}); dis.dispatch({action: 'focus_composer'});
self.setState({ready: true}); self.setState({
ready: true,
showNotifierToolbar: Notifier.shouldShowToolbar(),
});
}); });
cli.on('Call.incoming', function(call) { cli.on('Call.incoming', function(call) {
// we dispatch this synchronously to make sure that the event // we dispatch this synchronously to make sure that the event
@ -1696,9 +1708,14 @@ export default React.createClass({
dis.dispatch({ action: 'show_right_panel' }); dis.dispatch({ action: 'show_right_panel' });
} }
this.state.resizeNotifier.notifyWindowResized();
this._windowWidth = window.innerWidth; this._windowWidth = window.innerWidth;
}, },
_dispatchTimelineResize() {
dis.dispatch({ action: 'timeline_resize' });
},
onRoomCreated: function(roomId) { onRoomCreated: function(roomId) {
dis.dispatch({ dis.dispatch({
action: "view_room", action: "view_room",

View file

@ -21,7 +21,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent'; import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils'; import {wantsDateSeparator} from '../../DateUtils';
import dis from "../../dispatcher";
import sdk from '../../index'; import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
@ -628,16 +627,29 @@ module.exports = React.createClass({
_onHeightChanged: function() { _onHeightChanged: function() {
const scrollPanel = this.refs.scrollPanel; const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) { if (scrollPanel) {
scrollPanel.forceUpdate(); scrollPanel.checkScroll();
} }
}, },
_onTypingVisible: function() { _onTypingShown: function() {
const scrollPanel = this.refs.scrollPanel; const scrollPanel = this.refs.scrollPanel;
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { // this will make the timeline grow, so checkScroll
// scroll down if at bottom scrollPanel.checkScroll();
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
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.checkScroll();
scrollPanel.blockShrinking();
} }
}, },
@ -653,22 +665,18 @@ module.exports = React.createClass({
// update the min-height, so once the last // update the min-height, so once the last
// person stops typing, no jumping occurs // person stops typing, no jumping occurs
if (isAtBottom && isTypingVisible) { if (isAtBottom && isTypingVisible) {
scrollPanel.blockShrinking(); scrollPanel.preventShrinking();
} }
} }
}, },
clearTimelineHeight: function() { onTimelineReset: function() {
const scrollPanel = this.refs.scrollPanel; const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) { if (scrollPanel) {
scrollPanel.clearBlockShrinking(); scrollPanel.clearPreventShrinking();
} }
}, },
onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},
render: function() { render: function() {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile"); const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
@ -693,7 +701,12 @@ module.exports = React.createClass({
let whoIsTyping; let whoIsTyping;
if (this.props.room) { 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 ( return (
@ -703,7 +716,8 @@ module.exports = React.createClass({
onFillRequest={this.props.onFillRequest} onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest} onUnfillRequest={this.props.onUnfillRequest}
style={style} style={style}
stickyBottom={this.props.stickyBottom}> stickyBottom={this.props.stickyBottom}
resizeNotifier={this.props.resizeNotifier}>
{ topSpinner } { topSpinner }
{ this._getEventTiles() } { this._getEventTiles() }
{ whoIsTyping } { whoIsTyping }

View file

@ -198,7 +198,7 @@ export default class RightPanel extends React.Component {
} else if (this.state.phase === RightPanel.Phase.NotificationPanel) { } else if (this.state.phase === RightPanel.Phase.NotificationPanel) {
panel = <NotificationPanel />; panel = <NotificationPanel />;
} else if (this.state.phase === RightPanel.Phase.FilePanel) { } 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", { const classes = classNames("mx_RightPanel", "mx_fadable", {

View file

@ -549,7 +549,6 @@ module.exports = React.createClass({
onFillRequest={ this.onFillRequest } onFillRequest={ this.onFillRequest }
stickyBottom={false} stickyBottom={false}
startAtBottom={false} startAtBottom={false}
onResize={function() {}}
> >
{ scrollpanel_content } { scrollpanel_content }
</ScrollPanel>; </ScrollPanel>;

View file

@ -394,7 +394,9 @@ module.exports = React.createClass({
this._updateConfCallNotification(); this._updateConfCallNotification();
window.addEventListener('beforeunload', this.onPageUnload); window.addEventListener('beforeunload', this.onPageUnload);
window.addEventListener('resize', this.onResize); if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.onResize(); this.onResize();
document.addEventListener("keydown", this.onKeyDown); document.addEventListener("keydown", this.onKeyDown);
@ -486,7 +488,9 @@ module.exports = React.createClass({
} }
window.removeEventListener('beforeunload', this.onPageUnload); 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); document.removeEventListener("keydown", this.onKeyDown);
@ -879,10 +883,6 @@ module.exports = React.createClass({
} }
}, },
onSearchResultsResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},
onSearchResultsFillRequest: function(backwards) { onSearchResultsFillRequest: function(backwards) {
if (!backwards) { if (!backwards) {
return Promise.resolve(false); return Promise.resolve(false);
@ -1378,8 +1378,7 @@ module.exports = React.createClass({
const showBar = this.refs.messagePanel.canJumpToReadMarker(); const showBar = this.refs.messagePanel.canJumpToReadMarker();
if (this.state.showTopUnreadMessagesBar != showBar) { if (this.state.showTopUnreadMessagesBar != showBar) {
this.setState({showTopUnreadMessagesBar: showBar}, this.setState({showTopUnreadMessagesBar: showBar});
this.onChildResize);
} }
}, },
@ -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 // 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 // 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 // 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; if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); 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() { onFullscreenClick: function() {
@ -1472,10 +1468,6 @@ module.exports = React.createClass({
this.forceUpdate(); // TODO: just update the voip buttons this.forceUpdate(); // TODO: just update the voip buttons
}, },
onChildResize: function() {
// no longer anything to do here
},
onStatusBarVisible: function() { onStatusBarVisible: function() {
if (this.unmounted) return; if (this.unmounted) return;
this.setState({ this.setState({
@ -1687,7 +1679,6 @@ module.exports = React.createClass({
isPeeking={myMembership !== "join"} isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick} onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick} onStopWarningClick={this.onStopAloneWarningClick}
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible} onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden} onHidden={this.onStatusBarHidden}
/>; />;
@ -1768,7 +1759,6 @@ module.exports = React.createClass({
draggingFile={this.state.draggingFile} draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification} displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight} maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize}
showApps={this.state.showApps} showApps={this.state.showApps}
hideAppsDrawer={false} > hideAppsDrawer={false} >
{ aux } { aux }
@ -1784,7 +1774,6 @@ module.exports = React.createClass({
messageComposer = messageComposer =
<MessageComposer <MessageComposer
room={this.state.room} room={this.state.room}
onResize={this.onChildResize}
uploadFile={this.uploadFile} uploadFile={this.uploadFile}
callState={this.state.callState} callState={this.state.callState}
disabled={this.props.disabled} disabled={this.props.disabled}
@ -1859,7 +1848,7 @@ module.exports = React.createClass({
<ScrollPanel ref="searchResultsPanel" <ScrollPanel ref="searchResultsPanel"
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel" className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest} onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize} resizeNotifier={this.props.resizeNotifier}
> >
<li className={scrollheader_classes}></li> <li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() } { this.getSearchResultTiles() }
@ -1894,6 +1883,7 @@ module.exports = React.createClass({
className="mx_RoomView_messagePanel" className="mx_RoomView_messagePanel"
membersLoaded={this.state.membersLoaded} membersLoaded={this.state.membersLoaded}
permalinkCreator={this.state.permalinkCreator} permalinkCreator={this.state.permalinkCreator}
resizeNotifier={this.props.resizeNotifier}
/>); />);
let topUnreadMessagesBar = null; 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 ( return (
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView"> <main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
@ -1942,7 +1932,11 @@ module.exports = React.createClass({
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus} 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}> <div className={fadableSectionClasses}>
{ auxPanel } { auxPanel }
<div className="mx_RoomView_timeline"> <div className="mx_RoomView_timeline">

View file

@ -15,14 +15,13 @@ limitations under the License.
*/ */
const React = require("react"); const React = require("react");
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Promise from 'bluebird'; import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard'; import { KeyCode } from '../../Keyboard';
import sdk from '../../index.js'; import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
const DEBUG_SCROLL = false; const DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true;
// The amount of extra scroll distance to allow prior to unfilling. // The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight. // See _getExcessHeight.
@ -31,11 +30,14 @@ const UNPAGINATION_PADDING = 6000;
// many scroll events causing many unfilling requests. // many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200; const UNFILL_REQUEST_DEBOUNCE_MS = 200;
const PAGE_SIZE = 200;
let debuglog;
if (DEBUG_SCROLL) { if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console // 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 { } else {
var debuglog = function() {}; debuglog = function() {};
} }
/* This component implements an intelligent scrolling list. /* This component implements an intelligent scrolling list.
@ -129,11 +131,6 @@ module.exports = React.createClass({
*/ */
onScroll: PropTypes.func, 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: classnames to add to the top-level div
*/ */
className: PropTypes.string, className: PropTypes.string,
@ -141,6 +138,9 @@ module.exports = React.createClass({
/* style: styles to add to the top-level div /* style: styles to add to the top-level div
*/ */
style: PropTypes.object, style: PropTypes.object,
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier: PropTypes.object,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -150,12 +150,18 @@ module.exports = React.createClass({
onFillRequest: function(backwards) { return Promise.resolve(false); }, onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {}, onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {}, onScroll: function() {},
onResize: function() {},
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._fillRequestWhileRunning = false;
this._isFilling = false;
this._pendingFillRequests = {b: null, f: null}; this._pendingFillRequests = {b: null, f: null};
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.resetScrollState(); 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 will also re-check the fill state, in case the paginate was inadequate
this.checkScroll(); this.checkScroll();
this.updatePreventShrinking();
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -178,54 +185,27 @@ module.exports = React.createClass({
// //
// (We could use isMounted(), but facebook have deprecated that.) // (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true; this.unmounted = true;
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
}, },
onScroll: function(ev) { onScroll: function(ev) {
const sn = this._getScrollNode(); debuglog("onScroll", this._getScrollNode().scrollTop);
debuglog("Scroll event: offset now:", sn.scrollTop, this._scrollTimeout.restart();
"_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(); this._saveScrollState();
} else { this.updatePreventShrinking();
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;
}
this.props.onScroll(ev); this.props.onScroll(ev);
this.checkFillState(); this.checkFillState();
}, },
onResize: function() { onResize: function() {
this.clearBlockShrinking();
this.props.onResize();
this.checkScroll(); 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 // 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. // 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 // 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. // whether it will stay that way when the children update.
isAtBottom: function() { isAtBottom: function() {
const sn = this._getScrollNode(); const sn = this._getScrollNode();
// fractional values for scrollTop happen on certain browsers/platforms
// there seems to be some bug with flexbox/gemini/chrome/richvdh's // when scrolled all the way down. E.g. Chrome 72 on debian.
// understanding of the box model, wherein the scrollNode ends up 2 // so ceil everything upwards to make sure it aligns.
// pixels higher than the available space, even when there are less return Math.ceil(sn.scrollTop) === Math.ceil(sn.scrollHeight - sn.clientHeight);
// 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;
}, },
// returns the vertical height in the given direction that can be removed from // returns the vertical height in the given direction that can be removed from
@ -285,19 +261,25 @@ module.exports = React.createClass({
// `---------' - // `---------' -
_getExcessHeight: function(backwards) { _getExcessHeight: function(backwards) {
const sn = this._getScrollNode(); const sn = this._getScrollNode();
const contentHeight = this._getMessagesHeight();
const listHeight = this._getListHeight();
const clippedHeight = contentHeight - listHeight;
const unclippedScrollTop = sn.scrollTop + clippedHeight;
if (backwards) { if (backwards) {
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING; return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else { } 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. // check the scroll state and send out backfill requests if necessary.
checkFillState: function() { checkFillState: async function(depth=0) {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
const isFirstCall = depth === 0;
const sn = this._getScrollNode(); const sn = this._getScrollNode();
// if there is less than a screenful of messages above or below the // 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) { // as filling is async and recursive,
// need to back-fill // don't allow more than 1 chain of calls concurrently
this._maybeFill(true); // 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;
} }
if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) { debuglog("_isFilling: setting");
this._isFilling = true;
}
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 // 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) { if (excessHeight <= 0) {
return; return;
} }
const origExcessHeight = excessHeight;
const tiles = this.refs.itemlist.children; const tiles = this.refs.itemlist.children;
// The scroll token of the first/last tile to be unpaginated // The scroll token of the first/last tile to be unpaginated
@ -351,8 +376,9 @@ module.exports = React.createClass({
// pagination. // pagination.
// //
// If backwards is true, we unpaginate (remove) tiles from the back (top). // If backwards is true, we unpaginate (remove) tiles from the back (top).
let tile;
for (let i = 0; i < tiles.length; i++) { 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 // Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight; excessHeight -= tile.clientHeight;
//If removing the tile would lead to future pagination, break before setting scroll token //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 = setTimeout(() => {
this._unfillDebouncer = null; this._unfillDebouncer = null;
debuglog("unfilling now", backwards, origExcessHeight);
this.props.onUnfillRequest(backwards, markerScrollToken); this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS); }, UNFILL_REQUEST_DEBOUNCE_MS);
} }
}, },
// check if there is already a pending fill request. If not, set one off. // 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'; const dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) { 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; return;
} }
debuglog("ScrollPanel: starting "+dir+" fill"); debuglog("starting "+dir+" fill");
// onFillRequest can end up calling us recursively (via onScroll // onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call. // events) so make sure we set this before firing off the call.
this._pendingFillRequests[dir] = true; 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); return this.props.onFillRequest(backwards);
}).finally(() => { }).finally(() => {
this._pendingFillRequests[dir] = false; this._pendingFillRequests[dir] = false;
@ -403,14 +434,14 @@ module.exports = React.createClass({
// Unpaginate once filling is complete // Unpaginate once filling is complete
this._checkUnfillState(!backwards); this._checkUnfillState(!backwards);
debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults); debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) { if (hasMoreResults) {
// further pagination requests have been disabled until now, so // further pagination requests have been disabled until now, so
// it's time to check the fill state again in case the pagination // it's time to check the fill state again in case the pagination
// was insufficient. // was insufficient.
this.checkFillState(); return this.checkFillState(depth + 1);
} }
}).done(); });
}, },
/* get the current scroll state. This returns an object with the following /* 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 * false, the first token in data-scroll-tokens of the child which we are
* tracking. * 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 * the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel. * bottom of the scroll panel.
*/ */
@ -444,14 +475,20 @@ module.exports = React.createClass({
* child list.) * child list.)
*/ */
resetScrollState: function() { 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. * jump to the top of the content.
*/ */
scrollToTop: function() { scrollToTop: function() {
this._setScrollTop(0); this._getScrollNode().scrollTop = 0;
this._saveScrollState(); this._saveScrollState();
}, },
@ -463,24 +500,26 @@ module.exports = React.createClass({
// saved is to do the scroll, then save the updated state. (Calculating // 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 // it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here). // 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(); this._saveScrollState();
}, },
/** /**
* Page up/down. * 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) { scrollRelative: function(mult) {
const scrollNode = this._getScrollNode(); const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5; const delta = mult * scrollNode.clientHeight * 0.5;
this._setScrollTop(scrollNode.scrollTop + delta); scrollNode.scrollTop = scrollNode.scrollTop + delta;
this._saveScrollState(); this._saveScrollState();
}, },
/** /**
* Scroll up/down in response to a scroll key * Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event
*/ */
handleScrollKey: function(ev) { handleScrollKey: function(ev) {
switch (ev.keyCode) { switch (ev.keyCode) {
@ -525,32 +564,156 @@ module.exports = React.createClass({
pixelOffset = pixelOffset || 0; pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0; offsetBase = offsetBase || 0;
// convert pixelOffset so that it is based on the bottom of the // set the trackedScrollToken so we can get the node through _getTrackedNode
// 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.
this.scrollState = { this.scrollState = {
stuckAtBottom: false, stuckAtBottom: false,
trackedScrollToken: scrollToken, trackedScrollToken: scrollToken,
pixelOffset: pixelOffset,
}; };
const trackedNode = this._getTrackedNode();
// ... then make it so. const scrollNode = this._getScrollNode();
this._restoreSavedScrollState(); 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();
}
}, },
// set the scrollTop attribute appropriately to position the given child at the _saveScrollState: function() {
// given offset in the window. A helper for _restoreSavedScrollState. if (this.props.stickyBottom && this.isAtBottom()) {
_scrollToToken: function(scrollToken, pixelOffset) { this.scrollState = { stuckAtBottom: true };
/* find the dom node with the right scrolltoken */ debuglog("saved stuckAtBottom state");
return;
}
const scrollNode = this._getScrollNode();
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) {
continue;
}
node = messages[i];
// break at the first message (coming from the bottom)
// that has it's offsetTop above the bottom of the viewport.
if (this._topFromBottom(node) > viewportBottom) {
// Use this node as the scrollToken
break;
}
}
if (!node) {
debuglog("unable to save scroll state: found no children in the viewport");
return;
}
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,
trackedNode: node,
trackedScrollToken: scrollToken,
bottomOffset: bottomOffset,
pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room
};
},
_restoreSavedScrollState: async function() {
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
} else if (scrollState.trackedScrollToken) {
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});
}
}
},
_getTrackedNode() {
const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode;
if (!trackedNode || !trackedNode.parentElement) {
let node; let node;
const messages = this.refs.itemlist.children; const messages = this.refs.itemlist.children;
const scrollToken = scrollState.trackedScrollToken;
for (let i = messages.length-1; i >= 0; --i) { for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i]; const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens // 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
@ -561,102 +724,33 @@ module.exports = React.createClass({
break; break;
} }
} }
if (node) {
debuglog("had to find tracked node again for " + scrollState.trackedScrollToken);
}
scrollState.trackedNode = node;
}
if (!node) { if (!scrollState.trackedNode) {
debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'"); debuglog("No node with ; '"+scrollState.trackedScrollToken+"'");
return; return;
} }
const scrollNode = this._getScrollNode(); return scrollState.trackedNode;
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);
}
}, },
_saveScrollState: function() { _getListHeight() {
if (this.props.stickyBottom && this.isAtBottom()) { return this._bottomGrowth + (this._pages * PAGE_SIZE);
this.scrollState = { stuckAtBottom: true }; },
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
return;
}
const scrollNode = this._getScrollNode();
const viewportBottom = scrollNode.scrollTop + scrollNode.clientHeight;
_getMessagesHeight() {
const itemlist = this.refs.itemlist; const itemlist = this.refs.itemlist;
const messages = itemlist.children; const lastNode = itemlist.lastElementChild;
let node = null; // 18 is itemlist padding
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
// 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) {
continue;
}
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) {
// Use this node as the scrollToken
break;
}
}
if (!node) {
debuglog("ScrollPanel: 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);
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: viewportBottom - nodeBottom,
};
}, },
_restoreSavedScrollState: function() { _topFromBottom(node) {
const scrollState = this.scrollState; return this.refs.itemlist.clientHeight - node.offsetTop;
if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE);
} else if (scrollState.trackedScrollToken) {
this._scrollToToken(scrollState.trackedScrollToken,
scrollState.pixelOffset);
}
},
_setScrollTop: function(scrollTop) {
const scrollNode = this._getScrollNode();
const prevScroll = scrollNode.scrollTop;
// 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;
}
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll);
}, },
/* get the DOM node which has the scrollTop property we care about for our /* 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"); 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 // Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful. // turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected"); throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
} }
return this._gemScroll.scrollbar.getViewElement(); return this._divScroll;
}, },
_collectGeminiScroll: function(gemScroll) { _collectScroll: function(divScroll) {
this._gemScroll = gemScroll; this._divScroll = divScroll;
}, },
/** /**
* Set the current height as the min height for the message list Mark the bottom offset of the last tile so we can balance it out when
* so the timeline cannot shrink. This is used to avoid anything below it changes, by calling updatePreventShrinking, to keep
* jumping when the typing indicator gets replaced by a smaller message. the same minimum bottom offset, effectively preventing the timeline to shrink.
*/ */
blockShrinking: function() { preventShrinking: function() {
// Disabled for now because of https://github.com/vector-im/riot-web/issues/9205 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 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.
*/ */
clearBlockShrinking: function() { updatePreventShrinking: function() {
// Disabled for now because of https://github.com/vector-im/riot-web/issues/9205 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() { render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// TODO: the classnames on the div and ol could do with being updated to // 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. // 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. // it's not obvious why we have a separate div and ol anyway.
return (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll} return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll} onResize={this.onResize} onScroll={this.onScroll}
className={this.props.className} style={this.props.style}> className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper"> <div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite"> <ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{ this.props.children } { this.props.children }
</ol> </ol>
</div> </div>
</GeminiScrollbarWrapper> </AutoHideScrollbar>
); );
}, },
}); });

View file

@ -939,7 +939,7 @@ var TimelinePanel = React.createClass({
// clear the timeline min-height when // clear the timeline min-height when
// (re)loading the timeline // (re)loading the timeline
if (this.refs.messagePanel) { if (this.refs.messagePanel) {
this.refs.messagePanel.clearTimelineHeight(); this.refs.messagePanel.onTimelineReset();
} }
this._reloadEvents(); this._reloadEvents();
@ -1228,6 +1228,7 @@ var TimelinePanel = React.createClass({
alwaysShowTimestamps={this.state.alwaysShowTimestamps} alwaysShowTimestamps={this.state.alwaysShowTimestamps}
className={this.props.className} className={this.props.className}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
/> />
); );
}, },

View file

@ -44,6 +44,7 @@ import SdkConfig from '../../../SdkConfig';
import MultiInviter from "../../../utils/MultiInviter"; import MultiInviter from "../../../utils/MultiInviter";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import E2EIcon from "./E2EIcon"; import E2EIcon from "./E2EIcon";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
module.exports = withMatrixClient(React.createClass({ module.exports = withMatrixClient(React.createClass({
displayName: 'MemberInfo', displayName: 'MemberInfo',
@ -1003,7 +1004,7 @@ module.exports = withMatrixClient(React.createClass({
{ roomMemberDetails } { roomMemberDetails }
</div> </div>
</div> </div>
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberInfo_scrollContainer"> <AutoHideScrollbar className="mx_MemberInfo_scrollContainer">
<div className="mx_MemberInfo_container"> <div className="mx_MemberInfo_container">
{ this._renderUserOptions() } { this._renderUserOptions() }
@ -1015,7 +1016,7 @@ module.exports = withMatrixClient(React.createClass({
{ spinner } { spinner }
</div> </div>
</GeminiScrollbarWrapper> </AutoHideScrollbar>
</div> </div>
); );
}, },

View file

@ -20,6 +20,7 @@ import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {isValid3pidInvite} from "../../../RoomInvite"; import {isValid3pidInvite} from "../../../RoomInvite";
const MatrixClientPeg = require("../../../MatrixClientPeg"); const MatrixClientPeg = require("../../../MatrixClientPeg");
const sdk = require('../../../index'); const sdk = require('../../../index');
@ -444,7 +445,6 @@ module.exports = React.createClass({
const SearchBox = sdk.getComponent('structures.SearchBox'); const SearchBox = sdk.getComponent('structures.SearchBox');
const TruncatedList = sdk.getComponent("elements.TruncatedList"); const TruncatedList = sdk.getComponent("elements.TruncatedList");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
@ -471,7 +471,7 @@ module.exports = React.createClass({
return ( return (
<div className="mx_MemberList"> <div className="mx_MemberList">
{ inviteButton } { inviteButton }
<GeminiScrollbarWrapper autoshow={true}> <AutoHideScrollbar>
<div className="mx_MemberList_wrapper"> <div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined} <TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined} createOverflowElement={this._createOverflowTileJoined}
@ -480,7 +480,7 @@ module.exports = React.createClass({
{ invitedHeader } { invitedHeader }
{ invitedSection } { invitedSection }
</div> </div>
</GeminiScrollbarWrapper> </AutoHideScrollbar>
<SearchBox className="mx_MemberList_query mx_textinput_icon mx_textinput_search" <SearchBox className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={ _t('Filter room members') } placeholder={ _t('Filter room members') }

View file

@ -412,7 +412,6 @@ export default class MessageComposer extends React.Component {
<MessageComposerInput <MessageComposerInput
ref={(c) => this.messageComposerInput = c} ref={(c) => this.messageComposerInput = c}
key="controls_input" key="controls_input"
onResize={this.props.onResize}
room={this.props.room} room={this.props.room}
placeholder={placeholderText} placeholder={placeholderText}
onFilesPasted={this.uploadFiles} onFilesPasted={this.uploadFiles}
@ -505,10 +504,6 @@ export default class MessageComposer extends React.Component {
} }
MessageComposer.propTypes = { 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 // js-sdk Room object
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,

View file

@ -135,10 +135,6 @@ function rangeEquals(a: Range, b: Range): boolean {
*/ */
export default class MessageComposerInput extends React.Component { export default class MessageComposerInput extends React.Component {
static propTypes = { 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 // js-sdk Room object
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,

View file

@ -212,7 +212,9 @@ module.exports = React.createClass({
this._checkSubListsOverflow(); this._checkSubListsOverflow();
this.resizer.attach(); this.resizer.attach();
window.addEventListener("resize", this.onWindowResize); if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("leftPanelResized", this.onResize);
}
this.mounted = true; this.mounted = true;
}, },
@ -260,7 +262,6 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
this.mounted = false; this.mounted = false;
window.removeEventListener("resize", this.onWindowResize);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room", this.onRoom);
@ -273,6 +274,11 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
} }
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("leftPanelResized", this.onResize);
}
if (this._tagStoreToken) { if (this._tagStoreToken) {
this._tagStoreToken.remove(); this._tagStoreToken.remove();
} }
@ -293,13 +299,14 @@ module.exports = React.createClass({
this._delayedRefreshRoomList.cancelPendingCall(); this._delayedRefreshRoomList.cancelPendingCall();
}, },
onWindowResize: function() {
onResize: function() {
if (this.mounted && this._layout && this.resizeContainer && if (this.mounted && this._layout && this.resizeContainer &&
Array.isArray(this._layoutSections) Array.isArray(this._layoutSections)
) { ) {
this._layout.update( this._layout.update(
this._layoutSections, this._layoutSections,
this.resizeContainer.offsetHeight this.resizeContainer.offsetHeight,
); );
} }
}, },

View file

@ -29,7 +29,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// the room this statusbar is representing. // the room this statusbar is representing.
room: PropTypes.object.isRequired, 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 // Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing." // result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number, whoIsTypingLimit: PropTypes.number,
@ -59,11 +60,12 @@ module.exports = React.createClass({
}, },
componentDidUpdate: function(_, prevState) { componentDidUpdate: function(_, prevState) {
if (this.props.onVisible && const wasVisible = this._isVisible(prevState);
!prevState.usersTyping.length && const isVisible = this._isVisible(this.state);
this.state.usersTyping.length if (this.props.onShown && !wasVisible && isVisible) {
) { this.props.onShown();
this.props.onVisible(); } 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()); 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() { isVisible: function() {
return this.state.usersTyping.length !== 0 || Object.keys(this.state.delayedStopTypingTimers).length !== 0; return this._isVisible(this.state);
}, },
onRoomTimeline: function(event, room) { onRoomTimeline: function(event, room) {

View file

@ -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();
}
}

View file

@ -21,6 +21,7 @@ const ReactDOM = require("react-dom");
const TestUtils = require('react-addons-test-utils'); const TestUtils = require('react-addons-test-utils');
const expect = require('expect'); const expect = require('expect');
import sinon from 'sinon'; import sinon from 'sinon';
import { EventEmitter } from "events";
const sdk = require('matrix-react-sdk'); const sdk = require('matrix-react-sdk');
@ -48,8 +49,14 @@ const WrappedMessagePanel = React.createClass({
}; };
}, },
getInitialState: function() {
return {
resizeNotifier: new EventEmitter(),
};
},
render: function() { render: function() {
return <MessagePanel room={room} {...this.props} />; return <MessagePanel room={room} {...this.props} resizeNotifier={this.state.resizeNotifier} />;
}, },
}); });

View file

@ -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);
});
});

View file

@ -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);
});
});