diff --git a/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift b/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift index a3ad658fc38..ad82c604509 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/ConversationsAssembly.swift @@ -26,14 +26,18 @@ public enum WireMessagingAssembly { @MainActor public static func makeConversationScreen( loadMessagesRepo: any (LoadConversationMessagesRepositoryProtocol & MonitorMessagesRepositoryProtocol), - senderNameObserverProvider: SenderNameObserverProvider? + senderNameObserverProvider: SenderNameObserverProvider?, + reactionsObserverProvider: ReactionsObserverProvider? ) -> UIViewController { ConversationMessagesViewController( viewModel: ConversationMessagesViewModel( dataSource: ConversationDataSource( loadMessagesUseCase: LoadConversationMessagesUseCase(repo: loadMessagesRepo), monitorMessagesUseCase: MonitorMessagesUseCase(repo: loadMessagesRepo), - senderNameObserverProvider: AnySenderNameObserverProvider(senderNameObserverProvider) + observersProvider: AnyObserverProvider( + senderNameObserverProvider: senderNameObserverProvider, + reactionsObserverProvider: reactionsObserverProvider + ) ) ) ) diff --git a/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift index acc8392ba99..ff0854b41e1 100644 --- a/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/Conversation/LoadConversationMessagesUseCase.swift @@ -22,7 +22,7 @@ public protocol LoadConversationMessagesRepositoryProtocol: Sendable { func loadMessages(offset: Int, limit: Int) async -> [MessageModel] } -private let kLoadMessagesDefaultBatchSize = 30 // Magic number: amount of messages per screen (upper bound). +private let kLoadMessagesDefaultBatchSize = 30 package protocol LoadConversationMessagesUseCaseProtocol: Sendable { func loadMessages(offset: Int) async -> [MessageModel] diff --git a/WireMessaging/Sources/WireMessagingDomain/Conversation/MessageModel.swift b/WireMessaging/Sources/WireMessagingDomain/Conversation/MessageModel.swift index cfab5278531..9b2159822fc 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 typealias ReactionsModel = [String: [UserModel]] + public struct MessageModel: Sendable { public enum Kind: Sendable { @@ -24,12 +26,25 @@ public struct MessageModel: Sendable { case text(TextMessageModel) } + public let objectID: any Sendable public let sender: UserModel? public let kind: Kind + public let reactions: ReactionsModel - public init(sender: UserModel?, kind: Kind) { + public init( + objectID: any Sendable, + sender: UserModel?, + kind: Kind, + reactions: ReactionsModel + ) { + self.objectID = objectID self.sender = sender self.kind = kind + self.reactions = reactions + } + + public func hasReactions() -> Bool { + reactions.hasReactions() } } @@ -45,3 +60,11 @@ public struct TextMessageModel: Sendable { self.text = text } } + +public extension ReactionsModel { + func hasReactions() -> Bool { + contains { _, users in + !users.isEmpty + } + } +} diff --git a/WireMessaging/Sources/WireMessagingDomain/ReactionsObserverProvider.swift b/WireMessaging/Sources/WireMessagingDomain/ReactionsObserverProvider.swift new file mode 100644 index 00000000000..dfdfa208da7 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/ReactionsObserverProvider.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 ReactionsObserverProtocol { + var reactionsPublisher: AnyPublisher? { get } +} + +public typealias ReactionsObserverProvider = (MessageModel) -> (any ReactionsObserverProtocol)? diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift index ca9f466b97c..b66b0e87bcc 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/AnySenderNameObserverProvider.swift @@ -16,22 +16,26 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // +import Combine package import WireMessagingDomain // 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 { +package struct AnyObserverProvider: @unchecked Sendable { - private var observerProvider: SenderNameObserverProvider? + package let senderNameObserverProvider: SenderNameObserverProvider? + package let reactionsObserverProvider: ReactionsObserverProvider? package init( - _ observerProvider: SenderNameObserverProvider? + senderNameObserverProvider: SenderNameObserverProvider?, + reactionsObserverProvider: ReactionsObserverProvider? ) { - self.observerProvider = observerProvider + self.senderNameObserverProvider = senderNameObserverProvider + self.reactionsObserverProvider = reactionsObserverProvider } - func get(for model: UserModel?) -> (any SenderNameObserverProtocol)? { - observerProvider?(model) + 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 6610cf907e2..55aaad6eb3b 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationDataSource.swift @@ -49,18 +49,18 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt private let loadMessagesUseCase: any LoadConversationMessagesUseCaseProtocol private let monitorMessagesUseCase: any MonitorMessagesUseCaseProtocol - private let senderNameObserverProvider: AnySenderNameObserverProvider + private let observersProvider: AnyObserverProvider // 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, - senderNameObserverProvider: AnySenderNameObserverProvider + observersProvider: AnyObserverProvider ) { self.loadMessagesUseCase = loadMessagesUseCase self.monitorMessagesUseCase = monitorMessagesUseCase - self.senderNameObserverProvider = senderNameObserverProvider + self.observersProvider = observersProvider } // store cached message view models @@ -108,13 +108,18 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt } else { .empty } + return ConversationElement.text( TextMessageViewModel( content: AttributedString(stringLiteral: textModel.text ?? ""), senderViewModel: SenderViewModel( state: senderState, - namePublisher: senderNameObserverProvider - .get(for: model.sender)?.authorChangedPublisher + namePublisher: observersProvider + .senderNameObserverProvider?(model.sender)?.authorChangedPublisher + ), + reactionsViewModel: ReactionsViewModel( + state: ReactionsViewModel.state(from: model.reactions), + publisher: observersProvider.get(for: model) ) ) ) @@ -158,8 +163,6 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt // MARK: - Handle notifications about something changed - // here will be subscribed to any messages updates notifications - // and start processing them private func subscribeToNotifications() { observeTask = Task { [weak self] in guard let self else { return } diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift index 6f2b650acbd..de79c37b3cf 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/ConversationMessagesViewController.swift @@ -167,9 +167,10 @@ private struct ConversationMessagesViewControllerPreview: UIViewControllerRepres dataSource: ConversationDataSource( loadMessagesUseCase: MockLoadConversationMessagesUseCaseProtocol(), monitorMessagesUseCase: MockMonitorMessagesUseCaseProtocol(), - senderNameObserverProvider: AnySenderNameObserverProvider { _ in - nil - } + observersProvider: AnyObserverProvider( + senderNameObserverProvider: { _ in nil }, + reactionsObserverProvider: { _ in nil } + ) ) ) ) diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/Reactions/ReactionsViewModel.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/Reactions/ReactionsViewModel.swift new file mode 100644 index 00000000000..9f0783c2a3b --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/Reactions/ReactionsViewModel.swift @@ -0,0 +1,47 @@ +// +// 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 Foundation +import WireMessagingDomain + +class ReactionsViewModel: ObservableObject { + + package enum State { + case empty + case exists(ReactionsModel) + } + + @Published var state: State + + private var cancellables: Set = [] + + init( + state: State, + publisher: AnyPublisher? + ) { + self.state = state + publisher?.sink { [weak self] reactions in + self?.state = Self.state(from: reactions) + }.store(in: &cancellables) + } + + static func state(from reactions: ReactionsModel) -> State { + reactions.hasReactions() ? .exists(reactions) : .empty + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/Reactions/RectionsView.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/Reactions/RectionsView.swift new file mode 100644 index 00000000000..528cac028f8 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/Reactions/RectionsView.swift @@ -0,0 +1,59 @@ +// +// 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 SwiftUI + +struct ReactionsView: View { + + @ObservedObject var viewModel: ReactionsViewModel + + var body: some View { + HStack(spacing: 0) { + switch viewModel.state { + case .empty: + EmptyView() + case let .exists(reactions): + Text(reactions.map { "\($0.0): \($0.1.count)" + }.joined(separator: " ")) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + } +} + +import WireMessagingDomain + +#Preview { + ReactionsView( + viewModel: .init( + state: .exists( + ["😂": [ + UserModel( + objectID: UUID(), + remoteIdentifier: UUID(), + name: "User", + handle: "@handle" + ) + ]] + ), + publisher: nil + ) + ) +} diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderMessageView.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderMessageView.swift index 55d47a73790..7c455864023 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderMessageView.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/SenderView/SenderMessageView.swift @@ -20,11 +20,11 @@ import SwiftUI struct SenderMessageView: View { - @ObservedObject var model: SenderViewModel + @ObservedObject var viewModel: SenderViewModel var body: some View { HStack(spacing: 0) { - switch model.state { + switch viewModel.state { case .empty: EmptyView() case let .exists(name): diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageView.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageView.swift index 20b932b3990..d6ca2c310e4 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageView.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageView.swift @@ -25,9 +25,11 @@ struct TextMessageView: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - SenderMessageView(model: viewModel.senderViewModel) + SenderMessageView(viewModel: viewModel.senderViewModel) text + + ReactionsView(viewModel: viewModel.reactionsViewModel) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) diff --git a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift index 03e90379f73..6d3780200ba 100644 --- a/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/Conversation/Message Views/TextMessageView/TextMessageViewModel.swift @@ -26,13 +26,16 @@ package class TextMessageViewModel: ObservableObject, Hashable, @unchecked Senda @Published var content: AttributedString @Published var senderViewModel: SenderViewModel + @Published var reactionsViewModel: ReactionsViewModel init( content: AttributedString, - senderViewModel: SenderViewModel + senderViewModel: SenderViewModel, + reactionsViewModel: ReactionsViewModel ) { self.content = content self.senderViewModel = senderViewModel + self.reactionsViewModel = reactionsViewModel } package static func == (lhs: TextMessageViewModel, rhs: TextMessageViewModel) -> Bool { diff --git a/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift b/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift index b8d55b936af..f7ae19b2fdd 100644 --- a/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift +++ b/WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift @@ -21,18 +21,27 @@ import WireMessagingDomain struct GenerateMessagesRepo: LoadConversationMessagesRepositoryProtocol { + var messagesUpdatesStream: AsyncStream = AsyncStream { _ in + + } + func loadMessages(offset: Int, limit: Int) async -> [MessageModel] { let base = "This is a line. " return (0 ..< 7).map { _ in let repeatCount = Int.random(in: 1 ... 5) return MessageModel( + objectID: UUID(), sender: .init( + objectID: UUID(), remoteIdentifier: .init(), name: "Sender", handle: nil ), - kind: .text(.init(text: String(repeating: base, count: repeatCount))) + kind: .text(.init(text: String(repeating: base, count: repeatCount))), + reactions: [:] ) } } } + +extension GenerateMessagesRepo: MonitorMessagesRepositoryProtocol {} diff --git a/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift b/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift index 2e5f015bf4d..e410f8e46b2 100644 --- a/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift +++ b/WirePreviewApps/WIreChatBubbles/SceneDelegate.swift @@ -38,7 +38,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window = UIWindow(windowScene: windowScene) window?.rootViewController = UINavigationController( rootViewController: WireMessagingAssembly.makeConversationScreen( - loadMessagesRepo: GenerateMessagesRepo() + loadMessagesRepo: GenerateMessagesRepo(), + senderNameObserverProvider: { _ in nil }, + reactionsObserverProvider: { _ in nil } ) ) window?.makeKeyAndVisible() diff --git a/wire-ios-data-model/Source/Model/Reaction/Reaction.swift b/wire-ios-data-model/Source/Model/Reaction/Reaction.swift index 4383443b2e8..1598c0b99c4 100644 --- a/wire-ios-data-model/Source/Model/Reaction/Reaction.swift +++ b/wire-ios-data-model/Source/Model/Reaction/Reaction.swift @@ -36,9 +36,9 @@ public enum TransportReaction: UInt32 { @objcMembers open class Reaction: ZMManagedObject { - @NSManaged var unicodeValue: String? + @NSManaged public var unicodeValue: String? @NSManaged var message: ZMMessage? - @NSManaged var users: Set + @NSManaged public var users: Set @NSManaged private var firstReactionDate: Date? public var creationDate: Date { 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 165bdbb74c7..70ce6381b22 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/LoadConversationMessagesRepository.swift @@ -18,6 +18,7 @@ @preconcurrency import CoreData @preconcurrency import WireDataModel +import WireLogging import WireMessagingDomain final class LoadConversationMessagesRepository: NSObject, LoadConversationMessagesRepositoryProtocol, @@ -76,6 +77,7 @@ final class LoadConversationMessagesRepository: NSObject, LoadConversationMessag func loadMessages(offset: Int, limit: Int) async -> [MessageModel] { guard let conversation = await getConversation() else { + WireLogger.conversation.error("Failed to fetch conversation to load more messages") return [] } @@ -147,36 +149,10 @@ extension LoadConversationMessagesRepository: NSFetchedResultsControllerDelegate for changeType: NSFetchedResultsChangeType ) { // no-op + print("DS: didChange sectionInfo") } func controllerDidChangeContent(_ controller: NSFetchedResultsController) { // no-op } } - -extension ZMMessage { - func toDomain() -> MessageModel { - MessageModel( - sender: sender?.toDomain(), - kind: getMessageKind() - ) - } - - func getMessageKind() -> MessageModel.Kind { - if let textMessageData { - return .text(TextMessageModel(text: textMessageData.messageText)) - } - return .text(TextMessageModel(text: "Not supported message type yet")) - } -} - -extension ZMUser { - func toDomain() -> UserModel { - UserModel( - objectID: objectID, - remoteIdentifier: remoteIdentifier, - name: name, - handle: handle - ) - } -} 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 new file mode 100644 index 00000000000..94882a3d061 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/MessageToDomain.swift @@ -0,0 +1,49 @@ +// +// 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 WireDataModel +import WireMessagingDomain + +extension ZMMessage { + func toDomain() -> MessageModel { + MessageModel( + objectID: objectID, + sender: sender?.toDomain(), + kind: getMessageKind(), + reactions: reactions.toDomain() + ) + } + + func getMessageKind() -> MessageModel.Kind { + if let textMessageData { + return .text(TextMessageModel(text: textMessageData.messageText)) + } + return .text(TextMessageModel(text: "Not supported message type yet")) + } +} + +extension Collection { + func toDomain() -> ReactionsModel { + Array(self) + .partition(by: \.unicodeValue) + .mapValues { + $0.flatMap(\.users) + .map { $0.toDomain() } + } + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/UserToDomain.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/UserToDomain.swift new file mode 100644 index 00000000000..a935e358ba1 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Mapping Models/UserToDomain.swift @@ -0,0 +1,31 @@ +// +// 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 WireDataModel +import WireMessagingDomain + +extension UserType { + 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 c1aade8eb1d..fabb3d04355 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/ConversationViewController.swift @@ -163,6 +163,10 @@ final class ConversationViewController: UIViewController { ), senderNameObserverProvider: { [individualChangesFactory] model in individualChangesFactory.makeSenderNameObserver(user: model) + }, + reactionsObserverProvider: { [individualChangesFactory] model in + 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 39b5eaa7cf3..790cabebf1f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/MessagesIndividualUpdatesFactory.swift @@ -23,7 +23,8 @@ import WireMessagingDomain final actor MessagesIndividualUpdatesFactory { private let context: NSManagedObjectContext - private var dict = [NSManagedObjectID: SenderObserver]() + private var senderObservers = [NSManagedObjectID: SenderObserver]() + private var messageObservers = [NSManagedObjectID: ReactionsObserver]() init(context: NSManagedObjectContext) { self.context = context @@ -34,7 +35,7 @@ final actor MessagesIndividualUpdatesFactory { return nil } - if let observer = dict[objectID] { + if let observer = senderObservers[objectID] { return observer } @@ -43,8 +44,28 @@ final actor MessagesIndividualUpdatesFactory { viewContext: context ) - dict[objectID] = observer + senderObservers[objectID] = observer return observer } + + func makeReactionsObserver(message: MessageModel) -> ReactionsObserverProtocol? { + guard let objectID = message.objectID as? NSManagedObjectID else { + return nil + } + + if let observer = messageObservers[objectID] { + return observer + } + + let observer = ReactionsObserver( + messageID: objectID, + viewContext: context + ) + + 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 443bfd0ce08..013e49da907 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/SenderObserver.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/SenderObserver.swift @@ -44,6 +44,29 @@ final class SenderObserver: SenderNameObserverProtocol { } } +final class ReactionsObserver: ReactionsObserverProtocol { + + var reactionsPublisher: AnyPublisher? + + init( + 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() + } + } + +} + extension NSManagedObject { static func publisher( for managedObject: T,