Merge remote-tracking branch 'origin/develop' into travis/cancel-3pid
This commit is contained in:
commit
86e4d29582
28 changed files with 577 additions and 911 deletions
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
26
res/css/structures/_ScrollPanel.scss
Normal file
26
res/css/structures/_ScrollPanel.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
// this will make the timeline grow, so checkScroll
|
||||||
|
scrollPanel.checkScroll();
|
||||||
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
|
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.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 }
|
||||||
|
|
|
@ -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", {
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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);
|
this._saveScrollState();
|
||||||
|
this.updatePreventShrinking();
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
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
|
// 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,77 +564,41 @@ 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.
|
|
||||||
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 scrollNode = this._getScrollNode();
|
const scrollNode = this._getScrollNode();
|
||||||
const scrollTop = scrollNode.scrollTop;
|
if (trackedNode) {
|
||||||
const viewportBottom = scrollTop + scrollNode.clientHeight;
|
// set the scrollTop to the position we want.
|
||||||
const nodeBottom = node.offsetTop + node.clientHeight;
|
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
|
||||||
const intendedViewportBottom = nodeBottom + pixelOffset;
|
// would position the trackedNode towards the top of the viewport.
|
||||||
const scrollDelta = intendedViewportBottom - viewportBottom;
|
// This because when setting the scrollTop only 10 or so events might be loaded,
|
||||||
|
// not giving enough content below the trackedNode to scroll downwards
|
||||||
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
|
// enough so it ends up in the top of the viewport.
|
||||||
pixelOffset + " (delta: "+scrollDelta+")");
|
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
|
||||||
|
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
|
||||||
if (scrollDelta !== 0) {
|
this._saveScrollState();
|
||||||
this._setScrollTop(scrollTop + scrollDelta);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_saveScrollState: function() {
|
_saveScrollState: function() {
|
||||||
if (this.props.stickyBottom && this.isAtBottom()) {
|
if (this.props.stickyBottom && this.isAtBottom()) {
|
||||||
this.scrollState = { stuckAtBottom: true };
|
this.scrollState = { stuckAtBottom: true };
|
||||||
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
|
debuglog("saved stuckAtBottom state");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollNode = this._getScrollNode();
|
const scrollNode = this._getScrollNode();
|
||||||
const viewportBottom = scrollNode.scrollTop + scrollNode.clientHeight;
|
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
|
||||||
|
|
||||||
const itemlist = this.refs.itemlist;
|
const itemlist = this.refs.itemlist;
|
||||||
const messages = itemlist.children;
|
const messages = itemlist.children;
|
||||||
let node = null;
|
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)
|
// loop backwards, from bottom-most message (as that is the most common case)
|
||||||
for (let i = messages.length-1; i >= 0; --i) {
|
for (let i = messages.length-1; i >= 0; --i) {
|
||||||
if (!messages[i].dataset.scrollTokens) {
|
if (!messages[i].dataset.scrollTokens) {
|
||||||
|
@ -604,59 +607,150 @@ module.exports = React.createClass({
|
||||||
node = messages[i];
|
node = messages[i];
|
||||||
// break at the first message (coming from the bottom)
|
// break at the first message (coming from the bottom)
|
||||||
// that has it's offsetTop above the bottom of the viewport.
|
// 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
|
// Use this node as the scrollToken
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
const scrollToken = node.dataset.scrollTokens.split(',')[0];
|
||||||
const nodeBottom = node.offsetTop + node.clientHeight;
|
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
|
||||||
debuglog("ScrollPanel: saved scroll state", this.scrollState);
|
const bottomOffset = this._topFromBottom(node);
|
||||||
this.scrollState = {
|
this.scrollState = {
|
||||||
stuckAtBottom: false,
|
stuckAtBottom: false,
|
||||||
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
|
trackedNode: node,
|
||||||
pixelOffset: viewportBottom - nodeBottom,
|
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;
|
const scrollState = this.scrollState;
|
||||||
|
|
||||||
if (scrollState.stuckAtBottom) {
|
if (scrollState.stuckAtBottom) {
|
||||||
this._setScrollTop(Number.MAX_VALUE);
|
const sn = this._getScrollNode();
|
||||||
|
sn.scrollTop = sn.scrollHeight;
|
||||||
} else if (scrollState.trackedScrollToken) {
|
} else if (scrollState.trackedScrollToken) {
|
||||||
this._scrollToToken(scrollState.trackedScrollToken,
|
const itemlist = this.refs.itemlist;
|
||||||
scrollState.pixelOffset);
|
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) {
|
_getTrackedNode() {
|
||||||
const scrollNode = this._getScrollNode();
|
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
|
for (let i = messages.length-1; i >= 0; --i) {
|
||||||
scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight);
|
const m = messages[i];
|
||||||
|
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
|
||||||
// If this change generates a scroll event, we should not update the
|
// There might only be one scroll token
|
||||||
// saved scroll state on it. See the comments in onScroll.
|
if (m.dataset.scrollTokens &&
|
||||||
//
|
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
|
||||||
// If we *don't* expect a scroll event, we need to leave _lastSetScroll
|
node = m;
|
||||||
// alone, otherwise we'll end up ignoring a future scroll event which is
|
break;
|
||||||
// nothing to do with this change.
|
}
|
||||||
|
}
|
||||||
if (scrollNode.scrollTop != prevScroll) {
|
if (node) {
|
||||||
this._lastSetScroll = scrollNode.scrollTop;
|
debuglog("had to find tracked node again for " + scrollState.trackedScrollToken);
|
||||||
|
}
|
||||||
|
scrollState.trackedNode = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
|
if (!scrollState.trackedNode) {
|
||||||
"requested:", scrollTop,
|
debuglog("No node with ; '"+scrollState.trackedScrollToken+"'");
|
||||||
"_lastSetScroll:", this._lastSetScroll);
|
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
|
/* 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
|
||||||
clearBlockShrinking: function() {
|
preventShrinking was called.
|
||||||
// Disabled for now because of https://github.com/vector-im/riot-web/issues/9205
|
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() {
|
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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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') }
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
59
src/utils/ResizeNotifier.js
Normal file
59
src/utils/ResizeNotifier.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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} />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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 a new issue