diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index 5841cf2853..028d9a7556 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -213,23 +213,36 @@ $left-gutter: 64px;
color: $accent-fg-color;
}
-.mx_EventTile_encrypting {
- color: $event-encrypting-color !important;
-}
-
-.mx_EventTile_sending {
- color: $event-sending-color;
-}
-
-.mx_EventTile_sending .mx_UserPill,
-.mx_EventTile_sending .mx_RoomPill {
- opacity: 0.5;
-}
-
.mx_EventTile_notSent {
color: $event-notsent-color;
}
+.mx_EventTile_receiptSent,
+.mx_EventTile_receiptSending {
+ // We don't use `position: relative` on the element because then it won't line
+ // up with the other read receipts
+
+ &::before {
+ background-color: $tertiary-fg-color;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: 14px;
+ width: 14px;
+ height: 14px;
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ }
+}
+.mx_EventTile_receiptSent::before {
+ mask-image: url('$(res)/img/element-icons/circle-sent.svg');
+}
+.mx_EventTile_receiptSending::before {
+ mask-image: url('$(res)/img/element-icons/circle-sending.svg');
+}
+
.mx_EventTile_contextual {
opacity: 0.4;
}
diff --git a/res/img/element-icons/circle-sending.svg b/res/img/element-icons/circle-sending.svg
new file mode 100644
index 0000000000..2d15a0f716
--- /dev/null
+++ b/res/img/element-icons/circle-sending.svg
@@ -0,0 +1,3 @@
+
diff --git a/res/img/element-icons/circle-sent.svg b/res/img/element-icons/circle-sent.svg
new file mode 100644
index 0000000000..04a00ceff7
--- /dev/null
+++ b/res/img/element-icons/circle-sent.svg
@@ -0,0 +1,4 @@
+
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index 0de5e69782..6c0e6d301d 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -138,9 +138,6 @@ $panel-divider-color: transparent;
$widget-menu-bar-bg-color: $header-panel-bg-color;
$widget-body-bg-color: rgba(141, 151, 165, 0.2);
-// event tile lifecycle
-$event-sending-color: $text-secondary-color;
-
// event redaction
$event-redacted-fg-color: #606060;
$event-redacted-border-color: #000000;
diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index 8c5f20178b..92b55e7c7d 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -133,9 +133,6 @@ $panel-divider-color: $header-panel-border-color;
$widget-menu-bar-bg-color: $header-panel-bg-color;
$widget-body-bg-color: #1A1D23;
-// event tile lifecycle
-$event-sending-color: $text-secondary-color;
-
// event redaction
$event-redacted-fg-color: #606060;
$event-redacted-border-color: #000000;
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 3ba10a68ea..d6ac54c364 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -223,8 +223,6 @@ $widget-body-bg-color: #fff;
$yellow-background: #fff8e3;
// event tile lifecycle
-$event-encrypting-color: #abddbc;
-$event-sending-color: #ddd;
$event-notsent-color: #f44;
$event-highlight-fg-color: $warning-color;
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index b6906d16be..4a2bafb50a 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -220,8 +220,6 @@ $widget-body-bg-color: #FFF;
$yellow-background: #fff8e3;
// event tile lifecycle
-$event-encrypting-color: #abddbc;
-$event-sending-color: #ddd;
$event-notsent-color: #f44;
$event-highlight-fg-color: $warning-color;
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 161227a139..9deda54bee 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -595,6 +595,19 @@ export default class MessagePanel extends React.Component {
const readReceipts = this._readReceiptsByEvent[eventId];
+ let isLastSuccessful = false;
+ const isSentState = s => !s || s === 'sent';
+ const isSent = isSentState(mxEv.getAssociatedStatus());
+ if (!nextEvent && isSent) {
+ isLastSuccessful = true;
+ } else if (nextEvent && isSent && !isSentState(nextEvent.getAssociatedStatus())) {
+ isLastSuccessful = true;
+ }
+
+ // We only want to consider "last successful" if the event is sent by us, otherwise of course
+ // it's successful: we received it.
+ isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId();
+
// use txnId as key if available so that we don't remount during sending
ret.push(
0) return false;
+ if (!this.props.mxEvent) return false;
+
+ // Sanity check (should never happen, but we shouldn't explode if it does)
+ const room = this.context.getRoom(this.props.mxEvent.getRoomId());
+ if (!room) return false;
+
+ // Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for
+ // special read receipts.
+ const myUserId = MatrixClientPeg.get().getUserId();
+ if (this.props.mxEvent.getSender() !== myUserId) return false;
+
+ // Finally, determine if the type is relevant to the user. This notably excludes state
+ // events and pretty much anything that can't be sent by the composer as a message. For
+ // those we rely on local echo giving the impression of things changing, and expect them
+ // to be quick.
+ const simpleSendableEvents = [
+ EventType.Sticker,
+ EventType.RoomMessage,
+ EventType.RoomMessageEncrypted,
+ ];
+ if (!simpleSendableEvents.includes(this.props.mxEvent.getType())) return false;
+
+ // Default case
+ return true;
+ }
+
+ get _shouldShowSentReceipt() {
+ // If we're not even eligible, don't show the receipt.
+ if (!this._isEligibleForSpecialReceipt) return false;
+
+ // We only show the 'sent' receipt on the last successful event.
+ if (!this.props.lastSuccessful) return false;
+
+ // Check to make sure the sending state is appropriate. A null/undefined send status means
+ // that the message is 'sent', so we're just double checking that it's explicitly not sent.
+ if (this.props.eventSendStatus && this.props.eventSendStatus !== 'sent') return false;
+
+ // If anyone has read the event besides us, we don't want to show a sent receipt.
+ const receipts = this.props.readReceipts || [];
+ const myUserId = MatrixClientPeg.get().getUserId();
+ if (receipts.some(r => r.userId !== myUserId)) return false;
+
+ // Finally, we should show a receipt.
+ return true;
+ }
+
+ get _shouldShowSendingReceipt() {
+ // If we're not even eligible, don't show the receipt.
+ if (!this._isEligibleForSpecialReceipt) return false;
+
+ // Check the event send status to see if we are pending. Null/undefined status means the
+ // message was sent, so check for that and 'sent' explicitly.
+ if (!this.props.eventSendStatus || this.props.eventSendStatus === 'sent') return false;
+
+ // Default to showing - there's no other event properties/behaviours we care about at
+ // this point.
+ return true;
}
// TODO: [REACT-WARNING] Move into constructor
@@ -281,6 +359,11 @@ export default class EventTile extends React.Component {
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
}
+
+ if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
+ client.on("Room.receipt", this._onRoomReceipt);
+ this._isListeningForReceipts = true;
+ }
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@@ -305,12 +388,42 @@ export default class EventTile extends React.Component {
const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
+ client.removeListener("Room.receipt", this._onRoomReceipt);
+ this._isListeningForReceipts = false;
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
}
}
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ // If we're not listening for receipts and expect to be, register a listener.
+ if (!this._isListeningForReceipts && (this._shouldShowSentReceipt || this._shouldShowSendingReceipt)) {
+ this.context.on("Room.receipt", this._onRoomReceipt);
+ this._isListeningForReceipts = true;
+ }
+ }
+
+ _onRoomReceipt = (ev, room) => {
+ // ignore events for other rooms
+ const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
+ if (room !== tileRoom) return;
+
+ if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt && !this._isListeningForReceipts) {
+ return;
+ }
+
+ // We force update because we have no state or prop changes to queue up, instead relying on
+ // the getters we use here to determine what needs rendering.
+ this.forceUpdate(() => {
+ // Per elsewhere in this file, we can remove the listener once we will have no further purpose for it.
+ if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt) {
+ this.context.removeListener("Room.receipt", this._onRoomReceipt);
+ this._isListeningForReceipts = false;
+ }
+ });
+ };
+
/** called when the event is decrypted after we show it.
*/
_onDecrypted = () => {
@@ -454,6 +567,13 @@ export default class EventTile extends React.Component {
};
getReadAvatars() {
+ if (this._shouldShowSentReceipt) {
+ return ;
+ }
+ if (this._shouldShowSendingReceipt) {
+ return ;
+ }
+
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
return ();
@@ -692,7 +812,7 @@ export default class EventTile extends React.Component {
mx_EventTile_isEditing: isEditing,
mx_EventTile_info: isInfoMessage,
mx_EventTile_12hr: this.props.isTwelveHour,
- mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting',
+ // Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),