Implement sticky date separators

Use `react-sticky` to implement sticky date separators. This will pin a date separator to the top of the timeline panel when the separator scrolls out of the top of the view.

A known issue of this is that the spinner, which is in line with event tiles in the timeline, will appear to push the stuck date separator down. In reality the first date separator after the spinner is in line with event tiles and is not stuck because the spinner forces the timeline to be scrolled slightly further down than it would be otherwise. But also, date separators in the timeline (not "stuck") have a greater height.

Ideally the date separator would be suppressed whilst back paginating, but this will cause the stuck separator to flicker on and off. This is why the suppression has been removed.
pull/21833/head
Luke Barnard 2017-08-30 13:52:46 +01:00
parent b678c2cf0f
commit d516906b36
5 changed files with 189 additions and 140 deletions

View File

@ -73,6 +73,7 @@
"react-addons-css-transition-group": "15.3.2",
"react-dom": "^15.4.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"react-sticky": "^6.0.1",
"sanitize-html": "^1.14.1",
"text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0",

View File

@ -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() {
return [
_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) {
if (showTwelveHour) {
return twelveHourTime(date);

View File

@ -61,9 +61,6 @@ module.exports = React.createClass({
// for pending messages.
ourUserId: React.PropTypes.string,
// true to suppress the date at the start of the timeline
suppressFirstDateSeparator: React.PropTypes.bool,
// whether to show read receipts
manageReadReceipts: React.PropTypes.bool,
@ -517,10 +514,10 @@ module.exports = React.createClass({
_wantsDateSeparator: function(prevEvent, nextEventDate) {
if (prevEvent == null) {
// first event in the panel: depends if we could back-paginate from
// here.
return !this.props.suppressFirstDateSeparator;
// First event in the panel always wants a DateSeparator
return true;
}
const prevEventDate = prevEvent.getDate();
if (!nextEventDate || !prevEventDate) {
return false;

View File

@ -17,6 +17,7 @@ limitations under the License.
var React = require("react");
var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar');
import { StickyContainer } from 'react-sticky';
import Promise from 'bluebird';
var KeyCode = require('../../KeyCode');
@ -77,111 +78,48 @@ if (DEBUG_SCROLL) {
* scroll down further. If stickyBottom is disabled, we just save the scroll
* offset as normal.
*/
module.exports = React.createClass({
displayName: 'ScrollPanel',
export default class ScrollPanel extends StickyContainer {
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,
constructor() {
super();
this.onResize = this.onResize.bind(this);
this.onScroll = this.onScroll.bind(this);
}
/* 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,
},
getDefaultProps: function() {
return {
stickyBottom: true,
startAtBottom: true,
onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {},
};
},
componentWillMount: function() {
componentWillMount() {
this._pendingFillRequests = {b: null, f: null};
this.resetScrollState();
},
}
componentDidMount: function() {
componentDidMount() {
this.checkFillState();
},
}
componentDidUpdate: function() {
componentDidUpdate() {
// 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
// adding events to the top).
//
// This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll();
},
}
componentWillUnmount: function() {
componentWillUnmount() {
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
//
// (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true;
},
}
onScroll: function(ev) {
onScroll(ev) {
var sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop,
"_lastSetScroll:", this._lastSetScroll);
this.node = sn;
this.notifySubscribers(ev);
// 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).
@ -217,27 +155,27 @@ module.exports = React.createClass({
this.props.onScroll(ev);
this.checkFillState();
},
}
onResize: function() {
onResize() {
this.props.onResize();
this.checkScroll();
this.refs.geminiPanel.forceUpdate();
},
}
// 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.
checkScroll: function() {
checkScroll() {
this._restoreSavedScrollState();
this.checkFillState();
},
}
// return true if the content is fully scrolled down right now; else false.
//
// note that this is independent of the 'stuckAtBottom' state - it is simply
// about whether the the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update.
isAtBottom: function() {
isAtBottom() {
var sn = this._getScrollNode();
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
@ -247,7 +185,7 @@ module.exports = React.createClass({
// 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
// the content box (which has a height of scrollHeight, see checkFillState) without
@ -280,17 +218,17 @@ module.exports = React.createClass({
// |#########| - |
// |#########| |
// `---------' -
_getExcessHeight: function(backwards) {
_getExcessHeight(backwards) {
var sn = this._getScrollNode();
if (backwards) {
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else {
return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
}
},
}
// check the scroll state and send out backfill requests if necessary.
checkFillState: function() {
checkFillState() {
if (this.unmounted) {
return;
}
@ -329,10 +267,10 @@ module.exports = React.createClass({
// need to forward-fill
this._maybeFill(false);
}
},
}
// check if unfilling is possible and send an unfill request if necessary
_checkUnfillState: function(backwards) {
_checkUnfillState(backwards) {
let excessHeight = this._getExcessHeight(backwards);
if (excessHeight <= 0) {
return;
@ -373,10 +311,10 @@ module.exports = React.createClass({
this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS);
}
},
}
// check if there is already a pending fill request. If not, set one off.
_maybeFill: function(backwards) {
_maybeFill(backwards) {
var dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) {
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
@ -408,7 +346,7 @@ module.exports = React.createClass({
this.checkFillState();
}
}).done();
},
}
/* get the current scroll state. This returns an object with the following
* properties:
@ -424,9 +362,9 @@ module.exports = React.createClass({
* the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel.
*/
getScrollState: function() {
getScrollState() {
return this.scrollState;
},
}
/* reset the saved scroll state.
*
@ -440,46 +378,46 @@ module.exports = React.createClass({
* no use if no children exist yet, or if you are about to replace the
* child list.)
*/
resetScrollState: function() {
resetScrollState() {
this.scrollState = {stuckAtBottom: this.props.startAtBottom};
},
}
/**
* jump to the top of the content.
*/
scrollToTop: function() {
scrollToTop() {
this._setScrollTop(0);
this._saveScrollState();
},
}
/**
* jump to the bottom of the content.
*/
scrollToBottom: function() {
scrollToBottom() {
// the easiest way to make sure that the scroll state is correctly
// saved is to do the scroll, then save the updated state. (Calculating
// it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here).
this._setScrollTop(Number.MAX_VALUE);
this._saveScrollState();
},
}
/**
* Page up/down.
*
* mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
scrollRelative(mult) {
var scrollNode = this._getScrollNode();
var delta = mult * scrollNode.clientHeight * 0.5;
this._setScrollTop(scrollNode.scrollTop + delta);
this._saveScrollState();
},
}
/**
* Scroll up/down in response to a scroll key
*/
handleScrollKey: function(ev) {
handleScrollKey(ev) {
switch (ev.keyCode) {
case KeyCode.PAGE_UP:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
@ -505,7 +443,7 @@ module.exports = React.createClass({
}
break;
}
},
}
/* Scroll the panel to bring the DOM node with the scroll token
* `scrollToken` into view.
@ -518,7 +456,7 @@ module.exports = React.createClass({
* node (specifically, the bottom of it) will be positioned. If omitted, it
* defaults to 0.
*/
scrollToToken: function(scrollToken, pixelOffset, offsetBase) {
scrollToToken(scrollToken, pixelOffset, offsetBase) {
pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0;
@ -540,11 +478,11 @@ module.exports = React.createClass({
// ... 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) {
_scrollToToken(scrollToken, pixelOffset) {
/* find the dom node with the right scrolltoken */
var node;
var messages = this.refs.itemlist.children;
@ -576,9 +514,9 @@ module.exports = React.createClass({
this._setScrollTop(scrollNode.scrollTop + scrollDelta);
}
},
}
_saveScrollState: function() {
_saveScrollState() {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
@ -616,9 +554,9 @@ module.exports = React.createClass({
} else {
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
}
},
}
_restoreSavedScrollState: function() {
_restoreSavedScrollState() {
var scrollState = this.scrollState;
var scrollNode = this._getScrollNode();
@ -628,9 +566,9 @@ module.exports = React.createClass({
this._scrollToToken(scrollState.trackedScrollToken,
scrollState.pixelOffset);
}
},
}
_setScrollTop: function(scrollTop) {
_setScrollTop(scrollTop) {
var scrollNode = this._getScrollNode();
var prevScroll = scrollNode.scrollTop;
@ -652,12 +590,12 @@ module.exports = React.createClass({
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll);
},
}
/* get the DOM node which has the scrollTop property we care about for our
* message panel.
*/
_getScrollNode: function() {
_getScrollNode() {
if (this.unmounted) {
// this shouldn't happen, but when it does, turn the NPE into
// something more meaningful.
@ -665,21 +603,91 @@ module.exports = React.createClass({
}
return this.refs.geminiPanel.scrollbar.getViewElement();
},
}
render: function() {
render() {
// TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway.
return (<GeminiScrollbar autoshow={true} ref="geminiPanel"
onScroll={this.onScroll} onResize={this.onResize}
className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{this.props.children}
</ol>
</div>
</GeminiScrollbar>
);
},
});
return (
<GeminiScrollbar autoshow={true} ref="geminiPanel"
onScroll={this.onScroll} onResize={this.onResize}
className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{this.props.children}
</ol>
</div>
</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() {},
};

View File

@ -1125,7 +1125,6 @@ var TimelinePanel = React.createClass({
highlightedEventId={ this.props.highlightedEventId }
readMarkerEventId={ this.state.readMarkerEventId }
readMarkerVisible={ this.state.readMarkerVisible }
suppressFirstDateSeparator={ this.state.canBackPaginate }
showUrlPreview = { this.props.showUrlPreview }
manageReadReceipts = { this.props.manageReadReceipts }
ourUserId={ MatrixClientPeg.get().credentials.userId }