diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3050549a7..4f450ee760e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +## StreamChat +### ✅ Added +- Add `ChatReactionListController` to query and filter reactions from a message [#3167](https://github.com/GetStream/stream-chat-swift/pull/3167) +- Add `ChatClient.reactionListController()` to create an instance of `ChatReactionListController` [#3167](https://github.com/GetStream/stream-chat-swift/pull/3167) # [4.52.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.52.0) _April 09, 2024_ diff --git a/Examples/SlackClone/SlackChatMessageListViewController.swift b/Examples/SlackClone/SlackChatMessageListViewController.swift index 5abdf70f18d..419d914d937 100644 --- a/Examples/SlackClone/SlackChatMessageListViewController.swift +++ b/Examples/SlackClone/SlackChatMessageListViewController.swift @@ -10,4 +10,108 @@ final class SlackChatMessageListViewController: ChatMessageListVC { override func cellContentClassForMessage(at indexPath: IndexPath) -> ChatMessageContentView.Type { SlackChatMessageContentView.self } + + override func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + let location = gesture.location(in: listView) + guard gesture.state == .began, + let indexPath = listView.indexPathForRow(at: location), + let cell = listView.cellForRow(at: indexPath) as? ChatMessageCell, + let messageContentView = cell.messageContentView as? SlackChatMessageContentView, + let message = messageContentView.content else { + return super.handleLongPress(gesture) + } + + let reactionsLocation = gesture.location(in: messageContentView.slackReactionsView.collectionView) + guard let reactionsIndexPath = messageContentView.slackReactionsView.collectionView.indexPathForItem(at: reactionsLocation) else { + return super.handleLongPress(gesture) + } + + let reaction = messageContentView.slackReactionsView.reactions[reactionsIndexPath.row] + openReactionsSheet(for: message, with: reaction.type) + } + + func openReactionsSheet(for message: ChatMessage, with reactionType: MessageReactionType) { + let reactionListController = client.reactionListController( + query: .init( + messageId: message.id, + filter: .equal(.reactionType, to: reactionType) + ) + ) + let slackReactionListView = SlackReactionListViewController( + reactionListController: reactionListController + ) + present(slackReactionListView, animated: true) + } +} + +/// Displays the reaction authors for a specific reaction type. +class SlackReactionListViewController: UITableViewController, ChatReactionListControllerDelegate { + let reactionListController: ChatReactionListController + + init(reactionListController: ChatReactionListController) { + self.reactionListController = reactionListController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var reactions: [ChatMessageReaction] = [] + + private var isLoadingReactions: Bool = false + + let prefetchThreshold: Int = 10 + + override func viewDidLoad() { + super.viewDidLoad() + + if let sheetController = presentationController as? UISheetPresentationController { + sheetController.detents = [.medium(), .large()] + sheetController.prefersGrabberVisible = true + } + + reactionListController.delegate = self + isLoadingReactions = true + reactionListController.synchronize { [weak self] _ in + self?.isLoadingReactions = false + } + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + reactions.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let reaction = reactions[indexPath.row] + let cell = UITableViewCell(style: .value1, reuseIdentifier: "slack-reaction-cell") + cell.detailTextLabel?.text = reaction.author.name ?? "Unknown" + cell.textLabel?.text = reaction.type.toEmoji() + return cell + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + prefetchNextReactions(currentIndexPath: indexPath) + } + + func prefetchNextReactions(currentIndexPath indexPath: IndexPath) { + if isLoadingReactions { + return + } + + if indexPath.row > reactions.count - prefetchThreshold { + return + } + + isLoadingReactions = true + reactionListController.loadMoreReactions { [weak self] _ in + self?.isLoadingReactions = false + } + } + + func controller(_ controller: ChatReactionListController, didChangeReactions changes: [ListChange]) { + reactions = Array(controller.reactions) + tableView.reloadData() + } } diff --git a/Examples/SlackClone/SlackReactionsView.swift b/Examples/SlackClone/SlackReactionsView.swift index a576cacb0c7..e1fe41e1770 100644 --- a/Examples/SlackClone/SlackReactionsView.swift +++ b/Examples/SlackClone/SlackReactionsView.swift @@ -6,6 +6,19 @@ import StreamChat import StreamChatUI import UIKit +extension MessageReactionType { + func toEmoji() -> String { + let emojis: [String: String] = [ + "love": "❤️", + "haha": "😂", + "like": "👍", + "sad": "😔", + "wow": "🤯" + ] + return emojis[rawValue] ?? "❓" + } +} + final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource, UICollectionViewDelegate { var content: ChatMessage? { didSet { updateContent() } @@ -25,8 +38,10 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource let reactionWidth: CGFloat = 40 let reactionRowHeight: CGFloat = 30 + let reactionsMarginLeft: CGFloat = 40 + let reactionInterSpacing: CGFloat = 4 - private lazy var collectionView: UICollectionView = { + lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.dataSource = self @@ -48,9 +63,9 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource ), subitems: [item] ) - group.interItemSpacing = .fixed(4) + group.interItemSpacing = .fixed(reactionInterSpacing) let section = NSCollectionLayoutSection(group: group) - section.interGroupSpacing = 4 + section.interGroupSpacing = reactionInterSpacing return UICollectionViewCompositionalLayout(section: section) } @@ -62,8 +77,8 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource addSubview(collectionView) NSLayoutConstraint.activate([ - collectionView.topAnchor.constraint(equalTo: topAnchor), - collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.topAnchor.constraint(equalTo: topAnchor, constant: -4), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: reactionsMarginLeft), collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), collectionView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) @@ -89,7 +104,8 @@ final class SlackReactionsView: _View, ThemeProvider, UICollectionViewDataSource collectionView.reloadData() - let numberOfRows = Double(reactions.count) * reactionWidth / UIScreen.main.bounds.width + let screenWidth = UIScreen.main.bounds.width + let numberOfRows = Double(reactions.count) * (reactionWidth + reactionInterSpacing) / (screenWidth - reactionsMarginLeft) heightConstraint?.constant = ceil(numberOfRows) * reactionRowHeight } diff --git a/Examples/SlackClone/SlackReactonsItemView.swift b/Examples/SlackClone/SlackReactonsItemView.swift index 4e435606344..72788e8142e 100644 --- a/Examples/SlackClone/SlackReactonsItemView.swift +++ b/Examples/SlackClone/SlackReactonsItemView.swift @@ -7,22 +7,13 @@ import StreamChatUI import UIKit final class SlackReactionsItemView: UICollectionViewCell { - var emojis: [String: String] = [ - "love": "❤️", - "haha": "😂", - "like": "👍", - "sad": "😔", - "wow": "🤯" - ] - var reaction: ChatMessageReactionData? { didSet { guard let reaction = reaction else { return } - let emoji = emojis[reaction.type.rawValue] ?? "🙂" - + let emoji = reaction.type.toEmoji() textLabel.text = "\(emoji) \(reaction.score)" textLabel.textColor = reaction.isChosenByCurrentUser ? .blue : .gray } diff --git a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift index 66bfc175edc..a6279b4077b 100644 --- a/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift +++ b/Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift @@ -50,50 +50,6 @@ extension Endpoint { ) } - static func loadReactions(messageId: MessageId, pagination: Pagination) -> Endpoint { - .init( - path: .reactions(messageId), - method: .get, - queryItems: nil, - requiresConnectionId: false, - body: pagination - ) - } - - static func addReaction( - _ type: MessageReactionType, - score: Int, - enforceUnique: Bool, - extraData: [String: RawJSON], - messageId: MessageId - ) -> Endpoint { - let body = MessageReactionRequestPayload( - enforceUnique: enforceUnique, - reaction: ReactionRequestPayload( - type: type, - score: score, - extraData: extraData - ) - ) - return .init( - path: .addReaction(messageId), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: body - ) - } - - static func deleteReaction(_ type: MessageReactionType, messageId: MessageId) -> Endpoint { - .init( - path: .deleteReaction(messageId, type), - method: .delete, - queryItems: nil, - requiresConnectionId: false, - body: nil - ) - } - static func dispatchEphemeralMessageAction( cid: ChannelId, messageId: MessageId, diff --git a/Sources/StreamChat/APIClient/Endpoints/ReactionEndpoints.swift b/Sources/StreamChat/APIClient/Endpoints/ReactionEndpoints.swift new file mode 100644 index 00000000000..f16ae2e8c2d --- /dev/null +++ b/Sources/StreamChat/APIClient/Endpoints/ReactionEndpoints.swift @@ -0,0 +1,61 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension Endpoint { + static func loadReactions(messageId: MessageId, pagination: Pagination) -> Endpoint { + .init( + path: .reactions(messageId), + method: .get, + queryItems: nil, + requiresConnectionId: false, + body: pagination + ) + } + + static func loadReactionsV2(query: ReactionListQuery) -> Endpoint { + .init( + path: .reactions(query.messageId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: query + ) + } + + static func addReaction( + _ type: MessageReactionType, + score: Int, + enforceUnique: Bool, + extraData: [String: RawJSON], + messageId: MessageId + ) -> Endpoint { + let body = MessageReactionRequestPayload( + enforceUnique: enforceUnique, + reaction: ReactionRequestPayload( + type: type, + score: score, + extraData: extraData + ) + ) + return .init( + path: .addReaction(messageId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: body + ) + } + + static func deleteReaction(_ type: MessageReactionType, messageId: MessageId) -> Endpoint { + .init( + path: .deleteReaction(messageId, type), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: nil + ) + } +} diff --git a/Sources/StreamChat/Controllers/ReactionListController/ChatClient+ReactionListController.swift b/Sources/StreamChat/Controllers/ReactionListController/ChatClient+ReactionListController.swift new file mode 100644 index 00000000000..a1206994a52 --- /dev/null +++ b/Sources/StreamChat/Controllers/ReactionListController/ChatClient+ReactionListController.swift @@ -0,0 +1,30 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension ChatClient { + /// Creates a new `ChatReactionListController` with the provided reaction list query. + /// + /// - Parameter query: The query to specify which reactions should be fetch from the message. + /// - Returns: A new instance of `ChatReactionListController`. + public func reactionListController(query: ReactionListQuery) -> ChatReactionListController { + .init(query: query, client: self) + } + + /// Creates a new `ChatReactionListController` with the default query. + /// It loads all reactions from the message. + /// + /// - Parameter messageId: The message id of the reactions to fetch. + /// - Returns: A new instance of `ChatReactionListController`. + public func reactionListController(for messageId: MessageId) -> ChatReactionListController { + .init( + query: ReactionListQuery( + messageId: messageId, + pagination: .init(pageSize: 25, offset: 0) + ), + client: self + ) + } +} diff --git a/Sources/StreamChat/Controllers/ReactionListController/ReactionListController+Combine.swift b/Sources/StreamChat/Controllers/ReactionListController/ReactionListController+Combine.swift new file mode 100644 index 00000000000..e0f06109c81 --- /dev/null +++ b/Sources/StreamChat/Controllers/ReactionListController/ReactionListController+Combine.swift @@ -0,0 +1,54 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +@available(iOS 13, *) +extension ChatReactionListController { + /// A publisher emitting a new value every time the state of the controller changes. + public var statePublisher: AnyPublisher { + basePublishers.state.keepAlive(self) + } + + /// A publisher emitting a new value every time the reactions change. + public var reactionsChangesPublisher: AnyPublisher<[ListChange], Never> { + basePublishers.reactionsChanges.keepAlive(self) + } + + /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose + /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, + /// and expose the published values by mapping them to a read-only `AnyPublisher` type. + class BasePublishers { + /// The wrapped controller. + unowned let controller: ChatReactionListController + + /// A backing subject for `statePublisher`. + let state: CurrentValueSubject + + /// A backing subject for `reactionsChangesPublisher`. + let reactionsChanges: PassthroughSubject<[ListChange], Never> = .init() + + init(controller: ChatReactionListController) { + self.controller = controller + state = .init(controller.state) + + controller.multicastDelegate.add(additionalDelegate: self) + } + } +} + +@available(iOS 13, *) +extension ChatReactionListController.BasePublishers: ChatReactionListControllerDelegate { + func controller(_ controller: DataController, didChangeState state: DataController.State) { + self.state.send(state) + } + + func controller( + _ controller: ChatReactionListController, + didChangeReactions changes: [ListChange] + ) { + reactionsChanges.send(changes) + } +} diff --git a/Sources/StreamChat/Controllers/ReactionListController/ReactionListController+SwiftUI.swift b/Sources/StreamChat/Controllers/ReactionListController/ReactionListController+SwiftUI.swift new file mode 100644 index 00000000000..26d067948c4 --- /dev/null +++ b/Sources/StreamChat/Controllers/ReactionListController/ReactionListController+SwiftUI.swift @@ -0,0 +1,49 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation +import SwiftUI + +@available(iOS 13, *) +extension ChatReactionListController { + /// A wrapper object that exposes the controller variables in the form of `ObservableObject` to be used in SwiftUI. + public var observableObject: ObservableObject { .init(controller: self) } + + /// A wrapper object for `ChatReactionListController` type which makes it possible to use the controller + /// comfortably in SwiftUI. + public class ObservableObject: SwiftUI.ObservableObject { + /// The underlying controller. You can still access it and call methods on it. + public let controller: ChatReactionListController + + /// The message reactions. + @Published public private(set) var reactions: LazyCachedMapCollection = [] + + /// The current state of the controller. + @Published public private(set) var state: DataController.State + + /// Creates a new `ObservableObject` wrapper with the provided controller instance. + init(controller: ChatReactionListController) { + self.controller = controller + state = controller.state + + controller.multicastDelegate.add(additionalDelegate: self) + + reactions = controller.reactions + } + } +} + +@available(iOS 13, *) +extension ChatReactionListController.ObservableObject: ChatReactionListControllerDelegate { + public func controller( + _ controller: ChatReactionListController, + didChangeReactions changes: [ListChange] + ) { + reactions = controller.reactions + } + + public func controller(_ controller: DataController, didChangeState state: DataController.State) { + self.state = state + } +} diff --git a/Sources/StreamChat/Controllers/ReactionListController/ReactionListController.swift b/Sources/StreamChat/Controllers/ReactionListController/ReactionListController.swift new file mode 100644 index 00000000000..a648b63ed83 --- /dev/null +++ b/Sources/StreamChat/Controllers/ReactionListController/ReactionListController.swift @@ -0,0 +1,178 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import CoreData +import Foundation + +/// `ChatReactionListController` uses this protocol to communicate changes to its delegate. +public protocol ChatReactionListControllerDelegate: DataControllerStateDelegate { + /// The controller changed the list of observed reactions. + /// + /// - Parameters: + /// - controller: The controller emitting the change callback. + /// - changes: The change to the list of reactions. + func controller( + _ controller: ChatReactionListController, + didChangeReactions changes: [ListChange] + ) +} + +/// A controller which allows querying and filtering the reactions of a message. +public class ChatReactionListController: DataController, DelegateCallable, DataStoreProvider { + /// The query specifying and filtering the list of users. + public let query: ReactionListQuery + + /// The `ChatClient` instance this controller belongs to. + public let client: ChatClient + + /// The total reactions of the message the controller represents. + /// + /// To observe changes of the reactions, set your class as a delegate of this controller or use the provided + /// `Combine` publishers. + public var reactions: LazyCachedMapCollection { + startReactionListObserverIfNeeded() + return reactionListObserver.items + } + + /// The worker used to fetch the remote data and communicate with servers. + private lazy var worker: ReactionListUpdater = self.environment + .reactionListQueryUpdaterBuilder( + client.databaseContainer, + client.apiClient + ) + + /// Set the delegate of `ReactionListController` to observe the changes in the system. + public weak var delegate: ChatReactionListControllerDelegate? { + get { multicastDelegate.mainDelegate } + set { multicastDelegate.set(mainDelegate: newValue) } + } + + /// A type-erased delegate. + var multicastDelegate: MulticastDelegate = .init() { + didSet { + stateMulticastDelegate.set(mainDelegate: multicastDelegate.mainDelegate) + stateMulticastDelegate.set(additionalDelegates: multicastDelegate.additionalDelegates) + + // After setting delegate local changes will be fetched and observed. + startReactionListObserverIfNeeded() + } + } + + /// Used for observing the database for changes. + private(set) lazy var reactionListObserver: ListDatabaseObserverWrapper = { + let request = MessageReactionDTO.reactionListFetchRequest(query: query) + + let observer = self.environment.createReactionListDatabaseObserver( + StreamRuntimeCheck._isBackgroundMappingEnabled, + client.databaseContainer, + request, + { try $0.asModel() } + ) + + observer.onDidChange = { [weak self] changes in + self?.delegateCallback { [weak self] in + guard let self = self else { + log.warning("Callback called while self is nil") + return + } + + $0.controller(self, didChangeReactions: changes) + } + } + + return observer + }() + + var _basePublishers: Any? + /// An internal backing object for all publicly available Combine publishers. We use it to simplify the way we expose + /// publishers. Instead of creating custom `Publisher` types, we use `CurrentValueSubject` and `PassthroughSubject` internally, + /// and expose the published values by mapping them to a read-only `AnyPublisher` type. + @available(iOS 13, *) + var basePublishers: BasePublishers { + if let value = _basePublishers as? BasePublishers { + return value + } + _basePublishers = BasePublishers(controller: self) + return _basePublishers as? BasePublishers ?? .init(controller: self) + } + + private let environment: Environment + + /// Creates a new `UserListController`. + /// + /// - Parameters: + /// - query: The query used for filtering the reactions. + /// - client: The `Client` instance this controller belongs to. + init(query: ReactionListQuery, client: ChatClient, environment: Environment = .init()) { + self.client = client + self.query = query + self.environment = environment + } + + override public func synchronize(_ completion: ((_ error: Error?) -> Void)? = nil) { + startReactionListObserverIfNeeded() + + worker.loadReactions(query: query) { result in + if let error = result.error { + self.state = .remoteDataFetchFailed(ClientError(with: error)) + } else { + self.state = .remoteDataFetched + } + self.callback { completion?(result.error) } + } + } + + /// If the `state` of the controller is `initialized`, this method calls `startObserving` on the + /// `reactionListObserver` to fetch the local data and start observing the changes. It also changes + /// `state` based on the result. + private func startReactionListObserverIfNeeded() { + guard state == .initialized else { return } + do { + try reactionListObserver.startObserving() + state = .localDataFetched + } catch { + state = .localDataFetchFailed(ClientError(with: error)) + log.error("Failed to perform fetch request with error: \(error). This is an internal error.") + } + } +} + +// MARK: - Actions + +public extension ChatReactionListController { + /// Loads more reactions. + /// + /// - Parameters: + /// - limit: Limit for the page size. + /// - completion: The completion callback. + func loadMoreReactions( + limit: Int = 25, + completion: ((Error?) -> Void)? = nil + ) { + var updatedQuery = query + updatedQuery.pagination = Pagination(pageSize: limit, offset: reactions.count) + worker.loadReactions(query: updatedQuery) { result in + self.callback { completion?(result.error) } + } + } +} + +extension ChatReactionListController { + struct Environment { + var reactionListQueryUpdaterBuilder: ( + _ database: DatabaseContainer, + _ apiClient: APIClient + ) -> ReactionListUpdater = ReactionListUpdater.init + + var createReactionListDatabaseObserver: ( + _ isBackgroundMappingEnabled: Bool, + _ database: DatabaseContainer, + _ fetchRequest: NSFetchRequest, + _ itemCreator: @escaping (MessageReactionDTO) throws -> ChatMessageReaction + ) + -> ListDatabaseObserverWrapper = { + ListDatabaseObserverWrapper(isBackground: $0, database: $1, fetchRequest: $2, itemCreator: $3) + } + } +} diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift index 8f6b15e50f6..9082d5f0421 100644 --- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift @@ -745,13 +745,13 @@ extension NSManagedObjectContext: MessageDatabaseSession { dto.latestReactions = payload .latestReactions - .compactMap { try? saveReaction(payload: $0, cache: cache) } + .compactMap { try? saveReaction(payload: $0, query: nil, cache: cache) } .map(\.id) if syncOwnReactions { dto.ownReactions = payload .ownReactions - .compactMap { try? saveReaction(payload: $0, cache: cache) } + .compactMap { try? saveReaction(payload: $0, query: nil, cache: cache) } .map(\.id) } diff --git a/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift index e47b6bc4f87..e70c1f4af40 100644 --- a/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift +++ b/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift @@ -33,6 +33,27 @@ final class MessageReactionDTO: NSManagedObject { } extension MessageReactionDTO { + static func reactionListFetchRequest(query: ReactionListQuery) -> NSFetchRequest { + let request = NSFetchRequest(entityName: MessageReactionDTO.entityName) + + // Fetch results controller requires at least one sorting descriptor. + // At the moment, we do not allow changing the query sorting. + request.sortDescriptors = [.init(key: #keyPath(MessageReactionDTO.createdAt), ascending: false)] + + let messageIdPredicate = NSPredicate(format: "message.id == %@", query.messageId) + var subpredicates = [messageIdPredicate] + + // If a filter exists, use is for the predicate. Otherwise, `nil` filter matches all reactions. + if let filterHash = query.filter?.filterHash { + let filterPredicate = NSPredicate(format: "ANY queries.filterHash == %@", filterHash) + subpredicates.append(filterPredicate) + } + + request.predicate = NSCompoundPredicate(type: .and, subpredicates: subpredicates) + + return request + } + static func load( userId: String, messageId: MessageId, @@ -101,16 +122,23 @@ extension NSManagedObjectContext { } @discardableResult - func saveReactions(payload: MessageReactionsPayload) -> [MessageReactionDTO] { + func saveReactions(payload: MessageReactionsPayload, query: ReactionListQuery?) -> [MessageReactionDTO] { + let isFirstPage = query?.pagination.offset == 0 + if let filterHash = query?.queryHash, isFirstPage { + let queryDTO = ReactionListQueryDTO.load(filterHash: filterHash, context: self) + queryDTO?.reactions = [] + } + let cache = payload.getPayloadToModelIdMappings(context: self) return payload.reactions.compactMapLoggingError { - try saveReaction(payload: $0, cache: cache) + try saveReaction(payload: $0, query: query, cache: cache) } } @discardableResult func saveReaction( payload: MessageReactionPayload, + query: ReactionListQuery?, cache: PreWarmedCache? ) throws -> MessageReactionDTO { guard let messageDTO = message(id: payload.messageId) else { @@ -132,6 +160,11 @@ extension NSManagedObjectContext { dto.localState = nil dto.version = nil + if let query = query { + let queryDTO = try saveQuery(query: query) + queryDTO?.reactions.insert(dto) + } + return dto } diff --git a/Sources/StreamChat/Database/DTOs/ReactionListQueryDTO.swift b/Sources/StreamChat/Database/DTOs/ReactionListQueryDTO.swift new file mode 100644 index 00000000000..a2293ce94d6 --- /dev/null +++ b/Sources/StreamChat/Database/DTOs/ReactionListQueryDTO.swift @@ -0,0 +1,72 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import CoreData + +@objc(ReactionListQueryDTO) +class ReactionListQueryDTO: NSManagedObject { + /// Unique identifier of the query. + @NSManaged var filterHash: String + + /// Serialized `Filter` JSON which can be used in cases the query needs to be repeated. + @NSManaged var filterJSONData: Data + + // MARK: - Relationships + + @NSManaged var reactions: Set + + static func load(filterHash: String, context: NSManagedObjectContext) -> ReactionListQueryDTO? { + load( + keyPath: #keyPath(ReactionListQueryDTO.filterHash), + equalTo: filterHash, + context: context + ).first + } + + static func loadOrCreate(filterHash: String, context: NSManagedObjectContext) -> ReactionListQueryDTO { + if let existing = load(filterHash: filterHash, context: context) { + return existing + } + + let request = fetchRequest( + keyPath: #keyPath(ReactionListQueryDTO.filterHash), + equalTo: filterHash + ) + let new = NSEntityDescription.insertNewObject(into: context, for: request) + new.filterHash = filterHash + return new + } +} + +extension NSManagedObjectContext { + func reactionListQuery(filterHash: String) -> ReactionListQueryDTO? { + ReactionListQueryDTO.load(filterHash: filterHash, context: self) + } + + func saveQuery(query: ReactionListQuery) throws -> ReactionListQueryDTO? { + guard let filterHash = query.filter?.filterHash else { + // A query without a filter doesn't have to be saved to the DB because it matches all users by default. + return nil + } + + if let existingDTO = ReactionListQueryDTO.load(filterHash: filterHash, context: self) { + return existingDTO + } + + let request = ReactionListQueryDTO.fetchRequest( + keyPath: #keyPath(ReactionListQueryDTO.filterHash), + equalTo: filterHash + ) + let newDTO = NSEntityDescription.insertNewObject(into: self, for: request) + newDTO.filterHash = filterHash + + do { + newDTO.filterJSONData = try JSONEncoder.default.encode(query.filter) + } catch { + log.error("Failed encoding query Filter data with error: \(error).") + } + + return newDTO + } +} diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index ef5376721c9..5c65330d31a 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -166,12 +166,19 @@ protocol MessageDatabaseSession { /// Saves the provided reactions payload to the DB. Ignores reactions that cannot be saved /// returns saved `MessageReactionDTO` entities. @discardableResult - func saveReactions(payload: MessageReactionsPayload) -> [MessageReactionDTO] + func saveReactions(payload: MessageReactionsPayload, query: ReactionListQuery?) -> [MessageReactionDTO] /// Saves the provided reaction payload to the DB. Throws an error if the save fails /// else returns saved `MessageReactionDTO` entity. @discardableResult - func saveReaction(payload: MessageReactionPayload, cache: PreWarmedCache?) throws -> MessageReactionDTO + func saveReaction( + payload: MessageReactionPayload, + query: ReactionListQuery?, + cache: PreWarmedCache? + ) throws -> MessageReactionDTO + + @discardableResult + func saveQuery(query: ReactionListQuery) throws -> ReactionListQueryDTO? /// Deletes the provided dto from a database /// - Parameter reaction: The DTO to be deleted @@ -479,12 +486,12 @@ extension DatabaseSession { do { switch try? payload.event() { case let event as ReactionNewEventDTO: - let reaction = try saveReaction(payload: event.reaction, cache: nil) + let reaction = try saveReaction(payload: event.reaction, query: nil, cache: nil) if !reaction.message.ownReactions.contains(reaction.id) { reaction.message.ownReactions.append(reaction.id) } case let event as ReactionUpdatedEventDTO: - try saveReaction(payload: event.reaction, cache: nil) + try saveReaction(payload: event.reaction, query: nil, cache: nil) case let event as ReactionDeletedEventDTO: if let dto = reaction( messageId: event.message.id, diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index 5e251f8a534..30ce8c08ad2 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -268,6 +268,7 @@ + @@ -301,6 +302,14 @@ + + + + + + + + diff --git a/Sources/StreamChat/Query/ReactionListQuery.swift b/Sources/StreamChat/Query/ReactionListQuery.swift new file mode 100644 index 00000000000..14398f8123d --- /dev/null +++ b/Sources/StreamChat/Query/ReactionListQuery.swift @@ -0,0 +1,63 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A query used for querying specific reactions from a message. +public struct ReactionListQuery: Encodable { + /// The message id that the reactions belong to. + public var messageId: MessageId + /// The pagination information to query the reactions. + public var pagination: Pagination + /// The filter details to query the reactions. + public var filter: Filter? + + public init( + messageId: MessageId, + pagination: Pagination = .init(pageSize: 25, offset: 0), + filter: Filter? = nil + ) { + self.messageId = messageId + self.pagination = pagination + self.filter = filter + } + + enum CodingKeys: CodingKey { + case pagination + case filter + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(filter, forKey: .filter) + try pagination.encode(to: encoder) + } +} + +/// A namespace for the `FilterKey`s suitable to be used for `ReactionListQuery`. +public protocol AnyReactionListFilterScope {} + +/// An extra-data-specific namespace for the `FilterKey`s suitable to be used for `ReactionListQuery`. +public class ReactionListFilterScope: FilterScope, AnyReactionListFilterScope {} + +/// Make the reaction type conform to FilterValue. +extension MessageReactionType: FilterValue {} + +/// Non extra-data-specific filer keys for reaction list. +public extension FilterKey where Scope: AnyReactionListFilterScope { + /// A filter key for matching the reaction type + static var reactionType: FilterKey { "type" } + + /// A filter key for matching the user id of the reaction's author. + static var authorId: FilterKey { "user_id" } +} + +extension ReactionListQuery { + var queryHash: String { + [ + messageId, + filter?.filterHash + ].compactMap { $0 }.joined(separator: "-") + } +} diff --git a/Sources/StreamChat/Workers/MessageUpdater.swift b/Sources/StreamChat/Workers/MessageUpdater.swift index 00124e62a6a..91175ce9d15 100644 --- a/Sources/StreamChat/Workers/MessageUpdater.swift +++ b/Sources/StreamChat/Workers/MessageUpdater.swift @@ -313,7 +313,7 @@ class MessageUpdater: Worker { case let .success(payload): var reactions: [ChatMessageReaction] = [] self.database.write({ session in - reactions = try session.saveReactions(payload: payload).map { try $0.asModel() } + reactions = try session.saveReactions(payload: payload, query: nil).map { try $0.asModel() } }, completion: { error in if let error = error { completion?(.failure(error)) diff --git a/Sources/StreamChat/Workers/ReactionListUpdater.swift b/Sources/StreamChat/Workers/ReactionListUpdater.swift new file mode 100644 index 00000000000..7f722fbd854 --- /dev/null +++ b/Sources/StreamChat/Workers/ReactionListUpdater.swift @@ -0,0 +1,32 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import CoreData + +class ReactionListUpdater: Worker { + func loadReactions( + query: ReactionListQuery, + completion: @escaping (Result<[ChatMessageReaction], Error>) -> Void + ) { + apiClient.request( + endpoint: .loadReactionsV2(query: query) + ) { [weak self] (result: Result) in + switch result { + case let .success(payload): + var reactions: [ChatMessageReaction] = [] + self?.database.write({ session in + reactions = try session.saveReactions(payload: payload, query: query).map { try $0.asModel() } + }, completion: { error in + if let error = error { + completion(.failure(error)) + } else { + completion(.success(reactions)) + } + }) + case let .failure(error): + completion(.failure(error)) + } + } + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 897c9484b00..16e233c01b6 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1234,6 +1234,25 @@ AD053BAB2B33638B003612B6 /* LocationAttachmentSnapshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053BAA2B33638B003612B6 /* LocationAttachmentSnapshotView.swift */; }; AD053BAD2B336493003612B6 /* DemoChatMessageListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD053BAC2B336493003612B6 /* DemoChatMessageListVC.swift */; }; AD0AD6C02A25140A00CB96CB /* MessagesPaginationState_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0AD6BF2A25140A00CB96CB /* MessagesPaginationState_Tests.swift */; }; + AD0CC0122BDBC1BF005E2C66 /* ReactionListQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0112BDBC1BF005E2C66 /* ReactionListQuery.swift */; }; + AD0CC0132BDBC1BF005E2C66 /* ReactionListQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0112BDBC1BF005E2C66 /* ReactionListQuery.swift */; }; + AD0CC0172BDBC71C005E2C66 /* ReactionListQuery_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0142BDBC68E005E2C66 /* ReactionListQuery_Tests.swift */; }; + AD0CC01C2BDBD22D005E2C66 /* ReactionEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC01B2BDBD22D005E2C66 /* ReactionEndpoints.swift */; }; + AD0CC01D2BDBD22D005E2C66 /* ReactionEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC01B2BDBD22D005E2C66 /* ReactionEndpoints.swift */; }; + AD0CC0212BDBD332005E2C66 /* ReactionEndpoint_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC01E2BDBD2EB005E2C66 /* ReactionEndpoint_Tests.swift */; }; + AD0CC0232BDBF715005E2C66 /* ReactionListUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0222BDBF715005E2C66 /* ReactionListUpdater.swift */; }; + AD0CC0242BDBF715005E2C66 /* ReactionListUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0222BDBF715005E2C66 /* ReactionListUpdater.swift */; }; + AD0CC0282BDBF9DD005E2C66 /* ReactionListUpdater_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0252BDBF9D1005E2C66 /* ReactionListUpdater_Tests.swift */; }; + AD0CC02B2BDC01A2005E2C66 /* ReactionListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC02A2BDC01A2005E2C66 /* ReactionListController.swift */; }; + AD0CC02C2BDC01A2005E2C66 /* ReactionListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC02A2BDC01A2005E2C66 /* ReactionListController.swift */; }; + AD0CC02E2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC02D2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift */; }; + AD0CC02F2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC02D2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift */; }; + AD0CC0312BDC1964005E2C66 /* ReactionListQueryDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0302BDC1964005E2C66 /* ReactionListQueryDTO.swift */; }; + AD0CC0322BDC1964005E2C66 /* ReactionListQueryDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0302BDC1964005E2C66 /* ReactionListQueryDTO.swift */; }; + AD0CC0342BDC4A6B005E2C66 /* ReactionListController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0332BDC4A6B005E2C66 /* ReactionListController+Combine.swift */; }; + AD0CC0352BDC4A6B005E2C66 /* ReactionListController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0332BDC4A6B005E2C66 /* ReactionListController+Combine.swift */; }; + AD0CC0372BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0362BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift */; }; + AD0CC0382BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0CC0362BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift */; }; AD0EC6D52A45AAAF005220B1 /* ChatMessageListVC_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0EC6D42A45AAAF005220B1 /* ChatMessageListVC_Mock.swift */; }; AD0F7F132B5ED64600914C4C /* ComposerLinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0F7F122B5ED64600914C4C /* ComposerLinkPreviewView.swift */; }; AD0F7F142B5ED64600914C4C /* ComposerLinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0F7F122B5ED64600914C4C /* ComposerLinkPreviewView.swift */; }; @@ -3844,6 +3863,17 @@ AD053BAA2B33638B003612B6 /* LocationAttachmentSnapshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationAttachmentSnapshotView.swift; sourceTree = ""; }; AD053BAC2B336493003612B6 /* DemoChatMessageListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatMessageListVC.swift; sourceTree = ""; }; AD0AD6BF2A25140A00CB96CB /* MessagesPaginationState_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationState_Tests.swift; sourceTree = ""; }; + AD0CC0112BDBC1BF005E2C66 /* ReactionListQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListQuery.swift; sourceTree = ""; }; + AD0CC0142BDBC68E005E2C66 /* ReactionListQuery_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListQuery_Tests.swift; sourceTree = ""; }; + AD0CC01B2BDBD22D005E2C66 /* ReactionEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionEndpoints.swift; sourceTree = ""; }; + AD0CC01E2BDBD2EB005E2C66 /* ReactionEndpoint_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionEndpoint_Tests.swift; sourceTree = ""; }; + AD0CC0222BDBF715005E2C66 /* ReactionListUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListUpdater.swift; sourceTree = ""; }; + AD0CC0252BDBF9D1005E2C66 /* ReactionListUpdater_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListUpdater_Tests.swift; sourceTree = ""; }; + AD0CC02A2BDC01A2005E2C66 /* ReactionListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListController.swift; sourceTree = ""; }; + AD0CC02D2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatClient+ReactionListController.swift"; sourceTree = ""; }; + AD0CC0302BDC1964005E2C66 /* ReactionListQueryDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListQueryDTO.swift; sourceTree = ""; }; + AD0CC0332BDC4A6B005E2C66 /* ReactionListController+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReactionListController+Combine.swift"; sourceTree = ""; }; + AD0CC0362BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReactionListController+SwiftUI.swift"; sourceTree = ""; }; AD0EC6D42A45AAAF005220B1 /* ChatMessageListVC_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListVC_Mock.swift; sourceTree = ""; }; AD0F7F122B5ED64600914C4C /* ComposerLinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerLinkPreviewView.swift; sourceTree = ""; }; AD0F7F162B6139D500914C4C /* TextLinkDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLinkDetector.swift; sourceTree = ""; }; @@ -5120,6 +5150,7 @@ 79877A212498E50D00015F8B /* MemberModelDTO.swift */, 799C942D247D2FB9001F1104 /* MessageDTO.swift */, 8899BC4C25430E40003CB98B /* MessageReactionDTO.swift */, + AD0CC0302BDC1964005E2C66 /* ReactionListQueryDTO.swift */, AD70DC382ADEC3C400CFC3B7 /* MessageModerationDetailsDTO.swift */, 7978FBBB26E16295002CA2DF /* MessageSearchQueryDTO.swift */, C1B49B3F2822C01C00F4E89E /* NSManagedObject+Validation.swift */, @@ -5174,14 +5205,15 @@ 792A4F412480103A00EAF71D /* Query */ = { isa = PBXGroup; children = ( + 792A4F432480107A00EAF71D /* Filter.swift */, + C1FFD9F827ECC7C7008A6848 /* Filter+ChatChannel.swift */, 792A4F4C248011E500EAF71D /* ChannelListQuery.swift */, 882C5745252C6FDF00E60C44 /* ChannelMemberListQuery.swift */, 792A4F422480107A00EAF71D /* ChannelQuery.swift */, 79C5CBF025F66E9700D98001 /* ChannelWatcherListQuery.swift */, AD6E32A02BBC50110073831B /* ThreadListQuery.swift */, AD6E32A32BBC502D0073831B /* ThreadQuery.swift */, - 792A4F432480107A00EAF71D /* Filter.swift */, - C1FFD9F827ECC7C7008A6848 /* Filter+ChatChannel.swift */, + AD0CC0112BDBC1BF005E2C66 /* ReactionListQuery.swift */, 7978FBB926E15A58002CA2DF /* MessageSearchQuery.swift */, 792A4F442480107A00EAF71D /* Pagination.swift */, 792A4F4A248010A600EAF71D /* QueryOptions.swift */, @@ -5333,6 +5365,7 @@ 43D3F0F8284106EE00B74921 /* CallEndpoints.swift */, 79877A132498E4EE00015F8B /* ChannelEndpoints.swift */, F6FF1DA924FD23D300151735 /* MessageEndpoints.swift */, + AD0CC01B2BDBD22D005E2C66 /* ReactionEndpoints.swift */, AD84377A2BB482CF000F3826 /* ThreadEndpoints.swift */, C143788F27BC03EE00E23965 /* EndpointPath.swift */, 790A4C41252DD36A001F4A23 /* DeviceEndpoints.swift */, @@ -5392,6 +5425,7 @@ 84A43CB226A9A54700302763 /* EventSender.swift */, F6FF1DA724FD232C00151735 /* MessageUpdater.swift */, 8A0175EF2501174000570345 /* TypingEventsSender.swift */, + AD0CC0222BDBF715005E2C66 /* ReactionListUpdater.swift */, DA8407022524F7E6005A0F62 /* UserListUpdater.swift */, 8819DFCE2525F3C600FD1A50 /* UserUpdater.swift */, 799C9446247D50F3001F1104 /* Worker.swift */, @@ -5477,6 +5511,7 @@ 888E8C53252B522F00195E03 /* MemberController */, 88D85DA5252F3C0900AE1030 /* MemberListController */, F649B23F250125C9008F98C8 /* MessageController */, + AD0CC0292BDC017F005E2C66 /* ReactionListController */, 795296FA2582648F00435B2E /* SearchControllers */, 8819DFD32525F48800FD1A50 /* UserController */, DA8406FE2524F761005A0F62 /* UserListController */, @@ -6531,6 +6566,7 @@ 8A0D649424E579A50017A3C0 /* GuestEndpoints_Tests.swift */, 882C575B252C79E900E60C44 /* MemberEndpoints_Tests.swift */, F61D7C3024FF9D1F00188A0E /* MessageEndpoints_Tests.swift */, + AD0CC01E2BDBD2EB005E2C66 /* ReactionEndpoint_Tests.swift */, 8819DFDD252622D900FD1A50 /* ModerationEndpoints_Tests.swift */, F62BE78225062FC400D13B86 /* SyncEndpoint_Tests.swift */, DA84072C2525EF8D005A0F62 /* UserEndpoints_Tests.swift */, @@ -6604,6 +6640,7 @@ AD90D18425D56196001D03BB /* CurrentUserUpdater_Tests.swift */, F69C4BC324F664A700A3D740 /* EventNotificationCenter_Tests.swift */, 84A1D2E926AAFB1D00014712 /* EventSender_Tests.swift */, + AD0CC0252BDBF9D1005E2C66 /* ReactionListUpdater_Tests.swift */, F61D7C3424FFA6FD00188A0E /* MessageUpdater_Tests.swift */, 8A0175F325013B6400570345 /* TypingEventSender_Tests.swift */, DA8407322526003D005A0F62 /* UserListUpdater_Tests.swift */, @@ -6958,6 +6995,7 @@ children = ( A3C7BAD027E4E02700BBF4FA /* ChannelListFilterScope_Tests.swift */, 799F611A2530B62C007F218C /* ChannelListQuery_Tests.swift */, + AD0CC0142BDBC68E005E2C66 /* ReactionListQuery_Tests.swift */, 889B00E4252C972C007709A8 /* ChannelMemberListQuery_Tests.swift */, DA15A20224DF256F00BE2423 /* ChannelQuery_Tests.swift */, 79D6CE1625F7C02400BE2EEC /* ChannelWatcherListQuery_Tests.swift */, @@ -7900,6 +7938,17 @@ path = LocationAttachment; sourceTree = ""; }; + AD0CC0292BDC017F005E2C66 /* ReactionListController */ = { + isa = PBXGroup; + children = ( + AD0CC02D2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift */, + AD0CC02A2BDC01A2005E2C66 /* ReactionListController.swift */, + AD0CC0362BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift */, + AD0CC0332BDC4A6B005E2C66 /* ReactionListController+Combine.swift */, + ); + path = ReactionListController; + sourceTree = ""; + }; AD4473DD263AC2B80030E583 /* ChatCommandSuggestionView */ = { isa = PBXGroup; children = ( @@ -10705,6 +10754,7 @@ F63CC36F24E591840052844D /* EventObserver.swift in Sources */, DA84070925250528005A0F62 /* UserEndpoints.swift in Sources */, AD70DC392ADEC3C400CFC3B7 /* MessageModerationDetailsDTO.swift in Sources */, + AD0CC0232BDBF715005E2C66 /* ReactionListUpdater.swift in Sources */, ADC40C3626E2980D005B616C /* MessageSearchController+SwiftUI.swift in Sources */, 437FCA1626D79A910000223C /* ChatRemoteNotificationHandler.swift in Sources */, 8A0D649724E579A50017A3C0 /* GuestEndpoints.swift in Sources */, @@ -10783,8 +10833,10 @@ 8A0CC9F124C606EF00705CF9 /* ReactionEvents.swift in Sources */, C143788D27BBEBB700E23965 /* OfflineRequestsRepository.swift in Sources */, 79877A0F2498E4BC00015F8B /* ChannelId.swift in Sources */, + AD0CC0312BDC1964005E2C66 /* ReactionListQueryDTO.swift in Sources */, 882C5760252C7CC400E60C44 /* ChannelMemberListQueryDTO.swift in Sources */, 88DA57E02631E80D00FA8C53 /* MutedChannelPayload.swift in Sources */, + AD0CC0122BDBC1BF005E2C66 /* ReactionListQuery.swift in Sources */, AD78568F298B273900C2FEAD /* ChatClient+ChannelController.swift in Sources */, 792A4F492480107A00EAF71D /* Sorting.swift in Sources */, 7937282A2498FFD300E13FE5 /* MemberPayload.swift in Sources */, @@ -10811,6 +10863,7 @@ 840B4FCF26A9E53100D5EFAB /* CustomEventRequestBody.swift in Sources */, 79CD959224F9380B00E87377 /* MulticastDelegate.swift in Sources */, 7964F3BC249A5E60002A09EC /* RequestEncoder.swift in Sources */, + AD0CC02B2BDC01A2005E2C66 /* ReactionListController.swift in Sources */, 79896D64250A63A200BA8F1C /* ChannelReadUpdaterMiddleware.swift in Sources */, 88E26D7D2580F95300F55AB5 /* AttachmentEndpoints.swift in Sources */, F6D61D9B2510B3FC00EB0624 /* NSManagedObject+Extensions.swift in Sources */, @@ -10849,6 +10902,7 @@ F688643624E6DA8700A71361 /* CurrentUserController.swift in Sources */, 88BEBCD32536FD7600D9E8B7 /* MemberListController+Combine.swift in Sources */, 888E8C36252B2AAF00195E03 /* UserController+SwiftUI.swift in Sources */, + AD0CC01C2BDBD22D005E2C66 /* ReactionEndpoints.swift in Sources */, C143789027BC03EE00E23965 /* EndpointPath.swift in Sources */, C10C7552299D1D67008C8F78 /* ChannelRepository.swift in Sources */, 790A4C45252DD4F1001F4A23 /* DevicePayloads.swift in Sources */, @@ -10915,6 +10969,7 @@ DA4EE5B2252B67F500CB26D4 /* UserListController+SwiftUI.swift in Sources */, C18F5B522840BD2C00527915 /* DBDate.swift in Sources */, AD483B962A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift in Sources */, + AD0CC02E2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift in Sources */, 841BA9F52BCE8089000C73E4 /* PollsEndpoints.swift in Sources */, 43D3F0F62841052600B74921 /* CallPayloads.swift in Sources */, A30C3F20276B428F00DA5968 /* UnknownUserEvent.swift in Sources */, @@ -10929,6 +10984,7 @@ 40789D1929F6AC500018C2BB /* AudioPlayerObserving.swift in Sources */, 84A43CAF26A9A25000302763 /* UnknownChannelEvent.swift in Sources */, C1B0B38327BFC08900C8207D /* EndpointPath+OfflineRequest.swift in Sources */, + AD0CC0372BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift in Sources */, 841BAA042BCE94F8000C73E4 /* QueryPollsRequestBody.swift in Sources */, C1EFF3F3285E459C0057B91B /* IdentifiableModel.swift in Sources */, DAFAD6A124DC476A0043ED06 /* Result+Extensions.swift in Sources */, @@ -10937,6 +10993,7 @@ AD37D7D02BC9937F00800D8C /* ThreadParticipant.swift in Sources */, C1EE5AA328B63CA300DD5D27 /* CallRepository.swift in Sources */, AD7DFC3625D2FA8100DD9DA3 /* CurrentUserUpdater.swift in Sources */, + AD0CC0342BDC4A6B005E2C66 /* ReactionListController+Combine.swift in Sources */, 88206FC425B18C88009D086A /* ConnectionRepository.swift in Sources */, 7991D83B24F5427E00D21BA3 /* EphemeralValuesContainer.swift in Sources */, 79682C4B24BF37CB0071578E /* ChannelListPayload.swift in Sources */, @@ -11022,6 +11079,7 @@ files = ( DA49714E2549C28000AC68C2 /* AttachmentDTO_Tests.swift in Sources */, A3960E0B27DA587B003AB2B0 /* RetryStrategy_Tests.swift in Sources */, + AD0CC0282BDBF9DD005E2C66 /* ReactionListUpdater_Tests.swift in Sources */, 88F6DF94252C8866009A8AF0 /* ChannelMemberUpdater_Tests.swift in Sources */, DB842E4525C9F94C000AAC46 /* LazyCachedMapCollection_Tests.swift in Sources */, 84EB4E76276A012900E47E73 /* ClientError_Tests.swift in Sources */, @@ -11117,6 +11175,7 @@ 84DCB855269F56A7006CDF32 /* EventsController+SwiftUI_Tests.swift in Sources */, 79B5517724E595DA00CE9FEC /* CurrentUserPayloads_Tests.swift in Sources */, 40B345F829C46AE500B96027 /* AudioPlaybackRate_Tests.swift in Sources */, + AD0CC0172BDBC71C005E2C66 /* ReactionListQuery_Tests.swift in Sources */, 7937282C249900CB00E13FE5 /* MemberPayload_Tests.swift in Sources */, DAF1BED82506612B003CEDC0 /* MessageController+Combine_Tests.swift in Sources */, AD45333A25D153CF00CD9D47 /* ConnectionController+SwiftUI_Tests.swift in Sources */, @@ -11128,6 +11187,7 @@ ADC40C3226E26E9F005B616C /* UserSearchController_Tests.swift in Sources */, 40D3962C2A0910CF0020DDC9 /* ArraySampling_Tests.swift in Sources */, ADA9DB8D2BCF2D9700C4AE3B /* ThreadDTO_Tests.swift in Sources */, + AD0CC0212BDBD332005E2C66 /* ReactionEndpoint_Tests.swift in Sources */, 8A0C3BC924C0BBAB00CAFD19 /* UserEvents_Tests.swift in Sources */, A34ECB4627F5C9C200A804C1 /* MessageEvents_IntegrationTests.swift in Sources */, 4042969529FC092F0089126D /* StreamAudioWaveformAnalyser_Tests.swift in Sources */, @@ -11499,6 +11559,7 @@ C121E815274544AD00023E4C /* EventDTOConverterMiddleware.swift in Sources */, C121E816274544AD00023E4C /* EventType.swift in Sources */, AD70DC3A2ADEC3C400CFC3B7 /* MessageModerationDetailsDTO.swift in Sources */, + AD0CC0242BDBF715005E2C66 /* ReactionListUpdater.swift in Sources */, C121E817274544AD00023E4C /* Event.swift in Sources */, C121E818274544AD00023E4C /* EventPayload.swift in Sources */, C121E819274544AD00023E4C /* EventDecoder.swift in Sources */, @@ -11532,6 +11593,7 @@ 40789D2E29F6AC500018C2BB /* AudioRecordingState.swift in Sources */, C14D27B72869EEE40063F6F2 /* Array+CompactMapLoggingError.swift in Sources */, C121E82C274544AD00023E4C /* APIClient.swift in Sources */, + AD0CC0322BDC1964005E2C66 /* ReactionListQueryDTO.swift in Sources */, C121E82D274544AD00023E4C /* CDNClient.swift in Sources */, AD84377F2BB48603000F3826 /* ThreadListPayload.swift in Sources */, C121E82E274544AD00023E4C /* RequestEncoder.swift in Sources */, @@ -11598,6 +11660,7 @@ CFE616BE28348AD000AE2ABF /* ScheduledStreamTimer.swift in Sources */, C121E85E274544AE00023E4C /* UserListUpdater.swift in Sources */, C121E85F274544AE00023E4C /* UserUpdater.swift in Sources */, + AD0CC02C2BDC01A2005E2C66 /* ReactionListController.swift in Sources */, C121E860274544AE00023E4C /* ChannelMemberListUpdater.swift in Sources */, 4042969029FBCE1D0089126D /* AudioSamplesExtractor_Tests.swift in Sources */, C121E861274544AE00023E4C /* ChannelMemberUpdater.swift in Sources */, @@ -11632,6 +11695,7 @@ C121E876274544AF00023E4C /* CurrentUserDTO.swift in Sources */, 841BA9F62BCE8089000C73E4 /* PollsEndpoints.swift in Sources */, AD57979F2978C4F7006CC435 /* UploadedAttachmentPostProcessor.swift in Sources */, + AD0CC02F2BDC08E9005E2C66 /* ChatClient+ReactionListController.swift in Sources */, 4042969929FE92320089126D /* AudioAnalysisEngine_Tests.swift in Sources */, AD84377C2BB482CF000F3826 /* ThreadEndpoints.swift in Sources */, C121E877274544AF00023E4C /* MemberModelDTO.swift in Sources */, @@ -11677,6 +11741,7 @@ C121E88C274544AF00023E4C /* Channel.swift in Sources */, C121E88D274544AF00023E4C /* BanEnabling.swift in Sources */, C121E88E274544AF00023E4C /* ChannelType.swift in Sources */, + AD0CC0352BDC4A6B005E2C66 /* ReactionListController+Combine.swift in Sources */, C121E88F274544AF00023E4C /* CurrentUser.swift in Sources */, 40789D1629F6AC500018C2BB /* AudioPlaybackRate.swift in Sources */, C121E890274544AF00023E4C /* Device.swift in Sources */, @@ -11703,6 +11768,7 @@ C121E89F274544B000023E4C /* MessageSearchController+Combine.swift in Sources */, C121E8A0274544B000023E4C /* MessageSearchController+SwiftUI.swift in Sources */, C121E8A1274544B000023E4C /* UserController.swift in Sources */, + AD0CC01D2BDBD22D005E2C66 /* ReactionEndpoints.swift in Sources */, C121E8A2274544B000023E4C /* UserController+Combine.swift in Sources */, C121E8A3274544B000023E4C /* UserController+SwiftUI.swift in Sources */, C121E8A4274544B000023E4C /* MemberController.swift in Sources */, @@ -11726,6 +11792,7 @@ C121E8B2274544B000023E4C /* ChannelListController+Combine.swift in Sources */, C121E8B3274544B000023E4C /* CurrentUserController.swift in Sources */, C174E0F7284DFA5A0040B936 /* IdentifiablePayload.swift in Sources */, + AD0CC0382BDC4B5A005E2C66 /* ReactionListController+SwiftUI.swift in Sources */, 841BAA052BCE94F8000C73E4 /* QueryPollsRequestBody.swift in Sources */, C121E8B4274544B000023E4C /* CurrentUserController+SwiftUI.swift in Sources */, 40789D1E29F6AC500018C2BB /* AudioPlayingDelegate.swift in Sources */, @@ -11826,6 +11893,7 @@ 404296DB2A0112D00089126D /* AudioQueuePlayer.swift in Sources */, 43D3F0FA284106EE00B74921 /* CallEndpoints.swift in Sources */, 40A458EE2A03AC7C00C198F7 /* AVAsset+TotalAudioSamples.swift in Sources */, + AD0CC0132BDBC1BF005E2C66 /* ReactionListQuery.swift in Sources */, C121E8F7274544B200023E4C /* FoundationSecurity.swift in Sources */, C121E8F8274544B200023E4C /* Data+Extensions.swift in Sources */, C121E8F9274544B200023E4C /* Server.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift index 3c39a0bda92..cddddf59856 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Database/DatabaseSession_Mock.swift @@ -57,7 +57,11 @@ class DatabaseSession_Mock: DatabaseSession { func saveChannelList(payload: ChannelListPayload, query: ChannelListQuery?) -> [ChannelDTO] { return underlyingSession.saveChannelList(payload: payload, query: query) } - + + func saveQuery(query: ReactionListQuery) throws -> ReactionListQueryDTO? { + return try underlyingSession.saveQuery(query: query) + } + func saveChannel( payload: ChannelDetailPayload, query: ChannelListQuery?, @@ -213,13 +217,13 @@ class DatabaseSession_Mock: DatabaseSession { underlyingSession.reaction(messageId: messageId, userId: userId, type: type) } - func saveReaction(payload: MessageReactionPayload, cache: PreWarmedCache?) throws -> MessageReactionDTO { + func saveReaction(payload: MessageReactionPayload, query: ReactionListQuery?, cache: PreWarmedCache?) throws -> MessageReactionDTO { try throwErrorIfNeeded() - return try underlyingSession.saveReaction(payload: payload, cache: cache) + return try underlyingSession.saveReaction(payload: payload, query: query, cache: cache) } - func saveReactions(payload: MessageReactionsPayload) -> [MessageReactionDTO] { - return underlyingSession.saveReactions(payload: payload) + func saveReactions(payload: MessageReactionsPayload, query: ReactionListQuery?) -> [MessageReactionDTO] { + return underlyingSession.saveReactions(payload: payload, query: query) } func delete(reaction: MessageReactionDTO) { diff --git a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift index aa20b562ca5..be6540e91c6 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/MessageEndpoints_Tests.swift @@ -115,73 +115,6 @@ final class MessageEndpoints_Tests: XCTestCase { XCTAssertEqual("messages/\(messageId)/replies", endpoint.path.value) } - func test_loadReactions_buildsCorrectly() { - let messageId: MessageId = "ID" - let pagination: Pagination = .init(pageSize: 10) - - let endpoint: Endpoint = .loadReactions( - messageId: messageId, - pagination: pagination - ) - - XCTAssertEqual(endpoint.path.value, "messages/ID/reactions") - XCTAssertEqual(endpoint.method, .get) - XCTAssertTrue(endpoint.queryItems == nil) - XCTAssertEqual(endpoint.requiresConnectionId, false) - XCTAssertEqual(endpoint.body?.asAnyEncodable, pagination.asAnyEncodable) - } - - func test_addReaction_buildsCorrectly() { - let messageId: MessageId = .unique - let reaction: MessageReactionType = .init(rawValue: "like") - let score = 5 - let extraData: [String: RawJSON] = [:] - - let expectedEndpoint = Endpoint( - path: .addReaction(messageId), - method: .post, - queryItems: nil, - requiresConnectionId: false, - body: MessageReactionRequestPayload( - enforceUnique: false, - reaction: ReactionRequestPayload(type: reaction, score: score, extraData: extraData) - ) - ) - - // Build endpoint. - let endpoint: Endpoint = .addReaction( - reaction, - score: score, - enforceUnique: false, - extraData: extraData, - messageId: messageId - ) - - // Assert endpoint is built correctly. - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("messages/\(messageId)/reaction", endpoint.path.value) - } - - func test_deleteReaction_buildsCorrectly() { - let messageId: MessageId = .unique - let reaction: MessageReactionType = .init(rawValue: "like") - - let expectedEndpoint = Endpoint( - path: .deleteReaction(messageId, reaction), - method: .delete, - queryItems: nil, - requiresConnectionId: false, - body: nil - ) - - // Build endpoint. - let endpoint: Endpoint = .deleteReaction(reaction, messageId: messageId) - - // Assert endpoint is built correctly - XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) - XCTAssertEqual("messages/\(messageId)/reaction/\(reaction.rawValue)", endpoint.path.value) - } - func test_sendMessageAction_buildsCorrectly() { let cid: ChannelId = .unique let messageId: MessageId = .unique diff --git a/Tests/StreamChatTests/APIClient/Endpoints/ReactionEndpoint_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/ReactionEndpoint_Tests.swift new file mode 100644 index 00000000000..d82c0b5a944 --- /dev/null +++ b/Tests/StreamChatTests/APIClient/Endpoints/ReactionEndpoint_Tests.swift @@ -0,0 +1,95 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ReactionEndpoints_Tests: XCTestCase { + func test_loadReactions_buildsCorrectly() { + let messageId: MessageId = "ID" + let pagination: Pagination = .init(pageSize: 10) + + let endpoint: Endpoint = .loadReactions( + messageId: messageId, + pagination: pagination + ) + + XCTAssertEqual(endpoint.path.value, "messages/ID/reactions") + XCTAssertEqual(endpoint.method, .get) + XCTAssertTrue(endpoint.queryItems == nil) + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertEqual(endpoint.body?.asAnyEncodable, pagination.asAnyEncodable) + } + + func test_loadReactionsV2_buildsCorrectly() { + let messageId: MessageId = "ID" + let query: ReactionListQuery = .init( + messageId: messageId, + pagination: .init(pageSize: 20, offset: 0), + filter: .equal(.reactionType, to: "like") + ) + + let endpoint: Endpoint = .loadReactionsV2( + query: query + ) + + XCTAssertEqual(endpoint.path.value, "messages/ID/reactions") + XCTAssertEqual(endpoint.method, .post) + XCTAssertTrue(endpoint.queryItems == nil) + XCTAssertEqual(endpoint.requiresConnectionId, false) + XCTAssertEqual(endpoint.body?.asAnyEncodable, query.asAnyEncodable) + } + + func test_addReaction_buildsCorrectly() { + let messageId: MessageId = .unique + let reaction: MessageReactionType = .init(rawValue: "like") + let score = 5 + let extraData: [String: RawJSON] = [:] + + let expectedEndpoint = Endpoint( + path: .addReaction(messageId), + method: .post, + queryItems: nil, + requiresConnectionId: false, + body: MessageReactionRequestPayload( + enforceUnique: false, + reaction: ReactionRequestPayload(type: reaction, score: score, extraData: extraData) + ) + ) + + // Build endpoint. + let endpoint: Endpoint = .addReaction( + reaction, + score: score, + enforceUnique: false, + extraData: extraData, + messageId: messageId + ) + + // Assert endpoint is built correctly. + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reaction", endpoint.path.value) + } + + func test_deleteReaction_buildsCorrectly() { + let messageId: MessageId = .unique + let reaction: MessageReactionType = .init(rawValue: "like") + + let expectedEndpoint = Endpoint( + path: .deleteReaction(messageId, reaction), + method: .delete, + queryItems: nil, + requiresConnectionId: false, + body: nil + ) + + // Build endpoint. + let endpoint: Endpoint = .deleteReaction(reaction, messageId: messageId) + + // Assert endpoint is built correctly + XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint)) + XCTAssertEqual("messages/\(messageId)/reaction/\(reaction.rawValue)", endpoint.path.value) + } +} diff --git a/Tests/StreamChatTests/Database/DTOs/MessageReactionDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageReactionDTO_Tests.swift index 7b1ec7be65f..a4353a3ca00 100644 --- a/Tests/StreamChatTests/Database/DTOs/MessageReactionDTO_Tests.swift +++ b/Tests/StreamChatTests/Database/DTOs/MessageReactionDTO_Tests.swift @@ -58,7 +58,7 @@ final class MessageReactionDTO_Tests: XCTestCase { // Assert saving message reaction with the message throws `MessageDoesNotExist` error. XCTAssertThrowsError( try database.writeSynchronously { session in - try session.saveReaction(payload: payload, cache: nil) + try session.saveReaction(payload: payload, query: nil, cache: nil) } ) { error in XCTAssertTrue(error is ClientError.MessageDoesNotExist) @@ -80,7 +80,7 @@ final class MessageReactionDTO_Tests: XCTestCase { // Save message reaction to the database and corrupt extra data. try database.writeSynchronously { session in - try session.saveReaction(payload: payload, cache: nil) + try session.saveReaction(payload: payload, query: nil, cache: nil) } // Load saved message reaction and build the model. @@ -113,7 +113,7 @@ final class MessageReactionDTO_Tests: XCTestCase { try database.writeSynchronously { session in // Save message reaction to the database. - let dto = try session.saveReaction(payload: payload, cache: nil) + let dto = try session.saveReaction(payload: payload, query: nil, cache: nil) // Corrupt extra data. dto.extraData = #"{"invalid": json}"#.data(using: .utf8)! } @@ -151,7 +151,7 @@ final class MessageReactionDTO_Tests: XCTestCase { // Save message reaction to the database. try database.writeSynchronously { session in - try session.saveReaction(payload: payload, cache: nil) + try session.saveReaction(payload: payload, query: nil, cache: nil) } // Delete message reaction from the database. @@ -246,6 +246,7 @@ final class MessageReactionDTO_Tests: XCTestCase { user: .dummy(userId: userId), extraData: [:] ), + query: nil, cache: nil ) reaction.localState = state @@ -267,7 +268,7 @@ final class MessageReactionDTO_Tests: XCTestCase { // Save message reaction to the database. try database.writeSynchronously { session in - try session.saveReaction(payload: payload, cache: nil) + try session.saveReaction(payload: payload, query: nil, cache: nil) } // Load saved message reaction. diff --git a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift index c89cf337f75..93b6a136d0b 100644 --- a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift +++ b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift @@ -111,7 +111,9 @@ final class DatabaseContainer_Tests: XCTestCase { try messages.forEach { let message = try session.saveMessage(payload: $0, for: cid, syncOwnReactions: true, cache: nil) try session.saveReaction( - payload: .dummy(messageId: message.id, user: .dummy(userId: currentUserId)), cache: nil + payload: .dummy(messageId: message.id, user: .dummy(userId: currentUserId)), + query: .init(messageId: message.id, filter: .equal(.authorId, to: currentUserId)), + cache: nil ) } try session.saveMessage( diff --git a/Tests/StreamChatTests/Query/ReactionListQuery_Tests.swift b/Tests/StreamChatTests/Query/ReactionListQuery_Tests.swift new file mode 100644 index 00000000000..de804ce4c83 --- /dev/null +++ b/Tests/StreamChatTests/Query/ReactionListQuery_Tests.swift @@ -0,0 +1,33 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ReactionListQuery_Tests: XCTestCase { + func test_encode() throws { + let query = ReactionListQuery( + messageId: "123", + pagination: .init(pageSize: 20, offset: 10), + filter: .and([ + .equal(.authorId, to: "123"), + .equal(.reactionType, to: "like") + ]) + ) + + let expectedData: [String: Any] = [ + "offset": 10, + "limit": 20, + "filter": ["$and": [ + ["user_id": ["$eq": "123"]], + ["type": ["$eq": "like"]] + ]] + ] + + let expectedJSON = try JSONSerialization.data(withJSONObject: expectedData, options: []) + let encodedJSON = try JSONEncoder.default.encode(query) + AssertJSONEqual(expectedJSON, encodedJSON) + } +} diff --git a/Tests/StreamChatTests/StreamChatIntegrationTests/ReactionEvents_IntegrationTests.swift b/Tests/StreamChatTests/StreamChatIntegrationTests/ReactionEvents_IntegrationTests.swift index 78e50a1ed04..d6778d52806 100644 --- a/Tests/StreamChatTests/StreamChatIntegrationTests/ReactionEvents_IntegrationTests.swift +++ b/Tests/StreamChatTests/StreamChatIntegrationTests/ReactionEvents_IntegrationTests.swift @@ -168,7 +168,7 @@ final class ReactionEvents_IntegrationTests: XCTestCase { try session.saveUser(payload: user) _ = try session.saveChannel(payload: channel, query: nil, cache: nil) _ = try session.saveMessage(payload: message, for: channel.cid, cache: nil) - try session.saveReaction(payload: reaction, cache: nil) + try session.saveReaction(payload: reaction, query: nil, cache: nil) // Assert event can be created and has correct fields let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? ReactionNewEvent) @@ -210,7 +210,7 @@ final class ReactionEvents_IntegrationTests: XCTestCase { try session.saveUser(payload: user) _ = try session.saveChannel(payload: channel, query: nil, cache: nil) _ = try session.saveMessage(payload: message, for: channel.cid, cache: nil) - try session.saveReaction(payload: reaction, cache: nil) + try session.saveReaction(payload: reaction, query: nil, cache: nil) // Assert event can be created and has correct fields let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? ReactionUpdatedEvent) @@ -252,7 +252,7 @@ final class ReactionEvents_IntegrationTests: XCTestCase { try session.saveUser(payload: user) _ = try session.saveChannel(payload: channel, query: nil, cache: nil) _ = try session.saveMessage(payload: message, for: channel.cid, cache: nil) - try session.saveReaction(payload: reaction, cache: nil) + try session.saveReaction(payload: reaction, query: nil, cache: nil) // Assert event can be created and has correct fields let event = try XCTUnwrap(dto.toDomainEvent(session: session) as? ReactionDeletedEvent) diff --git a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift index ac68234d5e1..d0eb137d85e 100644 --- a/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift +++ b/Tests/StreamChatTests/Workers/MessageUpdater_Tests.swift @@ -1650,7 +1650,7 @@ final class MessageUpdater_Tests: XCTestCase { messageId: messageId, user: .dummy(userId: userId), extraData: [:] - ), cache: nil) + ), query: nil, cache: nil) } // Simulate `deleteReaction` call. @@ -1699,7 +1699,7 @@ final class MessageUpdater_Tests: XCTestCase { messageId: messageId, user: .dummy(userId: userId), extraData: [:] - ), cache: nil) + ), query: nil, cache: nil) } recreateUpdater(isLocalStorageEnabled: true) @@ -1751,7 +1751,7 @@ final class MessageUpdater_Tests: XCTestCase { messageId: messageId, user: .dummy(userId: userId), extraData: [:] - ), cache: nil) + ), query: nil, cache: nil) } recreateUpdater(isLocalStorageEnabled: false) diff --git a/Tests/StreamChatTests/Workers/ReactionListUpdater_Tests.swift b/Tests/StreamChatTests/Workers/ReactionListUpdater_Tests.swift new file mode 100644 index 00000000000..d8ecc29c454 --- /dev/null +++ b/Tests/StreamChatTests/Workers/ReactionListUpdater_Tests.swift @@ -0,0 +1,101 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class ReactionListUpdater_Tests: XCTestCase { + var apiClient: APIClient_Spy! + var database: DatabaseContainer! + var reactionListUpdater: ReactionListUpdater! + + override func setUp() { + super.setUp() + + apiClient = APIClient_Spy() + database = DatabaseContainer_Spy() + reactionListUpdater = ReactionListUpdater(database: database, apiClient: apiClient) + } + + override func tearDown() { + apiClient.cleanUp() + + apiClient = nil + reactionListUpdater = nil + database = nil + + super.tearDown() + } + + func test_loadReactions_whenSuccessful() throws { + let messageId = MessageId.unique + let channelId = ChannelId.unique + try database.writeSynchronously { session in + try session.saveChannel(payload: .dummy(channel: .dummy(cid: channelId))) + try session.saveMessage( + payload: .dummy(messageId: messageId), + for: channelId, + syncOwnReactions: false, + cache: nil + ) + } + + let payload = MessageReactionsPayload(reactions: [ + .dummy(messageId: messageId, user: .dummy(userId: .unique)), + .dummy(messageId: messageId, user: .dummy(userId: .unique)), + .dummy(messageId: messageId, user: .dummy(userId: .unique)) + ]) + let query = ReactionListQuery( + messageId: messageId, + pagination: .init(pageSize: 10, offset: 0), + filter: .equal(.reactionType, to: "like") + ) + + let completionCalled = expectation(description: "completion called") + reactionListUpdater.loadReactions(query: query) { result in + XCTAssertNil(result.error) + XCTAssertEqual(result.value?.count, 3) + completionCalled.fulfill() + } + + apiClient.test_simulateResponse(.success(payload)) + + wait(for: [completionCalled], timeout: defaultTimeout) + + let referenceEndpoint: Endpoint = .loadReactionsV2( + query: query + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) + + let loadedReactions = try payload.reactions.compactMap { + try database.viewContext.reaction(messageId: messageId, userId: $0.user.id, type: $0.type)?.asModel() + } + XCTAssertEqual(loadedReactions.count, 3) + } + + func test_loadReactions_whenFailure() throws { + let messageId = MessageId.unique + let query = ReactionListQuery( + messageId: messageId, + pagination: .init(pageSize: 10, offset: 0), + filter: .equal(.reactionType, to: "like") + ) + let completionCalled = expectation(description: "completion called") + reactionListUpdater.loadReactions(query: query) { result in + XCTAssertNotNil(result.error) + completionCalled.fulfill() + } + + let error = TestError() + apiClient.test_simulateResponse(Result.failure(error)) + + wait(for: [completionCalled], timeout: defaultTimeout) + + let referenceEndpoint: Endpoint = .loadReactionsV2( + query: query + ) + XCTAssertEqual(apiClient.request_endpoint, AnyEndpoint(referenceEndpoint)) + } +} diff --git a/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift b/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift index 65bdaafb457..7caa1886d4e 100644 --- a/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift +++ b/Tests/StreamChatUITests/Extensions/ChatMessage+Equatable_Tests.swift @@ -138,6 +138,7 @@ final class ChatMessage_Equatable_Tests: XCTestCase { let reactions = try (1...numberOfReactions).map { index in try session.saveReaction( payload: .dummy(type: .init(rawValue: "reaction-\(index)"), messageId: message.id, user: self.userPayload(id: index)), + query: nil, cache: nil ) } diff --git a/docusaurus/docs/iOS/uikit/views/reactions.md b/docusaurus/docs/iOS/uikit/views/reactions.md index 13a0901c4f5..cf879ad05a5 100644 --- a/docusaurus/docs/iOS/uikit/views/reactions.md +++ b/docusaurus/docs/iOS/uikit/views/reactions.md @@ -63,6 +63,37 @@ extension MessageReactionType { Components.default.reactionsSorting = { $0.type.position < $1.type.position } ``` +### Querying reactions + +You can query reactions by their type or the author id, to provide an experience similar to Slack (for example, show all users who reacted with "like"). + +To do this, you need to create a `ChatReactionListController`: + +```swift +let reactionListController = client.reactionListController( + query: .init( + messageId: message.id, + filter: .equal(.reactionType, to: reactionType) + ) +) +``` + +The reactions are available via the property `reactionListController.reactions`. You can listen to updates by implementing the delegate method `controller(_ controller:, didChangeReactions:)` + +```swift +func controller(_ controller: ChatReactionListController, didChangeReactions changes: [ListChange]) { + reactions = controller.reactions +} +``` + +In order to load more reactions while paginating, you should call the method `loadMoreReactions`: + +```swift +reactionListController.loadMoreReactions { [weak self] _ in + // handle reactions. +} +``` + ## Message Reactions By default, the message reactions are displayed inline as a bubble view on top of the messages.