diff --git a/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift b/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift index ad82c604509..5854fe194ed 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift @@ -26,8 +26,8 @@ public enum WireMessagingAssembly { @MainActor public static func makeConversationScreen( loadMessagesRepo: any (LoadConversationMessagesRepositoryProtocol & MonitorMessagesRepositoryProtocol), - senderNameObserverProvider: SenderNameObserverProvider?, - reactionsObserverProvider: ReactionsObserverProvider? + senderNameObserverProvider: @escaping SenderNameObserverProvider, + reactionsObserverProvider: @escaping ReactionsObserverProvider ) -> UIViewController { ConversationMessagesViewController( viewModel: ConversationMessagesViewModel( diff --git a/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift index ff0854b41e1..54c858feb51 100644 --- a/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift @@ -16,27 +16,38 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import Foundation +public import Foundation public protocol LoadConversationMessagesRepositoryProtocol: Sendable { - func loadMessages(offset: Int, limit: Int) async -> [MessageModel] + var hasOlderMessagesToLoad: Bool { get } + func loadMessages(offset: Int) async -> [MessageModel] + func loadOlderMessages(lastMessageTimestamp: Date) async -> [MessageModel] } -private let kLoadMessagesDefaultBatchSize = 30 +public let kLoadMessagesDefaultBatchSize = 30 package protocol LoadConversationMessagesUseCaseProtocol: Sendable { + var hasOlderMessagesToLoad: Bool { get } func loadMessages(offset: Int) async -> [MessageModel] + func loadOlderMessages(lastMessageTimestamp: Date) async -> [MessageModel] } package final class LoadConversationMessagesUseCase: LoadConversationMessagesUseCaseProtocol { private let repo: any LoadConversationMessagesRepositoryProtocol + + package var hasOlderMessagesToLoad: Bool { repo.hasOlderMessagesToLoad } package init(repo: any LoadConversationMessagesRepositoryProtocol) { self.repo = repo } package func loadMessages(offset: Int) async -> [MessageModel] { - await repo.loadMessages(offset: offset, limit: kLoadMessagesDefaultBatchSize) + await repo.loadMessages(offset: offset) + } + + package func loadOlderMessages(lastMessageTimestamp: Date) async -> [MessageModel] { + await repo.loadOlderMessages(lastMessageTimestamp: lastMessageTimestamp) } + } diff --git a/WireMessaging/Sources/WireMessagingDomain/Conversation/MessageModel.swift b/WireMessaging/Sources/WireMessagingDomain/Conversation/MessageModel.swift index 9b2159822fc..7fb26ae80cf 100644 --- a/WireMessaging/Sources/WireMessagingDomain/Conversation/MessageModel.swift +++ b/WireMessaging/Sources/WireMessagingDomain/Conversation/MessageModel.swift @@ -16,6 +16,8 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // +public import Foundation + public typealias ReactionsModel = [String: [UserModel]] public struct MessageModel: Sendable { @@ -27,17 +29,20 @@ public struct MessageModel: Sendable { } public let objectID: any Sendable + public let serverTimestamp: Date? public let sender: UserModel? public let kind: Kind public let reactions: ReactionsModel public init( objectID: any Sendable, + serverTimestamp: Date?, sender: UserModel?, kind: Kind, reactions: ReactionsModel ) { self.objectID = objectID + self.serverTimestamp = serverTimestamp self.sender = sender self.kind = kind self.reactions = reactions diff --git a/WireMessaging/Sources/WireMessagingDomain/ReactionsObserverProvider.swift b/WireMessaging/Sources/WireMessagingDomain/ReactionsObserverProvider.swift index dfdfa208da7..bb3debe5ab9 100644 --- a/WireMessaging/Sources/WireMessagingDomain/ReactionsObserverProvider.swift +++ b/WireMessaging/Sources/WireMessagingDomain/ReactionsObserverProvider.swift @@ -23,4 +23,4 @@ public protocol ReactionsObserverProtocol { var reactionsPublisher: AnyPublisher? { get } } -public typealias ReactionsObserverProvider = (MessageModel) -> (any ReactionsObserverProtocol)? +public typealias ReactionsObserverProvider = (MessageModel) -> any ReactionsObserverProtocol diff --git a/WireMessaging/Sources/WireMessagingDomain/SenderNamePublisherProvider.swift b/WireMessaging/Sources/WireMessagingDomain/SenderNamePublisherProvider.swift index 02be1e175e5..7fff798f69d 100644 --- a/WireMessaging/Sources/WireMessagingDomain/SenderNamePublisherProvider.swift +++ b/WireMessaging/Sources/WireMessagingDomain/SenderNamePublisherProvider.swift @@ -23,4 +23,4 @@ public protocol SenderNameObserverProtocol { var authorChangedPublisher: AnyPublisher? { get } } -public typealias SenderNameObserverProvider = (UserModel?) -> (any SenderNameObserverProtocol)? +public typealias SenderNameObserverProvider = (UserModel) -> any SenderNameObserverProtocol diff --git a/WireMessaging/Sources/WireMessagingDomainSupport/Sourcery/AutoMockable.manual.swift b/WireMessaging/Sources/WireMessagingDomainSupport/Sourcery/AutoMockable.manual.swift index 05adce3f10e..dda883772b7 100644 --- a/WireMessaging/Sources/WireMessagingDomainSupport/Sourcery/AutoMockable.manual.swift +++ b/WireMessaging/Sources/WireMessagingDomainSupport/Sourcery/AutoMockable.manual.swift @@ -18,7 +18,7 @@ public import WireMessagingDomain -import UIKit +package import UIKit public class MockChannelHistoryUseCaseProtocol: ChannelHistoryUseCaseProtocol { @@ -188,6 +188,16 @@ package class MockLoadConversationMessagesUseCaseProtocol: LoadConversationMessa package init() { } + // MARK: - hasOlderMessagesToLoad + + package var hasOlderMessagesToLoad: Bool { + get { return underlyingHasOlderMessagesToLoad } + set(value) { underlyingHasOlderMessagesToLoad = value } + } + + package var underlyingHasOlderMessagesToLoad: Bool! + + // MARK: - loadMessages package var loadMessagesOffset_Invocations: [Int] = [] @@ -206,6 +216,24 @@ package class MockLoadConversationMessagesUseCaseProtocol: LoadConversationMessa } } + // MARK: - loadOlderMessages + + package var loadOlderMessagesLastMessageTimestamp_Invocations: [Date] = [] + package var loadOlderMessagesLastMessageTimestamp_MockMethod: ((Date) async -> [MessageModel])? + package var loadOlderMessagesLastMessageTimestamp_MockValue: [MessageModel]? + + package func loadOlderMessages(lastMessageTimestamp: Date) async -> [MessageModel] { + loadOlderMessagesLastMessageTimestamp_Invocations.append(lastMessageTimestamp) + + if let mock = loadOlderMessagesLastMessageTimestamp_MockMethod { + return await mock(lastMessageTimestamp) + } else if let mock = loadOlderMessagesLastMessageTimestamp_MockValue { + return mock + } else { + fatalError("no mock for `loadOlderMessagesLastMessageTimestamp`") + } + } + } package class MockMonitorMessagesUseCaseProtocol: MonitorMessagesUseCaseProtocol { diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift index b66b0e87bcc..6ab6a972bde 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift @@ -24,18 +24,14 @@ package import WireMessagingDomain // to UI model which is Attributed string package struct AnyObserverProvider: @unchecked Sendable { - package let senderNameObserverProvider: SenderNameObserverProvider? - package let reactionsObserverProvider: ReactionsObserverProvider? + package let senderNameObserverProvider: SenderNameObserverProvider + package let reactionsObserverProvider: ReactionsObserverProvider package init( - senderNameObserverProvider: SenderNameObserverProvider?, - reactionsObserverProvider: ReactionsObserverProvider? + senderNameObserverProvider: @escaping SenderNameObserverProvider, + reactionsObserverProvider: @escaping ReactionsObserverProvider ) { self.senderNameObserverProvider = senderNameObserverProvider self.reactionsObserverProvider = reactionsObserverProvider } - - func get(for message: MessageModel) -> AnyPublisher? { - reactionsObserverProvider?(message)?.reactionsPublisher - } } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift index 55aaad6eb3b..2430e991be3 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift @@ -20,6 +20,7 @@ import Combine import Foundation package import UIKit package import WireMessagingDomain +package import WireLogging package enum ConversationSection: Sendable { // one section for now, later we'd have probably one section for a day @@ -32,6 +33,8 @@ package protocol ConversationDataSourceProtocol: Sendable { func makeUpdatesStream() async -> AsyncStream func loadInitialMessages() async func reset() async + func loadOlderMessages() async + func loadNewerMessages() async } /// Actor to synchronise access to all that needed to conversation screen @@ -108,18 +111,23 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt } else { .empty } + + var senderNamePublisher: AnyPublisher? + if let sender = model.sender { + senderNamePublisher = observersProvider.senderNameObserverProvider(sender).authorChangedPublisher + } return ConversationElement.text( TextMessageViewModel( content: AttributedString(stringLiteral: textModel.text ?? ""), + serverTimestamp: model.serverTimestamp, senderViewModel: SenderViewModel( state: senderState, - namePublisher: observersProvider - .senderNameObserverProvider?(model.sender)?.authorChangedPublisher + namePublisher: senderNamePublisher ), reactionsViewModel: ReactionsViewModel( state: ReactionsViewModel.state(from: model.reactions), - publisher: observersProvider.get(for: model) + publisher: observersProvider.reactionsObserverProvider(model).reactionsPublisher ) ) ) @@ -143,10 +151,51 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt } // load older messages - func loadOlderMessages() {} + + var loadingMessages = false + var currentOffset = 0 + package func loadOlderMessages() async { + guard !loadingMessages, loadMessagesUseCase.hasOlderMessagesToLoad else { + print("DS: loadOlderMessages: guard already loading") + updatesStreamContinuation?.yield(.noMoreMessagesToLoad) + return + } + + // NOTE: we dispatch async because `didScroll(tableView:)` can be called inside a `performBatchUpdate()`, + // which would cause data source inconsistency if change the fetchLimit. + + guard let oldestMessageTimestamp = snapshot.itemIdentifiers.first(where: { $0.serverTimestamp != nil })?.serverTimestamp else { + print("DS: loadOlderMessages: can't find oldest message") + updatesStreamContinuation?.yield(.noMoreMessagesToLoad) + return + } + + loadingMessages = true + let messages = await loadMessagesUseCase.loadOlderMessages( + lastMessageTimestamp: oldestMessageTimestamp + ) + guard messages.count > 0, let beforeItem = snapshot.itemIdentifiers.first else { + WireLogger.conversation.error( + "Failed to get beforeItem for snapshot.itemIdentifiers.first to insert new loaded messages") + updatesStreamContinuation?.yield(.noMoreMessagesToLoad) + return + } + + snapshot.insertItems( + messages + .reversed() + .map { mapToUIModel($0) }, + beforeItem: beforeItem + ) + updatesStreamContinuation?.yield(.loadedOlderMessages(snapshot)) + + loadingMessages = false + } // load newer messages - func loadNewerMessages() {} + package func loadNewerMessages() { + + } // MARK: - private diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationElement.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationElement.swift index 25d6c6e1514..5a71b0aed0d 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationElement.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationElement.swift @@ -16,11 +16,17 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import Foundation +package import Foundation package enum ConversationElement: Hashable, Sendable { case text(TextMessageViewModel) // case image, video, system, etc + package var serverTimestamp: Date? { + switch self { + case .text(let textMessageViewModel): + return textMessageViewModel.serverTimestamp + } + } } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift index de79c37b3cf..f32d0cc49f7 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift @@ -41,7 +41,8 @@ package final class ConversationMessagesViewController: UIViewController { } private var observeTask: Task? - + private var waitingToLoadToFinish: Bool = false + package override func viewDidLoad() { super.viewDidLoad() @@ -73,6 +74,24 @@ package final class ConversationMessagesViewController: UIViewController { case let .messageAdded(snapshot): await dataSource.apply(snapshot) scrollToLastItem() + case let .loadedOlderMessages(snapshot): + let previousContentHeight = collectionView.contentSize.height + let previousOffset = collectionView.contentOffset.y + + await dataSource.apply(snapshot, animatingDifferences: false) + + let newContentHeight = collectionView.contentSize.height + let heightDifference = newContentHeight - previousContentHeight + let offset = CGPoint( + x: collectionView.contentOffset.x, + y: previousOffset + heightDifference + ) + + print("DS: new offset: \(offset)") + collectionView.contentOffset = offset + waitingToLoadToFinish = false + case .noMoreMessagesToLoad: + waitingToLoadToFinish = false } } } @@ -100,6 +119,8 @@ package final class ConversationMessagesViewController: UIViewController { collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) + + collectionView.delegate = self } private func createLayout() -> UICollectionViewLayout { @@ -158,7 +179,47 @@ package final class ConversationMessagesViewController: UIViewController { } } +extension ConversationMessagesViewController: UICollectionViewDelegate { + + package func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + let scrolledToBottom = ( + collectionView.contentOffset.y + collectionView.bounds.height + ) - collectionView.contentSize.height > 0 + + guard scrolledToBottom else { + print("DS: guard scroll, not yet to bottom") + return + } + print("DS: VC: scrolled to bottom") + viewModel.onScrollToBottom() + } + + package func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.contentOffset.y < 0 else { + print("DS: guard scroll, not yet to top, contentOffset: \(scrollView.contentOffset)") + return + } + guard !waitingToLoadToFinish else { + print("DS: guard on waitingToLoadToFinish, contentOffset: \(scrollView.contentOffset)") + return + } + + print("DS: VC: scrolled to top, contentOffset: \(scrollView.contentOffset)") + waitingToLoadToFinish = true + viewModel.onScrollToTop() + } + + package func scrollViewWillEndDragging(_ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer) { + // Same as in UITableView + print("Will end dragging") + } + +} + import WireMessagingDomainSupport +import WireMessagingDomain private struct ConversationMessagesViewControllerPreview: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> ConversationMessagesViewController { @@ -168,8 +229,8 @@ private struct ConversationMessagesViewControllerPreview: UIViewControllerRepres loadMessagesUseCase: MockLoadConversationMessagesUseCaseProtocol(), monitorMessagesUseCase: MockMonitorMessagesUseCaseProtocol(), observersProvider: AnyObserverProvider( - senderNameObserverProvider: { _ in nil }, - reactionsObserverProvider: { _ in nil } + senderNameObserverProvider: { _ in MockObserversProvider() }, + reactionsObserverProvider: { _ in MockObserversProvider() } ) ) ) @@ -179,6 +240,17 @@ private struct ConversationMessagesViewControllerPreview: UIViewControllerRepres func updateUIViewController(_ uiViewController: ConversationMessagesViewController, context: Context) {} } +struct MockObserversProvider: SenderNameObserverProtocol, ReactionsObserverProtocol { + + var authorChangedPublisher: AnyPublisher? { + Empty().eraseToAnyPublisher() + } + + var reactionsPublisher: AnyPublisher? { + Empty().eraseToAnyPublisher() + } +} + #Preview { ConversationMessagesViewControllerPreview() } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift index e4b86724284..a7ce795a215 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift @@ -23,6 +23,8 @@ package protocol ConversationMessagesViewModelProtocol { func makeUpdatesStream() async -> AsyncStream func onViewReady() func onWillDisappear() + func onScrollToTop() + func onScrollToBottom() } @MainActor @@ -52,4 +54,16 @@ package struct ConversationMessagesViewModel: ConversationMessagesViewModelProto await dataSource.reset() } } + + package func onScrollToTop() { + Task { [dataSource] in + await dataSource.loadOlderMessages() + } + } + + package func onScrollToBottom() { + Task { [dataSource] in + await dataSource.loadNewerMessages() + } + } } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift index 6d3780200ba..b81c5bb9337 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift @@ -22,6 +22,7 @@ package import Foundation package class TextMessageViewModel: ObservableObject, Hashable, @unchecked Sendable { package let id = UUID() + package let serverTimestamp: Date? @Published var content: AttributedString @@ -30,10 +31,12 @@ package class TextMessageViewModel: ObservableObject, Hashable, @unchecked Senda init( content: AttributedString, + serverTimestamp: Date?, senderViewModel: SenderViewModel, reactionsViewModel: ReactionsViewModel ) { self.content = content + self.serverTimestamp = serverTimestamp self.senderViewModel = senderViewModel self.reactionsViewModel = reactionsViewModel } @@ -45,5 +48,10 @@ package class TextMessageViewModel: ObservableObject, Hashable, @unchecked Senda package func hash(into hasher: inout Hasher) { hasher.combine(id) } +} +extension TextMessageViewModel: CustomDebugStringConvertible { + package var debugDescription: String { + "TextMessageViewModel: \(content), serverTimestamp: \(serverTimestamp?.timeIntervalSince1970 ?? 0)" + } } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift index b67bfdef1d1..fcc5671cba5 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift @@ -22,6 +22,9 @@ package enum MessagesUpdate: Sendable { case initiallyLoaded(ConversationSnapshot) case messageAdded(ConversationSnapshot) + case loadedOlderMessages(ConversationSnapshot) + case noMoreMessagesToLoad + // later to be added more updates like: // loaded new messages, new or older // re-sent failed message diff --git a/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift b/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift index f7ae19b2fdd..6a9dcec5ace 100644 --- a/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift +++ b/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift @@ -25,7 +25,7 @@ struct GenerateMessagesRepo: LoadConversationMessagesRepositoryProtocol { } - func loadMessages(offset: Int, limit: Int) async -> [MessageModel] { + func loadMessages(offset: Int) async -> [MessageModel] { let base = "This is a line. " return (0 ..< 7).map { _ in let repeatCount = Int.random(in: 1 ... 5) diff --git a/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift b/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift index e410f8e46b2..9eaaaa8df7d 100644 --- a/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift +++ b/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift @@ -18,6 +18,8 @@ import UIKit import WireMessagingAssembly +import WireMessagingDomain +import Combine class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -39,8 +41,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.rootViewController = UINavigationController( rootViewController: WireMessagingAssembly.makeConversationScreen( loadMessagesRepo: GenerateMessagesRepo(), - senderNameObserverProvider: { _ in nil }, - reactionsObserverProvider: { _ in nil } + senderNameObserverProvider: { _ in MockObserversProvider() }, + reactionsObserverProvider: { _ in MockObserversProvider() } ) ) window?.makeKeyAndVisible() @@ -77,3 +79,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } + +struct MockObserversProvider: SenderNameObserverProtocol, ReactionsObserverProtocol { + var authorChangedPublisher: AnyPublisher? { + Empty().eraseToAnyPublisher() + } + + var reactionsPublisher: AnyPublisher? { + Empty().eraseToAnyPublisher() + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift index d9cb2e55dde..89e6fb39128 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift @@ -463,6 +463,10 @@ final class ConversationTableViewDataSource: NSObject { fetchController?.delegate = self try! fetchController?.performFetch() + print( + "DS: OLD: loadMessages: offset: \(fetchRequest.fetchOffset), limit: \(fetchRequest.fetchLimit), messages count: \(fetchController?.fetchedObjects?.count ?? 0)" + ) + lastFetchedObjectCount = fetchController?.fetchedObjects?.count ?? 0 hasOlderMessagesToLoad = allMessages.count == fetchRequest.fetchLimit hasNewerMessagesToLoad = offset > 0 diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift index 70ce6381b22..507c232df6b 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift @@ -34,14 +34,18 @@ final class LoadConversationMessagesRepository: NSObject, LoadConversationMessag updatesStreamContinuation = continuation return stream }() + + private let batchSize: Int private var conversation: ZMConversation? init( + batchSize: Int, conversationObjectID: NSManagedObjectID, syncContext: NSManagedObjectContext, backgroundContext: NSManagedObjectContext ) { + self.batchSize = batchSize self.conversationObjectID = conversationObjectID self.backgroundContext = backgroundContext self.viewContext = syncContext @@ -72,43 +76,132 @@ final class LoadConversationMessagesRepository: NSObject, LoadConversationMessag } return conversation } + + private func makeFRC( + conversation: ZMConversation, + offset: Int, + limit: Int + ) -> NSFetchedResultsController { + let fetchRequest = fetchRequest(conversation: conversation) + + fetchRequest.fetchLimit = limit + + fetchRequest.fetchOffset = offset + + let fetchController = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: backgroundContext, + sectionNameKeyPath: nil, + cacheName: nil + ) - var fetchController: NSFetchedResultsController? + fetchController.delegate = self + + try! fetchController.performFetch() + return fetchController + } - func loadMessages(offset: Int, limit: Int) async -> [MessageModel] { + var fetchController: NSFetchedResultsController? + var hasOlderMessagesToLoad: Bool = true // fix-me: maybe optional + func loadMessages(offset: Int) async -> [MessageModel] { + // We need to fetch a bit more than requested so that there is overlap between messages in different + let limit = batchSize + 5 guard let conversation = await getConversation() else { WireLogger.conversation.error("Failed to fetch conversation to load more messages") return [] } - + + messagesToMonitorCurrentLimit = limit + messagesToMonitorCurrentOffset = offset + return await backgroundContext.perform { [unowned self] in - let fetchRequest = fetchRequest(conversation: conversation) - - // We need to fetch a bit more than requested so that there is overlap between messages in different - fetchRequest.fetchLimit = limit + 5 - - fetchRequest.fetchOffset = offset - - let fetchController = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: backgroundContext, - sectionNameKeyPath: nil, - cacheName: nil + self.fetchController = makeFRC( + conversation: conversation, + offset: offset, + limit: limit ) + let objects = fetchController?.fetchedObjects ?? [] + print("DS: fetchedObjects count \(objects.count), limit: \(limit), hasOlderMessages: \(hasOlderMessagesToLoad)") + return objects.map { $0.toDomain() } + } + } + + func loadOlderMessages(lastMessageTimestamp: Date) async -> [MessageModel] { + print( + "DS: repo: load older messages: lastMessageTimestamp: \(lastMessageTimestamp.timeIntervalSince1970)" + ) + guard let conversation = await getConversation() else { + WireLogger.conversation.error("Failed to fetch conversation to load more messages") + return [] + } + + do { + let messages = try await backgroundContext.perform { [backgroundContext, batchSize, unowned self] in + let fetchRequest = self.olderThenRequest( + conversation: conversation, + batchSize: batchSize, + lastMessageTimestamp: lastMessageTimestamp + ) + + let result = try backgroundContext.fetch(fetchRequest) + return result.map { $0.toDomain() } + } + let loadedCount = messages.count + hasOlderMessagesToLoad = loadedCount == batchSize + Task { // no need to wait + await recreatedFRCForUpdates( + olderMessagesLoadedCount: loadedCount, + conversation: conversation + ) + } + return messages + } catch { + return [] + } + } + + private var messagesToMonitorCurrentOffset = 0 + private var messagesToMonitorCurrentLimit = 0 + private func recreatedFRCForUpdates( + olderMessagesLoadedCount: Int, + conversation: ZMConversation + ) async { + return await backgroundContext.perform { [unowned self] in + + messagesToMonitorCurrentLimit += olderMessagesLoadedCount + + self.fetchController = makeFRC( + conversation: conversation, + offset: messagesToMonitorCurrentOffset, + limit: messagesToMonitorCurrentLimit + ) + } + } + + private func sortDescriptors() -> [NSSortDescriptor] { + [NSSortDescriptor(key: #keyPath(ZMMessage.serverTimestamp), ascending: false)] + } + + private func olderThenRequest( + conversation: ZMConversation, + batchSize: Int, + lastMessageTimestamp: Date + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: ZMMessage.entityName()) + let validMessage = conversation.visibleMessagesPredicate! - fetchController.delegate = self - self.fetchController = fetchController + let beforeGivenMessage = NSPredicate(format: "%K < %@", ZMMessageServerTimestampKey, lastMessageTimestamp as NSDate) - try! fetchController.performFetch() - return (fetchController.fetchedObjects ?? []) - .map { $0.toDomain() } - } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [validMessage, beforeGivenMessage]) + fetchRequest.sortDescriptors = sortDescriptors() + fetchRequest.fetchLimit = batchSize + return fetchRequest } private func fetchRequest(conversation: ZMConversation) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: ZMMessage.entityName()) fetchRequest.predicate = conversation.visibleMessagesPredicate - fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(ZMMessage.serverTimestamp), ascending: false)] + fetchRequest.sortDescriptors = sortDescriptors() return fetchRequest } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/MessageToDomain.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/MessageToDomain.swift index 94882a3d061..22038a0826e 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/MessageToDomain.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/MessageToDomain.swift @@ -23,6 +23,7 @@ extension ZMMessage { func toDomain() -> MessageModel { MessageModel( objectID: objectID, + serverTimestamp: serverTimestamp, sender: sender?.toDomain(), kind: getMessageKind(), reactions: reactions.toDomain() diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift index fabb3d04355..038431c307a 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift @@ -22,6 +22,7 @@ import WireDesign import WireLogging import WireMainNavigationUI import WireMessagingAssembly +import WireMessagingDomain import WireMessagingUI import WireSyncEngine @@ -157,6 +158,7 @@ final class ConversationViewController: UIViewController { self.exchangeableContentViewController = if DeveloperFlag.chatBubbles.isOn { WireMessagingAssembly.makeConversationScreen( loadMessagesRepo: LoadConversationMessagesRepository( + batchSize: kLoadMessagesDefaultBatchSize, conversationObjectID: conversation.objectID, syncContext: userSession.contextProvider.syncContext, backgroundContext: userSession.contextProvider.newBackgroundContext() @@ -165,8 +167,7 @@ final class ConversationViewController: UIViewController { individualChangesFactory.makeSenderNameObserver(user: model) }, reactionsObserverProvider: { [individualChangesFactory] model in - individualChangesFactory - .makeReactionsObserver(message: model) + individualChangesFactory.makeReactionsObserver(message: model) } ) } else { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift index 790cabebf1f..b5159db7280 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift @@ -30,10 +30,8 @@ final actor MessagesIndividualUpdatesFactory { self.context = context } - func makeSenderNameObserver(user: UserModel?) -> SenderNameObserverProtocol? { - guard let objectID = user?.objectID as? NSManagedObjectID else { - return nil - } + func makeSenderNameObserver(user: UserModel) -> SenderNameObserverProtocol { + let objectID = user.objectID as! NSManagedObjectID if let observer = senderObservers[objectID] { return observer @@ -49,10 +47,8 @@ final actor MessagesIndividualUpdatesFactory { return observer } - func makeReactionsObserver(message: MessageModel) -> ReactionsObserverProtocol? { - guard let objectID = message.objectID as? NSManagedObjectID else { - return nil - } + func makeReactionsObserver(message: MessageModel) -> ReactionsObserverProtocol { + let objectID = message.objectID as! NSManagedObjectID if let observer = messageObservers[objectID] { return observer @@ -66,6 +62,5 @@ final actor MessagesIndividualUpdatesFactory { messageObservers[objectID] = observer return observer - } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/SenderObserver.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/SenderObserver.swift index 013e49da907..3cec46320dc 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/SenderObserver.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/SenderObserver.swift @@ -30,17 +30,14 @@ final class SenderObserver: SenderNameObserverProtocol { userID: NSManagedObjectID, viewContext: NSManagedObjectContext ) { - viewContext.performAndWait { [weak self, viewContext] in - guard let user = try? viewContext.existingObject(with: userID) as? ZMUser else { - return - } - - self?.authorChangedPublisher = NSManagedObject.publisher(for: user, in: viewContext) - .map { $0.name ?? "" } - .removeDuplicates() - .eraseToAnyPublisher() + let user = viewContext.performAndWait { [viewContext] in + try? viewContext.existingObject(with: userID) as? ZMUser } - + guard let user else { return } + self.authorChangedPublisher = NSManagedObject.publisher(for: user, in: viewContext) + .map { $0.name ?? "" } + .removeDuplicates() + .eraseToAnyPublisher() } } @@ -52,17 +49,17 @@ final class ReactionsObserver: ReactionsObserverProtocol { messageID: NSManagedObjectID, viewContext: NSManagedObjectContext ) { - viewContext.performAndWait { [weak self, viewContext] in - guard let message = try? viewContext.existingObject(with: messageID) as? ZMMessage else { - return - } - - self?.reactionsPublisher = NSManagedObject.publisher(for: message, in: viewContext) - .map { - $0.usersReaction.mapValues { $0.map { $0.toDomain() } } - } - .eraseToAnyPublisher() + + let message = viewContext.performAndWait { [viewContext] in + try? viewContext.existingObject(with: messageID) as? ZMMessage } + guard let message else { return } + + self.reactionsPublisher = NSManagedObject.publisher(for: message, in: viewContext) + .map { + $0.usersReaction.mapValues { $0.map { $0.toDomain() } } + } + .eraseToAnyPublisher() } }