diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss
index 50d412ad58..5e826306c6 100644
--- a/res/css/structures/_RoomView.scss
+++ b/res/css/structures/_RoomView.scss
@@ -221,6 +221,9 @@ hr.mx_RoomView_myReadMarker {
position: relative;
top: -1px;
z-index: 1;
+ transition: width 400ms easeInSine 1s, opacity 400ms easeInSine 1s;
+ width: 99%;
+ opacity: 1;
}
.mx_RoomView_callStatusBar .mx_UploadBar_uploadProgressInner {
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 912b865b9f..d1cc1b7caf 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -16,8 +16,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-/* global Velocity */
-
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
@@ -111,14 +109,12 @@ export default class MessagePanel extends React.Component {
constructor() {
super();
- // the event after which we put a visible unread marker on the last
- // render cycle; null if readMarkerVisible was false or the RM was
- // suppressed (eg because it was at the end of the timeline)
- this.currentReadMarkerEventId = null;
- // the event after which we are showing a disappearing read marker
- // animation
- this.currentGhostEventId = null;
+ this.state = {
+ // previous positions the read marker has been in, so we can
+ // display 'ghost' read markers that are animating away
+ ghostReadMarkers: [],
+ };
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations
@@ -157,10 +153,6 @@ export default class MessagePanel extends React.Component {
// displayed event in the current render cycle.
this._readReceiptsByUserId = {};
- // Remember the read marker ghost node so we can do the cleanup that
- // Velocity requires
- this._readMarkerGhostNode = null;
-
// Cache hidden events setting on mount since Settings is expensive to
// query, and we check this in a hot code path.
this._showHiddenEventsInTimeline =
@@ -177,6 +169,16 @@ export default class MessagePanel extends React.Component {
this._isMounted = false;
}
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.readMarkerVisible && this.props.readMarkerEventId !== prevProps.readMarkerEventId) {
+ const ghostReadMarkers = this.state.ghostReadMarkers;
+ ghostReadMarkers.push(prevProps.readMarkerEventId);
+ this.setState({
+ ghostReadMarkers,
+ });
+ }
+ }
+
/* get the DOM node representing the given event */
getNodeForEventId(eventId) {
if (!this.eventNodes) {
@@ -325,6 +327,78 @@ export default class MessagePanel extends React.Component {
return !shouldHideEvent(mxEv);
}
+ _readMarkerForEvent(eventId, isLastEvent) {
+ const visible = !isLastEvent && this.props.readMarkerVisible;
+
+ if (this.props.readMarkerEventId === eventId) {
+ let hr;
+ // if the read marker comes at the end of the timeline (except
+ // for local echoes, which are excluded from RMs, because they
+ // don't have useful event ids), we don't want to show it, but
+ // we still want to create the
for it so that the
+ // algorithms which depend on its position on the screen aren't
+ // confused.
+ if (visible) {
+ hr =
;
+ }
+
+ return (
+
+ { hr }
+
+ );
+ } else if (this.state.ghostReadMarkers.includes(eventId)) {
+ // We render 'ghost' read markers in the DOM while they
+ // transition away. This allows the actual read marker
+ // to be in the right place straight away without having
+ // to wait for the transition to finish.
+ // There are probably much simpler ways to do this transition,
+ // possibly using react-transition-group which handles keeping
+ // elements in the DOM whilst they transition out, although our
+ // case is a little more complex because only some of the items
+ // transition (ie. the read markers do but the event tiles do not)
+ // and TransitionGroup requires that all its children are Transitions.
+ const hr =
;
+
+ // give it a key which depends on the event id. That will ensure that
+ // we get a new DOM node (restarting the animation) when the ghost
+ // moves to a different event.
+ return (
+
+ { hr }
+
+ );
+ }
+
+ return null;
+ }
+
+ _collectGhostReadMarker = (node) => {
+ if (node) {
+ // now the element has appeared, change the style which will trigger the CSS transition
+ requestAnimationFrame(() => {
+ node.style.width = '10%';
+ node.style.opacity = '0';
+ });
+ }
+ };
+
+ _onGhostTransitionEnd = (ev) => {
+ // we can now clean up the ghost element
+ const finishedEventId = ev.target.dataset.eventid;
+ this.setState({
+ ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId),
+ });
+ };
+
_getEventTiles() {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
@@ -332,7 +406,6 @@ export default class MessagePanel extends React.Component {
this.eventNodes = {};
- let visible = false;
let i;
// first figure out which is the last event in the list which we're
@@ -367,16 +440,6 @@ export default class MessagePanel extends React.Component {
let prevEvent = null; // the last event we showed
- // assume there is no read marker until proven otherwise
- let readMarkerVisible = false;
-
- // if the readmarker has moved, cancel any active ghost.
- if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
- this.props.readMarkerVisible &&
- this.currentReadMarkerEventId !== this.props.readMarkerEventId) {
- this.currentGhostEventId = null;
- }
-
this._readReceiptsByEvent = {};
if (this.props.showReadReceipts) {
this._readReceiptsByEvent = this._getReadReceiptsByShownEvent();
@@ -401,7 +464,7 @@ export default class MessagePanel extends React.Component {
return false;
};
if (mxEv.getType() === "m.room.create") {
- let readMarkerInSummary = false;
+ let summaryReadMarker = null;
const ts1 = mxEv.getTs();
if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
@@ -410,9 +473,7 @@ export default class MessagePanel extends React.Component {
}
// If RM event is the first in the summary, append the RM after the summary
- if (mxEv.getId() === this.props.readMarkerEventId) {
- readMarkerInSummary = true;
- }
+ summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
// If this m.room.create event should be shown (room upgrade) then show it before the summary
if (this._shouldShowEvent(mxEv)) {
@@ -427,9 +488,7 @@ export default class MessagePanel extends React.Component {
// Ignore redacted/hidden member events
if (!this._shouldShowEvent(collapsedMxEv)) {
// If this hidden event is the RM and in or at end of a summary put RM after the summary.
- if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
- readMarkerInSummary = true;
- }
+ summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
continue;
}
@@ -438,9 +497,7 @@ export default class MessagePanel extends React.Component {
}
// If RM event is in the summary, mark it as such and the RM will be appended after the summary.
- if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
- readMarkerInSummary = true;
- }
+ summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
summarisedEvents.push(collapsedMxEv);
}
@@ -468,8 +525,8 @@ export default class MessagePanel extends React.Component {
{ eventTiles }
);
- if (readMarkerInSummary) {
- ret.push(this._getReadMarkerTile(visible));
+ if (summaryReadMarker) {
+ ret.push(summaryReadMarker);
}
prevEvent = mxEv;
@@ -480,7 +537,7 @@ export default class MessagePanel extends React.Component {
// Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) && wantTile) {
- let readMarkerInMels = false;
+ let summaryReadMarker = null;
const ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and
@@ -498,9 +555,7 @@ export default class MessagePanel extends React.Component {
}
// If RM event is the first in the MELS, append the RM after MELS
- if (mxEv.getId() === this.props.readMarkerEventId) {
- readMarkerInMels = true;
- }
+ summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
const summarisedEvents = [mxEv];
for (;i + 1 < this.props.events.length; i++) {
@@ -509,9 +564,7 @@ export default class MessagePanel extends React.Component {
// Ignore redacted/hidden member events
if (!this._shouldShowEvent(collapsedMxEv)) {
// If this hidden event is the RM and in or at end of a MELS put RM after MELS.
- if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
- readMarkerInMels = true;
- }
+ summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
continue;
}
@@ -521,9 +574,7 @@ export default class MessagePanel extends React.Component {
}
// If RM event is in MELS mark it as such and the RM will be appended after MELS.
- if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
- readMarkerInMels = true;
- }
+ summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
summarisedEvents.push(collapsedMxEv);
}
@@ -554,8 +605,8 @@ export default class MessagePanel extends React.Component {
{ eventTiles }
);
- if (readMarkerInMels) {
- ret.push(this._getReadMarkerTile(visible));
+ if (summaryReadMarker) {
+ ret.push(summaryReadMarker);
}
prevEvent = mxEv;
@@ -570,40 +621,10 @@ export default class MessagePanel extends React.Component {
prevEvent = mxEv;
}
- let isVisibleReadMarker = false;
-
- if (eventId === this.props.readMarkerEventId) {
- visible = this.props.readMarkerVisible;
-
- // if the read marker comes at the end of the timeline (except
- // for local echoes, which are excluded from RMs, because they
- // don't have useful event ids), we don't want to show it, but
- // we still want to create the for it so that the
- // algorithms which depend on its position on the screen aren't
- // confused.
- if (i >= lastShownNonLocalEchoIndex) {
- visible = false;
- }
- ret.push(this._getReadMarkerTile(visible));
- readMarkerVisible = visible;
- isVisibleReadMarker = visible;
- }
-
- // XXX: there should be no need for a ghost tile - we should just use a
- // a dispatch (user_activity_end) to start the RM animation.
- if (eventId === this.currentGhostEventId) {
- // if we're showing an animation, continue to show it.
- ret.push(this._getReadMarkerGhostTile());
- } else if (!isVisibleReadMarker &&
- eventId === this.currentReadMarkerEventId) {
- // there is currently a read-up-to marker at this point, but no
- // more. Show an animation of it disappearing.
- ret.push(this._getReadMarkerGhostTile());
- this.currentGhostEventId = eventId;
- }
+ const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
+ if (readMarker) ret.push(readMarker);
}
- this.currentReadMarkerEventId = readMarkerVisible ? this.props.readMarkerEventId : null;
return ret;
}
@@ -797,53 +818,6 @@ export default class MessagePanel extends React.Component {
return receiptsByEvent;
}
- _getReadMarkerTile(visible) {
- let hr;
- if (visible) {
- hr =
;
- }
-
- return (
-
- { hr }
-
- );
- }
-
- _startAnimation = (ghostNode) => {
- if (this._readMarkerGhostNode) {
- Velocity.Utilities.removeData(this._readMarkerGhostNode);
- }
- this._readMarkerGhostNode = ghostNode;
-
- if (ghostNode) {
- // eslint-disable-next-line new-cap
- Velocity(ghostNode, {opacity: '0', width: '10%'},
- {duration: 400, easing: 'easeInSine',
- delay: 1000});
- }
- };
-
- _getReadMarkerGhostTile() {
- const hr =
;
-
- // give it a key which depends on the event id. That will ensure that
- // we get a new DOM node (restarting the animation) when the ghost
- // moves to a different event.
- return (
-
- { hr }
-
- );
- }
-
_collectEventNode = (eventId, node) => {
this.eventNodes[eventId] = node;
}
diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js
index f58f1b925c..7c52512bc2 100644
--- a/test/components/structures/MessagePanel-test.js
+++ b/test/components/structures/MessagePanel-test.js
@@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -81,6 +82,7 @@ describe('MessagePanel', function() {
// HACK: We assume all settings want to be disabled
SettingsStore.getValue = sinon.stub().returns(false);
+ SettingsStore.getValue.withArgs('showDisplaynameChanges').returns(true);
// This option clobbers the duration of all animations to be 1ms
// which makes unit testing a lot simpler (the animation doesn't
@@ -109,6 +111,44 @@ describe('MessagePanel', function() {
return events;
}
+
+ // make a collection of events with some member events that should be collapsed
+ // with a MemberEventListSummary
+ function mkMelsEvents() {
+ const events = [];
+ const ts0 = Date.now();
+
+ let i = 0;
+ events.push(test_utils.mkMessage({
+ event: true, room: "!room:id", user: "@user:id",
+ ts: ts0 + ++i*1000,
+ }));
+
+ for (i = 0; i < 10; i++) {
+ events.push(test_utils.mkMembership({
+ event: true, room: "!room:id", user: "@user:id",
+ target: {
+ userId: "@user:id",
+ name: "Bob",
+ getAvatarUrl: () => {
+ return "avatar.jpeg";
+ },
+ },
+ ts: ts0 + i*1000,
+ mship: 'join',
+ prevMship: 'join',
+ name: 'A user',
+ }));
+ }
+
+ events.push(test_utils.mkMessage({
+ event: true, room: "!room:id", user: "@user:id",
+ ts: ts0 + ++i*1000,
+ }));
+
+ return events;
+ }
+
it('should show the events', function() {
const res = TestUtils.renderIntoDocument(
,
@@ -120,6 +160,23 @@ describe('MessagePanel', function() {
expect(tiles.length).toEqual(10);
});
+ it('should collapse adjacent member events', function() {
+ const res = TestUtils.renderIntoDocument(
+ ,
+ );
+
+ // just check we have the right number of tiles for now
+ const tiles = TestUtils.scryRenderedComponentsWithType(
+ res, sdk.getComponent('rooms.EventTile'),
+ );
+ expect(tiles.length).toEqual(2);
+
+ const summaryTiles = TestUtils.scryRenderedComponentsWithType(
+ res, sdk.getComponent('elements.MemberEventListSummary'),
+ );
+ expect(summaryTiles.length).toEqual(1);
+ });
+
it('should show the read-marker in the right place', function() {
const res = TestUtils.renderIntoDocument(
,
+ );
+
+ const summary = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_EventListSummary');
+
+ // find the which wraps the read marker
+ const rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container');
+
+ expect(rm.previousSibling).toEqual(summary);
+ });
+
it('shows a ghost read-marker when the read-marker moves', function(done) {
// fake the clock so that we can test the velocity animation.
clock.install();
@@ -191,50 +263,4 @@ describe('MessagePanel', function() {
}, 100);
}, 100);
});
-
- it('shows only one ghost when the RM moves twice', function() {
- const parentDiv = document.createElement('div');
-
- // first render with the RM in one place
- let mp = ReactDOM.render(
- , parentDiv);
-
- const tiles = TestUtils.scryRenderedComponentsWithType(
- mp, sdk.getComponent('rooms.EventTile'));
- const tileContainers = tiles.map(function(t) {
- return ReactDOM.findDOMNode(t).parentNode;
- });
-
- // now move the RM
- mp = ReactDOM.render(
- , parentDiv);
-
- // now there should be two RM containers
- let found = TestUtils.scryRenderedDOMComponentsWithClass(mp, 'mx_RoomView_myReadMarker_container');
- expect(found.length).toEqual(2);
-
- // the first should be the ghost
- expect(tileContainers.indexOf(found[0].previousSibling)).toEqual(4);
-
- // the second should be the real RM
- expect(tileContainers.indexOf(found[1].previousSibling)).toEqual(6);
-
- // and move the RM again
- mp = ReactDOM.render(
- , parentDiv);
-
- // still two RM containers
- found = TestUtils.scryRenderedDOMComponentsWithClass(mp, 'mx_RoomView_myReadMarker_container');
- expect(found.length).toEqual(2);
-
- // they should have moved
- expect(tileContainers.indexOf(found[0].previousSibling)).toEqual(6);
- expect(tileContainers.indexOf(found[1].previousSibling)).toEqual(8);
- });
});