diff --git a/package.json b/package.json
index 661db4b6bc..c323e67ecf 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/DateUtils.js b/src/DateUtils.js
index 78eef57eae..e7be394c17 100644
--- a/src/DateUtils.js
+++ b/src/DateUtils.js
@@ -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);
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 460ed43e82..6d999c9619 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -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;
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js
index a8a2ec181b..cbd2954918 100644
--- a/src/components/structures/ScrollPanel.js
+++ b/src/components/structures/ScrollPanel.js
@@ -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 (
-
-
- {this.props.children}
-
-
-
- );
- },
-});
+ return (
+
+
+
+ {this.props.children}
+
+
+
+ );
+ }
+}
+
+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() {},
+};
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
index 6be31361dd..6c7073fd63 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.js
@@ -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 }