stream-chat 9.50.3 → 9.52.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 (57) hide show
  1. package/dist/cjs/index.browser.js +601 -275
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +610 -276
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +599 -273
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/channel.d.ts +4 -3
  8. package/dist/types/client.d.ts +23 -9
  9. package/dist/types/index.d.ts +7 -0
  10. package/dist/types/messageComposer/attachmentIdentity.d.ts +4 -0
  11. package/dist/types/messageComposer/attachmentManager.d.ts +8 -0
  12. package/dist/types/messageComposer/messageComposer.d.ts +13 -0
  13. package/dist/types/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.d.ts +20 -1
  14. package/dist/types/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.d.ts +6 -1
  15. package/dist/types/messageComposer/middleware/messageComposer/attachments.d.ts +24 -0
  16. package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +22 -1
  17. package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +15 -9
  18. package/dist/types/messageComposer/types.d.ts +5 -0
  19. package/dist/types/middleware.d.ts +8 -0
  20. package/dist/types/offline-support/offline_support_api.d.ts +7 -1
  21. package/dist/types/offline-support/offline_sync_manager.d.ts +12 -1
  22. package/dist/types/search/BaseSearchSource.d.ts +42 -24
  23. package/dist/types/search/ChannelMemberSearchSource.d.ts +9 -4
  24. package/dist/types/search/ChannelSearchSource.d.ts +9 -3
  25. package/dist/types/search/MessageSearchSource.d.ts +14 -3
  26. package/dist/types/search/UserSearchSource.d.ts +9 -3
  27. package/dist/types/search/types.d.ts +14 -1
  28. package/dist/types/types.d.ts +12 -1
  29. package/dist/types/uploadManager.d.ts +30 -0
  30. package/dist/types/utils.d.ts +21 -2
  31. package/package.json +7 -7
  32. package/src/channel.ts +20 -8
  33. package/src/client.ts +71 -13
  34. package/src/index.ts +12 -0
  35. package/src/messageComposer/attachmentIdentity.ts +27 -0
  36. package/src/messageComposer/attachmentManager.ts +61 -12
  37. package/src/messageComposer/messageComposer.ts +43 -8
  38. package/src/messageComposer/middleware/attachmentManager/postUpload/AttachmentPostUploadMiddlewareExecutor.ts +1 -1
  39. package/src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts +58 -33
  40. package/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts +4 -0
  41. package/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +9 -0
  42. package/src/messageComposer/middleware/messageComposer/attachments.ts +109 -0
  43. package/src/messageComposer/middleware/messageComposer/types.ts +22 -1
  44. package/src/messageComposer/middleware/textComposer/mentions.ts +75 -23
  45. package/src/messageComposer/types.ts +5 -0
  46. package/src/middleware.ts +11 -0
  47. package/src/offline-support/offline_support_api.ts +21 -2
  48. package/src/offline-support/offline_sync_manager.ts +36 -8
  49. package/src/search/BaseSearchSource.ts +123 -39
  50. package/src/search/ChannelMemberSearchSource.ts +22 -11
  51. package/src/search/ChannelSearchSource.ts +16 -5
  52. package/src/search/MessageSearchSource.ts +36 -4
  53. package/src/search/UserSearchSource.ts +16 -5
  54. package/src/search/types.ts +14 -1
  55. package/src/types.ts +13 -1
  56. package/src/uploadManager.ts +45 -0
  57. package/src/utils.ts +78 -3
@@ -9,7 +9,11 @@ import {
9
9
  userSuggestionToMentionEntity,
10
10
  userSuggestionToUserResponse,
11
11
  } from './mentionUtils';
12
- import { BaseSearchSource, type SearchSourceOptions } from '../../../search';
12
+ import {
13
+ BaseSearchSource,
14
+ type SearchQueryOptions,
15
+ type SearchSourceOptions,
16
+ } from '../../../search';
13
17
  import { mergeWith } from '../../../utils/mergeWith';
14
18
  import type {
15
19
  ChannelMentionSuggestion,
@@ -92,6 +96,13 @@ export const calculateLevenshtein = (query: string, name: string) => {
92
96
  };
93
97
 
94
98
  export type MentionsSearchSourceOptions = SearchSourceOptions & {
99
+ /** Static base filters for the app-wide user query (mentionAllAppUsers). */
100
+ userFilters?: UserFilters;
101
+ userSort?: UserSort;
102
+ /** Static base filters for the channel member query. */
103
+ memberFilters?: MemberFilters;
104
+ memberSort?: MemberSort;
105
+ searchOptions?: Omit<UserOptions, 'limit' | 'offset'>;
95
106
  mentionAllAppUsers?: boolean;
96
107
  suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides;
97
108
  textComposerText?: string;
@@ -299,20 +310,34 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
299
310
  userSort: UserSort | undefined;
300
311
  memberSort: MemberSort | undefined; // todo: document there are filters and sort options for users and members
301
312
  searchOptions: Omit<UserOptions, 'limit' | 'offset'> | undefined;
302
- config: MentionsSearchSourceOptions;
313
+ config: Pick<
314
+ MentionsSearchSourceOptions,
315
+ 'mentionAllAppUsers' | 'suggestionFactoryMappers' | 'textComposerText' | 'trigger'
316
+ >;
303
317
 
304
318
  constructor(channel: Channel, options?: MentionsSearchSourceOptions) {
305
319
  const {
306
320
  mentionAllAppUsers,
321
+ memberFilters,
322
+ memberSort,
323
+ searchOptions,
307
324
  suggestionFactoryMappers,
308
325
  textComposerText,
309
326
  transliterate,
310
327
  trigger,
328
+ userFilters,
329
+ userSort,
311
330
  ...restOptions
312
331
  } = options || {};
313
- super(restOptions);
332
+ // suggestions are shown for a bare trigger, so the empty query must be allowed
333
+ super({ ...restOptions, allowEmptySearchString: true });
314
334
  this.client = channel.getClient();
315
335
  this.channel = channel;
336
+ this.userFilters = userFilters;
337
+ this.userSort = userSort;
338
+ this.memberFilters = memberFilters;
339
+ this.memberSort = memberSort;
340
+ this.searchOptions = searchOptions;
316
341
  this.config = {
317
342
  mentionAllAppUsers,
318
343
  suggestionFactoryMappers,
@@ -425,11 +450,6 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
425
450
  };
426
451
  }
427
452
 
428
- canExecuteQuery = (newSearchString?: string) => {
429
- const hasNewSearchQuery = typeof newSearchString !== 'undefined';
430
- return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext);
431
- };
432
-
433
453
  protected updatePaginationStateFromQuery() {
434
454
  const userPaginationState = this.latestUserPaginationState ?? { itemCount: 0 };
435
455
 
@@ -472,10 +492,13 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
472
492
  : []),
473
493
  ].filter(({ name }) => this.matchesPrefixSearchQuery(name, searchQuery));
474
494
 
475
- getRoleMentionSuggestions = async (query: string): Promise<RoleMentionSuggestion[]> => {
495
+ getRoleMentionSuggestions = async (
496
+ query: string,
497
+ queryOptions: SearchQueryOptions = {},
498
+ ): Promise<RoleMentionSuggestion[]> => {
476
499
  if (!this.isMentionTypeAllowed('role')) return [];
477
500
  if (!query) return [];
478
- const { roles } = await this.client.searchRoles({ query });
501
+ const { roles } = await this.client.searchRoles({ query }, queryOptions);
479
502
  return [...(roles?.map((role) => role.name) ?? [])]
480
503
  .sort((left, right) => left.localeCompare(right))
481
504
  .map((role) => this.toRoleMentionSuggestion(role, query));
@@ -558,23 +581,40 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
558
581
  };
559
582
  };
560
583
 
561
- queryUsers = async (searchQuery: string, offset = 0) => {
584
+ queryUsers = async (
585
+ searchQuery: string,
586
+ offset = 0,
587
+ queryOptions: SearchQueryOptions = {},
588
+ ) => {
562
589
  const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset);
563
- const { users } = await this.client.queryUsers(filters, sort, options);
590
+ const { users } = await this.client.queryUsers(filters, sort, options, queryOptions);
564
591
  return users;
565
592
  };
566
593
 
567
- queryMembers = async (searchQuery: string, offset = 0) => {
594
+ queryMembers = async (
595
+ searchQuery: string,
596
+ offset = 0,
597
+ queryOptions: SearchQueryOptions = {},
598
+ ) => {
568
599
  const { filters, sort, options } = this.prepareQueryMembersParams(
569
600
  searchQuery,
570
601
  offset,
571
602
  );
572
- const response = await this.channel.queryMembers(filters, sort, options);
603
+ const response = await this.channel.queryMembers(
604
+ filters,
605
+ sort,
606
+ options,
607
+ queryOptions,
608
+ );
573
609
 
574
610
  return response.members.map((member) => member.user) as UserResponse[];
575
611
  };
576
612
 
577
- getUserSuggestionsPage = async (searchQuery: string, userOffset = 0) => {
613
+ getUserSuggestionsPage = async (
614
+ searchQuery: string,
615
+ userOffset = 0,
616
+ queryOptions: SearchQueryOptions = {},
617
+ ) => {
578
618
  if (!this.isMentionTypeAllowed('user')) {
579
619
  return {
580
620
  items: [],
@@ -587,7 +627,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
587
627
  this.allMembersLoadedWithInitialChannelQuery || !searchQuery;
588
628
 
589
629
  if (this.config.mentionAllAppUsers) {
590
- users = await this.queryUsers(searchQuery, userOffset);
630
+ users = await this.queryUsers(searchQuery, userOffset, queryOptions);
591
631
  } else if (shouldSearchLocally) {
592
632
  const localUsers = this.searchMembersLocally(searchQuery);
593
633
  const items = localUsers
@@ -601,7 +641,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
601
641
  : undefined,
602
642
  };
603
643
  } else {
604
- users = await this.queryMembers(searchQuery, userOffset);
644
+ users = await this.queryMembers(searchQuery, userOffset, queryOptions);
605
645
  }
606
646
 
607
647
  const items = users.map((user) => this.toUserSuggestion(user, searchQuery));
@@ -623,7 +663,11 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
623
663
  } satisfies UserGroupSearchCursor);
624
664
  };
625
665
 
626
- getUserGroupSuggestionsPage = async (searchQuery: string, cursor?: string) => {
666
+ getUserGroupSuggestionsPage = async (
667
+ searchQuery: string,
668
+ cursor?: string,
669
+ queryOptions: SearchQueryOptions = {},
670
+ ) => {
627
671
  if (!this.isMentionTypeAllowed('user_group')) {
628
672
  return {
629
673
  items: [],
@@ -647,7 +691,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
647
691
  ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}),
648
692
  ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}),
649
693
  };
650
- const { user_groups } = await this.client.searchUserGroups(options);
694
+ const { user_groups } = await this.client.searchUserGroups(options, queryOptions);
651
695
 
652
696
  return {
653
697
  items: user_groups.map((userGroup) =>
@@ -657,20 +701,28 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
657
701
  };
658
702
  };
659
703
 
660
- async query(searchQuery: string) {
704
+ async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
661
705
  const userOffset = this.offset ?? 0;
662
706
  const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined';
663
707
  const previousUserPaginationState = this.latestUserPaginationState;
664
708
  const previousUserGroupCursor = this.userGroupCursor;
665
709
  const [userResultsState, userGroupResultsState, roleSuggestionsState] =
666
710
  await Promise.allSettled([
667
- this.getUserSuggestionsPage(searchQuery, userOffset),
668
- this.getUserGroupSuggestionsPage(searchQuery, previousUserGroupCursor),
711
+ this.getUserSuggestionsPage(searchQuery, userOffset, queryOptions),
712
+ this.getUserGroupSuggestionsPage(
713
+ searchQuery,
714
+ previousUserGroupCursor,
715
+ queryOptions,
716
+ ),
669
717
  isFirstPage
670
- ? this.getRoleMentionSuggestions(searchQuery)
718
+ ? this.getRoleMentionSuggestions(searchQuery, queryOptions)
671
719
  : Promise.resolve([] as RoleMentionSuggestion[]),
672
720
  ]);
673
721
 
722
+ // On abort the requests above reject and the fallback branches below would write
723
+ // empty results into the pagination cursors, corrupting them for the newer query.
724
+ if (queryOptions.signal?.aborted) return { items: [] };
725
+
674
726
  const userResults =
675
727
  userResultsState.status === 'fulfilled'
676
728
  ? userResultsState.value
@@ -119,6 +119,11 @@ export type LocalAttachmentUploadMetadata = {
119
119
  */
120
120
  previewUri?: string;
121
121
  uploadState: AttachmentLoadingState;
122
+ /**
123
+ * Mirrors {@link UploadRecord.uploadConfirmationPending}: every byte has been sent, but the server has
124
+ * not confirmed yet - not returned response to the upload request. Render an indeterminate indicator rather than a full bar.
125
+ */
126
+ uploadConfirmationPending?: boolean;
122
127
  uploadPermissionCheck?: UploadPermissionCheckResult; // added new
123
128
  /** 0–100 while uploading when progress tracking is enabled; undefined otherwise or when indeterminate */
124
129
  uploadProgress?: number;
package/src/middleware.ts CHANGED
@@ -58,6 +58,17 @@ export class MiddlewareExecutor<TValue, THandlers extends string> {
58
58
  this.id = generateUUIDv4();
59
59
  }
60
60
 
61
+ /**
62
+ * The middleware currently installed, in execution order.
63
+ *
64
+ * Read-only: use `use` / `insert` / `replace` / `setOrder` / `remove` to change the chain. It
65
+ * exists so components can ask what the chain is capable of - a middleware may declare
66
+ * behaviour its host has to account for, and the host has no other way to find out.
67
+ */
68
+ get installedMiddleware(): ReadonlyArray<Middleware<TValue, THandlers>> {
69
+ return this.middleware;
70
+ }
71
+
61
72
  use(middleware: Middleware<TValue, THandlers> | Middleware<TValue, THandlers>[]) {
62
73
  this.middleware = this.middleware.concat(middleware);
63
74
  return this;
@@ -40,9 +40,24 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
40
40
  public syncManager: OfflineDBSyncManager;
41
41
  public state: StateStore<OfflineDBState>;
42
42
 
43
- constructor({ client }: { client: StreamChat }) {
43
+ constructor({
44
+ client,
45
+ syncMaxEventCount,
46
+ }: {
47
+ client: StreamChat;
48
+ /**
49
+ * Optional positive cap forwarded to the {@link OfflineDBSyncManager}, which
50
+ * owns sync. `undefined` or any non-positive value means "no limit". See
51
+ * {@link OfflineDBSyncManager.syncMaxEventCount}.
52
+ */
53
+ syncMaxEventCount?: number;
54
+ }) {
44
55
  this.client = client;
45
- this.syncManager = new OfflineDBSyncManager({ client, offlineDb: this });
56
+ this.syncManager = new OfflineDBSyncManager({
57
+ client,
58
+ offlineDb: this,
59
+ syncMaxEventCount,
60
+ });
46
61
  this.state = new StateStore<OfflineDBState>({
47
62
  initialized: false,
48
63
  userId: this.client.userID,
@@ -1035,6 +1050,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
1035
1050
  }
1036
1051
 
1037
1052
  if (type === 'message.read' || type === 'notification.mark_read') {
1053
+ // We make sure not to update channel reads (which is what's stored in
1054
+ // the offline DB in any case) whenever we receive a read event for a
1055
+ // a thread specifically.
1056
+ if (event.thread) return [];
1038
1057
  return this.handleRead({ event, unreadMessages: 0, execute });
1039
1058
  }
1040
1059
 
@@ -20,16 +20,29 @@ export class OfflineDBSyncManager {
20
20
  new Map();
21
21
  private client: StreamChat;
22
22
  private offlineDb: AbstractOfflineDB;
23
+ /**
24
+ * Optional positive cap on the number of events a single `/sync` response may
25
+ * contain before this manager skips event replay into local storage and relies
26
+ * on the reconnect-time channel refresh (queryChannels + `channel.watch()`) to
27
+ * bring visible surfaces up to date.
28
+ *
29
+ * `undefined` or any non-positive value means "no limit" and event replay always
30
+ * runs with whatever maximum the server decides to return.
31
+ */
32
+ public readonly syncMaxEventCount?: number;
23
33
 
24
34
  constructor({
25
35
  client,
26
36
  offlineDb,
37
+ syncMaxEventCount,
27
38
  }: {
28
39
  client: StreamChat;
29
40
  offlineDb: AbstractOfflineDB;
41
+ syncMaxEventCount?: number;
30
42
  }) {
31
43
  this.client = client;
32
44
  this.offlineDb = offlineDb;
45
+ this.syncMaxEventCount = syncMaxEventCount;
33
46
  }
34
47
 
35
48
  /**
@@ -173,14 +186,29 @@ export class OfflineDBSyncManager {
173
186
  await this.offlineDb.resetDB();
174
187
  } else {
175
188
  const result = await this.client.sync(cids, lastSyncedAtDate.toISOString());
176
- const queryPromises = result.events.map((event) =>
177
- this.offlineDb.handleEvent({ event, execute: false }),
178
- );
179
- const queriesArray = await Promise.all(queryPromises);
180
- const queries = queriesArray.flat() as ExecuteBatchDBQueriesType;
181
-
182
- if (queries.length) {
183
- await this.offlineDb.executeSqlBatch(queries);
189
+
190
+ // Opt-in positive cap owned by this manager; undefined/non-positive = no limit.
191
+ const { syncMaxEventCount } = this;
192
+ const exceedsLimit =
193
+ typeof syncMaxEventCount === 'number' &&
194
+ syncMaxEventCount > 0 &&
195
+ result.events.length > syncMaxEventCount;
196
+ if (exceedsLimit) {
197
+ this.client.logger(
198
+ 'warn',
199
+ `Skipping sync event replay: received ${result.events.length} events, which exceeds the configured limit of ${syncMaxEventCount}. Visible channels are refreshed on reconnect instead.`,
200
+ { tags: ['sync', 'offline'] },
201
+ );
202
+ } else {
203
+ const queryPromises = result.events.map((event) =>
204
+ this.offlineDb.handleEvent({ event, execute: false }),
205
+ );
206
+ const queriesArray = await Promise.all(queryPromises);
207
+ const queries = queriesArray.flat() as ExecuteBatchDBQueriesType;
208
+
209
+ if (queries.length) {
210
+ await this.offlineDb.executeSqlBatch(queries);
211
+ }
184
212
  }
185
213
  }
186
214
  }
@@ -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