diff --git a/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift b/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift index e40f06afcac..a3ad658fc38 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift @@ -19,18 +19,21 @@ public import UIKit import WireMessagingUI public import WireMessagingDomain +import Combine public enum WireMessagingAssembly { @MainActor public static func makeConversationScreen( - loadMessagesRepo: any (LoadConversationMessagesRepositoryProtocol & MonitorMessagesRepositoryProtocol) + loadMessagesRepo: any (LoadConversationMessagesRepositoryProtocol & MonitorMessagesRepositoryProtocol), + senderNameObserverProvider: SenderNameObserverProvider? ) -> UIViewController { ConversationMessagesViewController( viewModel: ConversationMessagesViewModel( - dataSource: ConversationMessagesDataSource( + dataSource: ConversationDataSource( loadMessagesUseCase: LoadConversationMessagesUseCase(repo: loadMessagesRepo), - monitorMessagesUseCase: MonitorMessagesUseCase(repo: loadMessagesRepo) + monitorMessagesUseCase: MonitorMessagesUseCase(repo: loadMessagesRepo), + senderNameObserverProvider: AnySenderNameObserverProvider(senderNameObserverProvider) ) ) ) diff --git a/WireMessaging/Sources/WireMessagingDomain/Conversation/UserModel.swift b/WireMessaging/Sources/WireMessagingDomain/Conversation/UserModel.swift index 9d7f3df5bed..42a208c2f22 100644 --- a/WireMessaging/Sources/WireMessagingDomain/Conversation/UserModel.swift +++ b/WireMessaging/Sources/WireMessagingDomain/Conversation/UserModel.swift @@ -18,16 +18,26 @@ public import Foundation +// To be refined later public struct UserModel: Sendable { - // To be refined later + + // 'objectID' to abstract id from data layer hide behind abstract 'any Sendable' + // used as a way to map domain models back to data models + public let objectID: any Sendable + public let remoteIdentifier: UUID public let name: String? public let handle: String? - public init(remoteIdentifier: UUID, name: String?, handle: String?) { + public init( + objectID: any Sendable, + remoteIdentifier: UUID, + name: String?, + handle: String? + ) { self.remoteIdentifier = remoteIdentifier self.name = name self.handle = handle + self.objectID = objectID } - } diff --git a/WireMessaging/Sources/WireMessagingDomain/SenderNamePublisherProvider.swift b/WireMessaging/Sources/WireMessagingDomain/SenderNamePublisherProvider.swift new file mode 100644 index 00000000000..02be1e175e5 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/SenderNamePublisherProvider.swift @@ -0,0 +1,26 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation +public import Combine + +public protocol SenderNameObserverProtocol { + var authorChangedPublisher: AnyPublisher? { get } +} + +public typealias SenderNameObserverProvider = (UserModel?) -> (any SenderNameObserverProtocol)? diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageCollectionViewCell.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift similarity index 53% rename from WireMessaging/Sources/WireMessagingUI/Conversation/MessageCollectionViewCell.swift rename to WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift index cfca4c6c3e8..ca9f466b97c 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageCollectionViewCell.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift @@ -16,25 +16,22 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import SwiftUI -import UIKit +package import WireMessagingDomain -class MessageCollectionViewCell: UICollectionViewCell { +// Need to be wrapped to type eraser as @unchecked Sendable to be able to pass to datasource actor +// also performs mapping of domain model which is just raw string +// to UI model which is Attributed string +package struct AnySenderNameObserverProvider: @unchecked Sendable { - // reuse identifier for each message type - // one for now, later will be improved - static let reuseIdentifier = "MessageCollectionViewCell" + private var observerProvider: SenderNameObserverProvider? - var messageType: MessageType? { - didSet { - guard let messageType else { return } - switch messageType { - case let .text(viewModel): - let config = UIHostingConfiguration { - TextMessageView(viewModel: viewModel) - } - contentConfiguration = config - } - } + package init( + _ observerProvider: SenderNameObserverProvider? + ) { + self.observerProvider = observerProvider + } + + func get(for model: UserModel?) -> (any SenderNameObserverProtocol)? { + observerProvider?(model) } } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesDataSource.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift similarity index 77% rename from WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesDataSource.swift rename to WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift index cabc97a994b..6610cf907e2 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesDataSource.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift @@ -16,31 +16,32 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // +import Combine import Foundation package import UIKit package import WireMessagingDomain -package enum MessagesSection: Sendable { +package enum ConversationSection: Sendable { // one section for now, later we'd have probably one section for a day case main } -package typealias MessagesSnapshot = NSDiffableDataSourceSnapshot +package typealias ConversationSnapshot = NSDiffableDataSourceSnapshot -package protocol ConversationMessagesDataSourceProtocol: Sendable { - func updatesStream() async -> AsyncStream +package protocol ConversationDataSourceProtocol: Sendable { + func makeUpdatesStream() async -> AsyncStream func loadInitialMessages() async func reset() async } /// Actor to synchronise access to all that needed to conversation screen /// Does all calculations in background -package actor ConversationMessagesDataSource: @preconcurrency ConversationMessagesDataSourceProtocol { +package actor ConversationDataSource: @preconcurrency ConversationDataSourceProtocol { // AsyncStream because Combine's AnyPublisher is not Sendable // As it's a stream, has to be one subscriber only private var updatesStreamContinuation: AsyncStream.Continuation? - package func updatesStream() async -> AsyncStream { + package func makeUpdatesStream() async -> AsyncStream { let (stream, continuation) = AsyncStream.makeStream(of: MessagesUpdate.self) updatesStreamContinuation = continuation return stream @@ -48,20 +49,22 @@ package actor ConversationMessagesDataSource: @preconcurrency ConversationMessag private let loadMessagesUseCase: any LoadConversationMessagesUseCaseProtocol private let monitorMessagesUseCase: any MonitorMessagesUseCaseProtocol + private let senderNameObserverProvider: AnySenderNameObserverProvider // here on later stages will be injected uses cases and // provider to ask for publishers needed for View Models package init( loadMessagesUseCase: any LoadConversationMessagesUseCaseProtocol, - monitorMessagesUseCase: any MonitorMessagesUseCaseProtocol + monitorMessagesUseCase: any MonitorMessagesUseCaseProtocol, + senderNameObserverProvider: AnySenderNameObserverProvider ) { self.loadMessagesUseCase = loadMessagesUseCase self.monitorMessagesUseCase = monitorMessagesUseCase + self.senderNameObserverProvider = senderNameObserverProvider } // store cached message view models - private var messages: [MessageType] = [] - private var snapshot = MessagesSnapshot() + private var snapshot = ConversationSnapshot() private var observeTask: Task? @@ -76,7 +79,11 @@ package actor ConversationMessagesDataSource: @preconcurrency ConversationMessag let messages = await loadMessagesUseCase.loadMessages(offset: 0) snapshot.appendSections([.main]) - snapshot.appendItems(messages.reversed().toUIModel()) + snapshot.appendItems( + messages + .reversed() + .map { mapToUIModel($0) } + ) updatesStreamContinuation?.yield(.initiallyLoaded(snapshot)) subscribeToNotifications() @@ -86,13 +93,36 @@ package actor ConversationMessagesDataSource: @preconcurrency ConversationMessag for await event in monitorMessagesUseCase.messagesUpdatesStream { switch event { case let .inserted(model): - let uiModel = model.toUIModel() + let uiModel = mapToUIModel(model) snapshot.appendItems([uiModel]) updatesStreamContinuation?.yield(.messageAdded(snapshot)) } } } + private func mapToUIModel(_ model: MessageModel) -> ConversationElement { + switch model.kind { + case let .text(textModel): + let senderState: SenderViewModel.State = if let name = model.sender?.name { + .exists(AttributedString(stringLiteral: name)) + } else { + .empty + } + return ConversationElement.text( + TextMessageViewModel( + content: AttributedString(stringLiteral: textModel.text ?? ""), + senderViewModel: SenderViewModel( + state: senderState, + namePublisher: senderNameObserverProvider + .get(for: model.sender)?.authorChangedPublisher + ) + ) + ) + default: fatalError() + } + + } + package func reset() async { // Need to be called to clean up subscription and avoid memory leak observeTask?.cancel() @@ -138,29 +168,3 @@ package actor ConversationMessagesDataSource: @preconcurrency ConversationMessag } } - -extension [MessageModel] { - func toUIModel() -> [MessageType] { - map { $0.toUIModel() } - } -} - -extension MessageModel { - func toUIModel() -> MessageType { - switch kind { - case let .text(textModel): - let senderState: SenderViewModel.State = if let name = sender?.name { - .exists(AttributedString(stringLiteral: name)) - } else { - .empty - } - return MessageType.text( - TextMessageViewModel( - content: AttributedString(stringLiteral: textModel.text ?? ""), - senderViewModel: SenderViewModel(state: senderState) - ) - ) - default: fatalError() - } - } -} diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageType.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationElement.swift similarity index 93% rename from WireMessaging/Sources/WireMessagingUI/Conversation/MessageType.swift rename to WireMessaging/Sources/WireMessagingUI/Conversation/ConversationElement.swift index 57f219f7129..25d6c6e1514 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageType.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationElement.swift @@ -18,7 +18,7 @@ import Foundation -package enum MessageType: Hashable, Sendable { +package enum ConversationElement: Hashable, Sendable { case text(TextMessageViewModel) // case image, video, system, etc diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift index 64f27b61789..6f2b650acbd 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift @@ -21,7 +21,7 @@ package import SwiftUI package final class ConversationMessagesViewController: UIViewController { - typealias DataSource = UICollectionViewDiffableDataSource + typealias DataSource = UICollectionViewDiffableDataSource let viewModel: any ConversationMessagesViewModelProtocol @@ -64,7 +64,7 @@ package final class ConversationMessagesViewController: UIViewController { } private func observeUpdates() async { - let stream = await viewModel.updatesStream() + let stream = await viewModel.makeUpdatesStream() for await update in stream { switch update { case let .initiallyLoaded(snapshot): @@ -138,7 +138,7 @@ package final class ConversationMessagesViewController: UIViewController { } } - private func setContent(cell: UICollectionViewCell, message: MessageType) { + private func setContent(cell: UICollectionViewCell, message: ConversationElement) { switch message { case let .text(viewModel): let config = UIHostingConfiguration { @@ -164,9 +164,12 @@ private struct ConversationMessagesViewControllerPreview: UIViewControllerRepres func makeUIViewController(context: Context) -> ConversationMessagesViewController { ConversationMessagesViewController( viewModel: ConversationMessagesViewModel( - dataSource: ConversationMessagesDataSource( + dataSource: ConversationDataSource( loadMessagesUseCase: MockLoadConversationMessagesUseCaseProtocol(), - monitorMessagesUseCase: MockMonitorMessagesUseCaseProtocol() + monitorMessagesUseCase: MockMonitorMessagesUseCaseProtocol(), + senderNameObserverProvider: AnySenderNameObserverProvider { _ in + nil + } ) ) ) diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift index d2aedd52b16..e4b86724284 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewModel.swift @@ -20,7 +20,7 @@ import Foundation @MainActor package protocol ConversationMessagesViewModelProtocol { - func updatesStream() async -> AsyncStream + func makeUpdatesStream() async -> AsyncStream func onViewReady() func onWillDisappear() } @@ -31,14 +31,14 @@ package protocol ConversationMessagesViewModelProtocol { // since DataSource is actor and works on background thread package struct ConversationMessagesViewModel: ConversationMessagesViewModelProtocol { - private let dataSource: any ConversationMessagesDataSourceProtocol + private let dataSource: any ConversationDataSourceProtocol - package init(dataSource: any ConversationMessagesDataSourceProtocol) { + package init(dataSource: any ConversationDataSourceProtocol) { self.dataSource = dataSource } - package func updatesStream() async -> AsyncStream { - await dataSource.updatesStream() + package func makeUpdatesStream() async -> AsyncStream { + await dataSource.makeUpdatesStream() } package func onViewReady() { diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderViewModel.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderViewModel.swift index a509909a9d4..5929992667d 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderViewModel.swift @@ -16,18 +16,27 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import Foundation +package import Foundation +package import Combine -class SenderViewModel: ObservableObject { +package class SenderViewModel: ObservableObject { - enum State { + package enum State { case empty case exists(AttributedString) } @Published var state: State - init(state: State) { + private var cancellables: Set = [] + + package init( + state: State, + namePublisher: AnyPublisher? + ) { self.state = state + namePublisher?.sink { [weak self] name in + self?.state = .exists(AttributedString(name)) + }.store(in: &cancellables) } } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift index 84d15daeb2a..b67bfdef1d1 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/MessageUpdateType.swift @@ -20,8 +20,8 @@ import Foundation package enum MessagesUpdate: Sendable { - case initiallyLoaded(MessagesSnapshot) - case messageAdded(MessagesSnapshot) + case initiallyLoaded(ConversationSnapshot) + case messageAdded(ConversationSnapshot) // later to be added more updates like: // loaded new messages, new or older // re-sent failed message 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 c2cf9648cde..165bdbb74c7 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift @@ -173,6 +173,7 @@ extension ZMMessage { extension ZMUser { func toDomain() -> UserModel { UserModel( + objectID: objectID, remoteIdentifier: remoteIdentifier, name: name, handle: handle diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift index 5817830a0e9..d0c0420c674 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift @@ -133,6 +133,8 @@ final class ConversationViewController: UIViewController { return UINavigationController(rootViewController: viewController) } + private let individualChangesFactory: MessagesIndividualUpdatesFactory + required init( conversation: ZMConversation, visibleMessage: ZMMessage?, @@ -150,13 +152,20 @@ final class ConversationViewController: UIViewController { self.userSession = userSession self.mainCoordinator = mainCoordinator self.selfProfileUIBuilder = selfProfileUIBuilder + + self.individualChangesFactory = MessagesIndividualUpdatesFactory( + context: userSession.contextProvider.viewContext + ) self.exchangeableContentViewController = if DeveloperFlag.chatBubbles.isOn { WireMessagingAssembly.makeConversationScreen( loadMessagesRepo: LoadConversationMessagesRepository( conversationObjectID: conversation.objectID, syncContext: userSession.contextProvider.syncContext, backgroundContext: userSession.contextProvider.newBackgroundContext() - ) + ), + senderNameObserverProvider: { [individualChangesFactory] model in + individualChangesFactory.makeSenderNameObserver(user: model) + } ) } else { ConversationContentViewController( diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift new file mode 100644 index 00000000000..39b5eaa7cf3 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift @@ -0,0 +1,50 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation +import WireDataModel +import WireMessagingDomain + +final actor MessagesIndividualUpdatesFactory { + + private let context: NSManagedObjectContext + private var dict = [NSManagedObjectID: SenderObserver]() + + init(context: NSManagedObjectContext) { + self.context = context + } + + func makeSenderNameObserver(user: UserModel?) -> SenderNameObserverProtocol? { + guard let objectID = user?.objectID as? NSManagedObjectID else { + return nil + } + + if let observer = dict[objectID] { + return observer + } + + let observer = SenderObserver( + userID: objectID, + viewContext: context + ) + + dict[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 new file mode 100644 index 00000000000..443bfd0ce08 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/SenderObserver.swift @@ -0,0 +1,68 @@ +// +// Wire +// Copyright (C) 2025 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Combine +import WireDataModel +import WireMessagingDomain + +final class SenderObserver: SenderNameObserverProtocol { + + var authorChangedPublisher: AnyPublisher? + + // later to add publisher for avatar + + init( + 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() + } + + } +} + +extension NSManagedObject { + static func publisher( + for managedObject: T, + in context: NSManagedObjectContext + ) -> AnyPublisher { + NotificationCenter.default.publisher( + for: NSManagedObjectContext.didMergeChangesObjectIDsNotification, + object: context + ) + .compactMap { notification in + if let updated = notification.userInfo?[NSUpdatedObjectIDsKey] as? Set, + updated.contains(managedObject.objectID), + let updatedObject = context.object(with: managedObject.objectID) as? T { + updatedObject + } else { + nil + } + } + .eraseToAnyPublisher() + } + +}