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-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",

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() { 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);

View File

@ -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
manageReadReceipts: React.PropTypes.bool, manageReadReceipts: 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;

View File

@ -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,48 @@ 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);
this.node = sn;
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 +155,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 +185,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 +218,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 +267,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 +311,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 +346,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 +362,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 +378,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 +443,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 +456,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 +478,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 +514,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 +554,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 +566,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 +590,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 +603,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() {},
};

View File

@ -1125,7 +1125,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 }
manageReadReceipts = { this.props.manageReadReceipts } manageReadReceipts = { this.props.manageReadReceipts }
ourUserId={ MatrixClientPeg.get().credentials.userId } ourUserId={ MatrixClientPeg.get().credentials.userId }