diff --git a/CHANGELOG.md b/CHANGELOG.md index 075e6a38231..38aedbbf39e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ## StreamChat +### ✅ Added +- Add `clearResults()` to user, member, role, and user group search APIs [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) +- Add `UserGroupSearchController` for debounced user group search [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) +- Add `MemberSearch` for debounced channel member search [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) ### 🐞 Fixed - Fix unread count not clearing immediately when marking a channel as read [#4214](https://github.com/GetStream/stream-chat-swift/pull/4214) +- Fix mention suggestions not showing members in channels with 100+ members in the `MentionSuggestionsProvider` [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) +### 🔄 Changed +- Apply dynamic search debouncing to `UserGroupSearch` [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) +- Apply dynamic search debouncing to `RoleSearch` and `RoleSearchController` [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213) ## StreamChatUI ### 🐞 Fixed diff --git a/Sources/StreamChat/Controllers/RoleSearchController/RoleSearchController.swift b/Sources/StreamChat/Controllers/RoleSearchController/RoleSearchController.swift index 4608a38c036..f2e84ed5f69 100644 --- a/Sources/StreamChat/Controllers/RoleSearchController/RoleSearchController.swift +++ b/Sources/StreamChat/Controllers/RoleSearchController/RoleSearchController.swift @@ -13,7 +13,12 @@ public extension ChatClient { /// A controller for searching roles. /// +/// Text searches are debounced: 500ms for 1-2 characters, 300ms for 3 or more. +/// Scheduling a new search cancels any pending debounced work. +/// /// Results are replaced on every new search. +/// +/// - Note: For an async-await alternative, please check ``RoleSearch``. public class RoleSearchController: DataController, DelegateCallable, DataStoreProvider, @unchecked Sendable { /// The `ChatClient` instance this controller belongs to. public let client: ChatClient @@ -44,13 +49,22 @@ public class RoleSearchController: DataController, DelegateCallable, DataStorePr } private let rolesRepository: RolesRepository + private let searchDebouncer: SearchDebouncer - init(client: ChatClient) { + init( + client: ChatClient, + debouncePolicy: SearchDebouncePolicy = .default + ) { self.client = client rolesRepository = client.rolesRepository + searchDebouncer = SearchDebouncer(policy: debouncePolicy) super.init() } + deinit { + searchDebouncer.cancel() + } + /// Searches roles by name. /// /// The `roles` property is updated with the results on completion. @@ -74,12 +88,41 @@ public class RoleSearchController: DataController, DelegateCallable, DataStorePr /// /// The `roles` property is updated with the results on completion. /// + /// - Note: Searches are debounced on the length of ``RoleSearchQuery/query``. + /// /// - Parameters: /// - query: The query describing the search term and filters. /// - completion: Called with the matching roles or an error. public func searchRoles( query: RoleSearchQuery, completion: (@MainActor (Result<[Role], Error>) -> Void)? = nil + ) { + let scheduled = searchDebouncer.schedule(queryLength: query.query.count) { [weak self] in + self?.fetch(query, completion: completion) + } + if !scheduled { + callback { [weak self] in + completion?(.success(self?.roles ?? [])) + } + } + } + + /// Cancels any pending search and clears the current results. + public func clearResults() { + searchDebouncer.cancel() + let previousRoles = roles + guard !previousRoles.isEmpty else { return } + + roles = [] + delegateCallback { [weak self] in + guard let self else { return } + $0.controller(self, didChangeRoles: self.roles) + } + } + + private func fetch( + _ query: RoleSearchQuery, + completion: (@MainActor (Result<[Role], Error>) -> Void)? ) { rolesRepository.searchRoles(query: query) { [weak self] result in guard let self else { return } diff --git a/Sources/StreamChat/Controllers/UserGroupSearchController/UserGroupSearchController.swift b/Sources/StreamChat/Controllers/UserGroupSearchController/UserGroupSearchController.swift new file mode 100644 index 00000000000..4a0d62bf660 --- /dev/null +++ b/Sources/StreamChat/Controllers/UserGroupSearchController/UserGroupSearchController.swift @@ -0,0 +1,151 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +public extension ChatClient { + /// Creates a new `UserGroupSearchController`. + func userGroupSearchController() -> UserGroupSearchController { + .init(client: self) + } +} + +/// A controller for searching user groups. +/// +/// Text searches are debounced: 500ms for 1-2 characters, 300ms for 3 or more. +/// Scheduling a new search cancels any pending debounced work. +/// +/// Results are replaced on every new search. +/// +/// - Note: For an async-await alternative, please check ``UserGroupSearch``. +public class UserGroupSearchController: DataController, DelegateCallable, DataStoreProvider, @unchecked Sendable { + /// The `ChatClient` instance this controller belongs to. + public let client: ChatClient + + /// The user groups returned by the last search. + public private(set) var userGroups: [UserGroup] = [] + + /// Set the delegate of `UserGroupSearchController` to observe changes. + public weak var delegate: UserGroupSearchControllerDelegate? { + get { multicastDelegate.mainDelegate } + set { multicastDelegate.set(mainDelegate: newValue) } + } + + var multicastDelegate: MulticastDelegate = .init() { + didSet { + stateMulticastDelegate.set(mainDelegate: multicastDelegate.mainDelegate) + stateMulticastDelegate.set(additionalDelegates: multicastDelegate.additionalDelegates) + } + } + + private let userGroupsRepository: UserGroupsRepository + private let searchDebouncer: SearchDebouncer + + init( + client: ChatClient, + debouncePolicy: SearchDebouncePolicy = .default + ) { + self.client = client + userGroupsRepository = client.userGroupsRepository + searchDebouncer = SearchDebouncer(policy: debouncePolicy) + super.init() + } + + deinit { + searchDebouncer.cancel() + } + + /// Searches user groups by name. + /// + /// The `userGroups` property is updated with the results on completion. + /// + /// - Parameters: + /// - text: The search term used to match group names. + /// - teamId: When set, restricts the search to groups scoped to the given team. + /// - completion: Called with the matching user groups or an error. + public func searchUserGroups( + text: String, + teamId: String? = nil, + completion: (@MainActor (Result<[UserGroup], Error>) -> Void)? = nil + ) { + searchUserGroups( + query: UserGroupSearchQuery(query: text, teamId: teamId), + completion: completion + ) + } + + /// Searches user groups using the provided query. + /// + /// The `userGroups` property is updated with the results on completion. + /// + /// - Note: Searches are debounced on the length of ``UserGroupSearchQuery/query``. + /// + /// - Parameters: + /// - query: The query describing the search term and filters. + /// - completion: Called with the matching user groups or an error. + public func searchUserGroups( + query: UserGroupSearchQuery, + completion: (@MainActor (Result<[UserGroup], Error>) -> Void)? = nil + ) { + let scheduled = searchDebouncer.schedule(queryLength: query.query.count) { [weak self] in + self?.fetch(query, completion: completion) + } + if !scheduled { + callback { [weak self] in + completion?(.success(self?.userGroups ?? [])) + } + } + } + + /// Cancels any pending search and clears the current results. + public func clearResults() { + searchDebouncer.cancel() + let previousUserGroups = userGroups + guard !previousUserGroups.isEmpty else { return } + + userGroups = [] + delegateCallback { [weak self] in + guard let self else { return } + $0.controller(self, didChangeUserGroups: self.userGroups) + } + } + + private func fetch( + _ query: UserGroupSearchQuery, + completion: (@MainActor (Result<[UserGroup], Error>) -> Void)? + ) { + userGroupsRepository.searchUserGroups(query: query) { [weak self] result in + guard let self else { return } + switch result { + case .success(let fetchedUserGroups): + self.userGroups = fetchedUserGroups + self.state = .remoteDataFetched + self.delegateCallback { [weak self] in + guard let self else { return } + $0.controller(self, didChangeUserGroups: self.userGroups) + } + self.callback { completion?(.success(fetchedUserGroups)) } + case .failure(let error): + self.state = .remoteDataFetchFailed(ClientError(with: error)) + self.callback { completion?(.failure(error)) } + } + } + } +} + +/// `UserGroupSearchController` uses this protocol to communicate changes to its delegate. +public protocol UserGroupSearchControllerDelegate: DataControllerStateDelegate { + /// The controller updated its list of user groups. + func controller( + _ controller: UserGroupSearchController, + didChangeUserGroups userGroups: [UserGroup] + ) +} + +public extension UserGroupSearchControllerDelegate { + func controller( + _ controller: UserGroupSearchController, + didChangeUserGroups userGroups: [UserGroup] + ) {} +} diff --git a/Sources/StreamChat/MentionSuggestions/DefaultMentionSuggestionsProvider.swift b/Sources/StreamChat/MentionSuggestions/DefaultMentionSuggestionsProvider.swift index 5ec61e76efd..53a8bfc900a 100644 --- a/Sources/StreamChat/MentionSuggestions/DefaultMentionSuggestionsProvider.swift +++ b/Sources/StreamChat/MentionSuggestions/DefaultMentionSuggestionsProvider.swift @@ -9,12 +9,17 @@ import Foundation /// Suggests users only, preserving the historical mention behaviour. By default /// it searches the channel's members and watchers; pass `mentionAllAppUsers: true` /// at initialization to search across all app users instead. +/// +/// When the channel has more members than are available in +/// `ChatChannel.lastActiveMembers`, members are searched remotely via +/// ``MemberSearch``. public final class DefaultMentionSuggestionsProvider: MentionSuggestionsProvider, Sendable { /// When `true`, user suggestions are searched across all app users instead /// of only the channel's members and watchers. public let mentionAllAppUsers: Bool private let userSearch: UserSearch + private let memberSearch: MemberSearch private let currentUserId: @Sendable () -> UserId? /// Creates a new default mention suggestions provider. @@ -25,22 +30,23 @@ public final class DefaultMentionSuggestionsProvider: MentionSuggestionsProvider public init(client: ChatClient, mentionAllAppUsers: Bool = false) { self.mentionAllAppUsers = mentionAllAppUsers userSearch = client.makeUserSearch() + memberSearch = client.makeMemberSearch() currentUserId = { [weak client] in client?.currentUserId } } public func mentionSuggestions(for request: MentionSuggestionsRequest) async throws -> [MentionSuggestion] { - let users: [ChatUser] - if mentionAllAppUsers { - let query = MentionSuggestionsSearch.allAppUsersQuery(for: request.text) - users = try await userSearch.search(query: query) - } else { - let channel = request.channel - users = MentionSuggestionsSearch.searchUsers( - channel.lastActiveWatchers.map(\.self) + channel.lastActiveMembers.map(\.self), - by: request.text, - excludingId: currentUserId() - ) - } + let users = try await MentionSuggestionsSearch.fetchUsers( + for: request, + mentionAllAppUsers: mentionAllAppUsers, + currentUserId: currentUserId(), + userSearch: userSearch, + memberSearch: memberSearch + ) return users.map { MentionSuggestion.user($0) } } + + public func clearResults() async { + await userSearch.clearResults() + await memberSearch.clearResults() + } } diff --git a/Sources/StreamChat/MentionSuggestions/EnhancedMentionSuggestionsProvider.swift b/Sources/StreamChat/MentionSuggestions/EnhancedMentionSuggestionsProvider.swift index b46140f7e56..fd530f65e9a 100644 --- a/Sources/StreamChat/MentionSuggestions/EnhancedMentionSuggestionsProvider.swift +++ b/Sources/StreamChat/MentionSuggestions/EnhancedMentionSuggestionsProvider.swift @@ -9,6 +9,10 @@ import Foundation /// In addition to user mentions, it suggests `@here`, `@channel`, roles and user groups. /// Which of these types is suggested is determined by the channel's own /// capabilities (`notify-here`, `notify-channel`, `notify-role`, `notify-group`). +/// +/// When the channel has more members than are available in +/// `ChatChannel.lastActiveMembers`, members are searched remotely via +/// ``MemberSearch``. public final class EnhancedMentionSuggestionsProvider: MentionSuggestionsProvider, Sendable { /// When `true`, user suggestions are searched across all app users instead /// of only the channel's members and watchers. @@ -17,6 +21,7 @@ public final class EnhancedMentionSuggestionsProvider: MentionSuggestionsProvide private let userSearch: UserSearch private let roleSearch: RoleSearch private let userGroupSearch: UserGroupSearch + private let memberSearch: MemberSearch private let currentUserId: @Sendable () -> UserId? /// Creates a new enhanced mention suggestions provider. @@ -29,6 +34,7 @@ public final class EnhancedMentionSuggestionsProvider: MentionSuggestionsProvide userSearch = client.makeUserSearch() roleSearch = client.makeRoleSearch() userGroupSearch = client.makeUserGroupSearch() + memberSearch = client.makeMemberSearch() currentUserId = { [weak client] in client?.currentUserId } } @@ -98,23 +104,25 @@ public final class EnhancedMentionSuggestionsProvider: MentionSuggestionsProvide } private func fetchUsers(for request: MentionSuggestionsRequest) async -> [MentionSuggestion] { - let users: [ChatUser] - if mentionAllAppUsers { - do { - let query = MentionSuggestionsSearch.allAppUsersQuery(for: request.text) - users = try await userSearch.search(query: query) - } catch { - log.error("Failed to fetch user suggestions: \(error)") - users = [] - } - } else { - let channel = request.channel - users = MentionSuggestionsSearch.searchUsers( - channel.lastActiveWatchers.map(\.self) + channel.lastActiveMembers.map(\.self), - by: request.text, - excludingId: currentUserId() + do { + let users = try await MentionSuggestionsSearch.fetchUsers( + for: request, + mentionAllAppUsers: mentionAllAppUsers, + currentUserId: currentUserId(), + userSearch: userSearch, + memberSearch: memberSearch ) + return users.map { MentionSuggestion.user($0) } + } catch { + log.error("Failed to fetch user suggestions: \(error)") + return [] } - return users.map { MentionSuggestion.user($0) } + } + + public func clearResults() async { + await userSearch.clearResults() + await memberSearch.clearResults() + await roleSearch.clearResults() + await userGroupSearch.clearResults() } } diff --git a/Sources/StreamChat/MentionSuggestions/MentionSuggestionsProvider.swift b/Sources/StreamChat/MentionSuggestions/MentionSuggestionsProvider.swift index 2a4ea916b9a..e1561af180c 100644 --- a/Sources/StreamChat/MentionSuggestions/MentionSuggestionsProvider.swift +++ b/Sources/StreamChat/MentionSuggestions/MentionSuggestionsProvider.swift @@ -30,6 +30,12 @@ public protocol MentionSuggestionsProvider: Sendable { /// - Parameter request: The context describing the current mention query. /// - Returns: The suggestions to present, in display order. func mentionSuggestions(for request: MentionSuggestionsRequest) async throws -> [MentionSuggestion] + + /// Cancels any pending or in-flight suggestion searches and clears cached results. + /// + /// Call this when the mention query ends (for example when the user deletes `@`) + /// so a slower, superseded search cannot surface stale suggestions. + func clearResults() async } public extension MentionSuggestionsProvider { @@ -55,4 +61,6 @@ public extension MentionSuggestionsProvider { } } } + + func clearResults() async {} } diff --git a/Sources/StreamChat/MentionSuggestions/MentionSuggestionsSearch.swift b/Sources/StreamChat/MentionSuggestions/MentionSuggestionsSearch.swift index c570c16cfb3..446f331df6e 100644 --- a/Sources/StreamChat/MentionSuggestions/MentionSuggestionsSearch.swift +++ b/Sources/StreamChat/MentionSuggestions/MentionSuggestionsSearch.swift @@ -50,4 +50,57 @@ enum MentionSuggestionsSearch { sort: [.init(key: .name, isAscending: true)] ) } + + /// Builds the query used to search channel members for mention suggestions. + static func channelMembersQuery(cid: ChannelId, for searchInput: String) -> ChannelMemberListQuery { + ChannelMemberListQuery( + cid: cid, + filter: .autocomplete(.name, text: searchInput), + sort: [.init(key: .name, isAscending: true)] + ) + } + + /// Whether mention suggestions should query members from the backend. + /// + /// `ChatChannel.lastActiveMembers` is capped (default 100), so larger channels + /// need a remote member search. + static func requiresRemoteMemberSearch(for channel: ChatChannel) -> Bool { + channel.memberCount > channel.lastActiveMembers.count + } + + /// Resolves user mention suggestions for the given request. + /// + /// Search strategy, in order: + /// 1. All app users, when `mentionAllAppUsers` is `true`. + /// 2. Remote channel member search via ``MemberSearch``, when the channel has + /// more members than are available in `lastActiveMembers`. + /// 3. Local search over `lastActiveMembers` and `lastActiveWatchers`. + static func fetchUsers( + for request: MentionSuggestionsRequest, + mentionAllAppUsers: Bool, + currentUserId: UserId?, + userSearch: UserSearch, + memberSearch: MemberSearch + ) async throws -> [ChatUser] { + if mentionAllAppUsers { + let query = allAppUsersQuery(for: request.text) + return try await userSearch.search(query: query) + } + + let channel = request.channel + if requiresRemoteMemberSearch(for: channel) { + // Empty `@` should not load every member of a large channel. + guard !request.text.isEmpty else { return [] } + + let query = channelMembersQuery(cid: channel.cid, for: request.text) + let members = try await memberSearch.search(query: query) + return members.filter { $0.id != currentUserId } + } + + return searchUsers( + channel.lastActiveWatchers.map(\.self) + channel.lastActiveMembers.map(\.self), + by: request.text, + excludingId: currentUserId + ) + } } diff --git a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift index 8488adf734c..8f26b59c709 100644 --- a/Sources/StreamChat/StateLayer/ChatClient+Factory.swift +++ b/Sources/StreamChat/StateLayer/ChatClient+Factory.swift @@ -282,6 +282,20 @@ extension ChatClient { } } +// MARK: - Factory Methods for Searching Channel Members + +extension ChatClient { + /// Creates an instance of ``MemberSearch`` which represents an array of channel members matching to the specified ``ChannelMemberListQuery``. + /// + /// Use ``MemberSearch`` as a data source for member search UIs. Consecutive searches are debounced + /// and cancel in-flight work, unlike ``MemberList`` which is bound to a single query. + /// + /// - Returns: An instance of ``MemberSearch`` which represents search actions and the search state. + public func makeMemberSearch() -> MemberSearch { + MemberSearch(client: self) + } +} + // MARK: - Factory Methods for Creating Message Reaction Lists extension ChatClient { diff --git a/Sources/StreamChat/StateLayer/MemberSearch.swift b/Sources/StreamChat/StateLayer/MemberSearch.swift new file mode 100644 index 00000000000..a21234d1200 --- /dev/null +++ b/Sources/StreamChat/StateLayer/MemberSearch.swift @@ -0,0 +1,132 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// An object which represents a list of `ChatChannelMember` for the specified search query. +/// +/// Text searches are debounced: 500ms for 1-2 characters, 300ms for 3 or more. +/// Calling ``search(text:in:)`` or ``search(query:)`` again cancels the previous in-flight search. +/// +/// - Note: For a fixed query without debounce, use ``MemberList``. +public class MemberSearch: @unchecked Sendable { + @MainActor private var stateBuilder: StateBuilder + private let memberListUpdater: ChannelMemberListUpdater + private let searchDebouncer: AsyncSearchDebouncer + + init( + client: ChatClient, + environment: Environment = .init(), + debouncePolicy: SearchDebouncePolicy = .default + ) { + memberListUpdater = environment.memberListUpdaterBuilder( + client.databaseContainer, + client.apiClient + ) + searchDebouncer = AsyncSearchDebouncer(policy: debouncePolicy) + stateBuilder = StateBuilder { MemberSearchState() } + } + + // MARK: - Accessing the State + + /// An observable object representing the current state of the search. + @MainActor public var state: MemberSearchState { stateBuilder.state } + + // MARK: - Search Results and Pagination + + /// Searches for channel members whose name matches the given text and updates ``MemberSearchState/members``. + /// + /// - Parameters: + /// - text: The member name search text. + /// - cid: The channel to search members in. + /// + /// - Throws: An error while communicating with the Stream API. + /// - Returns: An array of members matching the search text. When a newer search supersedes + /// this one, the current ``MemberSearchState/members`` are returned. + @discardableResult public func search(text: String, in cid: ChannelId) async throws -> [ChatChannelMember] { + try await search( + query: ChannelMemberListQuery( + cid: cid, + filter: .autocomplete(.name, text: text), + sort: [.init(key: .name, isAscending: true)] + ) + ) + } + + /// Searches for channel members with the specified query and updates ``MemberSearchState/members``. + /// + /// - Parameter query: The channel member list query used for searching. + /// + /// - Note: A query built around a text-search operator (`.autocomplete`, `.query`) is + /// debounced on the length of that text, even when combined with other filters. A query + /// with no search text is not typed character by character, so it runs right away — a + /// later search still cancels it either way. + /// + /// - Throws: An error while communicating with the Stream API. + /// - Returns: An array of members for the query. When a newer search supersedes this one, + /// the current ``MemberSearchState/members`` are returned. + @discardableResult public func search(query: ChannelMemberListQuery) async throws -> [ChatChannelMember] { + let pagination = Pagination(pageSize: query.pagination.pageSize, offset: 0) + let result = try await searchDebouncer.schedule(filter: query.filter) { [weak self] in + guard let self else { throw ClientError("MemberSearch was deallocated") } + return try await self.performSearch(query: query, pagination: pagination) + } + if let result { + return result + } + return await state.members + } + + /// Loads more members for the last search and updates ``MemberSearchState/members``. + /// + /// - Parameter limit: The limit for the page size. The default limit is 30. + /// + /// - Throws: An error while communicating with the Stream API. + /// - Returns: An array of loaded members. + @discardableResult public func loadMoreMembers(limit: Int? = nil) async throws -> [ChatChannelMember] { + guard let query = await state.query else { + throw ClientError("Call search() before calling for next page") + } + let limit = limit ?? query.pagination.pageSize + let offset = await state.members.count + let pagination = Pagination(pageSize: limit, offset: offset) + return try await performSearch(query: query, pagination: pagination) + } + + /// Cancels any pending or in-flight search and clears ``MemberSearchState/members``. + public func clearResults() async { + await searchDebouncer.cancel() + await state.clear() + } + + // MARK: - Private + + private func performSearch( + query: ChannelMemberListQuery, + pagination: Pagination + ) async throws -> [ChatChannelMember] { + let query = query.withPagination(pagination) + // A superseded search must not publish its query. `state.query` is what + // `handleDidFetchQuery` compares against and what `loadMoreMembers` paginates, so a + // stale write here would make the winning search discard its own results and would + // paginate the wrong query. + try Task.checkCancellation() + await state.setQuery(query) + let members = try await memberListUpdater.load(query) + try Task.checkCancellation() + await state.handleDidFetchQuery(query, members: members) + return members + } +} + +extension MemberSearch { + struct Environment { + var memberListUpdaterBuilder: ( + _ database: DatabaseContainer, + _ apiClient: APIClient + ) -> ChannelMemberListUpdater = { + ChannelMemberListUpdater(database: $0, apiClient: $1) + } + } +} diff --git a/Sources/StreamChat/StateLayer/MemberSearchState.swift b/Sources/StreamChat/StateLayer/MemberSearchState.swift new file mode 100644 index 00000000000..5622ba1e1bc --- /dev/null +++ b/Sources/StreamChat/StateLayer/MemberSearchState.swift @@ -0,0 +1,65 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation + +/// Represents a list of channel member search results. +@MainActor public final class MemberSearchState: ObservableObject { + /// The last initiated search query. + /// + /// - Note: If searching fails, this property points to the failing query. + @Published public internal(set) var query: ChannelMemberListQuery? + + /// An array of search results for the specified query and pagination state. + @Published public internal(set) var members: [ChatChannelMember] = [] +} + +extension MemberSearchState { + /// Updates the query to point to the last query the user started. + /// + /// When user is typing and triggers multiple queries, then that last initiated query is used for discarding results from already running queries. + func setQuery(_ query: ChannelMemberListQuery) { + self.query = query + } + + /// Clears the current query and results. + func clear() { + query = nil + members = [] + } + + /// Updates the state to include query results if user has not already started a new query. + /// + /// * Case 1: User triggered a new search. Then we need to reset the state. + /// * Case 2: More results are loaded for the same query. + /// Then we need to merge results while handling possible duplicates (example: calling loadMoreMembers multiple times). + func handleDidFetchQuery( + _ completedQuery: ChannelMemberListQuery, + members incomingMembers: [ChatChannelMember] + ) { + if let query = self.query, query.hasFilterOrSortingChanged(completedQuery) { + // Discard since filter or sorting has changed + return + } + if completedQuery.pagination.offset == 0 { + // Reset to the first page + members = incomingMembers + } else { + // Filter and sorting are the same but incoming members might contain duplicates + let incomingIds = Set(incomingMembers.map(\.id)) + let existingWithoutIncoming = members.filter { !incomingIds.contains($0.id) } + members = existingWithoutIncoming + incomingMembers + } + } +} + +extension ChannelMemberListQuery { + func hasFilterOrSortingChanged(_ otherQuery: ChannelMemberListQuery) -> Bool { + guard cid == otherQuery.cid else { return true } + guard filter?.filterHash == otherQuery.filter?.filterHash else { return true } + guard sort == otherQuery.sort else { return true } + return false + } +} diff --git a/Sources/StreamChat/StateLayer/RoleSearch.swift b/Sources/StreamChat/StateLayer/RoleSearch.swift index 1deff0aed10..7f3050b1cbc 100644 --- a/Sources/StreamChat/StateLayer/RoleSearch.swift +++ b/Sources/StreamChat/StateLayer/RoleSearch.swift @@ -6,13 +6,23 @@ import Foundation /// An object which represents a list of `Role` for the specified search query. /// +/// Text searches are debounced: 500ms for 1-2 characters, 300ms for 3 or more. +/// Calling ``search(text:roleType:)`` or ``search(query:)`` again cancels the previous +/// in-flight search. The superseded call returns the current results rather than throwing, +/// so typing does not surface an error per keystroke. +/// /// Results are kept in ``RoleSearchState`` and replaced on every new search. public class RoleSearch: @unchecked Sendable { @MainActor private var stateBuilder: StateBuilder private let rolesRepository: RolesRepository + private let searchDebouncer: AsyncSearchDebouncer - init(client: ChatClient) { + init( + client: ChatClient, + debouncePolicy: SearchDebouncePolicy = .default + ) { rolesRepository = client.rolesRepository + searchDebouncer = AsyncSearchDebouncer(policy: debouncePolicy) stateBuilder = StateBuilder { RoleSearchState() } } @@ -30,7 +40,8 @@ public class RoleSearch: @unchecked Sendable { /// - roleType: When set, restricts the search to roles of the given type. /// /// - Throws: An error while communicating with the Stream API. - /// - Returns: An array of roles for the search term. + /// - Returns: An array of roles for the search term. When a newer search supersedes this + /// one, the current ``RoleSearchState/roles`` are returned. @discardableResult public func search(text: String, roleType: RoleType? = nil) async throws -> [Role] { try await search(query: RoleSearchQuery(query: text, roleType: roleType)) } @@ -39,11 +50,38 @@ public class RoleSearch: @unchecked Sendable { /// /// - Parameter query: The query describing the search term and filters. /// + /// - Note: Searches are debounced on the length of ``RoleSearchQuery/query``. + /// /// - Throws: An error while communicating with the Stream API. - /// - Returns: An array of roles for the query. + /// - Returns: An array of roles for the query. When a newer search supersedes this one, + /// the current ``RoleSearchState/roles`` are returned. @discardableResult public func search(query: RoleSearchQuery) async throws -> [Role] { + let result = try await searchDebouncer.schedule(queryLength: query.query.count) { [weak self] in + guard let self else { throw ClientError("RoleSearch was deallocated") } + return try await self.performSearch(query: query) + } + if let result { + return result + } + return await state.roles + } + + /// Cancels any pending or in-flight search and clears ``RoleSearchState/roles``. + public func clearResults() async { + await searchDebouncer.cancel() + await state.clear() + } + + // MARK: - Private + + private func performSearch(query: RoleSearchQuery) async throws -> [Role] { + // A superseded search must not publish its query. `state.query` is what + // `handleDidFetchQuery` compares against, so a stale write here would make the + // winning search discard its own results. + try Task.checkCancellation() await state.setQuery(query) let roles = try await rolesRepository.searchRoles(query: query) + try Task.checkCancellation() await state.handleDidFetchQuery(query, roles: roles) return roles } diff --git a/Sources/StreamChat/StateLayer/RoleSearchState.swift b/Sources/StreamChat/StateLayer/RoleSearchState.swift index e14478f6f11..920177fecca 100644 --- a/Sources/StreamChat/StateLayer/RoleSearchState.swift +++ b/Sources/StreamChat/StateLayer/RoleSearchState.swift @@ -22,6 +22,12 @@ extension RoleSearchState { self.query = query } + /// Clears the current query and results. + func clear() { + query = nil + roles = [] + } + /// Updates the state with the results of the completed query. /// /// Results from an outdated query are discarded so the state always diff --git a/Sources/StreamChat/StateLayer/UserGroupSearch.swift b/Sources/StreamChat/StateLayer/UserGroupSearch.swift index bae93ba1a53..0adff852f44 100644 --- a/Sources/StreamChat/StateLayer/UserGroupSearch.swift +++ b/Sources/StreamChat/StateLayer/UserGroupSearch.swift @@ -6,13 +6,25 @@ import Foundation /// An object which represents a list of `UserGroup` for the specified search query. /// +/// Text searches are debounced: 500ms for 1-2 characters, 300ms for 3 or more. +/// Calling ``search(text:teamId:)`` or ``search(query:)`` again cancels the previous +/// in-flight search. The superseded call returns the current results rather than throwing, +/// so typing does not surface an error per keystroke. +/// /// Search results are kept in ``UserGroupSearchState`` and replaced on every new search. +/// +/// - Note: For a delegate based alternative, please check ``UserGroupSearchController``. public class UserGroupSearch: @unchecked Sendable { @MainActor private var stateBuilder: StateBuilder private let userGroupsRepository: UserGroupsRepository + private let searchDebouncer: AsyncSearchDebouncer - init(client: ChatClient) { + init( + client: ChatClient, + debouncePolicy: SearchDebouncePolicy = .default + ) { userGroupsRepository = client.userGroupsRepository + searchDebouncer = AsyncSearchDebouncer(policy: debouncePolicy) stateBuilder = StateBuilder { UserGroupSearchState() } } @@ -30,7 +42,8 @@ public class UserGroupSearch: @unchecked Sendable { /// - teamId: When set, restricts the search to groups scoped to the given team. /// /// - Throws: An error while communicating with the Stream API. - /// - Returns: An array of user groups for the search term. + /// - Returns: An array of user groups for the search term. When a newer search supersedes + /// this one, the current ``UserGroupSearchState/userGroups`` are returned. @discardableResult public func search(text: String, teamId: String? = nil) async throws -> [UserGroup] { try await search(query: UserGroupSearchQuery(query: text, teamId: teamId)) } @@ -39,11 +52,38 @@ public class UserGroupSearch: @unchecked Sendable { /// /// - Parameter query: The query describing the search term and filters. /// + /// - Note: Searches are debounced on the length of ``UserGroupSearchQuery/query``. + /// /// - Throws: An error while communicating with the Stream API. - /// - Returns: An array of user groups for the query. + /// - Returns: An array of user groups for the query. When a newer search supersedes this + /// one, the current ``UserGroupSearchState/userGroups`` are returned. @discardableResult public func search(query: UserGroupSearchQuery) async throws -> [UserGroup] { + let result = try await searchDebouncer.schedule(queryLength: query.query.count) { [weak self] in + guard let self else { throw ClientError("UserGroupSearch was deallocated") } + return try await self.performSearch(query: query) + } + if let result { + return result + } + return await state.userGroups + } + + /// Cancels any pending or in-flight search and clears ``UserGroupSearchState/userGroups``. + public func clearResults() async { + await searchDebouncer.cancel() + await state.clear() + } + + // MARK: - Private + + private func performSearch(query: UserGroupSearchQuery) async throws -> [UserGroup] { + // A superseded search must not publish its query. `state.query` is what + // `handleDidFetchQuery` compares against, so a stale write here would make the + // winning search discard its own results. + try Task.checkCancellation() await state.setQuery(query) let userGroups = try await userGroupsRepository.searchUserGroups(query: query) + try Task.checkCancellation() await state.handleDidFetchQuery(query, userGroups: userGroups) return userGroups } diff --git a/Sources/StreamChat/StateLayer/UserGroupSearchState.swift b/Sources/StreamChat/StateLayer/UserGroupSearchState.swift index ea5e93bbe9c..e71ad03db83 100644 --- a/Sources/StreamChat/StateLayer/UserGroupSearchState.swift +++ b/Sources/StreamChat/StateLayer/UserGroupSearchState.swift @@ -22,6 +22,12 @@ extension UserGroupSearchState { self.query = query } + /// Clears the current query and results. + func clear() { + query = nil + userGroups = [] + } + /// Updates the state with the results of the completed query. /// /// Results from an outdated query are discarded so the state always diff --git a/Sources/StreamChat/StateLayer/UserSearch.swift b/Sources/StreamChat/StateLayer/UserSearch.swift index 5d764b1f8e0..723eeb6e20e 100644 --- a/Sources/StreamChat/StateLayer/UserSearch.swift +++ b/Sources/StreamChat/StateLayer/UserSearch.swift @@ -81,6 +81,12 @@ public class UserSearch: @unchecked Sendable { let pagination = Pagination(pageSize: limit, offset: offset) return try await performSearch(query: query, pagination: pagination) } + + /// Cancels any pending or in-flight search and clears ``UserSearchState/users``. + public func clearResults() async { + await searchDebouncer.cancel() + await state.clear() + } // MARK: - Private diff --git a/Sources/StreamChat/StateLayer/UserSearchState.swift b/Sources/StreamChat/StateLayer/UserSearchState.swift index 05b36ce69a0..8f3df02cb22 100644 --- a/Sources/StreamChat/StateLayer/UserSearchState.swift +++ b/Sources/StreamChat/StateLayer/UserSearchState.swift @@ -23,6 +23,12 @@ extension UserSearchState { func setQuery(_ query: UserListQuery) { self.query = query } + + /// Clears the current query and results. + func clear() { + query = nil + users = [] + } /// Updates the state to include query results if user has not already started a new query. /// diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index ffd66c743a6..8130ac7ea43 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -866,6 +866,7 @@ StreamChatTests/Controllers/ThreadListController/ChatThreadListController_Tests.swift, StreamChatTests/Controllers/UserGroupController/UserGroupController_Tests.swift, StreamChatTests/Controllers/UserGroupController/UserGroupListController_Tests.swift, + StreamChatTests/Controllers/UserGroupSearchController/UserGroupSearchController_Tests.swift, StreamChatTests/Database/DatabaseContainer_Tests.swift, StreamChatTests/Database/DatabaseSession_Tests.swift, StreamChatTests/Database/DataStore_Tests.swift, @@ -965,6 +966,7 @@ StreamChatTests/StateLayer/ConnectedUser_Tests.swift, StreamChatTests/StateLayer/LivestreamChat_Tests.swift, StreamChatTests/StateLayer/MemberList_Tests.swift, + StreamChatTests/StateLayer/MemberSearch_Tests.swift, StreamChatTests/StateLayer/MessageSearch_Tests.swift, StreamChatTests/StateLayer/MessageState_Tests.swift, StreamChatTests/StateLayer/ReactionList_Tests.swift, diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberListUpdater_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberListUpdater_Mock.swift index e09fecfe966..a1d62503e76 100644 --- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberListUpdater_Mock.swift +++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Workers/ChannelMemberListUpdater_Mock.swift @@ -8,15 +8,36 @@ import XCTest /// Mock implementation of `ChannelMemberListUpdater` final class ChannelMemberListUpdater_Mock: ChannelMemberListUpdater, @unchecked Sendable { @Atomic var load_query: ChannelMemberListQuery? + @Atomic var load_queries: [ChannelMemberListQuery] = [] @Atomic var load_completion: (@Sendable (Result<[ChatChannelMember], Error>) -> Void)? + @Atomic var load_completions: [(@Sendable (Result<[ChatChannelMember], Error>) -> Void)] = [] + @Atomic var load_query_called: (ChannelMemberListQuery) -> Void = { _ in } + @Atomic var load_completion_result: Result<[ChatChannelMember], Error>? func cleanUp() { load_query = nil + load_queries.removeAll() + load_query_called = { _ in } + releaseCompletions() + load_completion_result = nil + } + + /// Drops retained load completions so controllers under test can deallocate. + func releaseCompletions() { load_completion = nil + load_completions.removeAll() } override func load(_ query: ChannelMemberListQuery, completion: (@Sendable (Result<[ChatChannelMember], Error>) -> Void)? = nil) { load_query = query - load_completion = completion + _load_queries.mutate { $0.append(query) } + load_query_called(query) + if let completion { + load_completion = completion + _load_completions.mutate { $0.append(completion) } + if let result = load_completion_result { + completion(result) + } + } } } diff --git a/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift b/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift index e4208096f62..9a7e09359d9 100644 --- a/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MemberController/MemberController_Tests.swift @@ -95,7 +95,7 @@ final class MemberController_Tests: XCTestCase { // Simulate successful network call. env.memberListUpdater!.load_completion!(.success([])) // Release reference of completion so we can deallocate stuff - env.memberListUpdater!.load_completion = nil + env.memberListUpdater!.releaseCompletions() // Assert completion is called AssertAsync.willBeTrue(completionIsCalled) diff --git a/Tests/StreamChatTests/Controllers/MemberListController/MemberListController_Tests.swift b/Tests/StreamChatTests/Controllers/MemberListController/MemberListController_Tests.swift index 8da006e2116..e3ca7f3b7ff 100644 --- a/Tests/StreamChatTests/Controllers/MemberListController/MemberListController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/MemberListController/MemberListController_Tests.swift @@ -91,7 +91,7 @@ final class MemberListController_Tests: XCTestCase { // Simulate successful network call. env.memberListUpdater!.load_completion!(.success([])) // Release reference of completion so we can deallocate stuff - env.memberListUpdater!.load_completion = nil + env.memberListUpdater!.releaseCompletions() // Assert completion is called AssertAsync.willBeTrue(completionIsCalled) @@ -393,7 +393,7 @@ final class MemberListController_Tests: XCTestCase { // Simulate successful network response. env.memberListUpdater!.load_completion!(.success([])) // Release reference of completion so we can deallocate stuff - env.memberListUpdater!.load_completion = nil + env.memberListUpdater!.releaseCompletions() // Assert completion is called. AssertAsync.willBeTrue(completionIsCalled) diff --git a/Tests/StreamChatTests/Controllers/RoleSearchController/RoleSearchController_Tests.swift b/Tests/StreamChatTests/Controllers/RoleSearchController/RoleSearchController_Tests.swift index 1617dd1be87..defebe2c392 100644 --- a/Tests/StreamChatTests/Controllers/RoleSearchController/RoleSearchController_Tests.swift +++ b/Tests/StreamChatTests/Controllers/RoleSearchController/RoleSearchController_Tests.swift @@ -15,7 +15,11 @@ final class RoleSearchController_Tests: XCTestCase { super.setUp() client = ChatClient.mock repository = client.mockRolesRepository - controller = client.roleSearchController() + controller = RoleSearchController( + client: client, + // Keep search synchronous in unit tests unless a test opts into debouncing. + debouncePolicy: .constant(0) + ) } override func tearDown() { @@ -108,6 +112,23 @@ final class RoleSearchController_Tests: XCTestCase { XCTAssertEqual(delegate.roles.map(\.name), ["admin"]) } + // MARK: - Clearing Results + + func test_clearResults_clearsRoles() { + repository.searchRoles_completion_result = .success([Role.dummy(name: "admin")]) + + let exp = expectation(description: "search completes") + controller.searchRoles(text: "adm") { _ in + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + XCTAssertFalse(controller.roles.isEmpty) + + controller.clearResults() + + XCTAssertTrue(controller.roles.isEmpty) + } + // MARK: - Failure path func test_searchRoles_whenRequestFails_thenErrorIsForwardedAndStateIsFailed() { diff --git a/Tests/StreamChatTests/Controllers/UserGroupSearchController/UserGroupSearchController_Tests.swift b/Tests/StreamChatTests/Controllers/UserGroupSearchController/UserGroupSearchController_Tests.swift new file mode 100644 index 00000000000..aefbaa7bcbc --- /dev/null +++ b/Tests/StreamChatTests/Controllers/UserGroupSearchController/UserGroupSearchController_Tests.swift @@ -0,0 +1,170 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class UserGroupSearchController_Tests: XCTestCase { + var client: ChatClient_Mock! + var repository: UserGroupsRepository_Mock! + var controller: UserGroupSearchController! + + override func setUp() { + super.setUp() + client = ChatClient.mock + repository = client.mockUserGroupsRepository + controller = UserGroupSearchController( + client: client, + // Keep search synchronous in unit tests unless a test opts into debouncing. + debouncePolicy: .constant(0) + ) + } + + override func tearDown() { + controller = nil + repository = nil + client?.cleanUp() + client = nil + super.tearDown() + } + + // MARK: - searchUserGroups(text:teamId:) + + func test_searchUserGroups_withText_forwardsRequestToRepository() { + let userGroup = UserGroup.dummy( + id: "backendsupport", + name: "Backend Support", + teamId: "engineering" + ) + repository.searchUserGroups_completion_result = .success([userGroup]) + + let exp = expectation(description: "search completes") + controller.searchUserGroups(text: "backend", teamId: "engineering") { result in + XCTAssertEqual(result.value?.map(\.id), ["backendsupport"]) + exp.fulfill() + } + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertEqual(repository.searchUserGroups_query?.query, "backend") + XCTAssertEqual(repository.searchUserGroups_query?.teamId, "engineering") + } + + func test_searchUserGroups_withText_whenTeamIdIsNil_thenQueryHasNoTeam() { + repository.searchUserGroups_completion_result = .success([]) + + let exp = expectation(description: "search completes") + controller.searchUserGroups(text: "backend") { _ in + exp.fulfill() + } + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertEqual(repository.searchUserGroups_query?.query, "backend") + XCTAssertNil(repository.searchUserGroups_query?.teamId) + } + + // MARK: - searchUserGroups(query:) + + func test_searchUserGroups_withQuery_forwardsRequestToRepository() { + let userGroup = UserGroup.dummy(id: "backendsupport", name: "Backend Support") + repository.searchUserGroups_completion_result = .success([userGroup]) + + let query = UserGroupSearchQuery(query: "backend", limit: 5, teamId: "engineering") + let exp = expectation(description: "search completes") + controller.searchUserGroups(query: query) { result in + XCTAssertEqual(result.value?.map(\.id), ["backendsupport"]) + exp.fulfill() + } + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertEqual(repository.searchUserGroups_query, query) + } + + func test_searchUserGroups_whenRequestSucceeds_thenUserGroupsAndStateAreUpdated() { + let userGroups = [ + UserGroup.dummy(id: "backendsupport", name: "Backend Support"), + UserGroup.dummy(id: "backendcore", name: "Backend Core") + ] + repository.searchUserGroups_completion_result = .success(userGroups) + + let exp = expectation(description: "search completes") + controller.searchUserGroups(text: "backend") { _ in + exp.fulfill() + } + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertEqual(controller.userGroups.map(\.id), ["backendsupport", "backendcore"]) + XCTAssertEqual(controller.state, .remoteDataFetched) + } + + @MainActor func test_searchUserGroups_whenRequestSucceeds_thenDelegateIsNotified() { + class DelegateMock: UserGroupSearchControllerDelegate { + var userGroups: [UserGroup] = [] + let expectation = XCTestExpectation(description: "Did Change User Groups") + + func controller( + _ controller: UserGroupSearchController, + didChangeUserGroups userGroups: [UserGroup] + ) { + self.userGroups = userGroups + expectation.fulfill() + } + } + + let delegate = DelegateMock() + controller.delegate = delegate + repository.searchUserGroups_completion_result = .success([ + UserGroup.dummy(id: "backendsupport", name: "Backend Support") + ]) + + controller.searchUserGroups(text: "backend") + + wait(for: [delegate.expectation], timeout: defaultTimeout) + XCTAssertEqual(delegate.userGroups.map(\.id), ["backendsupport"]) + } + + // MARK: - Clearing Results + + func test_clearResults_clearsUserGroups() { + repository.searchUserGroups_completion_result = .success([ + UserGroup.dummy(id: "backendsupport", name: "Backend Support") + ]) + + let exp = expectation(description: "search completes") + controller.searchUserGroups(text: "backend") { _ in + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + XCTAssertFalse(controller.userGroups.isEmpty) + + controller.clearResults() + + XCTAssertTrue(controller.userGroups.isEmpty) + } + + // MARK: - Failure path + + func test_searchUserGroups_whenRequestFails_thenErrorIsForwardedAndStateIsFailed() { + let testError = TestError() + repository.searchUserGroups_completion_result = .failure(testError) + + let exp = expectation(description: "search completes") + controller.searchUserGroups(text: "backend") { result in + XCTAssertEqual(result.error as? TestError, testError) + exp.fulfill() + } + + wait(for: [exp], timeout: defaultTimeout) + XCTAssertTrue(controller.userGroups.isEmpty) + if case .remoteDataFetchFailed = controller.state {} else { + XCTFail("Expected remoteDataFetchFailed, got \(controller.state)") + } + } + + // MARK: - Factory + + func test_userGroupSearchController_factoryReturnsController() { + XCTAssertNotNil(client.userGroupSearchController()) + } +} diff --git a/Tests/StreamChatTests/MentionSuggestions/DefaultMentionSuggestionsProvider_Tests.swift b/Tests/StreamChatTests/MentionSuggestions/DefaultMentionSuggestionsProvider_Tests.swift index a835ecf005f..54db3bc2dbf 100644 --- a/Tests/StreamChatTests/MentionSuggestions/DefaultMentionSuggestionsProvider_Tests.swift +++ b/Tests/StreamChatTests/MentionSuggestions/DefaultMentionSuggestionsProvider_Tests.swift @@ -105,6 +105,47 @@ final class DefaultMentionSuggestionsProvider_Tests: XCTestCase { XCTAssertTrue(suggestions.allSatisfy { $0.kind is MentionSuggestion.User }) } + func test_mentionSuggestions_whenLargeChannel_searchesMembersRemotely() async throws { + let cid = ChannelId.unique + try client.mockDatabaseContainer.createChannel(cid: cid, withMessages: false) + + let remoteMember = ChannelMemberResponse.dummy( + user: .dummy(userId: "remote-user", name: "Remote User") + ) + let response: Result = .success(.dummy(members: [remoteMember])) + client.mockAPIClient.test_mockResponseResult(response) + + let provider = DefaultMentionSuggestionsProvider(client: client) + let channel = ChatChannel.mock( + cid: cid, + lastActiveMembers: [.mock(id: "local-only", name: "Local")], + memberCount: 1000 + ) + + let suggestions = try await provider.mentionSuggestions( + for: MentionSuggestionsRequest(text: "Rem", channel: channel) + ) + + XCTAssertEqual(client.mockAPIClient.request_endpoint?.path.value, "/api/v2/chat/members") + XCTAssertEqual(userIds(from: suggestions), ["remote-user"]) + } + + func test_mentionSuggestions_whenLargeChannelAndEmptyText_returnsNoUsers() async throws { + let provider = DefaultMentionSuggestionsProvider(client: client) + let channel = ChatChannel.mock( + cid: .unique, + lastActiveMembers: [.mock(id: "local-only", name: "Local")], + memberCount: 1000 + ) + + let suggestions = try await provider.mentionSuggestions( + for: MentionSuggestionsRequest(text: "", channel: channel) + ) + + XCTAssertNil(client.mockAPIClient.request_endpoint) + XCTAssertTrue(suggestions.isEmpty) + } + // MARK: - private private func userIds(from suggestions: [MentionSuggestion]) -> [String] { diff --git a/Tests/StreamChatTests/MentionSuggestions/EnhancedMentionSuggestionsProvider_Tests.swift b/Tests/StreamChatTests/MentionSuggestions/EnhancedMentionSuggestionsProvider_Tests.swift index f523bcd661a..f232f549c27 100644 --- a/Tests/StreamChatTests/MentionSuggestions/EnhancedMentionSuggestionsProvider_Tests.swift +++ b/Tests/StreamChatTests/MentionSuggestions/EnhancedMentionSuggestionsProvider_Tests.swift @@ -106,6 +106,52 @@ final class EnhancedMentionSuggestionsProvider_Tests: XCTestCase { XCTAssertTrue(suggestions[0].kind is MentionSuggestion.Channel) } + func test_mentionSuggestions_whenLargeChannel_searchesMembersRemotely() async throws { + client.mockRolesRepository.searchRoles_completion_result = .success([]) + client.mockUserGroupsRepository.searchUserGroups_completion_result = .success([]) + + let cid = ChannelId.unique + try client.mockDatabaseContainer.createChannel(cid: cid, withMessages: false) + + let remoteMember = ChannelMemberResponse.dummy( + user: .dummy(userId: "remote-user", name: "Remote User") + ) + let response: Result = .success(.dummy(members: [remoteMember])) + client.mockAPIClient.test_mockResponseResult(response) + + let provider = makeProvider() + let channel = makeChannel( + cid: cid, + capabilities: [], + lastActiveMembers: [.mock(id: "local-only", name: "Local")], + memberCount: 1000 + ) + + let suggestions = try await provider.mentionSuggestions( + for: MentionSuggestionsRequest(text: "Rem", channel: channel) + ) + + XCTAssertEqual(client.mockAPIClient.request_endpoint?.path.value, "/api/v2/chat/members") + XCTAssertEqual(userIds(from: suggestions), ["remote-user"]) + } + + func test_mentionSuggestions_whenLargeChannelAndEmptyText_returnsBroadcastsWithoutFetchingMembers() async throws { + let provider = makeProvider() + let channel = makeChannel( + lastActiveMembers: [.mock(id: "local-only", name: "Local")], + memberCount: 1000 + ) + + let suggestions = try await provider.mentionSuggestions( + for: MentionSuggestionsRequest(text: "", channel: channel) + ) + + XCTAssertNil(client.mockAPIClient.request_endpoint) + XCTAssertTrue(suggestions.contains { $0.kind is MentionSuggestion.Channel }) + XCTAssertTrue(suggestions.contains { $0.kind is MentionSuggestion.Here }) + XCTAssertFalse(suggestions.contains { $0.kind is MentionSuggestion.User }) + } + // MARK: - private private func makeProvider(mentionAllAppUsers: Bool = false) -> EnhancedMentionSuggestionsProvider { @@ -113,18 +159,26 @@ final class EnhancedMentionSuggestionsProvider_Tests: XCTestCase { } private func makeChannel( - capabilities: Set = [.notifyHere, .notifyChannel, .notifyRole, .notifyGroup] + cid: ChannelId = .unique, + capabilities: Set = [.notifyHere, .notifyChannel, .notifyRole, .notifyGroup], + lastActiveMembers: [ChatChannelMember] = [ + .mock(id: "martin", name: "Martin"), + .mock(id: "john", name: "John") + ], + memberCount: Int = 0 ) -> ChatChannel { ChatChannel.mock( - cid: .unique, + cid: cid, ownCapabilities: capabilities, - lastActiveMembers: [ - .mock(id: "martin", name: "Martin"), - .mock(id: "john", name: "John") - ] + lastActiveMembers: lastActiveMembers, + memberCount: memberCount ) } + private func userIds(from suggestions: [MentionSuggestion]) -> [String] { + suggestions.compactMap { ($0.kind as? MentionSuggestion.User)?.user.id } + } + private func makeGroup(id: String) -> UserGroup { UserGroup.dummy(id: id, name: "Group \(id)") } diff --git a/Tests/StreamChatTests/MentionSuggestions/MentionSuggestionsSearch_Tests.swift b/Tests/StreamChatTests/MentionSuggestions/MentionSuggestionsSearch_Tests.swift index 81895519689..45fb3558a29 100644 --- a/Tests/StreamChatTests/MentionSuggestions/MentionSuggestionsSearch_Tests.swift +++ b/Tests/StreamChatTests/MentionSuggestions/MentionSuggestionsSearch_Tests.swift @@ -99,4 +99,38 @@ final class MentionSuggestionsSearch_Tests: XCTestCase { ) XCTAssertEqual(query.sort, [.init(key: .name, isAscending: true)]) } + + // MARK: - channelMembersQuery + + func test_channelMembersQuery_buildsAutocompleteNameFilterSortedByName() { + let cid = ChannelId.unique + let query = MentionSuggestionsSearch.channelMembersQuery(cid: cid, for: "abc") + + XCTAssertEqual(query.cid, cid) + XCTAssertEqual(query.filter, .autocomplete(.name, text: "abc")) + XCTAssertEqual(query.sort, [.init(key: .name, isAscending: true)]) + } + + // MARK: - requiresRemoteMemberSearch + + func test_requiresRemoteMemberSearch_whenMemberCountExceedsLastActiveMembers() { + let channel = ChatChannel.mock( + cid: .unique, + lastActiveMembers: [.mock(id: "a"), .mock(id: "b")], + memberCount: 1000 + ) + + XCTAssertTrue(MentionSuggestionsSearch.requiresRemoteMemberSearch(for: channel)) + } + + func test_requiresRemoteMemberSearch_whenAllMembersAreLocal_returnsFalse() { + let members: [ChatChannelMember] = [.mock(id: "a"), .mock(id: "b")] + let channel = ChatChannel.mock( + cid: .unique, + lastActiveMembers: members, + memberCount: members.count + ) + + XCTAssertFalse(MentionSuggestionsSearch.requiresRemoteMemberSearch(for: channel)) + } } diff --git a/Tests/StreamChatTests/StateLayer/MemberSearch_Tests.swift b/Tests/StreamChatTests/StateLayer/MemberSearch_Tests.swift new file mode 100644 index 00000000000..80b31252e02 --- /dev/null +++ b/Tests/StreamChatTests/StateLayer/MemberSearch_Tests.swift @@ -0,0 +1,179 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class MemberSearch_Tests: XCTestCase { + private var channelId: ChannelId! + private var env: TestEnvironment! + private var testError: TestError! + private var memberSearch: MemberSearch! + + @MainActor override func setUpWithError() throws { + channelId = .unique + env = TestEnvironment() + testError = TestError() + memberSearch = MemberSearch( + client: env.client, + environment: env.memberSearchEnvironment, + // Keep search synchronous in unit tests unless a test opts into debouncing. + debouncePolicy: .constant(0) + ) + // Explicitly load the state + _ = memberSearch.state + } + + override func tearDownWithError() throws { + env.cleanUp() + env = nil + testError = nil + memberSearch = nil + channelId = nil + } + + // MARK: - Searching Members + + func test_searchText_whenTextMatches_thenResultsAreReturnedAndStateUpdates() async throws { + let fetchResult = makeMembers(name: "name", count: 5, offset: 0) + env.memberListUpdaterMock.load_completion_result = .success(fetchResult) + let result = try await memberSearch.search(text: "name", in: channelId) + XCTAssertEqual(fetchResult.map(\.id), result.map(\.id)) + await XCTAssertEqual(fetchResult.map(\.id), memberSearch.state.members.map(\.id)) + + XCTAssertEqual(1, env.memberListUpdaterMock.load_queries.count) + try await MainActor.run { + let query = try XCTUnwrap(memberSearch.state.query) + XCTAssertEqual(env.memberListUpdaterMock.load_queries.first?.queryHash, query.queryHash) + XCTAssertEqual(env.memberListUpdaterMock.load_queries.first?.pagination, query.pagination) + + XCTAssertEqual(channelId, query.cid) + XCTAssertEqual(Filter.autocomplete(.name, text: "name"), query.filter) + XCTAssertEqual(Pagination(pageSize: .channelMembersPageSize), query.pagination) + XCTAssertEqual([Sorting(key: .name, isAscending: true)], query.sort) + } + } + + func test_searchText_whenRequestFails_thenResultsAndStateAreEmpty() async throws { + env.memberListUpdaterMock.load_completion_result = .failure(testError) + await XCTAssertAsyncFailure(try await memberSearch.search(text: "name", in: channelId), testError) + } + + func test_searchOrder_whenSendingMultipleRequests_thenIrrelevantResultsAreIgnored() async throws { + // Search for "nam" + let expectation1 = XCTestExpectation() + env.memberListUpdaterMock.load_query_called = { _ in + expectation1.fulfill() + } + async let result1 = memberSearch.search(text: "nam", in: channelId) + + await fulfillment(of: [expectation1], timeout: defaultTimeout) + + // Search for "name" — cancels the in-flight "nam" search + let expectation2 = XCTestExpectation() + env.memberListUpdaterMock.load_query_called = { _ in + expectation2.fulfill() + } + async let result2 = memberSearch.search(text: "name", in: channelId) + + await fulfillment(of: [expectation2], timeout: defaultTimeout) + + XCTAssertEqual(2, env.memberListUpdaterMock.load_completions.count) + + let secondResult = makeMembers(name: "name", count: 5, offset: 0) + env.memberListUpdaterMock.load_completions[1](.success(secondResult)) + + // Completing the cancelled request must not overwrite state. + let firstResult = makeMembers(name: "nam", count: 10, offset: 0) + env.memberListUpdaterMock.load_completions[0](.success(firstResult)) + + // The superseded search resolves without an error, so typing does not surface one + // per keystroke. Its own results are dropped in favour of the newer search. + let supersededResult = try await result1 + XCTAssertEqual(secondResult.map(\.id), supersededResult.map(\.id)) + + XCTAssertEqual(5, try await result2.count) + await XCTAssertEqual(secondResult.map(\.id), memberSearch.state.members.map(\.id)) + } + + // MARK: - Clearing Results + + func test_clearResults_cancelsPendingSearchAndClearsState() async throws { + let fetchResult = makeMembers(name: "name", count: 5, offset: 0) + env.memberListUpdaterMock.load_completion_result = .success(fetchResult) + try await memberSearch.search(text: "name", in: channelId) + await XCTAssertEqual(5, memberSearch.state.members.count) + + await memberSearch.clearResults() + + await XCTAssertEqual([], memberSearch.state.members.map(\.id)) + try await MainActor.run { + XCTAssertNil(memberSearch.state.query) + } + } + + // MARK: - Results Pagination + + func test_loadMoreMembers_whenMoreResultsAreAvailable_thenResultsAndStateAreUpdated() async throws { + let fetchResult1 = makeMembers(name: "name", count: Int.channelMembersPageSize, offset: 0) + env.memberListUpdaterMock.load_completion_result = .success(fetchResult1) + try await memberSearch.search(text: "name", in: channelId) + + let fetchResult2 = makeMembers(name: "name", count: 5, offset: Int.channelMembersPageSize) + env.memberListUpdaterMock.load_completion_result = .success(fetchResult2) + try await memberSearch.loadMoreMembers(limit: 10) + + let expectedIds = (fetchResult1 + fetchResult2).map(\.id) + await XCTAssertEqual(expectedIds, memberSearch.state.members.map(\.id)) + + XCTAssertEqual(2, env.memberListUpdaterMock.load_queries.count) + try await MainActor.run { + let query = try XCTUnwrap(memberSearch.state.query) + XCTAssertEqual(env.memberListUpdaterMock.load_queries.last?.queryHash, query.queryHash) + XCTAssertEqual(env.memberListUpdaterMock.load_queries.last?.pagination, query.pagination) + + XCTAssertEqual(channelId, query.cid) + XCTAssertEqual(Filter.autocomplete(.name, text: "name"), query.filter) + XCTAssertEqual(Pagination(pageSize: 10, offset: .channelMembersPageSize), query.pagination) + XCTAssertEqual([Sorting(key: .name, isAscending: true)], query.sort) + } + } + + // MARK: - Test Data + + private func makeMembers(name: String, count: Int, offset: Int) -> [ChatChannelMember] { + (0..