From 8b80cfb9e3fae938c88006247e75cfe8d8ae76ac Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Mon, 8 Jul 2024 13:26:04 +0100 Subject: [PATCH] feat: display message outgoing state (#15450) * chore: storybook page * feat: propagate outgoing status to StatusMessageHeader * feat: improve message outgoing status UI * fix: lock message `delivered` state --- .../modules/shared_models/message_model.nim | 2 + storybook/pages/StatusMessagePage.qml | 75 +++++++++- .../src/StatusQ/Components/StatusMessage.qml | 18 ++- .../Components/StatusMessageHeader.qml | 131 +++++++++++------- ui/imports/shared/views/chat/MessageView.qml | 53 +++++-- ui/imports/utils/Constants.qml | 13 +- 6 files changed, 222 insertions(+), 70 deletions(-) diff --git a/src/app/modules/shared_models/message_model.nim b/src/app/modules/shared_models/message_model.nim index 23d0a77609..5aea49fb7c 100644 --- a/src/app/modules/shared_models/message_model.nim +++ b/src/app/modules/shared_models/message_model.nim @@ -546,6 +546,8 @@ QtObject: let ind = self.findIndexForMessageId(messageId) if(ind == -1): return + if self.items[ind].outgoingStatus == PARSED_TEXT_OUTGOING_STATUS_DELIVERED: + return self.items[ind].outgoingStatus = status let index = self.createIndex(ind, 0, nil) defer: index.delete diff --git a/storybook/pages/StatusMessagePage.qml b/storybook/pages/StatusMessagePage.qml index 985b17b85a..5c5100d6a9 100644 --- a/storybook/pages/StatusMessagePage.qml +++ b/storybook/pages/StatusMessagePage.qml @@ -29,6 +29,7 @@ SplitView { isContact: true isAReply: false trustIndicator: StatusContactVerificationIcons.TrustedType.Verified + outgoingStatus: StatusMessage.OutgoingStatus.Delivered } ListElement { timestamp: 1657937930135 @@ -39,6 +40,7 @@ SplitView { isContact: false isAReply: false trustIndicator: StatusContactVerificationIcons.TrustedType.Untrustworthy + outgoingStatus: StatusMessage.OutgoingStatus.Delivered } ListElement { timestamp: 1667937930159 @@ -49,6 +51,7 @@ SplitView { isContact: true isAReply: true trustIndicator: StatusContactVerificationIcons.TrustedType.None + outgoingStatus: StatusMessage.OutgoingStatus.Delivered } ListElement { timestamp: 1667937930489 @@ -59,6 +62,71 @@ SplitView { isContact: true isAReply: true trustIndicator: StatusContactVerificationIcons.TrustedType.None + outgoingStatus: StatusMessage.OutgoingStatus.Delivered + } + ListElement { + timestamp: 1719769718000 + senderId: "zq123456790" + senderDisplayName: "Alice" + contentType: StatusMessage.ContentType.Text + message: "Sending message" + isAReply: false + isContact: true + amISender: true + trustIndicator: StatusContactVerificationIcons.TrustedType.None + outgoingStatus: StatusMessage.OutgoingStatus.Sending + } + ListElement { + timestamp: 1719769718000 + senderId: "zq123456790" + senderDisplayName: "Alice" + contentType: StatusMessage.ContentType.Text + message: "Sent message" + isAReply: false + isContact: true + amISender: true + trustIndicator: StatusContactVerificationIcons.TrustedType.None + outgoingStatus: StatusMessage.OutgoingStatus.Sent + resendError: "" + } + ListElement { + timestamp: 1719769718000 + senderId: "zq123456790" + senderDisplayName: "Alice" + contentType: StatusMessage.ContentType.Text + message: "Delivered message" + isAReply: false + isContact: true + amISender: true + trustIndicator: StatusContactVerificationIcons.TrustedType.None + outgoingStatus: StatusMessage.OutgoingStatus.Delivered + resendError: "" + } + ListElement { + timestamp: 1719769718000 + senderId: "zq123456790" + senderDisplayName: "Alice" + contentType: StatusMessage.ContentType.Text + message: "Expired message" + isAReply: false + isContact: true + amISender: true + trustIndicator: StatusContactVerificationIcons.TrustedType.None + outgoingStatus: StatusMessage.OutgoingStatus.Expired + resendError: "" + } + ListElement { + timestamp: 1719769718000 + senderId: "zq123456790" + senderDisplayName: "Alice" + contentType: StatusMessage.ContentType.Text + message: "Message with resend error" + isAReply: false + isContact: true + amISender: true + trustIndicator: StatusContactVerificationIcons.TrustedType.None + outgoingStatus: StatusMessage.OutgoingStatus.Expired + resendError: "can't send message on Tuesday" } } readonly property var colorHash: ListModel { @@ -90,10 +158,15 @@ SplitView { delegate: StatusMessage { width: ListView.view.width timestamp: model.timestamp + isAReply: model.isAReply + outgoingStatus: model.outgoingStatus + resendError: model.outgoingStatus === StatusMessage.OutgoingStatus.Expired ? model.resendError : "" + messageDetails { readonly property bool isEnsVerified: model.senderDisplayName.endsWith(".eth") messageText: model.message contentType: model.contentType + amISender: model.amISender sender.id: isEnsVerified ? "" : model.senderId sender.displayName: model.senderDisplayName sender.isContact: model.isContact @@ -106,7 +179,6 @@ SplitView { } } - isAReply: model.isAReply replyDetails { amISender: true sender.id: "0xdeadbeef" @@ -123,6 +195,7 @@ SplitView { onProfilePictureClicked: logs.logEvent("StatusMessage::profilePictureClicked") onReplyProfileClicked: logs.logEvent("StatusMessage::replyProfileClicked") onReplyMessageClicked: logs.logEvent("StatusMessage::replyMessageClicked") + onResendClicked: logs.logEvent("StatusMessage::resendClicked") } } } diff --git a/ui/StatusQ/src/StatusQ/Components/StatusMessage.qml b/ui/StatusQ/src/StatusQ/Components/StatusMessage.qml index 4017597e0e..732899c221 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusMessage.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusMessage.qml @@ -29,6 +29,14 @@ Control { BridgeMessage = 18 } + enum OutgoingStatus { + Unknown = 0, + Sending, + Sent, + Delivered, + Expired + } + property list quickActions property var statusChatInput property alias linksComponent: linksLoader.sourceComponent @@ -51,9 +59,8 @@ Control { property bool hasMention: false property bool isPinned: false property string pinnedBy: "" - property bool hasExpired: false - property bool isSending: false property string resendError: "" + property int outgoingStatus: StatusMessage.OutgointStatus.Unknown property double timestamp: 0 property var reactionsModel: [] @@ -111,6 +118,7 @@ Control { } hoverEnabled: (!root.isActiveMessage && !root.disableHover) + opacity: outgoingStatus === StatusMessage.OutgoingStatus.Sending ? 0.5 : 1.0 background: Rectangle { color: { if (root.overrideBackground) @@ -254,14 +262,14 @@ Control { sender: root.messageDetails.sender amISender: root.messageDetails.amISender messageOriginInfo: root.messageDetails.messageOriginInfo - showResendButton: root.hasExpired && root.messageDetails.amISender && !editMode && !root.isInPinnedPopup - showSendingLoader: root.isSending && root.messageDetails.amISender && !editMode - resendError: root.messageDetails.amISender && !editMode ? root.resendError : "" + resendError: root.messageDetails.amISender ? root.resendError : "" onClicked: root.senderNameClicked(sender, mouse) onResendClicked: root.resendClicked() timestamp: root.timestamp showFullTimestamp: root.isInPinnedPopup displayNameClickable: root.profileClickable + outgoingStatus: root.outgoingStatus + showOutgointStatusLabel: root.hovered && !root.isInPinnedPopup } } Loader { diff --git a/ui/StatusQ/src/StatusQ/Components/StatusMessageHeader.qml b/ui/StatusQ/src/StatusQ/Components/StatusMessageHeader.qml index 525722665f..7c06c41271 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusMessageHeader.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusMessageHeader.qml @@ -18,9 +18,6 @@ Item { property double timestamp: 0 property string tertiaryDetail: sender.id - property string resendText: qsTr("Resend") - property bool showResendButton: false - property bool showSendingLoader: false property string resendError: "" property bool isContact: sender.isContact property int trustIndicator: sender.trustIndicator @@ -28,6 +25,8 @@ Item { property bool displayNameClickable: true property string messageOriginInfo: "" property bool showFullTimestamp + property int outgoingStatus: StatusMessage.OutgoingStatus.Unknown + property bool showOutgointStatusLabel: false signal clicked(var sender, var mouse) signal resendClicked() @@ -35,6 +34,13 @@ Item { implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth + QtObject { + id: d + + readonly property bool expired: root.outgoingStatus === StatusMessage.OutgoingStatus.Expired + readonly property color outgoingStatusColor: expired ? Theme.palette.warningColor1 : Theme.palette.baseColor1 + } + RowLayout { id: layout spacing: 4 @@ -133,56 +139,12 @@ Item { } StatusTimeStampLabel { - verticalAlignment: Text.AlignVCenter id: timestampText + verticalAlignment: Text.AlignVCenter timestamp: root.timestamp showFullTimestamp: root.showFullTimestamp } - Loader { - id: resendButtonLoader - active: showResendButton && !!timestampText.text - asynchronous: true - sourceComponent: StatusBaseText { - id: resendButton - verticalAlignment: Text.AlignVCenter - color: Theme.palette.dangerColor1 - font.pixelSize: Theme.tertiaryTextFontSize - text: root.resendText - MouseArea { - cursorShape: Qt.PointingHandCursor - anchors.fill: parent - onClicked: root.resendClicked() - } - } - } - - Loader { - id: resendErrorTextLoader - active: resendError && !!timestampText.text - asynchronous: true - sourceComponent: StatusBaseText { - id: resendErrorText - verticalAlignment: Text.AlignVCenter - color: Theme.palette.baseColor1 - font.pixelSize: Theme.tertiaryTextFontSize - text: qsTr("Failed to resend: %1").arg(resendError) // TODO replace this with the required design - } - } - - Loader { - id: sendingInProgressLoader - active: showSendingLoader && !!timestampText.text - asynchronous: true - sourceComponent: StatusBaseText { - id: sendingInProgress - verticalAlignment: Text.AlignVCenter - color: Theme.palette.baseColor1 - font.pixelSize: Theme.tertiaryTextFontSize - text: qsTr("Sending...") // TODO replace this with the required design - } - } - Component { id: dotComponent StatusBaseText { @@ -194,6 +156,79 @@ Item { } } + Loader { + id: deliveryStatusLoader + Layout.alignment: Qt.AlignVCenter + active: root.outgoingStatus !== StatusMessage.OutgoingStatus.Unknown + asynchronous: true + sourceComponent: RowLayout { + spacing: 0 + StatusIcon { + Layout.preferredHeight: 15 + Layout.preferredWidth: 15 + Layout.alignment: Qt.AlignVCenter + color: d.outgoingStatusColor + icon: { + if (root.resendError != "") + return "tiny/tiny-exclamation" + switch (root.outgoingStatus) { + case StatusMessage.OutgoingStatus.Delivered: + return "tiny/message/delivered" + case StatusMessage.OutgoingStatus.Sent: + return "tiny/message/sent" + case StatusMessage.OutgoingStatus.Sending: + return "tiny/pending" + case StatusMessage.OutgoingStatus.Expired: + return "tiny/tiny-exclamation" + default: + return "" + } + } + } + Loader { + active: root.showOutgointStatusLabel + asynchronous: true + sourceComponent: StatusBaseText { + Layout.alignment: Qt.AlignVCenter + color: d.outgoingStatusColor + font.pixelSize: Theme.asideTextFontSize + text: { + if (root.resendError != "") + return qsTr("Failed to resend: %1").arg(root.resendError) + switch (root.outgoingStatus) { + case StatusMessage.OutgoingStatus.Delivered: + return qsTr("Delivered") + case StatusMessage.OutgoingStatus.Sent: + return qsTr("Sent") + case StatusMessage.OutgoingStatus.Sending: + return qsTr("Sending") + case StatusMessage.OutgoingStatus.Expired: + return qsTr("Sending failed") + default: + return "" + } + } + } + } + } + } + + Loader { + id: resendButtonLoader + active: root.showOutgointStatusLabel && d.expired + asynchronous: true + sourceComponent: StatusButton { + Layout.fillHeight: true + verticalPadding: 1 + horizontalPadding: 5 + size: StatusBaseButton.Tiny + type: StatusBaseButton.Warning + font.pixelSize: 9 + text: qsTr("Resend") + onClicked: root.resendClicked() + } + } + Item { Layout.fillWidth: true } diff --git a/ui/imports/shared/views/chat/MessageView.qml b/ui/imports/shared/views/chat/MessageView.qml index 2040d0b0f7..0a436549ac 100644 --- a/ui/imports/shared/views/chat/MessageView.qml +++ b/ui/imports/shared/views/chat/MessageView.qml @@ -124,7 +124,7 @@ Loader { property string deletedByContactIcon: "" property string deletedByContactColorHash: "" - property bool shouldRepeatHeader: d.getShouldRepeatHeader(messageTimestamp, prevMessageTimestamp, messageOutgoingStatus) + property bool shouldRepeatHeader: d.shouldRepeatHeader property bool hasMention: false @@ -143,9 +143,6 @@ Loader { property bool isMessage: isEmoji || isImage || isSticker || isText || isAudio || messageContentType === Constants.messageContentType.communityInviteType || messageContentType === Constants.messageContentType.transactionType - readonly property bool isExpired: d.getIsExpired(messageTimestamp, messageOutgoingStatus) - readonly property bool isSending: messageOutgoingStatus === Constants.sending && !isExpired - function openProfileContextMenu(sender, mouse, isReply = false) { if (isReply && !quotedMessageFrom) { // The responseTo message was deleted @@ -274,8 +271,8 @@ Loader { readonly property bool canPost: root.chatContentModule.chatDetails.canPost readonly property bool canView: canPost || root.chatContentModule.chatDetails.canView - function nextMessageHasHeader() { - if(!root.nextMessageAsJsonObj) { + function getNextMessageHasHeader() { + if (!root.nextMessageAsJsonObj) { return false } return root.senderId !== root.nextMessageAsJsonObj.senderId || @@ -284,12 +281,27 @@ Loader { } function getShouldRepeatHeader(messageTimeStamp, prevMessageTimeStamp, messageOutgoingStatus) { - return ((messageTimeStamp - prevMessageTimeStamp) / 60 / 1000) > Constants.repeatHeaderInterval + return ((messageTimeStamp - prevMessageTimeStamp) / 60 / 1000) > Constants.repeatHeaderInterval || d.getIsExpired(messageTimeStamp, messageOutgoingStatus) } function getIsExpired(messageTimeStamp, messageOutgoingStatus) { - return (messageOutgoingStatus === Constants.sending && (Math.floor(messageTimeStamp) + 180000) < Date.now()) || messageOutgoingStatus === Constants.expired + return (messageOutgoingStatus === Constants.messageOutgoingStatus.sending && (Math.floor(messageTimeStamp) + 180000) < Date.now()) + || messageOutgoingStatus === Constants.expired + } + + property bool isExpired: false + property bool shouldRepeatHeader: false + property bool nextMessageHasHeader: false + + Component.onCompleted: { + onTimeChanged() + } + + function onTimeChanged() { + isExpired = getIsExpired(root.messageTimestamp, root.messageOutgoingStatus) + shouldRepeatHeader = getShouldRepeatHeader(root.messageTimestamp, root.prevMessageTimestamp, root.messageOutgoingStatus) + nextMessageHasHeader = getNextMessageHasHeader() } function convertContentType(value) { @@ -332,6 +344,17 @@ Loader { } } + function convertOutgoingStatus(value) { + switch (value) { + case Constants.messageOutgoingStatus.sending: + return StatusMessage.OutgoingStatus.Sending + case Constants.messageOutgoingStatus.sent: + return StatusMessage.OutgoingStatus.Sent + case Constants.messageOutgoingStatus.delivered: + return StatusMessage.OutgoingStatus.Delivered + } + } + function addReactionClicked(mouseArea, mouse) { if (!d.addReactionAllowed) return @@ -357,6 +380,13 @@ Loader { } } + Connections { + target: StatusSharedUpdateTimer + onTriggered: { + d.onTimeChanged() + } + } + Component { id: gapComponent GapComponent { @@ -646,8 +676,9 @@ Loader { return ProfileUtils.displayName(contact.localNickname, contact.name, contact.displayName, contact.alias) } isInPinnedPopup: root.isInPinnedPopup - hasExpired: root.isExpired - isSending: root.isSending + outgoingStatus: d.isExpired ? StatusMessage.OutgoingStatus.Expired + : d.convertOutgoingStatus(messageOutgoingStatus) + resendError: root.resendError reactionsModel: root.reactionsModel linkPreviewModel: root.linkPreviewModel @@ -663,7 +694,7 @@ Loader { root.senderId !== root.prevMessageSenderId || root.prevMessageDeleted isActiveMessage: d.isMessageActive topPadding: showHeader ? Style.current.halfPadding : 0 - bottomPadding: showHeader && d.nextMessageHasHeader() ? Style.current.halfPadding : 2 + bottomPadding: showHeader && d.nextMessageHasHeader ? Style.current.halfPadding : 2 disableHover: root.disableHover || (delegate.hideQuickActions && !d.addReactionAllowed) || (root.chatLogView && root.chatLogView.moving) || diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml index b50d1f857d..fc0d81780c 100644 --- a/ui/imports/utils/Constants.qml +++ b/ui/imports/utils/Constants.qml @@ -2,6 +2,7 @@ pragma Singleton import QtQuick 2.13 +import StatusQ.Components 0.1 import StatusQ.Controls.Validators 0.1 import StatusQ.Core.Theme 0.1 @@ -1202,11 +1203,13 @@ QtObject { } // Message outgoing status - readonly property string sending: "sending" - readonly property string sent: "sent" - readonly property string delivered: "delivered" - readonly property string expired: "expired" - readonly property string failedResending: "failedResending" + readonly property QtObject messageOutgoingStatus: QtObject { + readonly property string sending: "sending" + readonly property string sent: "sent" + readonly property string delivered: "delivered" + readonly property string expired: "expired" + readonly property string failedResending: "failedResending" + } readonly property QtObject appTranslatableConstants: QtObject { readonly property string loginAccountsListAddNewUser: "LOGIN-ACCOUNTS-LIST-ADD-NEW-USER"