Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## 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)
- Add `ChatMessage.member` with the author's channel role, notification mute state, and member extra data [#4206](https://github.com/GetStream/stream-chat-swift/pull/4206)
- Add `ChannelSearch` and `ChatChannelSearchController` [#4198](https://github.com/GetStream/stream-chat-swift/pull/4198)
### 🐞 Fixed
- Fix mention suggestions not showing members in channels with 100+ members in the `MentionSuggestionsProvider` [#4213](https://github.com/GetStream/stream-chat-swift/pull/4213)
- Fix concurrent channel watches hanging indefinitely due to a FetchCache deadlock [#4200](https://github.com/GetStream/stream-chat-swift/pull/4200)
### πŸ”„ 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)
- Apply dynamic search debouncing to message, user, and channel search controllers [#4198](https://github.com/GetStream/stream-chat-swift/pull/4198)

## StreamChatCommonUI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 ?? []))
}
}
Comment thread
nuno-vieira marked this conversation as resolved.
}

/// 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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserGroupSearchControllerDelegate> = .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]
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 }
}

Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -55,4 +61,6 @@ public extension MentionSuggestionsProvider {
}
}
}

func clearResults() async {}
}
Loading
Loading