diff --git a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift index 6512a40ab..bca0a6e4e 100644 --- a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift +++ b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift @@ -14,31 +14,6 @@ import CoreData public class Chatroom: NSManagedObject, @unchecked Sendable { static let entityName = "Chatroom" - var hasUnread: Bool { - return hasUnreadMessages || (lastTransaction?.isUnread ?? false) - } - - func markAsReaded() { - hasUnreadMessages = false - - if let trs = transactions as? Set { - trs.filter { $0.isUnread }.forEach { $0.isUnread = false } - } - lastTransaction?.isUnread = false - } - - func markAsUnread() { - hasUnreadMessages = true - lastTransaction?.isUnread = true - } - - func getFirstUnread() -> ChatTransaction? { - if let trs = transactions as? Set { - return trs.filter { $0.isUnread }.map { $0 }.first - } - return nil - } - @MainActor func getName(addressBookService: AddressBookService) -> String? { guard let partner = partner else { return nil } let result: String? diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 35d8399a7..389ff4f21 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -207,6 +207,7 @@ final class ChatViewController: MessagesViewController { super.scrollViewDidScroll(scrollView) updateIsScrollPositionNearlyTheBottom() updateScrollDownButtonVisibility() + identifyBottomVisibleMessage() if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating { updateDateHeaderIfNeeded() @@ -712,6 +713,24 @@ private extension ChatViewController { scrollDownButton.isHidden = isScrollPositionNearlyTheBottom } + func identifyBottomVisibleMessage() { + let targetY: CGFloat = view.frame.height - view.safeAreaInsets.bottom - targetYOffsetBottom + let visibleIndexPaths = messagesCollectionView.indexPathsForVisibleItems + + for indexPath in visibleIndexPaths { + guard let cell = messagesCollectionView.cellForItem(at: indexPath) + else { continue } + + let cellRect = messagesCollectionView.convert(cell.frame, to: self.view) + + guard cellRect.maxY >= targetY && cellRect.minY <= targetY + else { continue } + + viewModel.checkBottomMessage(indexPath: indexPath) + break + } + } + func updateDateHeaderIfNeeded() { guard viewAppeared else { return } @@ -1093,3 +1112,4 @@ private let scrollDownButtonInset: CGFloat = 20 private let messagePadding: CGFloat = 12 private let filesToolbarViewHeight: CGFloat = 140 private let targetYOffset: CGFloat = 20 +private let targetYOffsetBottom: CGFloat = 100 diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 3d0ac0587..0eb8c9357 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -410,10 +410,19 @@ final class ChatViewModel: NSObject { Task { guard let chatroom = chatroom, - chatroom.hasUnreadMessages == true || chatroom.lastTransaction?.isUnread == true + let address = chatroom.partner?.address, + let lastTransaction = chatroom.lastTransaction, + await chatsProvider.isUnreadChat(chatroom: chatroom) else { return } - await chatsProvider.markChatAsRead(chatroom: chatroom) + guard let transactions = chatroom.transactions as? Set + else { return } + + await chatsProvider.setLastReadMessage( + height: lastTransaction.height, + transactions: transactions, + chatroom: address + ) } } @@ -1040,6 +1049,36 @@ extension ChatViewModel { hideHeaderTimer = nil } + func checkBottomMessage(indexPath: IndexPath) { + guard let message = messages[safe: indexPath.section], + let transaction = chatTransactions.first( + where: { $0.chatMessageId == message.id } + ) + else { + return + } + + Task { + guard + let address = chatroom?.partner?.address, + let lastReadMessage = await chatsProvider.getLastReadMessage(chatroom: address), + lastReadMessage.height <= transaction.height || transaction.height == .zero + else { + return + } + + await chatsProvider.appendLastReadMessage( + readMessage: .init( + height: transaction.height > .zero + ? transaction.height + : lastReadMessage.height, + transactionsId: [transaction.transactionId] + ), + chatroom: address + ) + } + } + func startHideDateTimer() { hideHeaderTimer?.cancel() hideHeaderTimer = Timer diff --git a/Adamant/Modules/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index c18e87dcf..a1e1c3911 100644 --- a/Adamant/Modules/ChatsList/ChatListViewController.swift +++ b/Adamant/Modules/ChatsList/ChatListViewController.swift @@ -201,6 +201,12 @@ final class ChatListViewController: KeyboardObservingViewController { } } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + tableView.reloadData() + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() @@ -691,7 +697,8 @@ extension ChatListViewController { cell.hasUnreadMessages = chatroom.hasUnreadMessages if let lastTransaction = chatroom.lastTransaction { - cell.hasUnreadMessages = lastTransaction.isUnread + let isUnread = chatsProvider.isUnreadChat(chatroom: chatroom) + cell.hasUnreadMessages = isUnread cell.lastMessageLabel.attributedText = shortDescription(for: lastTransaction) } else { cell.lastMessageLabel.text = nil @@ -1164,14 +1171,42 @@ extension ChatListViewController { let markAsRead = UIContextualAction( style: .normal, title: "👀" - ) { (_, _, completionHandler) in - if chatroom.hasUnread { - chatroom.markAsReaded() - } else { - chatroom.markAsUnread() + ) { [weak self] (_, _, completionHandler) in + guard let self = self else { return } + + Task { @MainActor in + defer { + completionHandler(true) + self.tableView.reloadData() + } + + guard + let address = chatroom.partner?.address, + let lastTransaction = chatroom.lastTransaction + else { + return + } + + let isUnread = await self.chatsProvider.isUnreadChat(chatroom: chatroom) + + guard let transactions = chatroom.transactions as? Set + else { return } + + if isUnread { + await self.chatsProvider.setLastReadMessage( + height: lastTransaction.height, + transactions: transactions, + chatroom: address + ) + return + } + + await self.chatsProvider.setLastReadMessage( + height: lastTransaction.height - 1, + transactions: [], + chatroom: address + ) } - try? chatroom.managedObjectContext?.save() - completionHandler(true) } markAsRead.backgroundColor = UIColor.adamant.contextMenuDefaultBackgroundColor diff --git a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift index 3ba06143a..e4ecf4f9c 100644 --- a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift +++ b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift @@ -209,6 +209,26 @@ protocol ChatsProvider: DataProvider, Actor { /// Unread messages controller. Sections by chatroom. func getUnreadMessagesController() -> NSFetchedResultsController + func setLastReadMessage( + readMessage: ReadMessage, + chatroom: String + ) + + func getLastReadMessage(chatroom: String) -> ReadMessage? + + func isUnreadChat(chatroom: Chatroom) -> Bool + + func appendLastReadMessage( + readMessage: ReadMessage, + chatroom: String + ) + + func setLastReadMessage( + height: Int64, + transactions: Set, + chatroom: String + ) + // ForceUpdate chats func update(notifyState: Bool) async -> ChatsProviderResult? @@ -248,7 +268,6 @@ protocol ChatsProvider: DataProvider, Actor { func validateMessage(_ message: AdamantMessage) -> ValidateMessageResult func blockChat(with address: String) func removeMessage(with id: String) - func markChatAsRead(chatroom: Chatroom) @MainActor func removeChatPositon(for address: String) @MainActor func setChatPositon(for address: String, position: Double?) diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index ebd8ec840..98c4ca3fb 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -12,6 +12,11 @@ import MarkdownKit import Combine import CommonKit +struct ReadMessage: Codable { + let height: Int64 + var transactionsId: Set +} + actor AdamantChatsProvider: ChatsProvider { // MARK: Dependencies @@ -371,6 +376,19 @@ extension AdamantChatsProvider { privateKey: privateKey ) + if !accountService.hasStayInAccount { + chatrooms.chats?.forEach({ chatroom in + guard let lastTransaction = chatroom.lastTransaction else { return } + setLastReadMessage( + readMessage: .init( + height: lastTransaction.height, + transactionsId: [String(lastTransaction.id)] + ), + chatroom: lastTransaction.recipientId + ) + }) + } + if !isInitiallySynced { isInitiallySynced = true preLoadChats(array, address: address) @@ -473,7 +491,7 @@ extension AdamantChatsProvider { } let offset = (offset ?? 0) + messageCount - + let loadedCount = chatLoadedMessages[addressRecipient] ?? 0 chatLoadedMessages[addressRecipient] = loadedCount + messageCount @@ -532,7 +550,7 @@ extension AdamantChatsProvider { let privateKey = accountService.keypair?.privateKey else { return } - + // MARK: 3. Get transactions socketService.connect(address: address) { [weak self] result in @@ -555,6 +573,70 @@ extension AdamantChatsProvider { self.socketService.disconnect() } + func appendLastReadMessage( + readMessage: ReadMessage, + chatroom: String + ) { + var lastReadMessage = getLastReadMessage(chatroom: chatroom) ?? readMessage + + if lastReadMessage.height == readMessage.height { + lastReadMessage.transactionsId.formUnion(readMessage.transactionsId) + } else { + lastReadMessage = readMessage + } + + setLastReadMessage(readMessage: lastReadMessage, chatroom: chatroom) + } + + func setLastReadMessage( + height: Int64, + transactions: Set, + chatroom: String + ) { + let unreadTransactions = transactions.filter { + $0.height == height || $0.height == .zero + }.compactMap { $0.transactionId } + + setLastReadMessage( + readMessage: .init( + height: height, + transactionsId: Set(unreadTransactions) + ), + chatroom: chatroom + ) + } + + func setLastReadMessage( + readMessage: ReadMessage, + chatroom: String + ) { + securedStore.set(readMessage, for: StoreKey.chat.lastReadHeight(for: chatroom)) + } + + func getLastReadMessage(chatroom: String) -> ReadMessage? { + guard let result: ReadMessage = securedStore.get(StoreKey.chat.lastReadHeight(for: chatroom)) + else { + return nil + } + return result + } + + func isUnreadChat(chatroom: Chatroom) -> Bool { + guard + let address = chatroom.partner?.address, + let lastTransaction = chatroom.lastTransaction, + let lastReadMessage = getLastReadMessage(chatroom: address) else { + return true + } + + if lastReadMessage.height == lastTransaction.height + || lastTransaction.height == .zero { + return !lastReadMessage.transactionsId.contains(lastTransaction.transactionId) + } + + return lastReadMessage.height < lastTransaction.height + } + func update(notifyState: Bool) async -> ChatsProviderResult? { // MARK: 1. Check state guard isInitiallySynced, @@ -1805,22 +1887,6 @@ extension AdamantChatsProvider { } } - // MARK: 4. Unread messagess - if let readedLastHeight = readedLastHeight { - var unreadTransactions = newMessageTransactions.filter { $0.height > readedLastHeight } - if unreadTransactions.count == 0 { - unreadTransactions = newMessageTransactions.filter { $0.height == 0 } - } - let chatrooms = Dictionary(grouping: unreadTransactions, by: ({ (t: ChatTransaction) -> Chatroom in t.chatroom! })) - for (chatroom, trs) in chatrooms { - if let address = chatroom.partner?.address { - chatroom.isHidden = self.blockList.contains(address) - } - chatroom.hasUnreadMessages = true - trs.forEach { $0.isUnread = true } - } - } - // MARK: 5. Dump new transactions if privateContext.hasChanges { do { @@ -1954,13 +2020,6 @@ extension AdamantChatsProvider { } } - func markChatAsRead(chatroom: Chatroom) { - chatroom.managedObjectContext?.perform { - chatroom.markAsReaded() - try? chatroom.managedObjectContext?.save() - } - } - private func onConnectionToTheInternetRestored() { onConnectionToTheInternetRestoredTasks.forEach { $0() } onConnectionToTheInternetRestoredTasks = [] diff --git a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift index 91cd2608a..51c5a9bab 100644 --- a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift +++ b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift @@ -66,6 +66,12 @@ public extension StoreKey { public static let autoDownloadFullMedia = "autoDownloadFullMediaEnabled" public static let saveFileEncrypted = "saveFileEncrypted" } + + enum chat { + public static func lastReadHeight(for chatRoom: String) -> String { + "lastReadHeight\(chatRoom)" + } + } } public protocol SecuredStore: AnyObject, Sendable {