Merge pull request #1353 from matrix-org/luke/feature-sticky-date-separators
Implement sticky date separators
This commit is contained in:
commit
262d66f579
6 changed files with 193 additions and 143 deletions
|
@ -73,6 +73,7 @@
|
||||||
"react-addons-css-transition-group": "15.3.2",
|
"react-addons-css-transition-group": "15.3.2",
|
||||||
"react-dom": "^15.4.0",
|
"react-dom": "^15.4.0",
|
||||||
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
|
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
|
||||||
|
"react-sticky": "^6.0.1",
|
||||||
"sanitize-html": "^1.14.1",
|
"sanitize-html": "^1.14.1",
|
||||||
"text-encoding-utf-8": "^1.0.1",
|
"text-encoding-utf-8": "^1.0.1",
|
||||||
"url": "^0.11.0",
|
"url": "^0.11.0",
|
||||||
|
|
|
@ -30,6 +30,18 @@ function getDaysArray() {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLongDaysArray() {
|
||||||
|
return [
|
||||||
|
_t('Sunday'),
|
||||||
|
_t('Monday'),
|
||||||
|
_t('Tuesday'),
|
||||||
|
_t('Wednesday'),
|
||||||
|
_t('Thursday'),
|
||||||
|
_t('Friday'),
|
||||||
|
_t('Saturday'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function getMonthsArray() {
|
function getMonthsArray() {
|
||||||
return [
|
return [
|
||||||
_t('Jan'),
|
_t('Jan'),
|
||||||
|
@ -96,6 +108,38 @@ module.exports = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
formatDateSeparator: function(date) {
|
||||||
|
const days = getDaysArray();
|
||||||
|
const longDays = getLongDaysArray();
|
||||||
|
const months = getMonthsArray();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return _t('Today');
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return _t('Yesterday');
|
||||||
|
} else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
|
||||||
|
return longDays[date.getDay()];
|
||||||
|
} else if (today.getTime() - date.getTime() < 365 * 24 * 60 * 60 * 1000) {
|
||||||
|
return _t('%(weekDayName)s, %(monthName)s %(day)s', {
|
||||||
|
weekDayName: days[date.getDay()],
|
||||||
|
monthName: months[date.getMonth()],
|
||||||
|
day: date.getDate(),
|
||||||
|
fullYear: date.getFullYear(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
|
||||||
|
weekDayName: days[date.getDay()],
|
||||||
|
monthName: months[date.getMonth()],
|
||||||
|
day: date.getDate(),
|
||||||
|
fullYear: date.getFullYear(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
formatTime: function(date, showTwelveHour=false) {
|
formatTime: function(date, showTwelveHour=false) {
|
||||||
if (showTwelveHour) {
|
if (showTwelveHour) {
|
||||||
return twelveHourTime(date);
|
return twelveHourTime(date);
|
||||||
|
|
|
@ -61,9 +61,6 @@ module.exports = React.createClass({
|
||||||
// for pending messages.
|
// for pending messages.
|
||||||
ourUserId: React.PropTypes.string,
|
ourUserId: React.PropTypes.string,
|
||||||
|
|
||||||
// true to suppress the date at the start of the timeline
|
|
||||||
suppressFirstDateSeparator: React.PropTypes.bool,
|
|
||||||
|
|
||||||
// whether to show read receipts
|
// whether to show read receipts
|
||||||
showReadReceipts: React.PropTypes.bool,
|
showReadReceipts: React.PropTypes.bool,
|
||||||
|
|
||||||
|
@ -517,10 +514,10 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
_wantsDateSeparator: function(prevEvent, nextEventDate) {
|
_wantsDateSeparator: function(prevEvent, nextEventDate) {
|
||||||
if (prevEvent == null) {
|
if (prevEvent == null) {
|
||||||
// first event in the panel: depends if we could back-paginate from
|
// First event in the panel always wants a DateSeparator
|
||||||
// here.
|
return true;
|
||||||
return !this.props.suppressFirstDateSeparator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevEventDate = prevEvent.getDate();
|
const prevEventDate = prevEvent.getDate();
|
||||||
if (!nextEventDate || !prevEventDate) {
|
if (!nextEventDate || !prevEventDate) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
var React = require("react");
|
var React = require("react");
|
||||||
var ReactDOM = require("react-dom");
|
var ReactDOM = require("react-dom");
|
||||||
var GeminiScrollbar = require('react-gemini-scrollbar');
|
var GeminiScrollbar = require('react-gemini-scrollbar');
|
||||||
|
import { StickyContainer } from 'react-sticky';
|
||||||
import Promise from 'bluebird';
|
import Promise from 'bluebird';
|
||||||
var KeyCode = require('../../KeyCode');
|
var KeyCode = require('../../KeyCode');
|
||||||
|
|
||||||
|
@ -77,111 +78,52 @@ if (DEBUG_SCROLL) {
|
||||||
* scroll down further. If stickyBottom is disabled, we just save the scroll
|
* scroll down further. If stickyBottom is disabled, we just save the scroll
|
||||||
* offset as normal.
|
* offset as normal.
|
||||||
*/
|
*/
|
||||||
module.exports = React.createClass({
|
export default class ScrollPanel extends StickyContainer {
|
||||||
displayName: 'ScrollPanel',
|
|
||||||
|
|
||||||
propTypes: {
|
constructor() {
|
||||||
/* stickyBottom: if set to true, then once the user hits the bottom of
|
super();
|
||||||
* the list, any new children added to the list will cause the list to
|
this.onResize = this.onResize.bind(this);
|
||||||
* scroll down to show the new element, rather than preserving the
|
this.onScroll = this.onScroll.bind(this);
|
||||||
* existing view.
|
}
|
||||||
*/
|
|
||||||
stickyBottom: React.PropTypes.bool,
|
|
||||||
|
|
||||||
/* startAtBottom: if set to true, the view is assumed to start
|
componentWillMount() {
|
||||||
* scrolled to the bottom.
|
|
||||||
* XXX: It's likley this is unecessary and can be derived from
|
|
||||||
* stickyBottom, but I'm adding an extra parameter to ensure
|
|
||||||
* behaviour stays the same for other uses of ScrollPanel.
|
|
||||||
* If so, let's remove this parameter down the line.
|
|
||||||
*/
|
|
||||||
startAtBottom: React.PropTypes.bool,
|
|
||||||
|
|
||||||
/* onFillRequest(backwards): a callback which is called on scroll when
|
|
||||||
* the user nears the start (backwards = true) or end (backwards =
|
|
||||||
* false) of the list.
|
|
||||||
*
|
|
||||||
* This should return a promise; no more calls will be made until the
|
|
||||||
* promise completes.
|
|
||||||
*
|
|
||||||
* The promise should resolve to true if there is more data to be
|
|
||||||
* retrieved in this direction (in which case onFillRequest may be
|
|
||||||
* called again immediately), or false if there is no more data in this
|
|
||||||
* directon (at this time) - which will stop the pagination cycle until
|
|
||||||
* the user scrolls again.
|
|
||||||
*/
|
|
||||||
onFillRequest: React.PropTypes.func,
|
|
||||||
|
|
||||||
/* onUnfillRequest(backwards): a callback which is called on scroll when
|
|
||||||
* there are children elements that are far out of view and could be removed
|
|
||||||
* without causing pagination to occur.
|
|
||||||
*
|
|
||||||
* This function should accept a boolean, which is true to indicate the back/top
|
|
||||||
* of the panel and false otherwise, and a scroll token, which refers to the
|
|
||||||
* first element to remove if removing from the front/bottom, and last element
|
|
||||||
* to remove if removing from the back/top.
|
|
||||||
*/
|
|
||||||
onUnfillRequest: React.PropTypes.func,
|
|
||||||
|
|
||||||
/* onScroll: a callback which is called whenever any scroll happens.
|
|
||||||
*/
|
|
||||||
onScroll: React.PropTypes.func,
|
|
||||||
|
|
||||||
/* onResize: a callback which is called whenever the Gemini scroll
|
|
||||||
* panel is resized
|
|
||||||
*/
|
|
||||||
onResize: React.PropTypes.func,
|
|
||||||
|
|
||||||
/* className: classnames to add to the top-level div
|
|
||||||
*/
|
|
||||||
className: React.PropTypes.string,
|
|
||||||
|
|
||||||
/* style: styles to add to the top-level div
|
|
||||||
*/
|
|
||||||
style: React.PropTypes.object,
|
|
||||||
},
|
|
||||||
|
|
||||||
getDefaultProps: function() {
|
|
||||||
return {
|
|
||||||
stickyBottom: true,
|
|
||||||
startAtBottom: true,
|
|
||||||
onFillRequest: function(backwards) { return Promise.resolve(false); },
|
|
||||||
onUnfillRequest: function(backwards, scrollToken) {},
|
|
||||||
onScroll: function() {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
componentWillMount: function() {
|
|
||||||
this._pendingFillRequests = {b: null, f: null};
|
this._pendingFillRequests = {b: null, f: null};
|
||||||
this.resetScrollState();
|
this.resetScrollState();
|
||||||
},
|
}
|
||||||
|
|
||||||
componentDidMount: function() {
|
componentDidMount() {
|
||||||
this.checkFillState();
|
this.checkFillState();
|
||||||
},
|
}
|
||||||
|
|
||||||
componentDidUpdate: function() {
|
componentDidUpdate() {
|
||||||
// after adding event tiles, we may need to tweak the scroll (either to
|
// after adding event tiles, we may need to tweak the scroll (either to
|
||||||
// keep at the bottom of the timeline, or to maintain the view after
|
// keep at the bottom of the timeline, or to maintain the view after
|
||||||
// adding events to the top).
|
// adding events to the top).
|
||||||
//
|
//
|
||||||
// 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();
|
||||||
},
|
}
|
||||||
|
|
||||||
componentWillUnmount: function() {
|
componentWillUnmount() {
|
||||||
// set a boolean to say we've been unmounted, which any pending
|
// set a boolean to say we've been unmounted, which any pending
|
||||||
// promises can use to throw away their results.
|
// promises can use to throw away their results.
|
||||||
//
|
//
|
||||||
// (We could use isMounted(), but facebook have deprecated that.)
|
// (We could use isMounted(), but facebook have deprecated that.)
|
||||||
this.unmounted = true;
|
this.unmounted = true;
|
||||||
},
|
}
|
||||||
|
|
||||||
onScroll: function(ev) {
|
onScroll(ev) {
|
||||||
var sn = this._getScrollNode();
|
var sn = this._getScrollNode();
|
||||||
debuglog("Scroll event: offset now:", sn.scrollTop,
|
debuglog("Scroll event: offset now:", sn.scrollTop,
|
||||||
"_lastSetScroll:", this._lastSetScroll);
|
"_lastSetScroll:", this._lastSetScroll);
|
||||||
|
|
||||||
|
// Set the node and notify subscribers of the StickyContainer
|
||||||
|
// By extending StickyContainer, we can set the scroll node to be that of the
|
||||||
|
// ScrolPanel to allow any `<Sticky>` children to be sticky, namely DateSeparators.
|
||||||
|
this.node = sn;
|
||||||
|
// Update subscribers - arbitrarily nested `<Sticky>` children
|
||||||
|
this.notifySubscribers(ev);
|
||||||
|
|
||||||
// Sometimes we see attempts to write to scrollTop essentially being
|
// Sometimes we see attempts to write to scrollTop essentially being
|
||||||
// ignored. (Or rather, it is successfully written, but on the next
|
// ignored. (Or rather, it is successfully written, but on the next
|
||||||
// scroll event, it's been reset again).
|
// scroll event, it's been reset again).
|
||||||
|
@ -217,27 +159,27 @@ module.exports = React.createClass({
|
||||||
this.props.onScroll(ev);
|
this.props.onScroll(ev);
|
||||||
|
|
||||||
this.checkFillState();
|
this.checkFillState();
|
||||||
},
|
}
|
||||||
|
|
||||||
onResize: function() {
|
onResize() {
|
||||||
this.props.onResize();
|
this.props.onResize();
|
||||||
this.checkScroll();
|
this.checkScroll();
|
||||||
this.refs.geminiPanel.forceUpdate();
|
this.refs.geminiPanel.forceUpdate();
|
||||||
},
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// where it ought to be, and set off pagination requests if necessary.
|
// where it ought to be, and set off pagination requests if necessary.
|
||||||
checkScroll: function() {
|
checkScroll() {
|
||||||
this._restoreSavedScrollState();
|
this._restoreSavedScrollState();
|
||||||
this.checkFillState();
|
this.checkFillState();
|
||||||
},
|
}
|
||||||
|
|
||||||
// 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 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() {
|
||||||
var sn = this._getScrollNode();
|
var sn = this._getScrollNode();
|
||||||
|
|
||||||
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
|
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
|
||||||
|
@ -247,7 +189,7 @@ module.exports = React.createClass({
|
||||||
// that we're at the bottom when we're still a few pixels off.
|
// that we're at the bottom when we're still a few pixels off.
|
||||||
|
|
||||||
return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3;
|
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
|
||||||
// the content box (which has a height of scrollHeight, see checkFillState) without
|
// the content box (which has a height of scrollHeight, see checkFillState) without
|
||||||
|
@ -280,17 +222,17 @@ module.exports = React.createClass({
|
||||||
// |#########| - |
|
// |#########| - |
|
||||||
// |#########| |
|
// |#########| |
|
||||||
// `---------' -
|
// `---------' -
|
||||||
_getExcessHeight: function(backwards) {
|
_getExcessHeight(backwards) {
|
||||||
var sn = this._getScrollNode();
|
var sn = this._getScrollNode();
|
||||||
if (backwards) {
|
if (backwards) {
|
||||||
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
|
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
|
||||||
} else {
|
} else {
|
||||||
return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
|
return sn.scrollHeight - (sn.scrollTop + 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() {
|
||||||
if (this.unmounted) {
|
if (this.unmounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -329,10 +271,10 @@ module.exports = React.createClass({
|
||||||
// need to forward-fill
|
// need to forward-fill
|
||||||
this._maybeFill(false);
|
this._maybeFill(false);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
// check if unfilling is possible and send an unfill request if necessary
|
// check if unfilling is possible and send an unfill request if necessary
|
||||||
_checkUnfillState: function(backwards) {
|
_checkUnfillState(backwards) {
|
||||||
let excessHeight = this._getExcessHeight(backwards);
|
let excessHeight = this._getExcessHeight(backwards);
|
||||||
if (excessHeight <= 0) {
|
if (excessHeight <= 0) {
|
||||||
return;
|
return;
|
||||||
|
@ -373,10 +315,10 @@ module.exports = React.createClass({
|
||||||
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(backwards) {
|
||||||
var dir = backwards ? 'b' : 'f';
|
var dir = backwards ? 'b' : 'f';
|
||||||
if (this._pendingFillRequests[dir]) {
|
if (this._pendingFillRequests[dir]) {
|
||||||
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
|
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
|
||||||
|
@ -408,7 +350,7 @@ module.exports = React.createClass({
|
||||||
this.checkFillState();
|
this.checkFillState();
|
||||||
}
|
}
|
||||||
}).done();
|
}).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
|
||||||
* properties:
|
* properties:
|
||||||
|
@ -424,9 +366,9 @@ module.exports = React.createClass({
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
getScrollState: function() {
|
getScrollState() {
|
||||||
return this.scrollState;
|
return this.scrollState;
|
||||||
},
|
}
|
||||||
|
|
||||||
/* reset the saved scroll state.
|
/* reset the saved scroll state.
|
||||||
*
|
*
|
||||||
|
@ -440,46 +382,46 @@ module.exports = React.createClass({
|
||||||
* no use if no children exist yet, or if you are about to replace the
|
* no use if no children exist yet, or if you are about to replace the
|
||||||
* child list.)
|
* child list.)
|
||||||
*/
|
*/
|
||||||
resetScrollState: function() {
|
resetScrollState() {
|
||||||
this.scrollState = {stuckAtBottom: this.props.startAtBottom};
|
this.scrollState = {stuckAtBottom: this.props.startAtBottom};
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* jump to the top of the content.
|
* jump to the top of the content.
|
||||||
*/
|
*/
|
||||||
scrollToTop: function() {
|
scrollToTop() {
|
||||||
this._setScrollTop(0);
|
this._setScrollTop(0);
|
||||||
this._saveScrollState();
|
this._saveScrollState();
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* jump to the bottom of the content.
|
* jump to the bottom of the content.
|
||||||
*/
|
*/
|
||||||
scrollToBottom: function() {
|
scrollToBottom() {
|
||||||
// the easiest way to make sure that the scroll state is correctly
|
// the easiest way to make sure that the scroll state is correctly
|
||||||
// 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);
|
this._setScrollTop(Number.MAX_VALUE);
|
||||||
this._saveScrollState();
|
this._saveScrollState();
|
||||||
},
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page up/down.
|
* Page up/down.
|
||||||
*
|
*
|
||||||
* mult: -1 to page up, +1 to page down
|
* mult: -1 to page up, +1 to page down
|
||||||
*/
|
*/
|
||||||
scrollRelative: function(mult) {
|
scrollRelative(mult) {
|
||||||
var scrollNode = this._getScrollNode();
|
var scrollNode = this._getScrollNode();
|
||||||
var delta = mult * scrollNode.clientHeight * 0.5;
|
var delta = mult * scrollNode.clientHeight * 0.5;
|
||||||
this._setScrollTop(scrollNode.scrollTop + delta);
|
this._setScrollTop(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
|
||||||
*/
|
*/
|
||||||
handleScrollKey: function(ev) {
|
handleScrollKey(ev) {
|
||||||
switch (ev.keyCode) {
|
switch (ev.keyCode) {
|
||||||
case KeyCode.PAGE_UP:
|
case KeyCode.PAGE_UP:
|
||||||
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
||||||
|
@ -505,7 +447,7 @@ module.exports = React.createClass({
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
/* Scroll the panel to bring the DOM node with the scroll token
|
/* Scroll the panel to bring the DOM node with the scroll token
|
||||||
* `scrollToken` into view.
|
* `scrollToken` into view.
|
||||||
|
@ -518,7 +460,7 @@ module.exports = React.createClass({
|
||||||
* node (specifically, the bottom of it) will be positioned. If omitted, it
|
* node (specifically, the bottom of it) will be positioned. If omitted, it
|
||||||
* defaults to 0.
|
* defaults to 0.
|
||||||
*/
|
*/
|
||||||
scrollToToken: function(scrollToken, pixelOffset, offsetBase) {
|
scrollToToken(scrollToken, pixelOffset, offsetBase) {
|
||||||
pixelOffset = pixelOffset || 0;
|
pixelOffset = pixelOffset || 0;
|
||||||
offsetBase = offsetBase || 0;
|
offsetBase = offsetBase || 0;
|
||||||
|
|
||||||
|
@ -540,11 +482,11 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
// ... then make it so.
|
// ... then make it so.
|
||||||
this._restoreSavedScrollState();
|
this._restoreSavedScrollState();
|
||||||
},
|
}
|
||||||
|
|
||||||
// set the scrollTop attribute appropriately to position the given child at the
|
// set the scrollTop attribute appropriately to position the given child at the
|
||||||
// given offset in the window. A helper for _restoreSavedScrollState.
|
// given offset in the window. A helper for _restoreSavedScrollState.
|
||||||
_scrollToToken: function(scrollToken, pixelOffset) {
|
_scrollToToken(scrollToken, pixelOffset) {
|
||||||
/* find the dom node with the right scrolltoken */
|
/* find the dom node with the right scrolltoken */
|
||||||
var node;
|
var node;
|
||||||
var messages = this.refs.itemlist.children;
|
var messages = this.refs.itemlist.children;
|
||||||
|
@ -576,9 +518,9 @@ module.exports = React.createClass({
|
||||||
this._setScrollTop(scrollNode.scrollTop + scrollDelta);
|
this._setScrollTop(scrollNode.scrollTop + scrollDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
}
|
||||||
|
|
||||||
_saveScrollState: function() {
|
_saveScrollState() {
|
||||||
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("ScrollPanel: Saved scroll state", this.scrollState);
|
||||||
|
@ -616,9 +558,9 @@ module.exports = React.createClass({
|
||||||
} else {
|
} else {
|
||||||
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
|
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
_restoreSavedScrollState: function() {
|
_restoreSavedScrollState() {
|
||||||
var scrollState = this.scrollState;
|
var scrollState = this.scrollState;
|
||||||
var scrollNode = this._getScrollNode();
|
var scrollNode = this._getScrollNode();
|
||||||
|
|
||||||
|
@ -628,9 +570,9 @@ module.exports = React.createClass({
|
||||||
this._scrollToToken(scrollState.trackedScrollToken,
|
this._scrollToToken(scrollState.trackedScrollToken,
|
||||||
scrollState.pixelOffset);
|
scrollState.pixelOffset);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
_setScrollTop: function(scrollTop) {
|
_setScrollTop(scrollTop) {
|
||||||
var scrollNode = this._getScrollNode();
|
var scrollNode = this._getScrollNode();
|
||||||
|
|
||||||
var prevScroll = scrollNode.scrollTop;
|
var prevScroll = scrollNode.scrollTop;
|
||||||
|
@ -652,12 +594,12 @@ module.exports = React.createClass({
|
||||||
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
|
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
|
||||||
"requested:", scrollTop,
|
"requested:", scrollTop,
|
||||||
"_lastSetScroll:", this._lastSetScroll);
|
"_lastSetScroll:", this._lastSetScroll);
|
||||||
},
|
}
|
||||||
|
|
||||||
/* get the DOM node which has the scrollTop property we care about for our
|
/* get the DOM node which has the scrollTop property we care about for our
|
||||||
* message panel.
|
* message panel.
|
||||||
*/
|
*/
|
||||||
_getScrollNode: function() {
|
_getScrollNode() {
|
||||||
if (this.unmounted) {
|
if (this.unmounted) {
|
||||||
// this shouldn't happen, but when it does, turn the NPE into
|
// this shouldn't happen, but when it does, turn the NPE into
|
||||||
// something more meaningful.
|
// something more meaningful.
|
||||||
|
@ -665,21 +607,91 @@ module.exports = React.createClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.refs.geminiPanel.scrollbar.getViewElement();
|
return this.refs.geminiPanel.scrollbar.getViewElement();
|
||||||
},
|
}
|
||||||
|
|
||||||
render: function() {
|
render() {
|
||||||
// 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 (<GeminiScrollbar autoshow={true} ref="geminiPanel"
|
return (
|
||||||
onScroll={this.onScroll} onResize={this.onResize}
|
<GeminiScrollbar autoshow={true} ref="geminiPanel"
|
||||||
className={this.props.className} style={this.props.style}>
|
onScroll={this.onScroll} onResize={this.onResize}
|
||||||
<div className="mx_RoomView_messageListWrapper">
|
className={this.props.className} style={this.props.style}>
|
||||||
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
|
<div className="mx_RoomView_messageListWrapper">
|
||||||
{this.props.children}
|
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
|
||||||
</ol>
|
{this.props.children}
|
||||||
</div>
|
</ol>
|
||||||
</GeminiScrollbar>
|
</div>
|
||||||
);
|
</GeminiScrollbar>
|
||||||
},
|
);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollPanel.propTypes = {
|
||||||
|
/* stickyBottom: if set to true, then once the user hits the bottom of
|
||||||
|
* the list, any new children added to the list will cause the list to
|
||||||
|
* scroll down to show the new element, rather than preserving the
|
||||||
|
* existing view.
|
||||||
|
*/
|
||||||
|
stickyBottom: React.PropTypes.bool,
|
||||||
|
|
||||||
|
/* startAtBottom: if set to true, the view is assumed to start
|
||||||
|
* scrolled to the bottom.
|
||||||
|
* XXX: It's likley this is unecessary and can be derived from
|
||||||
|
* stickyBottom, but I'm adding an extra parameter to ensure
|
||||||
|
* behaviour stays the same for other uses of ScrollPanel.
|
||||||
|
* If so, let's remove this parameter down the line.
|
||||||
|
*/
|
||||||
|
startAtBottom: React.PropTypes.bool,
|
||||||
|
|
||||||
|
/* onFillRequest(backwards): a callback which is called on scroll when
|
||||||
|
* the user nears the start (backwards = true) or end (backwards =
|
||||||
|
* false) of the list.
|
||||||
|
*
|
||||||
|
* This should return a promise; no more calls will be made until the
|
||||||
|
* promise completes.
|
||||||
|
*
|
||||||
|
* The promise should resolve to true if there is more data to be
|
||||||
|
* retrieved in this direction (in which case onFillRequest may be
|
||||||
|
* called again immediately), or false if there is no more data in this
|
||||||
|
* directon (at this time) - which will stop the pagination cycle until
|
||||||
|
* the user scrolls again.
|
||||||
|
*/
|
||||||
|
onFillRequest: React.PropTypes.func,
|
||||||
|
|
||||||
|
/* onUnfillRequest(backwards): a callback which is called on scroll when
|
||||||
|
* there are children elements that are far out of view and could be removed
|
||||||
|
* without causing pagination to occur.
|
||||||
|
*
|
||||||
|
* This function should accept a boolean, which is true to indicate the back/top
|
||||||
|
* of the panel and false otherwise, and a scroll token, which refers to the
|
||||||
|
* first element to remove if removing from the front/bottom, and last element
|
||||||
|
* to remove if removing from the back/top.
|
||||||
|
*/
|
||||||
|
onUnfillRequest: React.PropTypes.func,
|
||||||
|
|
||||||
|
/* onScroll: a callback which is called whenever any scroll happens.
|
||||||
|
*/
|
||||||
|
onScroll: React.PropTypes.func,
|
||||||
|
|
||||||
|
/* onResize: a callback which is called whenever the Gemini scroll
|
||||||
|
* panel is resized
|
||||||
|
*/
|
||||||
|
onResize: React.PropTypes.func,
|
||||||
|
|
||||||
|
/* className: classnames to add to the top-level div
|
||||||
|
*/
|
||||||
|
className: React.PropTypes.string,
|
||||||
|
|
||||||
|
/* style: styles to add to the top-level div
|
||||||
|
*/
|
||||||
|
style: React.PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
ScrollPanel.defaultProps = {
|
||||||
|
stickyBottom: true,
|
||||||
|
startAtBottom: true,
|
||||||
|
onFillRequest: function(backwards) { return Promise.resolve(false); },
|
||||||
|
onUnfillRequest: function(backwards, scrollToken) {},
|
||||||
|
onScroll: function() {},
|
||||||
|
};
|
||||||
|
|
|
@ -1147,7 +1147,6 @@ var TimelinePanel = React.createClass({
|
||||||
highlightedEventId={ this.props.highlightedEventId }
|
highlightedEventId={ this.props.highlightedEventId }
|
||||||
readMarkerEventId={ this.state.readMarkerEventId }
|
readMarkerEventId={ this.state.readMarkerEventId }
|
||||||
readMarkerVisible={ this.state.readMarkerVisible }
|
readMarkerVisible={ this.state.readMarkerVisible }
|
||||||
suppressFirstDateSeparator={ this.state.canBackPaginate }
|
|
||||||
showUrlPreview={ this.props.showUrlPreview }
|
showUrlPreview={ this.props.showUrlPreview }
|
||||||
showReadReceipts={ this.props.showReadReceipts }
|
showReadReceipts={ this.props.showReadReceipts }
|
||||||
ourUserId={ MatrixClientPeg.get().credentials.userId }
|
ourUserId={ MatrixClientPeg.get().credentials.userId }
|
||||||
|
|
|
@ -234,7 +234,6 @@ describe('TimelinePanel', function() {
|
||||||
// 5 times, and we should have given up paginating
|
// 5 times, and we should have given up paginating
|
||||||
expect(client.paginateEventTimeline.callCount).toEqual(5);
|
expect(client.paginateEventTimeline.callCount).toEqual(5);
|
||||||
expect(messagePanel.props.backPaginating).toBe(false);
|
expect(messagePanel.props.backPaginating).toBe(false);
|
||||||
expect(messagePanel.props.suppressFirstDateSeparator).toBe(false);
|
|
||||||
|
|
||||||
// now, if we update the events, there shouldn't be any
|
// now, if we update the events, there shouldn't be any
|
||||||
// more requests.
|
// more requests.
|
||||||
|
@ -339,7 +338,6 @@ describe('TimelinePanel', function() {
|
||||||
awaitScroll().then(() => {
|
awaitScroll().then(() => {
|
||||||
// we should now have loaded the first few events
|
// we should now have loaded the first few events
|
||||||
expect(messagePanel.props.backPaginating).toBe(false);
|
expect(messagePanel.props.backPaginating).toBe(false);
|
||||||
expect(messagePanel.props.suppressFirstDateSeparator).toBe(true);
|
|
||||||
|
|
||||||
// back-paginate until we hit the start
|
// back-paginate until we hit the start
|
||||||
return backPaginate();
|
return backPaginate();
|
||||||
|
@ -347,7 +345,6 @@ describe('TimelinePanel', function() {
|
||||||
// hopefully, we got to the start of the timeline
|
// hopefully, we got to the start of the timeline
|
||||||
expect(messagePanel.props.backPaginating).toBe(false);
|
expect(messagePanel.props.backPaginating).toBe(false);
|
||||||
|
|
||||||
expect(messagePanel.props.suppressFirstDateSeparator).toBe(false);
|
|
||||||
var events = scryEventTiles(panel);
|
var events = scryEventTiles(panel);
|
||||||
expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]);
|
expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]);
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue