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(),