diff --git a/CHANGELOG.md b/CHANGELOG.md index 60bd4d9c956..f8159380707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix showing empty search results with background mapping enabled [#3042](https://github.com/GetStream/stream-chat-swift/pull/3042) ## StreamChatUI +### ✅ Added +- Add support for user mentions in channels with 100+ members [#3043](https://github.com/GetStream/stream-chat-swift/pull/3043) ### 🐞 Fixed - Fix composer link preview overridden by previous enrichment [#3025](https://github.com/GetStream/stream-chat-swift/pull/3025) - Fix merged avatars changing sub-image locations when opening channel list [#3013](https://github.com/GetStream/stream-chat-swift/pull/3013) diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift index 6ba2cdff88c..652fb512db2 100644 --- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift +++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift @@ -176,6 +176,7 @@ class AppConfigViewController: UITableViewController { case channelListSearchStrategy case isUnreadMessageSeparatorEnabled case isJumpToUnreadEnabled + case mentionAllAppUsers } enum ChatClientConfigOption: String, CaseIterable { @@ -438,6 +439,10 @@ class AppConfigViewController: UITableViewController { cell.accessoryView = makeSwitchButton(Components.default.isJumpToUnreadEnabled) { newValue in Components.default.isJumpToUnreadEnabled = newValue } + case .mentionAllAppUsers: + cell.accessoryView = makeSwitchButton(Components.default.mentionAllAppUsers) { newValue in + Components.default.mentionAllAppUsers = newValue + } } } diff --git a/DemoApp/Screens/MembersViewController.swift b/DemoApp/Screens/MembersViewController.swift index 14d33541f6a..c9c076c90e4 100644 --- a/DemoApp/Screens/MembersViewController.swift +++ b/DemoApp/Screens/MembersViewController.swift @@ -54,7 +54,7 @@ class MembersViewController: UITableViewController, ChatChannelMemberListControl if let imageURL = member.imageURL { Nuke.loadImage(with: imageURL, into: cell.avatarView) } - cell.nameLabel.text = member.name + cell.nameLabel.text = member.name ?? member.id cell.removeButton.isHidden = true return cell } diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift index b8717bf4783..55b303a9508 100644 --- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift +++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift @@ -409,7 +409,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter { guard let cid = channelController.channel?.cid else { return } let client = channelController.client self.rootViewController.present(MembersViewController( - membersController: client.memberListController(query: .init(cid: cid)) + membersController: client.memberListController(query: .init(cid: cid, pageSize: 105)) ), animated: true) }), .init(title: "Show Banned Members", handler: { [unowned self] _ in diff --git a/Sources/StreamChat/Controllers/MemberListController/MemberListController.swift b/Sources/StreamChat/Controllers/MemberListController/MemberListController.swift index 41b685f3fdc..c96e08b74a0 100644 --- a/Sources/StreamChat/Controllers/MemberListController/MemberListController.swift +++ b/Sources/StreamChat/Controllers/MemberListController/MemberListController.swift @@ -98,7 +98,7 @@ public class ChatChannelMemberListController: DataController, DelegateCallable, private func createMemberListObserver() -> ListDatabaseObserverWrapper { let observer = environment.memberListObserverBuilder( - StreamRuntimeCheck._isBackgroundMappingEnabled, + false, client.databaseContainer, MemberDTO.members(matching: query), { try $0.asModel() }, diff --git a/Sources/StreamChat/Controllers/SearchControllers/UserSearchController/UserSearchController.swift b/Sources/StreamChat/Controllers/SearchControllers/UserSearchController/UserSearchController.swift index a206be63303..c4cf6a91099 100644 --- a/Sources/StreamChat/Controllers/SearchControllers/UserSearchController/UserSearchController.swift +++ b/Sources/StreamChat/Controllers/SearchControllers/UserSearchController/UserSearchController.swift @@ -23,7 +23,7 @@ public class ChatUserSearchController: DataController, DelegateCallable, DataSto public let client: ChatClient /// Copy of last search query made, used for getting next page. - private(set) var query: UserListQuery? + public private(set) var query: UserListQuery? /// The users matching the last query of this controller. private var _users: [ChatUser] = [] @@ -111,6 +111,11 @@ public class ChatUserSearchController: DataController, DelegateCallable, DataSto fetch(updatedQuery, completion: completion) } + + /// Clears the current search results. + public func clearResults() { + _users = [] + } } private extension ChatUserSearchController { diff --git a/Sources/StreamChat/Utils/Debouncer.swift b/Sources/StreamChat/Utils/Debouncer.swift index e21b210e124..d0e2170d9d2 100644 --- a/Sources/StreamChat/Utils/Debouncer.swift +++ b/Sources/StreamChat/Utils/Debouncer.swift @@ -35,6 +35,12 @@ public struct Debouncer { public mutating func execute(block: @escaping () -> Void) { /// Cancels the current job if there is one. job?.cancel() + + if interval == .zero { + block() + return + } + /// Creates a new job with the given block and assigns it to the newJob constant. let newJob = DispatchWorkItem { block() } /// Schedules the new job to be executed after a certain time interval on the provided queue. diff --git a/Sources/StreamChatUI/Composer/ComposerVC.swift b/Sources/StreamChatUI/Composer/ComposerVC.swift index 9aec35ec3c8..314030758ee 100644 --- a/Sources/StreamChatUI/Composer/ComposerVC.swift +++ b/Sources/StreamChatUI/Composer/ComposerVC.swift @@ -287,10 +287,15 @@ open class ComposerVC: _ViewController, } } + /// The component responsible for tracking cooldown timing when slow mode is enabled. open var cooldownTracker: CooldownTracker = CooldownTracker(timer: ScheduledStreamTimer(interval: 1)) + /// The debouncer to control requests when enriching urls. public var enrichUrlDebouncer = Debouncer(0.4, queue: .main) + /// The debouncer to control user searching requests when mentioning users. + public var userMentionsDebouncer = Debouncer(0.25, queue: .main) + lazy var linkDetector = TextLinkDetector() /// A symbol that is used to recognise when the user is mentioning a user. @@ -335,6 +340,9 @@ open class ComposerVC: _ViewController, /// A controller to search users and that is used to populate the mention suggestions. open var userSearchController: ChatUserSearchController! + /// A controller to search members in a channel and that is used to populate the mention suggestions. + open var memberListController: ChatChannelMemberListController? + /// A controller that manages the channel that the composer is creating content for. open var channelController: ChatChannelController? @@ -598,7 +606,9 @@ open class ComposerVC: _ViewController, } if isMentionsEnabled, let (typingMention, mentionRange) = typingMention(in: composerView.inputMessageView.textView) { - showMentionSuggestions(for: typingMention, mentionRange: mentionRange) + userMentionsDebouncer.execute { [weak self] in + self?.showMentionSuggestions(for: typingMention, mentionRange: mentionRange) + } return } @@ -875,7 +885,7 @@ open class ComposerVC: _ViewController, showSuggestions() } - /// Returns the query to be used for searching users for the given typing mention. + /// Returns the query to be used for searching users across the whole app. /// /// This function is called in `showMentionSuggestions` to retrieve the query /// that will be used to search the users. You should override this if you want to change the @@ -893,44 +903,54 @@ open class ComposerVC: _ViewController, ) } + /// Returns the query to be used for searching members inside a channel. + /// + /// This function is called in `showMentionSuggestions` to retrieve the query + /// that will be used to search for members. You should override this if you want to change the + /// member searching logic. + /// + /// - Parameter typingMention: The potential user mention the current user is typing. + /// - Returns: `ChannelMemberListQuery` instance that will be used for searching members in a channel. + open func queryForChannelMentionSuggestionsSearch(typingMention term: String) -> ChannelMemberListQuery? { + guard let cid = channelController?.cid else { + return nil + } + return ChannelMemberListQuery( + cid: cid, + filter: .autocomplete(.name, text: term), + sort: [.init(key: .name, isAscending: true)] + ) + } + + /// Returns the member list controller to be used for searching members inside a channel. + /// + /// - Parameter term: The potential user mention the current user is typing. + /// - Returns: `ChatChannelMemberListController` instance that will be used for searching members in a channel. + open func makeMemberListControllerForMemberSuggestions(typingMention term: String) -> ChatChannelMemberListController? { + guard let query = queryForChannelMentionSuggestionsSearch(typingMention: term) else { return nil } + return userSearchController.client.memberListController(query: query) + } + /// Shows the mention suggestions for the potential mention the current user is typing. /// - Parameters: /// - typingMention: The potential user mention the current user is typing. /// - mentionRange: The position where the current user is typing a mention to it can be replaced with the suggestion. open func showMentionSuggestions(for typingMention: String, mentionRange: NSRange) { - guard let channel = channelController?.channel else { + guard !content.text.isEmpty else { + // Because we do not have cancellation, when a mention request is finished it can happen + // that we already published the message, so we don't need to show the suggestions anymore. return } - guard let currentUserId = channelController?.client.currentUserId else { + guard let dataSource = makeMentionSuggestionsDataSource(for: typingMention) else { return } - - var usersCache: [ChatUser] = [] - - if mentionAllAppUsers { - userSearchController.search( - query: queryForMentionSuggestionsSearch(typingMention: typingMention) - ) - } else { - usersCache = searchUsers( - channel.lastActiveMembers, - by: typingMention, - excludingId: currentUserId - ) - } - - let dataSource = ChatMessageComposerSuggestionsMentionDataSource( - collectionView: suggestionsVC.collectionView, - searchController: userSearchController, - usersCache: usersCache - ) suggestionsVC.dataSource = dataSource suggestionsVC.didSelectItemAt = { [weak self] userIndex in guard let self = self else { return } - guard dataSource.usersCache.count >= userIndex else { + guard dataSource.users.count >= userIndex else { return } - guard let user = dataSource.usersCache[safe: userIndex] else { + guard let user = dataSource.users[safe: userIndex] else { indexNotFoundAssertion() return } @@ -938,6 +958,10 @@ open class ComposerVC: _ViewController, let textView = self.composerView.inputMessageView.textView let text = textView.text as NSString let mentionText = self.mentionText(for: user) + guard mentionRange.length <= mentionText.count else { + return self.dismissSuggestions() + } + let newText = text.replacingCharacters(in: mentionRange, with: mentionText) // Add additional spacing to help continue writing the message self.content.text = newText + " " @@ -953,6 +977,88 @@ open class ComposerVC: _ViewController, showSuggestions() } + /// Creates a `ChatMessageComposerSuggestionsMentionDataSource` with data from local cache, user search or channel members. + /// The source of the data will depend on `mentionAllAppUsers` flag and the amount of members in the channel. + /// - Parameter typingMention: The potential user mention the current user is typing. + /// - Returns: A `ChatMessageComposerSuggestionsMentionDataSource` instance. + public func makeMentionSuggestionsDataSource(for typingMention: String) -> ChatMessageComposerSuggestionsMentionDataSource? { + guard let channel = channelController?.channel else { + return nil + } + + guard let currentUserId = channelController?.client.currentUserId else { + return nil + } + + let trimmedTypingMention = typingMention.trimmingCharacters(in: .whitespacesAndNewlines) + let mentionedUsersNames = content.mentionedUsers.map(\.name) + let mentionedUsersIds = content.mentionedUsers.map(\.id) + let mentionIsAlreadyPresent = mentionedUsersNames.contains(trimmedTypingMention) || mentionedUsersIds.contains(trimmedTypingMention) + let shouldShowEmptyMentions = typingMention.isEmpty || mentionIsAlreadyPresent + + // Because we re-create the ChatMessageComposerSuggestionsMentionDataSource always from scratch + // We lose the results of the previous search query, so we need to provide it manually. + let initialUsers: (String, [ChatUser]) -> [ChatUser] = { previousQuery, previousResult in + if typingMention.isEmpty { + return [] + } + if typingMention.hasPrefix(previousQuery) || previousQuery.hasPrefix(typingMention) { + return previousResult + } + return [] + } + + if mentionAllAppUsers { + var previousResult = userSearchController.userArray + let previousQuery = (userSearchController?.query?.filter?.value as? String) ?? "" + if shouldShowEmptyMentions { + userSearchController.clearResults() + previousResult = [] + } else { + userSearchController.search( + query: queryForMentionSuggestionsSearch(typingMention: typingMention) + ) + } + return ChatMessageComposerSuggestionsMentionDataSource( + collectionView: suggestionsVC.collectionView, + searchController: userSearchController, + memberListController: nil, + initialUsers: initialUsers(previousQuery, previousResult) + ) + } + + let memberCount = channel.memberCount + if memberCount > channel.lastActiveMembers.count { + var previousResult = Array(memberListController?.members ?? []) + let previousQuery = (memberListController?.query.filter?.value as? String) ?? "" + memberListController = makeMemberListControllerForMemberSuggestions(typingMention: typingMention) + if shouldShowEmptyMentions { + memberListController = nil + previousResult = [] + } else { + memberListController?.synchronize() + } + return ChatMessageComposerSuggestionsMentionDataSource( + collectionView: suggestionsVC.collectionView, + searchController: userSearchController, + memberListController: memberListController, + initialUsers: initialUsers(previousQuery, previousResult) + ) + } + + let usersCache = searchUsers( + channel.lastActiveMembers, + by: typingMention, + excludingId: currentUserId + ) + return ChatMessageComposerSuggestionsMentionDataSource( + collectionView: suggestionsVC.collectionView, + searchController: userSearchController, + memberListController: nil, + initialUsers: usersCache + ) + } + /// Provides the mention text for composer text field, when the user selects a mention suggestion. open func mentionText(for user: ChatUser) -> String { if let name = user.name, !name.isEmpty { diff --git a/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift b/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift index 354d74784ee..7769f935407 100644 --- a/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift +++ b/Sources/StreamChatUI/Composer/Suggestions/ChatSuggestionsVC.swift @@ -190,16 +190,20 @@ open class ChatMessageComposerSuggestionsCommandDataSource: NSObject, UICollecti open class ChatMessageComposerSuggestionsMentionDataSource: NSObject, UICollectionViewDataSource, - ChatUserSearchControllerDelegate { - /// internal cache for users - private(set) var usersCache: [ChatUser] + ChatUserSearchControllerDelegate, + ChatChannelMemberListControllerDelegate { + /// The current users mentions. + private(set) var users: [ChatUser] /// The collection view of the mentions. open var collectionView: ChatSuggestionsCollectionView - /// The search controller to search for mentions. + /// The search controller to search for mentions across the whole app. open var searchController: ChatUserSearchController + /// The member list controller to search for users inside a channel. + open var memberListController: ChatChannelMemberListController? + /// The types to override ui components. var components: Components { collectionView.components @@ -209,20 +213,24 @@ open class ChatMessageComposerSuggestionsMentionDataSource: NSObject, /// - Parameters: /// - collectionView: The collection view of the mentions. /// - searchController: The search controller to find mentions. - /// - usersCache: The initial results + /// - memberListController: The member list controller to search for users inside a channel. + /// - usersCache: The initial results. init( collectionView: ChatSuggestionsCollectionView, searchController: ChatUserSearchController, - usersCache: [ChatUser] = [] + memberListController: ChatChannelMemberListController?, + initialUsers: [ChatUser] ) { self.collectionView = collectionView self.searchController = searchController - self.usersCache = usersCache + self.memberListController = memberListController + users = initialUsers super.init() registerCollectionViewCell() (collectionView.collectionViewLayout as? UICollectionViewFlowLayout)? .headerReferenceSize = CGSize(width: self.collectionView.frame.size.width, height: 0) searchController.delegate = self + memberListController?.delegate = self } private func registerCollectionViewCell() { @@ -241,13 +249,13 @@ open class ChatMessageComposerSuggestionsMentionDataSource: NSObject, } public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - usersCache.count + users.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(with: ChatMentionSuggestionCollectionViewCell.self, for: indexPath) - guard let user = usersCache[safe: indexPath.row] else { + guard let user = users[safe: indexPath.row] else { indexNotFoundAssertion() return cell } @@ -262,7 +270,22 @@ open class ChatMessageComposerSuggestionsMentionDataSource: NSObject, _ controller: ChatUserSearchController, didChangeUsers changes: [ListChange] ) { - usersCache = searchController.userArray + users = searchController.userArray + collectionView.reloadData() + } + + public func memberListController( + _ controller: ChatChannelMemberListController, + didChangeMembers changes: [ListChange] + ) { + users = Array(controller.members) collectionView.reloadData() } + + public func controller(_ controller: DataController, didChangeState state: DataController.State) { + if let memberListController = controller as? ChatChannelMemberListController { + users = Array(memberListController.members) + collectionView.reloadData() + } + } } diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift index 5e844b5d663..0bd645e1b04 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ChatClient_Mock.swift @@ -177,6 +177,10 @@ extension ChatClient { offlineRequestsRepository as! OfflineRequestsRepository_Mock } + var mockAuthenticationRepository: AuthenticationRepository_Mock { + authenticationRepository as! AuthenticationRepository_Mock + } + func simulateProvidedConnectionId(connectionId: ConnectionId?) { guard let connectionId = connectionId else { webSocketClient( diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatChannelController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatChannelController_Mock.swift index 76f3d000c3c..71cbaf74343 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatChannelController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatChannelController_Mock.swift @@ -26,6 +26,15 @@ public class ChatChannelController_Mock: ChatChannelController { ) } + /// Creates a new mock instance of `ChatChannelController`. + static func mock(chatClient: ChatClient_Mock) -> ChatChannelController_Mock { + .init( + channelQuery: .init(cid: try! .init(cid: "mock:channel")), + channelListQuery: nil, + client: chatClient + ) + } + public static func mock( channelQuery: ChannelQuery, channelListQuery: ChannelListQuery?, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatUserSearchController_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatUserSearchController_Mock.swift index 555235b41a1..004f2dbc50b 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatUserSearchController_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Controllers/Controllers/ChatUserSearchController_Mock.swift @@ -6,6 +6,9 @@ import Foundation @testable import StreamChat public class ChatUserSearchController_Mock: ChatUserSearchController { + + var searchCallCount = 0 + public static func mock(client: ChatClient? = nil) -> ChatUserSearchController_Mock { .init(client: client ?? .mock()) } @@ -16,10 +19,12 @@ public class ChatUserSearchController_Mock: ChatUserSearchController { } override public func search(query: UserListQuery, completion: ((Error?) -> Void)? = nil) { + searchCallCount += 1 completion?(nil) } override public func search(term: String?, completion: ((Error?) -> Void)? = nil) { + searchCallCount += 1 users_mock = users_mock?.filter { user in user.name?.contains(term ?? "") ?? true } diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift index 80046fb80ec..8d2bf5fb811 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/Suggestions/ChatSuggestionsVC_Tests.swift @@ -140,7 +140,9 @@ final class ChatSuggestionsVC_Tests: XCTestCase { searchController.users_mock = [] vc.dataSource = ChatMessageComposerSuggestionsMentionDataSource( collectionView: vc.collectionView, - searchController: searchController + searchController: searchController, + memberListController: nil, + initialUsers: [] ) AssertSnapshot(vc, variants: .onlyUserInterfaceStyles, screenSize: defaultSuggestionsSize) @@ -152,7 +154,8 @@ final class ChatSuggestionsVC_Tests: XCTestCase { vc.dataSource = ChatMessageComposerSuggestionsMentionDataSource( collectionView: vc.collectionView, searchController: searchController, - usersCache: mentions + memberListController: nil, + initialUsers: mentions ) vc.components = .mock @@ -188,7 +191,8 @@ final class ChatSuggestionsVC_Tests: XCTestCase { vc.dataSource = ChatMessageComposerSuggestionsMentionDataSource( collectionView: vc.collectionView, searchController: searchController, - usersCache: mentions + memberListController: nil, + initialUsers: mentions ) AssertSnapshot(vc, variants: .onlyUserInterfaceStyles, screenSize: defaultSuggestionsSize) @@ -216,7 +220,8 @@ final class ChatSuggestionsVC_Tests: XCTestCase { vc.dataSource = ChatMessageComposerSuggestionsMentionDataSource( collectionView: vc.collectionView, searchController: searchController, - usersCache: mentions + memberListController: nil, + initialUsers: mentions ) AssertSnapshot(vc, variants: [.defaultLight], screenSize: defaultSuggestionsSize) diff --git a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift index 242e939cff4..964541b6a49 100644 --- a/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/Composer/ComposerVC_Tests.swift @@ -25,7 +25,9 @@ final class ComposerVC_Tests: XCTestCase { override func setUp() { super.setUp() - mockedChatChannelController = ChatChannelController_Mock.mock() + let chatClient = ChatClient_Mock.mock + chatClient.mockAuthenticationRepository.mockedCurrentUserId = .newUniqueId + mockedChatChannelController = ChatChannelController_Mock.mock(chatClient: chatClient) mockedChatChannelController.channel_mock = .mock( cid: .unique, config: .mock(commands: []), @@ -287,12 +289,82 @@ final class ComposerVC_Tests: XCTestCase { let containerVC = ComposerContainerVC() containerVC.composerVC = composerVC + containerVC.composerVC.userMentionsDebouncer = .init(0, queue: .main) containerVC.textWithMention = "@Yo" containerVC.composerVC.composerView.inputMessageView.textView.placeholderLabel.isHidden = true AssertSnapshot(containerVC, variants: [.defaultLight]) } - + + func test_makeMentionSuggestionsDataSource_whenMentionAllAppUsers_shouldSearchUsers() throws { + composerVC.components.mentionAllAppUsers = true + composerVC.channelController = mockedChatChannelController + let mockedSearchController = ChatUserSearchController_Mock.mock() + mockedSearchController.users_mock = [.mock(id: .unique, name: "1"), .mock(id: .unique, name: "2")] + composerVC.userSearchController = mockedSearchController + + // When empty, should not search. + _ = composerVC.makeMentionSuggestionsDataSource(for: "") + XCTAssertEqual(mockedSearchController.searchCallCount, 0) + + // When mention already exists, should not search. + composerVC.content.mentionedUsers.insert(.mock(id: .unique, name: "Han Solo")) + let existingMentionDataSource = try XCTUnwrap(composerVC.makeMentionSuggestionsDataSource(for: "Han Solo")) + XCTAssertEqual(mockedSearchController.searchCallCount, 0) + + // Happy path + let dataSource = try XCTUnwrap(composerVC.makeMentionSuggestionsDataSource(for: "Leia")) + XCTAssertNil(dataSource.memberListController) + XCTAssertEqual(dataSource.users, mockedSearchController.users_mock ?? []) + XCTAssertEqual(mockedSearchController.searchCallCount, 1) + } + + func test_makeMentionSuggestionsDataSource_whenMentionAllAppUsersIsFalse_whenMemberCountBiggerThanLocalMembers_shouldSearchChannelMembers() throws { + composerVC.components.mentionAllAppUsers = false + composerVC.channelController = mockedChatChannelController + mockedChatChannelController.channel_mock = .mock(cid: .unique, lastActiveMembers: [.dummy, .dummy], memberCount: 10) + let mockedSearchController = ChatUserSearchController_Mock.mock() + mockedSearchController.users_mock = [] + composerVC.userSearchController = mockedSearchController + + // When empty, should not create member list controller. + let emptyDataSource = try XCTUnwrap(composerVC.makeMentionSuggestionsDataSource(for: "")) + XCTAssertNil(emptyDataSource.memberListController) + + // When mention already exists, should not create member list controller. + composerVC.content.mentionedUsers.insert(.mock(id: .unique, name: "Han Solo")) + let existingMentionDataSource = try XCTUnwrap(composerVC.makeMentionSuggestionsDataSource(for: "Han Solo")) + XCTAssertNil(existingMentionDataSource.memberListController) + + // Happy path + let dataSource = try XCTUnwrap(composerVC.makeMentionSuggestionsDataSource(for: "Leia")) + XCTAssertNotNil(dataSource.memberListController) + XCTAssertEqual(dataSource.memberListController?.state, .localDataFetched) + XCTAssertEqual(mockedSearchController.searchCallCount, 0) + } + + func test_makeMentionSuggestionsDataSource_whenMentionAllAppUsersIsFalse_whenMemberCountLowerThanLocalMembers_shoudSearchLocalMembers() throws { + composerVC.components.mentionAllAppUsers = false + composerVC.channelController = mockedChatChannelController + mockedChatChannelController.channel_mock = .mock( + cid: .unique, + lastActiveMembers: [.mock(id: .unique, name: "Leia Organa"), .mock(id: .unique, name: "Leia Rockstar")], + memberCount: 2 + ) + let mockedSearchController = ChatUserSearchController_Mock.mock() + mockedSearchController.users_mock = [] + composerVC.userSearchController = mockedSearchController + + // When empty, should still return a data source to include all local members. + XCTAssertNotNil(composerVC.makeMentionSuggestionsDataSource(for: "")) + + let dataSource = try XCTUnwrap(composerVC.makeMentionSuggestionsDataSource(for: "Leia")) + XCTAssertNil(dataSource.memberListController) + XCTAssertEqual(dataSource.users, mockedChatChannelController.channel?.lastActiveMembers) + XCTAssertEqual(dataSource.users.isEmpty, false) + XCTAssertEqual(mockedSearchController.searchCallCount, 0) + } + func test_channelWithSlowModeActive_messageIsSent_SlowModeIsOnWithCountdownShown() { composerVC.appearance = Appearance.default composerVC.content.text = "Test text"