status-desktop/ui/app/AppLayouts/Chat/views/ChatMessagesView.qml
Jonathan Rainville dc9951cfb0
Fix unread badge on the chat section button + fix unread count not being counted when the chat is active and the app is not in focus (#16851)
* fix(badge): fix missing badge on the Chat section

* fix(unread): fix unread count not incrementing when the chat is active but app is unfocused

Fixes #16098

The problem was that we were marking the message as read because the chat kept scrolling, even if the app was in the background.
I fixed that by only marking as read if the app is active. I added a Connections to the active property of the Applicaiton too to mark as read when the app comes back active.
I also removed a condition that prevented the Unread bar appearing in that condition.
Now, when a message is sent to the active chat, but the app is not in focus, the red dot appears, as well as the badges. Then when the app comes active, it is marked as read, but the unread messages line is shown to show when is the last time the user saw messages. This is similar to what Discord has.
2024-12-04 10:00:34 -05:00

464 lines
16 KiB
QML

import QtQuick 2.15
import QtQml 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Dialogs 1.3
import StatusQ.Components 0.1
import StatusQ.Controls 0.1
import StatusQ.Core 0.1
import StatusQ.Core.Backpressure 0.1
import StatusQ.Core.Theme 0.1
import utils 1.0
import shared 1.0
import shared.stores 1.0 as SharedStores
import shared.views 1.0
import shared.panels 1.0
import shared.popups 1.0
import shared.status 1.0
import shared.controls 1.0
import shared.views.chat 1.0
import AppLayouts.Chat.stores 1.0
import AppLayouts.Profile.stores 1.0
import "../controls"
import "../panels"
Item {
id: root
property var chatContentModule
property SharedStores.RootStore sharedRootStore
property SharedStores.UtilsStore utilsStore
property RootStore rootStore
property MessageStore messageStore
property UsersStore usersStore
property ContactsStore contactsStore
property string channelEmoji
property var formatBalance
property var emojiPopup
property var stickersPopup
property bool areTestNetworksEnabled
property string chatId: ""
property bool stickersLoaded: false
property alias chatLogView: chatLogView
property bool isContactBlocked: false
property bool isChatBlocked: false
property bool isOneToOne: false
property bool sendViaPersonalChatEnabled
signal openStickerPackPopup(string stickerPackId)
signal showReplyArea(string messageId, string author)
signal editModeChanged(bool editModeOn)
QtObject {
id: d
readonly property real scrollY: chatLogView.visibleArea.yPosition * chatLogView.contentHeight
readonly property bool isMostRecentMessageInViewport: chatLogView.visibleArea.yPosition >= 0.999 - chatLogView.visibleArea.heightRatio
readonly property var chatDetails: chatContentModule && chatContentModule.chatDetails || null
readonly property bool keepUnread: messageStore.keepUnread
readonly property var loadMoreMessagesIfScrollBelowThreshold: Backpressure.oneInTimeQueued(root, 100, function() {
if(scrollY < 1000) messageStore.loadMoreMessages()
})
function setKeepUnread(flag: bool) {
root.messageStore.setKeepUnread(flag)
}
function markAllMessagesReadIfMostRecentMessageIsInViewport() {
if (Qt.application.state != Qt.ApplicationActive || !isMostRecentMessageInViewport || !chatLogView.visible || keepUnread) {
return
}
if (chatDetails && chatDetails.active && (chatDetails.hasUnreadMessages || chatDetails.highlight) && !messageStore.loading) {
chatContentModule.markAllMessagesRead()
}
}
function goToMessage(messageIndex) {
chatLogView.currentIndex = -1
chatLogView.currentIndex = messageIndex
}
onIsMostRecentMessageInViewportChanged: markAllMessagesReadIfMostRecentMessageIsInViewport()
}
Connections {
target: Qt.application
onStateChanged: {
if (Qt.application.state == Qt.ApplicationActive) {
d.markAllMessagesReadIfMostRecentMessageIsInViewport()
}
}
}
Connections {
target: root.messageStore.messageModule
function onMessageSuccessfullySent() {
chatLogView.positionViewAtBeginning()
}
function onSendingMessageFailed(error) {
sendingMsgFailedPopup.error = error
sendingMsgFailedPopup.open()
}
function onScrollToMessage(messageIndex) {
d.goToMessage(messageIndex)
}
}
Connections {
target: root.messageStore
function onMessageSearchOngoingChanged() {
d.markAllMessagesReadIfMostRecentMessageIsInViewport()
}
function onLoadingChanged() {
d.markAllMessagesReadIfMostRecentMessageIsInViewport()
}
}
Connections {
target: !!d.chatDetails ? d.chatDetails : null
function onActiveChanged() {
d.setKeepUnread(false)
d.markAllMessagesReadIfMostRecentMessageIsInViewport()
d.loadMoreMessagesIfScrollBelowThreshold()
}
function onHasUnreadMessagesChanged() {
if (!d.chatDetails.hasUnreadMessages) {
return
}
// HACK: we call `addNewMessagesMarker` later because messages model
// may not be yet propagated with unread messages when this signal is emitted
if (chatLogView.visible && (Qt.application.state != Qt.ApplicationActive || !d.isMostRecentMessageInViewport)) {
Qt.callLater(() => messageStore.addNewMessagesMarker())
}
}
}
Connections {
target: root.rootStore
enabled: d.chatDetails && d.chatDetails.active
function onLoadingHistoryMessagesInProgressChanged() {
if(!root.rootStore.loadingHistoryMessagesInProgress) {
d.loadMoreMessagesIfScrollBelowThreshold()
}
}
}
Item {
id: loadingMessagesIndicator
visible: root.rootStore.loadingHistoryMessagesInProgress
anchors.top: parent.top
anchors.left: parent.left
height: visible? 20 : 0
width: parent.width
Loader {
active: root.rootStore.loadingHistoryMessagesInProgress
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
sourceComponent: Component {
LoadingAnimation {
width: 18
height: 18
}
}
}
}
Loader {
id: loadingMessagesView
anchors.top: loadingMessagesIndicator.bottom
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
active: messageStore.loading
visible: active
sourceComponent: MessagesLoadingView {
anchors.margins: 16
anchors.fill: parent
}
}
StatusListView {
id: chatLogView
visible: !loadingMessagesView.visible
objectName: "chatLogView"
anchors.top: loadingMessagesIndicator.bottom
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
verticalLayoutDirection: ListView.BottomToTop
cacheBuffer: height > 0 ? height * 2 : 0 // cache 2 screens worth of items
highlightRangeMode: ListView.ApplyRange
highlightMoveDuration: 200
preferredHighlightBegin: 0
preferredHighlightEnd: chatLogView.height / 2
Binding on flickDeceleration {
when: localAppSettings.isCustomMouseScrollingEnabled
value: localAppSettings.scrollDeceleration
restoreMode: Binding.RestoreBindingOrValue
}
Binding on maximumFlickVelocity {
when: localAppSettings.isCustomMouseScrollingEnabled
value: localAppSettings.scrollVelocity
restoreMode: Binding.RestoreBindingOrValue
}
model: messageStore.messagesModel
onContentYChanged: d.loadMoreMessagesIfScrollBelowThreshold()
onCountChanged: {
d.markAllMessagesReadIfMostRecentMessageIsInViewport()
// after inilial messages are loaded
// load as much messages as the view requires
if (!messageStore.loading) {
d.loadMoreMessagesIfScrollBelowThreshold()
}
}
onVisibleChanged: d.markAllMessagesReadIfMostRecentMessageIsInViewport()
onCurrentItemChanged: {
if(currentItem && currentIndex > 0) {
currentItem.startMessageFoundAnimation()
}
}
ScrollBar.vertical: StatusScrollBar {
visible: chatLogView.visibleArea.heightRatio < 1
}
ChatAnchorButtonsPanel {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.rightMargin: Theme.padding
mentionsCount: d.chatDetails ? d.chatDetails.notificationCount : 0
recentMessagesButtonVisible: {
chatLogView.contentY // trigger binding on contentY change
return chatLogView.contentHeight - (d.scrollY + chatLogView.height) > 400
}
onRecentMessagesButtonClicked: chatLogView.positionViewAtBeginning()
onMentionsButtonClicked: {
let id = messageStore.firstUnseenMentionMessageId()
if (id !== "") {
messageStore.jumpToMessage(id)
chatContentModule.markMessageRead(id)
}
}
}
delegate: MessageView {
id: msgDelegate
width: ListView.view.width
objectName: "chatMessageViewDelegate"
sharedRootStore: root.sharedRootStore
utilsStore: root.utilsStore
rootStore: root.rootStore
messageStore: root.messageStore
usersStore: root.usersStore
contactsStore: root.contactsStore
channelEmoji: root.channelEmoji
emojiPopup: root.emojiPopup
stickersPopup: root.stickersPopup
chatLogView: ListView.view
chatContentModule: root.chatContentModule
formatBalance: root.formatBalance
isChatBlocked: root.isChatBlocked
sendViaPersonalChatEnabled: root.sendViaPersonalChatEnabled
areTestNetworksEnabled: root.areTestNetworksEnabled
chatId: root.chatId
messageId: model.id
communityId: model.communityId
responseToMessageWithId: model.responseToMessageWithId
senderId: model.senderId
senderDisplayName: model.senderDisplayName
senderOptionalName: model.senderOptionalName
senderIsEnsVerified: model.senderEnsVerified
senderIcon: model.senderIcon
senderColorHash: model.senderColorHash
senderIsAdded: model.senderIsAdded
senderTrustStatus: model.senderTrustStatus
amISender: model.amISender
messageText: model.messageText
unparsedText: model.unparsedText
messageImage: model.messageImage
album: model.albumMessageImages.split(" ")
albumCount: model.albumImagesCount
messageTimestamp: model.timestamp
messageOutgoingStatus: model.outgoingStatus
resendError: model.resendError
messageContentType: model.contentType
pinnedMessage: model.pinned
messagePinnedBy: model.pinnedBy
reactionsModel: model.reactions
emojiReactionsModel: model.emojiReactionsModel
sticker: model.sticker
stickerPack: model.stickerPack
editModeOn: model.editMode
onEditModeOnChanged: root.editModeChanged(editModeOn)
isEdited: model.isEdited
deleted: model.deleted
deletedBy: model.deletedBy
deletedByContactDisplayName: model.deletedByContactDisplayName
deletedByContactIcon: model.deletedByContactIcon
deletedByContactColorHash: model.deletedByContactColorHash
linkPreviewModel: model.linkPreviewModel
links: model.links
paymentRequestModel: model.paymentRequestModel
messageAttachments: model.messageAttachments
transactionParams: model.transactionParameters
hasMention: model.mentioned
quotedMessageText: model.quotedMessageParsedText
quotedMessageFrom: model.quotedMessageFrom
quotedMessageContentType: model.quotedMessageContentType
quotedMessageDeleted: model.quotedMessageDeleted
quotedMessageAuthorDetailsName: model.quotedMessageAuthorName
quotedMessageAuthorDetailsDisplayName: model.quotedMessageAuthorDisplayName
quotedMessageAuthorDetailsThumbnailImage: model.quotedMessageAuthorThumbnailImage
quotedMessageAuthorDetailsEnsVerified: model.quotedMessageAuthorEnsVerified
quotedMessageAuthorDetailsIsContact: model.quotedMessageAuthorIsContact
quotedMessageAuthorDetailsColorHash: model.quotedMessageAuthorColorHash
quotedMessageAlbumMessageImages: model.quotedMessageAlbumMessageImages.split(" ")
quotedMessageAlbumImagesCount: model.quotedMessageAlbumImagesCount
bridgeName: model.bridgeName
gapFrom: model.gapFrom
gapTo: model.gapTo
// This is possible since we have all data loaded before we load qml.
// When we fetch messages to fulfill a gap we have to set them at once.
// Also one important thing here is that messages are set in descending order
// in terms of `timestamp` of a message, that means a message with the most
// recent time is added at index 0.
prevMessageIndex: model.prevMsgIndex
prevMessageTimestamp: model.prevMsgTimestamp
prevMessageSenderId: model.prevMsgSenderId
prevMessageContentType: model.prevMsgContentType
prevMessageDeleted: model.prevMsgDeleted
nextMessageIndex: model.nextMsgIndex
nextMessageTimestamp: model.nextMsgTimestamp
onOpenStickerPackPopup: {
root.openStickerPackPopup(stickerPackId);
}
onShowReplyArea: {
root.showReplyArea(messageId, author)
}
stickersLoaded: root.stickersLoaded
onVisibleChanged: {
if(!visible && model.editMode)
messageStore.setEditModeOff(model.id)
}
}
header: {
if (!root.isContactBlocked && root.isOneToOne && root.rootStore.oneToOneChatContact) {
switch (root.rootStore.oneToOneChatContact.contactRequestState) {
case Constants.ContactRequestState.None: // no break
case Constants.ContactRequestState.Dismissed:
return sendContactRequestComponent
case Constants.ContactRequestState.Received:
return acceptOrDeclineContactRequestComponent
case Constants.ContactRequestState.Sent:
return pendingContactRequestComponent
default:
break
}
}
return null
}
onHeaderChanged: chatLogView.positionViewAtBeginning()
}
MessageDialog {
property string error
id: sendingMsgFailedPopup
standardButtons: StandardButton.Ok
text: qsTr("Failed to send message.\n" + error)
icon: StandardIcon.Critical
}
Component {
id: sendContactRequestComponent
StatusButton {
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
text: qsTr("Send Contact Request")
onClicked: {
Global.openContactRequestPopup(root.chatId, null)
}
}
}
Component {
id: acceptOrDeclineContactRequestComponent
RowLayout {
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
StatusButton {
text: qsTr("Reject Contact Request")
type: StatusBaseButton.Type.Danger
onClicked: {
root.contactsStore.dismissContactRequest(root.chatId, "")
}
}
StatusButton {
text: qsTr("Accept Contact Request")
onClicked: {
root.contactsStore.acceptContactRequest(root.chatId, "")
}
}
}
}
Component {
id: pendingContactRequestComponent
StatusButton {
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
enabled: false
text: qsTr("Contact Request Pending...")
}
}
}