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
This commit is contained in:
Igor Sirotin 2024-07-08 13:26:04 +01:00 committed by GitHub
parent 2f1240602f
commit 8b80cfb9e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 222 additions and 70 deletions

View File

@ -546,6 +546,8 @@ QtObject:
let ind = self.findIndexForMessageId(messageId) let ind = self.findIndexForMessageId(messageId)
if(ind == -1): if(ind == -1):
return return
if self.items[ind].outgoingStatus == PARSED_TEXT_OUTGOING_STATUS_DELIVERED:
return
self.items[ind].outgoingStatus = status self.items[ind].outgoingStatus = status
let index = self.createIndex(ind, 0, nil) let index = self.createIndex(ind, 0, nil)
defer: index.delete defer: index.delete

View File

@ -29,6 +29,7 @@ SplitView {
isContact: true isContact: true
isAReply: false isAReply: false
trustIndicator: StatusContactVerificationIcons.TrustedType.Verified trustIndicator: StatusContactVerificationIcons.TrustedType.Verified
outgoingStatus: StatusMessage.OutgoingStatus.Delivered
} }
ListElement { ListElement {
timestamp: 1657937930135 timestamp: 1657937930135
@ -39,6 +40,7 @@ SplitView {
isContact: false isContact: false
isAReply: false isAReply: false
trustIndicator: StatusContactVerificationIcons.TrustedType.Untrustworthy trustIndicator: StatusContactVerificationIcons.TrustedType.Untrustworthy
outgoingStatus: StatusMessage.OutgoingStatus.Delivered
} }
ListElement { ListElement {
timestamp: 1667937930159 timestamp: 1667937930159
@ -49,6 +51,7 @@ SplitView {
isContact: true isContact: true
isAReply: true isAReply: true
trustIndicator: StatusContactVerificationIcons.TrustedType.None trustIndicator: StatusContactVerificationIcons.TrustedType.None
outgoingStatus: StatusMessage.OutgoingStatus.Delivered
} }
ListElement { ListElement {
timestamp: 1667937930489 timestamp: 1667937930489
@ -59,6 +62,71 @@ SplitView {
isContact: true isContact: true
isAReply: true isAReply: true
trustIndicator: StatusContactVerificationIcons.TrustedType.None 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 { readonly property var colorHash: ListModel {
@ -90,10 +158,15 @@ SplitView {
delegate: StatusMessage { delegate: StatusMessage {
width: ListView.view.width width: ListView.view.width
timestamp: model.timestamp timestamp: model.timestamp
isAReply: model.isAReply
outgoingStatus: model.outgoingStatus
resendError: model.outgoingStatus === StatusMessage.OutgoingStatus.Expired ? model.resendError : ""
messageDetails { messageDetails {
readonly property bool isEnsVerified: model.senderDisplayName.endsWith(".eth") readonly property bool isEnsVerified: model.senderDisplayName.endsWith(".eth")
messageText: model.message messageText: model.message
contentType: model.contentType contentType: model.contentType
amISender: model.amISender
sender.id: isEnsVerified ? "" : model.senderId sender.id: isEnsVerified ? "" : model.senderId
sender.displayName: model.senderDisplayName sender.displayName: model.senderDisplayName
sender.isContact: model.isContact sender.isContact: model.isContact
@ -106,7 +179,6 @@ SplitView {
} }
} }
isAReply: model.isAReply
replyDetails { replyDetails {
amISender: true amISender: true
sender.id: "0xdeadbeef" sender.id: "0xdeadbeef"
@ -123,6 +195,7 @@ SplitView {
onProfilePictureClicked: logs.logEvent("StatusMessage::profilePictureClicked") onProfilePictureClicked: logs.logEvent("StatusMessage::profilePictureClicked")
onReplyProfileClicked: logs.logEvent("StatusMessage::replyProfileClicked") onReplyProfileClicked: logs.logEvent("StatusMessage::replyProfileClicked")
onReplyMessageClicked: logs.logEvent("StatusMessage::replyMessageClicked") onReplyMessageClicked: logs.logEvent("StatusMessage::replyMessageClicked")
onResendClicked: logs.logEvent("StatusMessage::resendClicked")
} }
} }
} }

View File

@ -29,6 +29,14 @@ Control {
BridgeMessage = 18 BridgeMessage = 18
} }
enum OutgoingStatus {
Unknown = 0,
Sending,
Sent,
Delivered,
Expired
}
property list<Item> quickActions property list<Item> quickActions
property var statusChatInput property var statusChatInput
property alias linksComponent: linksLoader.sourceComponent property alias linksComponent: linksLoader.sourceComponent
@ -51,9 +59,8 @@ Control {
property bool hasMention: false property bool hasMention: false
property bool isPinned: false property bool isPinned: false
property string pinnedBy: "" property string pinnedBy: ""
property bool hasExpired: false
property bool isSending: false
property string resendError: "" property string resendError: ""
property int outgoingStatus: StatusMessage.OutgointStatus.Unknown
property double timestamp: 0 property double timestamp: 0
property var reactionsModel: [] property var reactionsModel: []
@ -111,6 +118,7 @@ Control {
} }
hoverEnabled: (!root.isActiveMessage && !root.disableHover) hoverEnabled: (!root.isActiveMessage && !root.disableHover)
opacity: outgoingStatus === StatusMessage.OutgoingStatus.Sending ? 0.5 : 1.0
background: Rectangle { background: Rectangle {
color: { color: {
if (root.overrideBackground) if (root.overrideBackground)
@ -254,14 +262,14 @@ Control {
sender: root.messageDetails.sender sender: root.messageDetails.sender
amISender: root.messageDetails.amISender amISender: root.messageDetails.amISender
messageOriginInfo: root.messageDetails.messageOriginInfo messageOriginInfo: root.messageDetails.messageOriginInfo
showResendButton: root.hasExpired && root.messageDetails.amISender && !editMode && !root.isInPinnedPopup resendError: root.messageDetails.amISender ? root.resendError : ""
showSendingLoader: root.isSending && root.messageDetails.amISender && !editMode
resendError: root.messageDetails.amISender && !editMode ? root.resendError : ""
onClicked: root.senderNameClicked(sender, mouse) onClicked: root.senderNameClicked(sender, mouse)
onResendClicked: root.resendClicked() onResendClicked: root.resendClicked()
timestamp: root.timestamp timestamp: root.timestamp
showFullTimestamp: root.isInPinnedPopup showFullTimestamp: root.isInPinnedPopup
displayNameClickable: root.profileClickable displayNameClickable: root.profileClickable
outgoingStatus: root.outgoingStatus
showOutgointStatusLabel: root.hovered && !root.isInPinnedPopup
} }
} }
Loader { Loader {

View File

@ -18,9 +18,6 @@ Item {
property double timestamp: 0 property double timestamp: 0
property string tertiaryDetail: sender.id property string tertiaryDetail: sender.id
property string resendText: qsTr("Resend")
property bool showResendButton: false
property bool showSendingLoader: false
property string resendError: "" property string resendError: ""
property bool isContact: sender.isContact property bool isContact: sender.isContact
property int trustIndicator: sender.trustIndicator property int trustIndicator: sender.trustIndicator
@ -28,6 +25,8 @@ Item {
property bool displayNameClickable: true property bool displayNameClickable: true
property string messageOriginInfo: "" property string messageOriginInfo: ""
property bool showFullTimestamp property bool showFullTimestamp
property int outgoingStatus: StatusMessage.OutgoingStatus.Unknown
property bool showOutgointStatusLabel: false
signal clicked(var sender, var mouse) signal clicked(var sender, var mouse)
signal resendClicked() signal resendClicked()
@ -35,6 +34,13 @@ Item {
implicitHeight: layout.implicitHeight implicitHeight: layout.implicitHeight
implicitWidth: layout.implicitWidth 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 { RowLayout {
id: layout id: layout
spacing: 4 spacing: 4
@ -133,56 +139,12 @@ Item {
} }
StatusTimeStampLabel { StatusTimeStampLabel {
verticalAlignment: Text.AlignVCenter
id: timestampText id: timestampText
verticalAlignment: Text.AlignVCenter
timestamp: root.timestamp timestamp: root.timestamp
showFullTimestamp: root.showFullTimestamp 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 { Component {
id: dotComponent id: dotComponent
StatusBaseText { 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 { Item {
Layout.fillWidth: true Layout.fillWidth: true
} }

View File

@ -124,7 +124,7 @@ Loader {
property string deletedByContactIcon: "" property string deletedByContactIcon: ""
property string deletedByContactColorHash: "" property string deletedByContactColorHash: ""
property bool shouldRepeatHeader: d.getShouldRepeatHeader(messageTimestamp, prevMessageTimestamp, messageOutgoingStatus) property bool shouldRepeatHeader: d.shouldRepeatHeader
property bool hasMention: false property bool hasMention: false
@ -143,9 +143,6 @@ Loader {
property bool isMessage: isEmoji || isImage || isSticker || isText || isAudio property bool isMessage: isEmoji || isImage || isSticker || isText || isAudio
|| messageContentType === Constants.messageContentType.communityInviteType || messageContentType === Constants.messageContentType.transactionType || 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) { function openProfileContextMenu(sender, mouse, isReply = false) {
if (isReply && !quotedMessageFrom) { if (isReply && !quotedMessageFrom) {
// The responseTo message was deleted // The responseTo message was deleted
@ -274,8 +271,8 @@ Loader {
readonly property bool canPost: root.chatContentModule.chatDetails.canPost readonly property bool canPost: root.chatContentModule.chatDetails.canPost
readonly property bool canView: canPost || root.chatContentModule.chatDetails.canView readonly property bool canView: canPost || root.chatContentModule.chatDetails.canView
function nextMessageHasHeader() { function getNextMessageHasHeader() {
if(!root.nextMessageAsJsonObj) { if (!root.nextMessageAsJsonObj) {
return false return false
} }
return root.senderId !== root.nextMessageAsJsonObj.senderId || return root.senderId !== root.nextMessageAsJsonObj.senderId ||
@ -284,12 +281,27 @@ Loader {
} }
function getShouldRepeatHeader(messageTimeStamp, prevMessageTimeStamp, messageOutgoingStatus) { function getShouldRepeatHeader(messageTimeStamp, prevMessageTimeStamp, messageOutgoingStatus) {
return ((messageTimeStamp - prevMessageTimeStamp) / 60 / 1000) > Constants.repeatHeaderInterval return ((messageTimeStamp - prevMessageTimeStamp) / 60 / 1000) > Constants.repeatHeaderInterval
|| d.getIsExpired(messageTimeStamp, messageOutgoingStatus) || d.getIsExpired(messageTimeStamp, messageOutgoingStatus)
} }
function 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) { 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) { function addReactionClicked(mouseArea, mouse) {
if (!d.addReactionAllowed) if (!d.addReactionAllowed)
return return
@ -357,6 +380,13 @@ Loader {
} }
} }
Connections {
target: StatusSharedUpdateTimer
onTriggered: {
d.onTimeChanged()
}
}
Component { Component {
id: gapComponent id: gapComponent
GapComponent { GapComponent {
@ -646,8 +676,9 @@ Loader {
return ProfileUtils.displayName(contact.localNickname, contact.name, contact.displayName, contact.alias) return ProfileUtils.displayName(contact.localNickname, contact.name, contact.displayName, contact.alias)
} }
isInPinnedPopup: root.isInPinnedPopup isInPinnedPopup: root.isInPinnedPopup
hasExpired: root.isExpired outgoingStatus: d.isExpired ? StatusMessage.OutgoingStatus.Expired
isSending: root.isSending : d.convertOutgoingStatus(messageOutgoingStatus)
resendError: root.resendError resendError: root.resendError
reactionsModel: root.reactionsModel reactionsModel: root.reactionsModel
linkPreviewModel: root.linkPreviewModel linkPreviewModel: root.linkPreviewModel
@ -663,7 +694,7 @@ Loader {
root.senderId !== root.prevMessageSenderId || root.prevMessageDeleted root.senderId !== root.prevMessageSenderId || root.prevMessageDeleted
isActiveMessage: d.isMessageActive isActiveMessage: d.isMessageActive
topPadding: showHeader ? Style.current.halfPadding : 0 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 || disableHover: root.disableHover ||
(delegate.hideQuickActions && !d.addReactionAllowed) || (delegate.hideQuickActions && !d.addReactionAllowed) ||
(root.chatLogView && root.chatLogView.moving) || (root.chatLogView && root.chatLogView.moving) ||

View File

@ -2,6 +2,7 @@ pragma Singleton
import QtQuick 2.13 import QtQuick 2.13
import StatusQ.Components 0.1
import StatusQ.Controls.Validators 0.1 import StatusQ.Controls.Validators 0.1
import StatusQ.Core.Theme 0.1 import StatusQ.Core.Theme 0.1
@ -1202,11 +1203,13 @@ QtObject {
} }
// Message outgoing status // Message outgoing status
readonly property string sending: "sending" readonly property QtObject messageOutgoingStatus: QtObject {
readonly property string sent: "sent" readonly property string sending: "sending"
readonly property string delivered: "delivered" readonly property string sent: "sent"
readonly property string expired: "expired" readonly property string delivered: "delivered"
readonly property string failedResending: "failedResending" readonly property string expired: "expired"
readonly property string failedResending: "failedResending"
}
readonly property QtObject appTranslatableConstants: QtObject { readonly property QtObject appTranslatableConstants: QtObject {
readonly property string loginAccountsListAddNewUser: "LOGIN-ACCOUNTS-LIST-ADD-NEW-USER" readonly property string loginAccountsListAddNewUser: "LOGIN-ACCOUNTS-LIST-ADD-NEW-USER"