22// Copyright © 2026 Stream.io Inc. All rights reserved.
33//
44
5+ import Dispatch
56import StreamChat
67import StreamChatCommonUI
78import StreamChatSwiftUI
@@ -12,7 +13,7 @@ import SwiftUI
1213
1314 @Published var searchText : String = " " {
1415 didSet {
15- searchUsers ( with : searchText )
16+ scheduleDebouncedUserSearch ( )
1617 }
1718 }
1819
@@ -49,11 +50,29 @@ import SwiftUI
4950 private lazy var searchController : ChatUserSearchController = chatClient. userSearchController ( )
5051 private let lastSeenDateFormatter = DateUtils . timeAgo
5152
53+ /// Matches UIKit demo `CreateChatViewController.throttleTime` / `CreateGroupViewController.throttleTime`.
54+ private let userSearchDebounceMilliseconds = 1000
55+ private var userSearchDebounceWorkItem : DispatchWorkItem ?
56+ private var userSearchRequestGeneration : UInt64 = 0
57+
58+ private struct ActiveUserSearch : Equatable {
59+ var apiTerm : String ?
60+ }
61+
62+ /// Last user-list query that finished successfully (`apiTerm == nil` is “all users”).
63+ private enum UserListFetchCursor : Equatable {
64+ case notYetFetched
65+ case fetched( apiTerm: String ? )
66+ }
67+
68+ private var activeUserSearch : ActiveUserSearch ?
69+ private var userListFetchCursor : UserListFetchCursor = . notYetFetched
70+
5271 init ( ) {
5372 chatUsers = searchController. userArray
5473 searchController. delegate = self
55- // Empty initial search to get all users
56- searchUsers ( with : nil )
74+ // Empty initial search to get all users (immediate — not debounced; same as UIKit `viewDidLoad`)
75+ performUserSearch ( term : nil )
5776 }
5877
5978 func userTapped( _ user: ChatUser ) {
@@ -117,17 +136,63 @@ import SwiftUI
117136
118137 // MARK: - private
119138
120- private func searchUsers( with term: String ? ) {
139+ private func scheduleDebouncedUserSearch( ) {
140+ let nextTerm = normalizedUserSearchTerm ( searchText)
141+ if let active = activeUserSearch, matchesUserSearchTerm ( active. apiTerm, nextTerm) {
142+ return
143+ }
144+ if case let . fetched( prev) = userListFetchCursor,
145+ matchesUserSearchTerm ( prev, nextTerm) ,
146+ state != . error {
147+ return
148+ }
149+
150+ state = . loading
151+ userSearchDebounceWorkItem? . cancel ( )
152+ let query = searchText
153+ let work = DispatchWorkItem { [ weak self] in
154+ self ? . performUserSearch ( term: query. isEmpty ? nil : query)
155+ }
156+ userSearchDebounceWorkItem = work
157+ DispatchQueue . main. asyncAfter (
158+ deadline: . now( ) + . milliseconds( userSearchDebounceMilliseconds) ,
159+ execute: work
160+ )
161+ }
162+
163+ private func performUserSearch( term: String ? ) {
164+ if let active = activeUserSearch, matchesUserSearchTerm ( active. apiTerm, term) {
165+ return
166+ }
167+ activeUserSearch = ActiveUserSearch ( apiTerm: term)
168+ userSearchRequestGeneration += 1
169+ let generation = userSearchRequestGeneration
121170 state = . loading
122171 searchController. search ( term: term) { [ weak self] error in
172+ guard let self else { return }
173+ guard generation == self . userSearchRequestGeneration else { return }
174+ self . activeUserSearch = nil
123175 if error != nil {
124- self ? . state = . error
176+ self . state = . error
125177 } else {
126- self ? . state = . loaded
178+ self . userListFetchCursor = . fetched( apiTerm: term)
179+ self . state = self . chatUsers. isEmpty ? . noUsers : . loaded
127180 }
128181 }
129182 }
130183
184+ private func normalizedUserSearchTerm( _ text: String ) -> String ? {
185+ text. isEmpty ? nil : text
186+ }
187+
188+ private func matchesUserSearchTerm( _ lhs: String ? , _ rhs: String ? ) -> Bool {
189+ switch ( lhs, rhs) {
190+ case ( nil , nil ) : true
191+ case let ( l? , r? ) : l == r
192+ default : false
193+ }
194+ }
195+
131196 private func makeChannelController( ) throws {
132197 let selectedUserIds = Set ( selectedUsers. map ( \. id) )
133198 channelController = try chatClient. channelController (
0 commit comments