stream-chat 8.54.1 → 8.55.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.
@@ -0,0 +1,489 @@
1
+ import { debounce, DebouncedFunc } from './utils';
2
+ import { StateStore } from './store';
3
+ import type { Channel } from './channel';
4
+ import type { StreamChat } from './client';
5
+ import type {
6
+ ChannelFilters,
7
+ ChannelOptions,
8
+ ChannelSort,
9
+ DefaultGenerics,
10
+ ExtendableGenerics,
11
+ MessageFilters,
12
+ MessageResponse,
13
+ SearchMessageSort,
14
+ SearchOptions,
15
+ UserFilters,
16
+ UserOptions,
17
+ UserResponse,
18
+ UserSort,
19
+ } from './types';
20
+
21
+ export type SearchSourceType = 'channels' | 'users' | 'messages' | (string & {});
22
+ export type QueryReturnValue<T> = { items: T[]; next?: string };
23
+ export type DebounceOptions = {
24
+ debounceMs: number;
25
+ };
26
+ type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise<void>>;
27
+
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ export interface SearchSource<T = any> {
30
+ activate(): void;
31
+ deactivate(): void;
32
+ readonly hasNext: boolean;
33
+ readonly hasResults: boolean;
34
+ readonly initialState: SearchSourceState<T>;
35
+ readonly isActive: boolean;
36
+ readonly isLoading: boolean;
37
+ readonly items: T[] | undefined;
38
+ readonly lastQueryError: Error | undefined;
39
+ readonly next: string | undefined;
40
+ readonly offset: number | undefined;
41
+ resetState(): void;
42
+ search(text?: string): void;
43
+ searchDebounced: DebouncedExecQueryFunction;
44
+ readonly searchQuery: string;
45
+ setDebounceOptions(options: DebounceOptions): void;
46
+ readonly state: StateStore<SearchSourceState<T>>;
47
+ readonly type: SearchSourceType;
48
+ }
49
+
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ export type SearchSourceState<T = any> = {
52
+ hasNext: boolean;
53
+ isActive: boolean;
54
+ isLoading: boolean;
55
+ items: T[] | undefined;
56
+ searchQuery: string;
57
+ lastQueryError?: Error;
58
+ next?: string;
59
+ offset?: number;
60
+ };
61
+
62
+ export type SearchSourceOptions = {
63
+ /** The number of milliseconds to debounce the search query. The default interval is 300ms. */
64
+ debounceMs?: number;
65
+ pageSize?: number;
66
+ };
67
+
68
+ const DEFAULT_SEARCH_SOURCE_OPTIONS: Required<SearchSourceOptions> = {
69
+ debounceMs: 300,
70
+ pageSize: 10,
71
+ } as const;
72
+
73
+ export abstract class BaseSearchSource<T> implements SearchSource<T> {
74
+ state: StateStore<SearchSourceState<T>>;
75
+ protected pageSize: number;
76
+ abstract readonly type: SearchSourceType;
77
+ searchDebounced!: DebouncedExecQueryFunction;
78
+
79
+ protected constructor(options?: SearchSourceOptions) {
80
+ const { debounceMs, pageSize } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
81
+ this.pageSize = pageSize;
82
+ this.state = new StateStore<SearchSourceState<T>>(this.initialState);
83
+ this.setDebounceOptions({ debounceMs });
84
+ }
85
+
86
+ get lastQueryError() {
87
+ return this.state.getLatestValue().lastQueryError;
88
+ }
89
+
90
+ get hasNext() {
91
+ return this.state.getLatestValue().hasNext;
92
+ }
93
+
94
+ get hasResults() {
95
+ return Array.isArray(this.state.getLatestValue().items);
96
+ }
97
+
98
+ get isActive() {
99
+ return this.state.getLatestValue().isActive;
100
+ }
101
+
102
+ get isLoading() {
103
+ return this.state.getLatestValue().isLoading;
104
+ }
105
+
106
+ get initialState() {
107
+ return {
108
+ hasNext: true,
109
+ isActive: false,
110
+ isLoading: false,
111
+ items: undefined,
112
+ lastQueryError: undefined,
113
+ next: undefined,
114
+ offset: 0,
115
+ searchQuery: '',
116
+ };
117
+ }
118
+
119
+ get items() {
120
+ return this.state.getLatestValue().items;
121
+ }
122
+
123
+ get next() {
124
+ return this.state.getLatestValue().next;
125
+ }
126
+
127
+ get offset() {
128
+ return this.state.getLatestValue().offset;
129
+ }
130
+
131
+ get searchQuery() {
132
+ return this.state.getLatestValue().searchQuery;
133
+ }
134
+
135
+ protected abstract query(searchQuery: string): Promise<QueryReturnValue<T>>;
136
+
137
+ protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
138
+
139
+ setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
140
+ this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
141
+ };
142
+
143
+ activate = () => {
144
+ if (this.isActive) return;
145
+ this.state.partialNext({ isActive: true });
146
+ };
147
+
148
+ deactivate = () => {
149
+ if (!this.isActive) return;
150
+ this.state.partialNext({ isActive: false });
151
+ };
152
+
153
+ async executeQuery(newSearchString?: string) {
154
+ const hasNewSearchQuery = typeof newSearchString !== 'undefined';
155
+ const searchString = newSearchString ?? this.searchQuery;
156
+ if (!this.isActive || this.isLoading || (!this.hasNext && !hasNewSearchQuery) || !searchString) return;
157
+
158
+ if (hasNewSearchQuery) {
159
+ this.state.next({
160
+ ...this.initialState,
161
+ isActive: this.isActive,
162
+ isLoading: true,
163
+ searchQuery: newSearchString ?? '',
164
+ });
165
+ } else {
166
+ this.state.partialNext({ isLoading: true });
167
+ }
168
+
169
+ const stateUpdate: Partial<SearchSourceState<T>> = {};
170
+ try {
171
+ const results = await this.query(searchString);
172
+ if (!results) return;
173
+ const { items, next } = results;
174
+
175
+ if (next) {
176
+ stateUpdate.next = next;
177
+ stateUpdate.hasNext = !!next;
178
+ } else {
179
+ stateUpdate.offset = (this.offset ?? 0) + items.length;
180
+ stateUpdate.hasNext = items.length === this.pageSize;
181
+ }
182
+
183
+ stateUpdate.items = await this.filterQueryResults(items);
184
+ } catch (e) {
185
+ stateUpdate.lastQueryError = e as Error;
186
+ } finally {
187
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
188
+ this.state.next(({ lastQueryError, ...current }: SearchSourceState<T>) => ({
189
+ ...current,
190
+ ...stateUpdate,
191
+ isLoading: false,
192
+ items: [...(current.items ?? []), ...(stateUpdate.items || [])],
193
+ }));
194
+ }
195
+ }
196
+
197
+ search = (searchQuery?: string) => {
198
+ this.searchDebounced(searchQuery);
199
+ };
200
+
201
+ resetState() {
202
+ this.state.next(this.initialState);
203
+ }
204
+ }
205
+
206
+ export class UserSearchSource<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> extends BaseSearchSource<
207
+ UserResponse<StreamChatGenerics>
208
+ > {
209
+ readonly type = 'users';
210
+ private client: StreamChat<StreamChatGenerics>;
211
+ filters: UserFilters<StreamChatGenerics> | undefined;
212
+ sort: UserSort<StreamChatGenerics> | undefined;
213
+ searchOptions: Omit<UserOptions, 'limit' | 'offset'> | undefined;
214
+
215
+ constructor(client: StreamChat<StreamChatGenerics>, options?: SearchSourceOptions) {
216
+ super(options);
217
+ this.client = client;
218
+ }
219
+
220
+ protected async query(searchQuery: string) {
221
+ const filters = {
222
+ $or: [{ id: { $autocomplete: searchQuery } }, { name: { $autocomplete: searchQuery } }],
223
+ ...this.filters,
224
+ } as UserFilters<StreamChatGenerics>;
225
+ const sort = { id: 1, ...this.sort } as UserSort<StreamChatGenerics>;
226
+ const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset };
227
+ const { users } = await this.client.queryUsers(filters, sort, options);
228
+ return { items: users };
229
+ }
230
+
231
+ protected filterQueryResults(items: UserResponse<StreamChatGenerics>[]) {
232
+ return items.filter((u) => u.id !== this.client.user?.id);
233
+ }
234
+ }
235
+
236
+ export class ChannelSearchSource<
237
+ StreamChatGenerics extends ExtendableGenerics = DefaultGenerics
238
+ > extends BaseSearchSource<Channel<StreamChatGenerics>> {
239
+ readonly type = 'channels';
240
+ private client: StreamChat<StreamChatGenerics>;
241
+ filters: ChannelFilters<StreamChatGenerics> | undefined;
242
+ sort: ChannelSort<StreamChatGenerics> | undefined;
243
+ searchOptions: Omit<ChannelOptions, 'limit' | 'offset'> | undefined;
244
+
245
+ constructor(client: StreamChat<StreamChatGenerics>, options?: SearchSourceOptions) {
246
+ super(options);
247
+ this.client = client;
248
+ }
249
+
250
+ protected async query(searchQuery: string) {
251
+ const filters = {
252
+ members: { $in: [this.client.userID] },
253
+ name: { $autocomplete: searchQuery },
254
+ ...this.filters,
255
+ } as ChannelFilters<StreamChatGenerics>;
256
+ const sort = this.sort ?? {};
257
+ const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset };
258
+ const items = await this.client.queryChannels(filters, sort, options);
259
+ return { items };
260
+ }
261
+
262
+ protected filterQueryResults(items: Channel<StreamChatGenerics>[]) {
263
+ return items;
264
+ }
265
+ }
266
+
267
+ export class MessageSearchSource<
268
+ StreamChatGenerics extends ExtendableGenerics = DefaultGenerics
269
+ > extends BaseSearchSource<MessageResponse<StreamChatGenerics>> {
270
+ readonly type = 'messages';
271
+ private client: StreamChat<StreamChatGenerics>;
272
+ messageSearchChannelFilters: ChannelFilters<StreamChatGenerics> | undefined;
273
+ messageSearchFilters: MessageFilters<StreamChatGenerics> | undefined;
274
+ messageSearchSort: SearchMessageSort<StreamChatGenerics> | undefined;
275
+ channelQueryFilters: ChannelFilters<StreamChatGenerics> | undefined;
276
+ channelQuerySort: ChannelSort<StreamChatGenerics> | undefined;
277
+ channelQueryOptions: Omit<ChannelOptions, 'limit' | 'offset'> | undefined;
278
+
279
+ constructor(client: StreamChat<StreamChatGenerics>, options?: SearchSourceOptions) {
280
+ super(options);
281
+ this.client = client;
282
+ }
283
+
284
+ protected async query(searchQuery: string) {
285
+ if (!this.client.userID) return { items: [] };
286
+
287
+ const channelFilters: ChannelFilters<StreamChatGenerics> = {
288
+ members: { $in: [this.client.userID] },
289
+ ...this.messageSearchChannelFilters,
290
+ } as ChannelFilters<StreamChatGenerics>;
291
+
292
+ const messageFilters: MessageFilters<StreamChatGenerics> = {
293
+ text: searchQuery,
294
+ type: 'regular', // FIXME: type: 'reply' resp. do not filter by type and allow to jump to a message in a thread - missing support
295
+ ...this.messageSearchFilters,
296
+ } as MessageFilters<StreamChatGenerics>;
297
+
298
+ const sort: SearchMessageSort<StreamChatGenerics> = {
299
+ created_at: -1,
300
+ ...this.messageSearchSort,
301
+ };
302
+
303
+ const options = {
304
+ limit: this.pageSize,
305
+ next: this.next,
306
+ sort,
307
+ } as SearchOptions<StreamChatGenerics>;
308
+
309
+ const { next, results } = await this.client.search(channelFilters, messageFilters, options);
310
+ const items = results.map(({ message }) => message);
311
+
312
+ const cids = Array.from(
313
+ items.reduce((acc, message) => {
314
+ if (message.cid && !this.client.activeChannels[message.cid]) acc.add(message.cid);
315
+ return acc;
316
+ }, new Set<string>()), // keep the cids unique
317
+ );
318
+ const allChannelsLoadedLocally = cids.length === 0;
319
+ if (!allChannelsLoadedLocally) {
320
+ await this.client.queryChannels(
321
+ {
322
+ cid: { $in: cids },
323
+ ...this.channelQueryFilters,
324
+ } as ChannelFilters<StreamChatGenerics>,
325
+ {
326
+ last_message_at: -1,
327
+ ...this.channelQuerySort,
328
+ },
329
+ this.channelQueryOptions,
330
+ );
331
+ }
332
+
333
+ return { items, next };
334
+ }
335
+
336
+ protected filterQueryResults(items: MessageResponse<StreamChatGenerics>[]) {
337
+ return items;
338
+ }
339
+ }
340
+
341
+ export type DefaultSearchSources<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> = [
342
+ UserSearchSource<StreamChatGenerics>,
343
+ ChannelSearchSource<StreamChatGenerics>,
344
+ MessageSearchSource<StreamChatGenerics>,
345
+ ];
346
+
347
+ export type SearchControllerState = {
348
+ isActive: boolean;
349
+ searchQuery: string;
350
+ sources: SearchSource[];
351
+ };
352
+
353
+ export type InternalSearchControllerState<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> = {
354
+ // FIXME: focusedMessage should live in a MessageListController class that does not exist yet.
355
+ // This state prop should be then removed
356
+ focusedMessage?: MessageResponse<StreamChatGenerics>;
357
+ };
358
+
359
+ export type SearchControllerConfig = {
360
+ // The controller will make sure there is always exactly one active source. Enabled by default.
361
+ keepSingleActiveSource: boolean;
362
+ };
363
+
364
+ export type SearchControllerOptions = {
365
+ config?: Partial<SearchControllerConfig>;
366
+ sources?: SearchSource[];
367
+ };
368
+
369
+ export class SearchController<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> {
370
+ /**
371
+ * Not intended for direct use by integrators, might be removed without notice resulting in
372
+ * broken integrations.
373
+ */
374
+ _internalState: StateStore<InternalSearchControllerState<StreamChatGenerics>>;
375
+ state: StateStore<SearchControllerState>;
376
+ config: SearchControllerConfig;
377
+
378
+ constructor({ config, sources }: SearchControllerOptions = {}) {
379
+ this.state = new StateStore<SearchControllerState>({
380
+ isActive: false,
381
+ searchQuery: '',
382
+ sources: sources ?? [],
383
+ });
384
+ this._internalState = new StateStore<InternalSearchControllerState<StreamChatGenerics>>({});
385
+ this.config = { keepSingleActiveSource: true, ...config };
386
+ }
387
+ get hasNext() {
388
+ return this.sources.some((source) => source.hasNext);
389
+ }
390
+
391
+ get sources() {
392
+ return this.state.getLatestValue().sources;
393
+ }
394
+
395
+ get activeSources() {
396
+ return this.state.getLatestValue().sources.filter((s) => s.isActive);
397
+ }
398
+
399
+ get isActive() {
400
+ return this.state.getLatestValue().isActive;
401
+ }
402
+
403
+ get searchQuery() {
404
+ return this.state.getLatestValue().searchQuery;
405
+ }
406
+
407
+ get searchSourceTypes(): Array<SearchSource['type']> {
408
+ return this.sources.map((s) => s.type);
409
+ }
410
+
411
+ addSource = (source: SearchSource) => {
412
+ this.state.partialNext({
413
+ sources: [...this.sources, source],
414
+ });
415
+ };
416
+
417
+ getSource = (sourceType: SearchSource['type']) => this.sources.find((s) => s.type === sourceType);
418
+
419
+ removeSource = (sourceType: SearchSource['type']) => {
420
+ const newSources = this.sources.filter((s) => s.type !== sourceType);
421
+ if (newSources.length === this.sources.length) return;
422
+ this.state.partialNext({ sources: newSources });
423
+ };
424
+
425
+ activateSource = (sourceType: SearchSource['type']) => {
426
+ const source = this.getSource(sourceType);
427
+ if (!source || source.isActive) return;
428
+ if (this.config.keepSingleActiveSource) {
429
+ this.sources.forEach((s) => {
430
+ if (s.type !== sourceType) {
431
+ s.deactivate();
432
+ }
433
+ });
434
+ }
435
+ source.activate();
436
+ this.state.partialNext({ sources: [...this.sources] });
437
+ };
438
+
439
+ deactivateSource = (sourceType: SearchSource['type']) => {
440
+ const source = this.getSource(sourceType);
441
+ if (!source?.isActive) return;
442
+ if (this.activeSources.length === 1) return;
443
+ source.deactivate();
444
+ this.state.partialNext({ sources: [...this.sources] });
445
+ };
446
+
447
+ activate = () => {
448
+ if (!this.activeSources.length) {
449
+ const sourcesToActivate = this.config.keepSingleActiveSource ? this.sources.slice(0, 1) : this.sources;
450
+ sourcesToActivate.forEach((s) => s.activate());
451
+ }
452
+ if (this.isActive) return;
453
+ this.state.partialNext({ isActive: true });
454
+ };
455
+
456
+ search = async (searchQuery?: string) => {
457
+ const searchedSources = this.activeSources;
458
+ this.state.partialNext({
459
+ searchQuery,
460
+ });
461
+ await Promise.all(searchedSources.map((source) => source.search(searchQuery)));
462
+ };
463
+
464
+ cancelSearchQueries = () => {
465
+ this.activeSources.forEach((s) => s.searchDebounced.cancel());
466
+ };
467
+
468
+ clear = () => {
469
+ this.cancelSearchQueries();
470
+ this.sources.forEach((source) => source.state.next({ ...source.initialState, isActive: source.isActive }));
471
+ this.state.next((current) => ({
472
+ ...current,
473
+ isActive: true,
474
+ queriesInProgress: [],
475
+ searchQuery: '',
476
+ }));
477
+ };
478
+
479
+ exit = () => {
480
+ this.cancelSearchQueries();
481
+ this.sources.forEach((source) => source.state.next({ ...source.initialState, isActive: source.isActive }));
482
+ this.state.next((current) => ({
483
+ ...current,
484
+ isActive: false,
485
+ queriesInProgress: [],
486
+ searchQuery: '',
487
+ }));
488
+ };
489
+ }
package/src/utils.ts CHANGED
@@ -485,6 +485,82 @@ function maybeGetReactionGroupsFallback(
485
485
  return null;
486
486
  }
487
487
 
488
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
489
+ export interface DebouncedFunc<T extends (...args: any[]) => any> {
490
+ /**
491
+ * Call the original function, but applying the debounce rules.
492
+ *
493
+ * If the debounced function can be run immediately, this calls it and returns its return
494
+ * value.
495
+ *
496
+ * Otherwise, it returns the return value of the last invocation, or undefined if the debounced
497
+ * function was not invoked yet.
498
+ */
499
+ (...args: Parameters<T>): ReturnType<T> | undefined;
500
+
501
+ /**
502
+ * Throw away any pending invocation of the debounced function.
503
+ */
504
+ cancel(): void;
505
+
506
+ /**
507
+ * If there is a pending invocation of the debounced function, invoke it immediately and return
508
+ * its return value.
509
+ *
510
+ * Otherwise, return the value from the last invocation, or undefined if the debounced function
511
+ * was never invoked.
512
+ */
513
+ flush(): ReturnType<T> | undefined;
514
+ }
515
+
516
+ // works exactly the same as lodash.debounce
517
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
518
+ export const debounce = <T extends (...args: any[]) => any>(
519
+ fn: T,
520
+ timeout = 0,
521
+ { leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {},
522
+ ): DebouncedFunc<T> => {
523
+ let runningTimeout: null | NodeJS.Timeout = null;
524
+ let argsForTrailingExecution: Parameters<T> | null = null;
525
+ let lastResult: ReturnType<T> | undefined;
526
+
527
+ const debouncedFn = (...args: Parameters<T>) => {
528
+ if (runningTimeout) {
529
+ clearTimeout(runningTimeout);
530
+ } else if (leading) {
531
+ lastResult = fn(...args);
532
+ }
533
+ if (trailing) argsForTrailingExecution = args;
534
+
535
+ const timeoutHandler = () => {
536
+ if (argsForTrailingExecution) {
537
+ lastResult = fn(...argsForTrailingExecution);
538
+ argsForTrailingExecution = null;
539
+ }
540
+ runningTimeout = null;
541
+ };
542
+
543
+ runningTimeout = setTimeout(timeoutHandler, timeout);
544
+ return lastResult;
545
+ };
546
+
547
+ debouncedFn.cancel = () => {
548
+ if (runningTimeout) clearTimeout(runningTimeout);
549
+ };
550
+
551
+ debouncedFn.flush = () => {
552
+ if (runningTimeout) {
553
+ clearTimeout(runningTimeout);
554
+ runningTimeout = null;
555
+ if (argsForTrailingExecution) {
556
+ lastResult = fn(...argsForTrailingExecution);
557
+ }
558
+ }
559
+ return lastResult;
560
+ };
561
+ return debouncedFn;
562
+ };
563
+
488
564
  // works exactly the same as lodash.throttle
489
565
  export const throttle = <T extends (...args: unknown[]) => unknown>(
490
566
  fn: T,