Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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,

Check warning on line 148 in Sources/StreamChat/Controllers/UserGroupSearchController/UserGroupSearchController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "controller" or name it "_".

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&issues=AZ_4aSRMwKT_LGJfl416&open=AZ_4aSRMwKT_LGJfl416&pullRequest=4213
didChangeUserGroups userGroups: [UserGroup]

Check warning on line 149 in Sources/StreamChat/Controllers/UserGroupSearchController/UserGroupSearchController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "userGroups" or name it "_".

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&issues=AZ_4aSRMwKT_LGJfl417&open=AZ_4aSRMwKT_LGJfl417&pullRequest=4213
) {}

Check failure on line 150 in Sources/StreamChat/Controllers/UserGroupSearchController/UserGroupSearchController.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&issues=AZ_4aSRMwKT_LGJfl415&open=AZ_4aSRMwKT_LGJfl415&pullRequest=4213
}
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 @@
/// - 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 @@
}
}
}

func clearResults() async {}

Check failure on line 65 in Sources/StreamChat/MentionSuggestions/MentionSuggestionsProvider.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty, or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-swift&issues=AZ_4aSUIwKT_LGJfl418&open=AZ_4aSUIwKT_LGJfl418&pullRequest=4213
}
Loading
Loading