stream-chat 8.54.1 → 8.56.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/store.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export type Patch<T> = (value: T) => T;
2
+ export type ValueOrPatch<T> = T | Patch<T>;
2
3
  export type Handler<T> = (nextValue: T, previousValue: T | undefined) => void;
3
4
  export type Unsubscribe = () => void;
4
5
 
5
- function isPatch<T>(value: T | Patch<T>): value is Patch<T> {
6
+ export const isPatch = <T>(value: ValueOrPatch<T>): value is Patch<T> => {
6
7
  return typeof value === 'function';
7
- }
8
+ };
8
9
 
9
10
  export class StateStore<T extends Record<string, unknown>> {
10
11
  private handlerSet = new Set<Handler<T>>();
@@ -13,7 +14,7 @@ export class StateStore<T extends Record<string, unknown>> {
13
14
 
14
15
  constructor(private value: T) {}
15
16
 
16
- public next = (newValueOrPatch: T | Patch<T>): void => {
17
+ public next = (newValueOrPatch: ValueOrPatch<T>): void => {
17
18
  // newValue (or patch output) should never be mutated previous value
18
19
  const newValue = isPatch(newValueOrPatch) ? newValueOrPatch(this.value) : newValueOrPatch;
19
20
 
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@ import { AxiosRequestConfig, AxiosResponse } from 'axios';
2
2
  import { StableWSConnection } from './connection';
3
3
  import { EVENT_MAP } from './events';
4
4
  import { Role } from './permissions';
5
+ import type { Channel } from './channel';
5
6
 
6
7
  /**
7
8
  * Utility Types
@@ -3796,3 +3797,14 @@ export type VelocityFilterConfig = {
3796
3797
  rules: VelocityFilterConfigRule[];
3797
3798
  async?: boolean;
3798
3799
  };
3800
+
3801
+ export type PromoteChannelParams<SCG extends ExtendableGenerics = DefaultGenerics> = {
3802
+ channels: Array<Channel<SCG>>;
3803
+ channelToMove: Channel<SCG>;
3804
+ sort: ChannelSort<SCG>;
3805
+ /**
3806
+ * If the index of the channel within `channels` list which is being moved upwards
3807
+ * (`channelToMove`) is known, you can supply it to skip extra calculation.
3808
+ */
3809
+ channelToMoveIndexWithinChannels?: number;
3810
+ };