stream-chat 9.50.3 → 9.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/cjs/index.browser.js +221 -90
  2. package/dist/cjs/index.browser.js.map +3 -3
  3. package/dist/cjs/index.node.js +221 -90
  4. package/dist/cjs/index.node.js.map +3 -3
  5. package/dist/esm/index.mjs +221 -90
  6. package/dist/esm/index.mjs.map +3 -3
  7. package/dist/types/channel.d.ts +3 -2
  8. package/dist/types/client.d.ts +13 -9
  9. package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +15 -9
  10. package/dist/types/offline-support/offline_support_api.d.ts +7 -1
  11. package/dist/types/offline-support/offline_sync_manager.d.ts +12 -1
  12. package/dist/types/search/BaseSearchSource.d.ts +42 -24
  13. package/dist/types/search/ChannelMemberSearchSource.d.ts +9 -4
  14. package/dist/types/search/ChannelSearchSource.d.ts +9 -3
  15. package/dist/types/search/MessageSearchSource.d.ts +14 -3
  16. package/dist/types/search/UserSearchSource.d.ts +9 -3
  17. package/dist/types/search/types.d.ts +14 -1
  18. package/dist/types/types.d.ts +12 -1
  19. package/dist/types/utils.d.ts +1 -1
  20. package/package.json +7 -7
  21. package/src/channel.ts +4 -0
  22. package/src/client.ts +54 -12
  23. package/src/messageComposer/middleware/textComposer/mentions.ts +75 -23
  24. package/src/offline-support/offline_support_api.ts +17 -2
  25. package/src/offline-support/offline_sync_manager.ts +36 -8
  26. package/src/search/BaseSearchSource.ts +123 -39
  27. package/src/search/ChannelMemberSearchSource.ts +22 -11
  28. package/src/search/ChannelSearchSource.ts +16 -5
  29. package/src/search/MessageSearchSource.ts +36 -4
  30. package/src/search/UserSearchSource.ts +16 -5
  31. package/src/search/types.ts +14 -1
  32. package/src/types.ts +13 -1
  33. package/src/utils.ts +6 -3
@@ -9,10 +9,24 @@ import type {
9
9
  import type { APIError } from '../errors';
10
10
  import { isAPIError, isErrorRetryable } from '../errors';
11
11
 
12
+ /**
13
+ * Passed to a search source's `query()`. Carries a cancellation handle and makes no
14
+ * claim that the query performs a request: a source may resolve from local data and
15
+ * ignore the signal. Where a source does issue requests, forward this straight through
16
+ * as the request options.
17
+ */
18
+ export type SearchQueryOptions = {
19
+ /** Aborted once a newer query supersedes this one. */
20
+ signal?: AbortSignal;
21
+ };
22
+
12
23
  export type DebounceOptions = {
13
- debounceMs: number;
24
+ /** Applies to both short and long queries unless overridden by the options below. */
25
+ debounceMs?: number;
26
+ shortQueryDebounceMs?: number;
27
+ longQueryDebounceMs?: number;
28
+ shortQueryMaxLength?: number;
14
29
  };
15
- type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise<void>>;
16
30
 
17
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
32
  interface ISearchSource<T = any> {
@@ -54,18 +68,44 @@ export interface SearchSourceSync<T = any> extends ISearchSource<T> {
54
68
  search(text?: string): void;
55
69
  }
56
70
 
57
- const DEFAULT_SEARCH_SOURCE_OPTIONS: Required<SearchSourceOptions> = {
58
- debounceMs: 300,
71
+ // Debounce defaults are resolved by resolveDebounceOptions, not here.
72
+ const DEFAULT_SEARCH_SOURCE_OPTIONS: Required<
73
+ Omit<SearchSourceOptions, keyof DebounceOptions>
74
+ > = {
59
75
  pageSize: 10,
60
76
  allowEmptySearchString: false,
61
77
  resetOnNewSearchQuery: true,
62
78
  } as const;
63
79
 
64
- abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
80
+ /**
81
+ * An explicit `debounceMs` applies to both buckets, so integrators who already tuned it
82
+ * keep exactly their configured interval. Only when it is absent do the 500/300 defaults
83
+ * kick in.
84
+ */
85
+ const resolveDebounceOptions = ({
86
+ debounceMs,
87
+ shortQueryDebounceMs,
88
+ longQueryDebounceMs,
89
+ shortQueryMaxLength,
90
+ }: DebounceOptions) => ({
91
+ shortQueryDebounceMs: shortQueryDebounceMs ?? debounceMs ?? 500,
92
+ longQueryDebounceMs: longQueryDebounceMs ?? debounceMs ?? 300,
93
+ shortQueryMaxLength: shortQueryMaxLength ?? 2,
94
+ });
95
+
96
+ abstract class BaseSearchSourceBase<
97
+ T,
98
+ R extends void | Promise<void>,
99
+ > implements ISearchSource<T> {
65
100
  state: StateStore<SearchSourceState<T>>;
66
101
  pageSize: number;
67
102
  protected allowEmptySearchString: boolean;
68
103
  protected resetOnNewSearchQuery: boolean;
104
+ // assigned by setDebounceOptions, which the constructor always calls
105
+ protected shortQueryDebounceMs!: number;
106
+ protected longQueryDebounceMs!: number;
107
+ protected shortQueryMaxLength!: number;
108
+ protected searchDebounced!: DebouncedFunc<(searchString?: string) => R>;
69
109
  abstract readonly type: SearchSourceType;
70
110
 
71
111
  protected constructor(options?: SearchSourceOptions) {
@@ -77,8 +117,44 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
77
117
  this.allowEmptySearchString = allowEmptySearchString;
78
118
  this.resetOnNewSearchQuery = resetOnNewSearchQuery;
79
119
  this.state = new StateStore<SearchSourceState<T>>(this.initialState);
120
+ // Field initializers run before this body, so setDebounceOptions is already
121
+ // assigned; it only captures getDebounceMs lazily.
122
+ this.setDebounceOptions(options ?? {});
123
+ }
124
+
125
+ abstract executeQuery(newSearchString?: string): R;
126
+
127
+ setDebounceOptions = (options: DebounceOptions = {}) => {
128
+ const resolved = resolveDebounceOptions(options);
129
+ this.shortQueryDebounceMs = resolved.shortQueryDebounceMs;
130
+ this.longQueryDebounceMs = resolved.longQueryDebounceMs;
131
+ this.shortQueryMaxLength = resolved.shortQueryMaxLength;
132
+ this.searchDebounced = debounce(this.executeQuery.bind(this), (searchString) =>
133
+ this.getDebounceMs(searchString),
134
+ );
135
+ };
136
+
137
+ /**
138
+ * Short queries match too much and are slow server-side, so they get a longer debounce.
139
+ * Resolved per call, which is what lets the interval switch as the user keeps typing.
140
+ */
141
+ protected getDebounceMs = (searchString?: string) => {
142
+ const { length } = searchString ?? this.searchQuery;
143
+ return length <= this.shortQueryMaxLength
144
+ ? this.shortQueryDebounceMs
145
+ : this.longQueryDebounceMs;
146
+ };
147
+
148
+ /**
149
+ * A new search query preempts an in-flight one; pagination waits for it to finish
150
+ * and needs a next page to fetch.
151
+ */
152
+ protected canDispatchQuery(hasNewSearchQuery: boolean) {
153
+ return hasNewSearchQuery || (!this.isLoading && this.hasNext);
80
154
  }
81
155
 
156
+ search = (searchQuery?: string) => this.searchDebounced(searchQuery);
157
+
82
158
  get lastQueryError() {
83
159
  return this.state.getLatestValue().lastQueryError;
84
160
  }
@@ -99,7 +175,7 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
99
175
  return this.state.getLatestValue().isLoading;
100
176
  }
101
177
 
102
- get initialState() {
178
+ get initialState(): SearchSourceState<T> {
103
179
  return {
104
180
  hasNext: true,
105
181
  isActive: false,
@@ -143,8 +219,7 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
143
219
  const searchString = newSearchString ?? this.searchQuery;
144
220
  return !!(
145
221
  this.isActive &&
146
- !this.isLoading &&
147
- (this.hasNext || hasNewSearchQuery) &&
222
+ this.canDispatchQuery(hasNewSearchQuery) &&
148
223
  (this.allowEmptySearchString || searchString)
149
224
  );
150
225
  };
@@ -217,34 +292,48 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
217
292
  }
218
293
 
219
294
  export abstract class BaseSearchSource<T>
220
- extends BaseSearchSourceBase<T>
295
+ extends BaseSearchSourceBase<T, Promise<void>>
221
296
  implements SearchSource<T>
222
297
  {
223
- protected searchDebounced!: DebouncedExecQueryFunction;
298
+ /** Aborts the in-flight request, if any, once a newer query is dispatched. */
299
+ protected queryAbortController: AbortController | null = null;
224
300
 
225
- constructor(options?: SearchSourceOptions) {
226
- const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
227
- super(options);
228
- this.setDebounceOptions({ debounceMs });
229
- }
230
-
231
- protected abstract query(searchQuery: string): Promise<QueryReturnValue<T>>;
301
+ protected abstract query(
302
+ searchQuery: string,
303
+ options?: SearchQueryOptions,
304
+ ): Promise<QueryReturnValue<T>>;
232
305
 
233
306
  protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
234
307
 
235
- setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
236
- this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
237
- };
308
+ /** Aborts the in-flight request, if any. Returns true when one was aborted. */
309
+ protected abortInFlightQuery() {
310
+ if (!this.queryAbortController) return false;
311
+ this.queryAbortController.abort();
312
+ this.queryAbortController = null;
313
+ return true;
314
+ }
315
+
316
+ resetState() {
317
+ // otherwise an in-flight response would repopulate the state we just cleared
318
+ this.abortInFlightQuery();
319
+ super.resetState();
320
+ }
238
321
 
239
322
  async executeQuery(newSearchString?: string) {
323
+ // Checked before anything is dispatched, so that the abort below always has a
324
+ // successor that will clear isLoading.
240
325
  if (!this.canExecuteQuery(newSearchString)) return;
241
326
 
327
+ // cancel the previous request before dispatching the new one
328
+ this.abortInFlightQuery();
329
+ const { signal } = (this.queryAbortController = new AbortController());
330
+
242
331
  const { hasNewSearchQuery, searchString } =
243
332
  this.prepareStateForQuery(newSearchString);
244
333
 
245
334
  let stateUpdate: Partial<SearchSourceState<T>> = {};
246
335
  try {
247
- const results = await this.query(searchString);
336
+ const results = await this.query(searchString, { signal });
248
337
  if (!results) return;
249
338
 
250
339
  const { items } = results;
@@ -256,37 +345,34 @@ export abstract class BaseSearchSource<T>
256
345
  stateUpdate.hasNext = false;
257
346
  }
258
347
  } finally {
259
- this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
348
+ // A newer query owns the state now - publishing here would clobber it with a
349
+ // stale result (and with the abort error).
350
+ if (!signal.aborted) {
351
+ this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
352
+ }
260
353
  }
261
354
  }
262
355
 
263
- search = (searchQuery?: string) => this.searchDebounced(searchQuery);
264
-
265
356
  cancelScheduledQuery() {
266
357
  this.searchDebounced.cancel();
358
+ // Nothing will dispatch a successor query, so release the loading state the
359
+ // aborted query owned - its own finally block skips the state write.
360
+ if (this.abortInFlightQuery() && this.isLoading) {
361
+ this.state.partialNext({ isLoading: false });
362
+ }
267
363
  }
268
364
  }
269
365
 
366
+ // Queries are resolved locally, so there is nothing to cancel here - only the
367
+ // dynamic debounce applies.
270
368
  export abstract class BaseSearchSourceSync<T>
271
- extends BaseSearchSourceBase<T>
369
+ extends BaseSearchSourceBase<T, void>
272
370
  implements SearchSourceSync<T>
273
371
  {
274
- protected searchDebounced!: DebouncedExecQueryFunction;
275
-
276
- constructor(options?: SearchSourceOptions) {
277
- const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
278
- super(options);
279
- this.setDebounceOptions({ debounceMs });
280
- }
281
-
282
372
  protected abstract query(searchQuery: string): QueryReturnValue<T>;
283
373
 
284
374
  protected abstract filterQueryResults(items: T[]): T[];
285
375
 
286
- setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
287
- this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
288
- };
289
-
290
376
  executeQuery(newSearchString?: string) {
291
377
  if (!this.canExecuteQuery(newSearchString)) return;
292
378
 
@@ -311,8 +397,6 @@ export abstract class BaseSearchSourceSync<T>
311
397
  }
312
398
  }
313
399
 
314
- search = (searchQuery?: string) => this.searchDebounced(searchQuery);
315
-
316
400
  cancelScheduledQuery() {
317
401
  this.searchDebounced.cancel();
318
402
  }
@@ -1,4 +1,4 @@
1
- import { BaseSearchSource } from './BaseSearchSource';
1
+ import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource';
2
2
  import { FilterBuilder, type FilterBuilderOptions } from '../pagination';
3
3
  import type { Channel } from '../channel';
4
4
  import type {
@@ -15,6 +15,13 @@ export type ChannelMemberSearchSourceFilterBuilderContext<
15
15
  C extends CustomContext = CustomContext,
16
16
  > = { searchQuery?: string } & C;
17
17
 
18
+ export type ChannelMemberSearchSourceOptions = SearchSourceOptions & {
19
+ /** Static base filters merged under the dynamically generated ones. */
20
+ filters?: MemberFilters;
21
+ sort?: MemberSort;
22
+ searchOptions?: Omit<QueryMembersOptions, 'limit' | 'offset'>;
23
+ };
24
+
18
25
  export class ChannelMemberSearchSource<
19
26
  TFilterContext extends CustomContext = CustomContext,
20
27
  > extends BaseSearchSource<ChannelMemberResponse> {
@@ -30,14 +37,19 @@ export class ChannelMemberSearchSource<
30
37
 
31
38
  constructor(
32
39
  channel: Channel,
33
- options?: SearchSourceOptions,
40
+ options?: ChannelMemberSearchSourceOptions,
34
41
  filterBuilderOptions: FilterBuilderOptions<
35
42
  MemberFilters,
36
43
  ChannelMemberSearchSourceFilterBuilderContext<TFilterContext>
37
44
  > = {},
38
45
  ) {
39
- super(options);
46
+ const { filters, sort, searchOptions, ...restOptions } = options || {};
47
+ // members are listed with an empty query, so the initial load must be allowed
48
+ super({ ...restOptions, allowEmptySearchString: true });
40
49
  this.channel = channel;
50
+ this.filters = filters;
51
+ this.sort = sort;
52
+ this.searchOptions = searchOptions;
41
53
  this.filterBuilder = new FilterBuilder<
42
54
  MemberFilters,
43
55
  ChannelMemberSearchSourceFilterBuilderContext<TFilterContext>
@@ -60,13 +72,7 @@ export class ChannelMemberSearchSource<
60
72
  });
61
73
  }
62
74
 
63
- canExecuteQuery = (newSearchString?: string) => {
64
- const hasNewSearchQuery = typeof newSearchString !== 'undefined';
65
-
66
- return this.isActive && !this.isLoading && (this.hasNext || hasNewSearchQuery);
67
- };
68
-
69
- protected async query(searchQuery: string) {
75
+ protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
70
76
  const filters = this.filterBuilder.buildFilters({
71
77
  baseFilters: this.filters,
72
78
  context: {
@@ -75,7 +81,12 @@ export class ChannelMemberSearchSource<
75
81
  });
76
82
  const sort = this.sort ?? [];
77
83
  const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset };
78
- const { members } = await this.channel.queryMembers(filters ?? {}, sort, options);
84
+ const { members } = await this.channel.queryMembers(
85
+ filters ?? {},
86
+ sort,
87
+ options,
88
+ queryOptions,
89
+ );
79
90
  return { items: members };
80
91
  }
81
92
 
@@ -1,4 +1,4 @@
1
- import { BaseSearchSource } from './BaseSearchSource';
1
+ import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource';
2
2
  import type { FilterBuilderOptions } from '../pagination';
3
3
  import { FilterBuilder } from '../pagination';
4
4
  import type { Channel } from '../channel';
@@ -12,6 +12,13 @@ export type ChannelSearchSourceFilterBuilderContext<
12
12
  C extends CustomContext = CustomContext,
13
13
  > = { searchQuery?: string } & C;
14
14
 
15
+ export type ChannelSearchSourceOptions = SearchSourceOptions & {
16
+ /** Static base filters merged under the dynamically generated ones. */
17
+ filters?: ChannelFilters;
18
+ sort?: ChannelSort;
19
+ searchOptions?: Omit<ChannelOptions, 'limit' | 'offset'>;
20
+ };
21
+
15
22
  export class ChannelSearchSource<
16
23
  TFilterContext extends CustomContext = CustomContext,
17
24
  > extends BaseSearchSource<Channel> {
@@ -27,14 +34,18 @@ export class ChannelSearchSource<
27
34
 
28
35
  constructor(
29
36
  client: StreamChat,
30
- options?: SearchSourceOptions,
37
+ options?: ChannelSearchSourceOptions,
31
38
  filterBuilderOptions: FilterBuilderOptions<
32
39
  ChannelFilters,
33
40
  ChannelSearchSourceFilterBuilderContext<TFilterContext>
34
41
  > = {},
35
42
  ) {
36
- super(options);
43
+ const { filters, sort, searchOptions, ...restOptions } = options || {};
44
+ super(restOptions);
37
45
  this.client = client;
46
+ this.filters = filters;
47
+ this.sort = sort;
48
+ this.searchOptions = searchOptions;
38
49
  this.filterBuilder = new FilterBuilder<
39
50
  ChannelFilters,
40
51
  ChannelSearchSourceFilterBuilderContext<TFilterContext>
@@ -51,7 +62,7 @@ export class ChannelSearchSource<
51
62
  });
52
63
  }
53
64
 
54
- protected async query(searchQuery: string) {
65
+ protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
55
66
  const filters = this.filterBuilder.buildFilters({
56
67
  baseFilters: {
57
68
  ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}),
@@ -63,7 +74,7 @@ export class ChannelSearchSource<
63
74
  });
64
75
  const sort = this.sort ?? {};
65
76
  const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset };
66
- const items = await this.client.queryChannels(filters, sort, options);
77
+ const items = await this.client.queryChannels(filters, sort, options, queryOptions);
67
78
  return { items };
68
79
  }
69
80
 
@@ -1,4 +1,4 @@
1
- import { BaseSearchSource } from './BaseSearchSource';
1
+ import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource';
2
2
  import type {
3
3
  ChannelFilters,
4
4
  ChannelOptions,
@@ -54,6 +54,18 @@ export type MessageSearchSourceFilterBuilderOptions<
54
54
  >;
55
55
  }>;
56
56
 
57
+ export type MessageSearchSourceOptions = SearchSourceOptions & {
58
+ /** Static base filters for the channel scope of the message search. */
59
+ messageSearchChannelFilters?: ChannelFilters;
60
+ /** Static base filters for the message search itself. */
61
+ messageSearchFilters?: MessageFilters;
62
+ messageSearchSort?: SearchMessageSort;
63
+ /** Static base filters for the follow-up query that hydrates unknown channels. */
64
+ channelQueryFilters?: ChannelFilters;
65
+ channelQuerySort?: ChannelSort;
66
+ channelQueryOptions?: Omit<ChannelOptions, 'limit' | 'offset'>;
67
+ };
68
+
57
69
  export class MessageSearchSource<
58
70
  TContexts extends MessageSearchSourceContexts = {},
59
71
  > extends BaseSearchSource<MessageResponse> {
@@ -86,11 +98,26 @@ export class MessageSearchSource<
86
98
 
87
99
  constructor(
88
100
  client: StreamChat,
89
- options?: SearchSourceOptions,
101
+ options?: MessageSearchSourceOptions,
90
102
  filterBuilderOptions?: MessageSearchSourceFilterBuilderOptions<TContexts>,
91
103
  ) {
92
- super(options);
104
+ const {
105
+ messageSearchChannelFilters,
106
+ messageSearchFilters,
107
+ messageSearchSort,
108
+ channelQueryFilters,
109
+ channelQuerySort,
110
+ channelQueryOptions,
111
+ ...restOptions
112
+ } = options || {};
113
+ super(restOptions);
93
114
  this.client = client;
115
+ this.messageSearchChannelFilters = messageSearchChannelFilters;
116
+ this.messageSearchFilters = messageSearchFilters;
117
+ this.messageSearchSort = messageSearchSort;
118
+ this.channelQueryFilters = channelQueryFilters;
119
+ this.channelQuerySort = channelQuerySort;
120
+ this.channelQueryOptions = channelQueryOptions;
94
121
 
95
122
  this.messageSearchChannelFilterBuilder = new FilterBuilder<
96
123
  ChannelFilters,
@@ -129,7 +156,7 @@ export class MessageSearchSource<
129
156
  });
130
157
  }
131
158
 
132
- protected async query(searchQuery: string) {
159
+ protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
133
160
  if (!this.client.userID || this.next === null) return { items: [] };
134
161
 
135
162
  const channelFilters = this.messageSearchChannelFilterBuilder.buildFilters({
@@ -170,9 +197,13 @@ export class MessageSearchSource<
170
197
  channelFilters,
171
198
  messageFilters,
172
199
  options,
200
+ queryOptions,
173
201
  );
174
202
  const items = results.map(({ message }) => message);
175
203
 
204
+ // a newer query already replaced this one - skip the cid scan and the hydration request
205
+ if (queryOptions.signal?.aborted) return { items, next };
206
+
176
207
  const cids = Array.from(
177
208
  items.reduce((acc, message) => {
178
209
  if (message.cid && !this.client.activeChannels[message.cid]) acc.add(message.cid);
@@ -194,6 +225,7 @@ export class MessageSearchSource<
194
225
  ...this.channelQuerySort,
195
226
  },
196
227
  this.channelQueryOptions,
228
+ queryOptions,
197
229
  );
198
230
  }
199
231
 
@@ -1,4 +1,4 @@
1
- import { BaseSearchSource } from './BaseSearchSource';
1
+ import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource';
2
2
  import { FilterBuilder, type FilterBuilderOptions } from '../pagination';
3
3
  import type { StreamChat } from '../client';
4
4
  import type { UserFilters, UserOptions, UserResponse, UserSort } from '../types';
@@ -10,6 +10,13 @@ export type UserSearchSourceFilterBuilderContext<
10
10
  C extends CustomContext = CustomContext,
11
11
  > = { searchQuery?: string } & C;
12
12
 
13
+ export type UserSearchSourceOptions = SearchSourceOptions & {
14
+ /** Static base filters merged under the dynamically generated ones. */
15
+ filters?: UserFilters;
16
+ sort?: UserSort;
17
+ searchOptions?: Omit<UserOptions, 'limit' | 'offset'>;
18
+ };
19
+
13
20
  export class UserSearchSource<
14
21
  TFilterContext extends CustomContext = CustomContext,
15
22
  > extends BaseSearchSource<UserResponse> {
@@ -25,14 +32,18 @@ export class UserSearchSource<
25
32
 
26
33
  constructor(
27
34
  client: StreamChat,
28
- options?: SearchSourceOptions,
35
+ options?: UserSearchSourceOptions,
29
36
  filterBuilderOptions: FilterBuilderOptions<
30
37
  UserFilters,
31
38
  UserSearchSourceFilterBuilderContext<TFilterContext>
32
39
  > = {},
33
40
  ) {
34
- super(options);
41
+ const { filters, sort, searchOptions, ...restOptions } = options || {};
42
+ super(restOptions);
35
43
  this.client = client;
44
+ this.filters = filters;
45
+ this.sort = sort;
46
+ this.searchOptions = searchOptions;
36
47
  this.filterBuilder = new FilterBuilder<
37
48
  UserFilters,
38
49
  UserSearchSourceFilterBuilderContext<TFilterContext>
@@ -55,7 +66,7 @@ export class UserSearchSource<
55
66
  });
56
67
  }
57
68
 
58
- protected async query(searchQuery: string) {
69
+ protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
59
70
  const filters = this.filterBuilder.buildFilters({
60
71
  baseFilters: this.filters,
61
72
  context: { searchQuery } as UserSearchSourceFilterBuilderContext<TFilterContext>,
@@ -68,7 +79,7 @@ export class UserSearchSource<
68
79
  sort = { id: 1, ...this.sort };
69
80
  }
70
81
  const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset };
71
- const { users } = await this.client.queryUsers(filters, sort, options);
82
+ const { users } = await this.client.queryUsers(filters, sort, options, queryOptions);
72
83
  return { items: users };
73
84
  }
74
85
 
@@ -11,8 +11,21 @@ export type SearchSourceState<T = any> = {
11
11
  };
12
12
 
13
13
  export type SearchSourceOptions = {
14
- /** The number of milliseconds to debounce the search query. The default interval is 300ms. */
14
+ /**
15
+ * Legacy single debounce interval. When set, it applies to both short and long queries,
16
+ * unless overridden by `shortQueryDebounceMs` / `longQueryDebounceMs`.
17
+ */
15
18
  debounceMs?: number;
19
+ /**
20
+ * Debounce interval for queries no longer than `shortQueryMaxLength`. Such queries have
21
+ * low selectivity and are expensive server-side, so they are debounced harder.
22
+ * Defaults to 500ms.
23
+ */
24
+ shortQueryDebounceMs?: number;
25
+ /** Debounce interval for queries longer than `shortQueryMaxLength`. Defaults to 300ms. */
26
+ longQueryDebounceMs?: number;
27
+ /** Query length (inclusive) that still counts as short. Defaults to 2. */
28
+ shortQueryMaxLength?: number;
16
29
  pageSize?: number;
17
30
  /** When true, the source can execute queries with an empty search string. Defaults to false. */
18
31
  allowEmptySearchString?: boolean;
package/src/types.ts CHANGED
@@ -1189,7 +1189,13 @@ export type ChannelQueryOptions = {
1189
1189
  watchers?: PaginationOptions;
1190
1190
  };
1191
1191
 
1192
- export type ChannelStateOptions = {
1192
+ /**
1193
+ * Composed with RequestOptions because `queryChannels` carries the abort signal in this
1194
+ * bag; neither the state flags nor the signal are serialized into the request. Declaring
1195
+ * the composition keeps passing a RequestOptions here intentional rather than incidental
1196
+ * structural compatibility.
1197
+ */
1198
+ export type ChannelStateOptions = RequestOptions & {
1193
1199
  offlineMode?: boolean;
1194
1200
  skipInitialization?: string[];
1195
1201
  skipHydration?: boolean;
@@ -1573,6 +1579,12 @@ export type SearchOptions = {
1573
1579
  sort?: SearchMessageSort;
1574
1580
  };
1575
1581
 
1582
+ /** Per-request options that are never part of the serialized request payload. */
1583
+ export type RequestOptions = {
1584
+ /** Aborts the request. See AbortController. */
1585
+ signal?: AbortSignal;
1586
+ };
1587
+
1576
1588
  export type StreamChatOptions = AxiosRequestConfig & {
1577
1589
  /**
1578
1590
  * Used to disable warnings that are triggered by using connectUser or connectAnonymousUser server-side.
package/src/utils.ts CHANGED
@@ -723,11 +723,13 @@ export interface DebouncedFunc<T extends (...args: any[]) => any> {
723
723
  flush(): ReturnType<T> | undefined;
724
724
  }
725
725
 
726
- // works exactly the same as lodash.debounce
726
+ // works exactly the same as lodash.debounce, except that the timeout can also be
727
+ // a function of the call arguments, resolved on every call (e.g. to debounce short,
728
+ // low-selectivity search queries harder than long ones)
727
729
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
728
730
  export const debounce = <T extends (...args: any[]) => any>(
729
731
  fn: T,
730
- timeout = 0,
732
+ timeout: number | ((...args: Parameters<T>) => number) = 0,
731
733
  { leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {},
732
734
  ): DebouncedFunc<T> => {
733
735
  let runningTimeout: null | NodeJS.Timeout = null;
@@ -750,7 +752,8 @@ export const debounce = <T extends (...args: any[]) => any>(
750
752
  runningTimeout = null;
751
753
  };
752
754
 
753
- runningTimeout = setTimeout(timeoutHandler, timeout);
755
+ const delay = typeof timeout === 'function' ? timeout(...args) : timeout;
756
+ runningTimeout = setTimeout(timeoutHandler, delay);
754
757
  return lastResult;
755
758
  };
756
759