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
@@ -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.
@@ -1,10 +1,44 @@
1
+ import axios from 'axios';
2
+
1
3
  import type { StreamChat } from './client';
2
4
  import type { UploadRequestOptions } from './messageComposer/configuration/types';
3
5
  import { StateStore } from './store';
4
6
  import type { AttachmentManager } from '.';
5
7
 
8
+ /**
9
+ * Whether an upload ended because it was aborted rather than because it failed.
10
+ *
11
+ * {@link UploadManager.deleteUploadRecord} and {@link UploadManager.cancelAllUploads} abort the
12
+ * request through its `AbortController`, which is what happens when an attachment is removed
13
+ * from the composer mid-upload or the client disconnects. Axios reports that as a
14
+ * `CanceledError` (`axios.isCancel`); a custom `doUploadRequest` on another transport
15
+ * conventionally throws a `DOMException` named `AbortError`.
16
+ *
17
+ * Callers use this to keep a deliberate cancellation from being reported as an error.
18
+ */
19
+ export const isUploadCancellation = (error: unknown) =>
20
+ axios.isCancel(error) || (error instanceof Error && error.name === 'AbortError');
21
+
6
22
  export type UploadRecord = {
7
23
  id: string;
24
+ /**
25
+ * `true` once every byte has been handed to the transport but the server has not responded
26
+ * yet.
27
+ *
28
+ * Upload progress measures bytes *written to the connection*, not bytes the server
29
+ * acknowledged, so it reaches 100% the moment the request body is flushed — then the
30
+ * connection sits idle while the CDN ingests the file and the response travels back. On a
31
+ * large file over a slow link that window is long, and a UI that renders 100% as "done"
32
+ * claims the upload is confirmed while it is not.
33
+ *
34
+ * Nothing measurable happens during that window, so it is the point at which a determinate
35
+ * progress bar should hand over to an indeterminate one. Confirmation is the record being
36
+ * removed, not progress reaching 100.
37
+ *
38
+ * Requires `AttachmentManagerConfig.trackUploadProgress`; without progress reporting there is
39
+ * no way to tell when the flush happened, and this stays `false`.
40
+ */
41
+ uploadConfirmationPending?: boolean;
8
42
  uploadProgress?: number;
9
43
  };
10
44
 
@@ -126,6 +160,7 @@ export class UploadManager {
126
160
  const trackProgress = attachmentManager.config.trackUploadProgress;
127
161
  try {
128
162
  this.upsertUpload({
163
+ uploadConfirmationPending: false,
129
164
  id,
130
165
  uploadProgress: trackProgress ? 0 : undefined,
131
166
  });
@@ -135,6 +170,16 @@ export class UploadManager {
135
170
  this.updateUpload({
136
171
  id,
137
172
  uploadProgress: progress,
173
+ // Only a number says anything about the flush, so a report without one leaves
174
+ // the flag alone: `undefined` means the transport cannot measure this upload,
175
+ // not that bytes were un-sent. Lowering it there would drop a UI that had
176
+ // already gone indeterminate back to a determinate one with nothing to show.
177
+ //
178
+ // The record is removed in the `finally` below, so a record that still exists
179
+ // while reporting 100% can only mean "flushed, awaiting the response".
180
+ ...(typeof progress === 'number'
181
+ ? { uploadConfirmationPending: progress >= 100 }
182
+ : {}),
138
183
  });
139
184
  }
140
185
  : undefined;
package/src/utils.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import FormData from 'form-data';
2
2
  import type {
3
3
  AscDesc,
4
+ Attachment,
4
5
  ChannelFilters,
5
6
  ChannelQueryOptions,
6
7
  ChannelSort,
@@ -382,6 +383,77 @@ export function unformatMessage(message: LocalMessage): MessageResponse {
382
383
  } as MessageResponse;
383
384
  }
384
385
 
386
+ /**
387
+ * Whether a URL points at something the API and other devices can actually fetch.
388
+ */
389
+ const isRemoteUrl = (url?: string) => !!url && /^https?:\/\//i.test(url.trim());
390
+
391
+ /**
392
+ * Strips composer-internal state from a message's attachments, and drops any whose upload never
393
+ * resolved.
394
+ *
395
+ * `localMetadata` is how `AttachmentManager` tracks an upload (its id, the `File`, the local
396
+ * preview); it must never reach the API. An attachment that still carries it and has no remote
397
+ * URL was never settled — which is what happens when
398
+ * `createSendWithPendingUploadsAttachmentsMiddleware` is installed by a UI SDK that does not
399
+ * implement the rest of the flow (awaiting those uploads before sending). Sending it would store
400
+ * an attachment pointing at nothing, so it is dropped and logged instead.
401
+ *
402
+ * Returns the same message object when there was nothing to change.
403
+ */
404
+ export const sanitizeOutgoingAttachments = <T extends { attachments?: Attachment[] }>({
405
+ client,
406
+ message,
407
+ }: {
408
+ client: StreamChat;
409
+ message: T;
410
+ }): T => {
411
+ const attachments = message.attachments;
412
+ if (!attachments?.length) return message;
413
+
414
+ const unresolved: Attachment[] = [];
415
+ let changed = false;
416
+
417
+ const sanitized = attachments.reduce<Attachment[]>((acc, attachment) => {
418
+ const { localMetadata, ...rest } = attachment as Attachment & {
419
+ localMetadata?: Record<string, unknown>;
420
+ };
421
+
422
+ if (!localMetadata) {
423
+ acc.push(attachment);
424
+ return acc;
425
+ }
426
+
427
+ changed = true;
428
+
429
+ // Any of these pointing at a remote URL means the attachment resolved to something the API
430
+ // can store and other devices can load.
431
+ const hasRemoteSource =
432
+ isRemoteUrl(rest.asset_url) ||
433
+ isRemoteUrl(rest.image_url) ||
434
+ isRemoteUrl(rest.og_scrape_url) ||
435
+ isRemoteUrl(rest.title_link);
436
+
437
+ if (hasRemoteSource) {
438
+ acc.push(rest as Attachment);
439
+ } else {
440
+ unresolved.push(rest as Attachment);
441
+ }
442
+
443
+ return acc;
444
+ }, []);
445
+
446
+ if (unresolved.length) {
447
+ const text = `Dropped ${unresolved.length} attachment(s) whose upload never completed. Composing with pending uploads requires awaiting them before sending.`;
448
+ const extraData = { attachments: unresolved, message, tags: ['attachments'] };
449
+
450
+ client.logger('warn', text, extraData);
451
+ console.warn(text, extraData);
452
+ }
453
+
454
+ return changed ? { ...message, attachments: sanitized } : message;
455
+ };
456
+
385
457
  export const localMessageToNewMessagePayload = (localMessage: LocalMessage): Message => {
386
458
  /* eslint-disable @typescript-eslint/no-unused-vars */
387
459
  const {
@@ -723,11 +795,13 @@ export interface DebouncedFunc<T extends (...args: any[]) => any> {
723
795
  flush(): ReturnType<T> | undefined;
724
796
  }
725
797
 
726
- // works exactly the same as lodash.debounce
798
+ // works exactly the same as lodash.debounce, except that the timeout can also be
799
+ // a function of the call arguments, resolved on every call (e.g. to debounce short,
800
+ // low-selectivity search queries harder than long ones)
727
801
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
728
802
  export const debounce = <T extends (...args: any[]) => any>(
729
803
  fn: T,
730
- timeout = 0,
804
+ timeout: number | ((...args: Parameters<T>) => number) = 0,
731
805
  { leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {},
732
806
  ): DebouncedFunc<T> => {
733
807
  let runningTimeout: null | NodeJS.Timeout = null;
@@ -750,7 +824,8 @@ export const debounce = <T extends (...args: any[]) => any>(
750
824
  runningTimeout = null;
751
825
  };
752
826
 
753
- runningTimeout = setTimeout(timeoutHandler, timeout);
827
+ const delay = typeof timeout === 'function' ? timeout(...args) : timeout;
828
+ runningTimeout = setTimeout(timeoutHandler, delay);
754
829
  return lastResult;
755
830
  };
756
831