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 { ChannelFilters, ChannelOptions, ChannelSort, MessageFilters, MessageResponse, SearchMessageSort } from '../types';
3
3
  import type { StreamChat } from '../client';
4
4
  import type { SearchSourceOptions } from './types';
@@ -26,6 +26,17 @@ export type MessageSearchSourceFilterBuilderOptions<TContexts extends MessageSea
26
26
  messageSearch: FilterBuilderOptions<MessageFilters, MergeContext<BuiltInContexts['messageSearch'], TContexts['messageSearchContext']>>;
27
27
  channelQuery: FilterBuilderOptions<ChannelFilters, MergeContext<BuiltInContexts['channelQuery'], TContexts['channelQueryContext']>>;
28
28
  }>;
29
+ export type MessageSearchSourceOptions = SearchSourceOptions & {
30
+ /** Static base filters for the channel scope of the message search. */
31
+ messageSearchChannelFilters?: ChannelFilters;
32
+ /** Static base filters for the message search itself. */
33
+ messageSearchFilters?: MessageFilters;
34
+ messageSearchSort?: SearchMessageSort;
35
+ /** Static base filters for the follow-up query that hydrates unknown channels. */
36
+ channelQueryFilters?: ChannelFilters;
37
+ channelQuerySort?: ChannelSort;
38
+ channelQueryOptions?: Omit<ChannelOptions, 'limit' | 'offset'>;
39
+ };
29
40
  export declare class MessageSearchSource<TContexts extends MessageSearchSourceContexts = {}> extends BaseSearchSource<MessageResponse> {
30
41
  readonly type = "messages";
31
42
  private client;
@@ -38,8 +49,8 @@ export declare class MessageSearchSource<TContexts extends MessageSearchSourceCo
38
49
  messageSearchChannelFilterBuilder: FilterBuilder<ChannelFilters, MergeContext<BuiltInContexts['messageSearchChannel'], TContexts['messageSearchChannelContext']>>;
39
50
  messageSearchFilterBuilder: FilterBuilder<MessageFilters, MergeContext<BuiltInContexts['messageSearch'], TContexts['messageSearchContext']>>;
40
51
  channelQueryFilterBuilder: FilterBuilder<ChannelFilters, MergeContext<BuiltInContexts['channelQuery'], TContexts['channelQueryContext']>>;
41
- constructor(client: StreamChat, options?: SearchSourceOptions, filterBuilderOptions?: MessageSearchSourceFilterBuilderOptions<TContexts>);
42
- protected query(searchQuery: string): Promise<{
52
+ constructor(client: StreamChat, options?: MessageSearchSourceOptions, filterBuilderOptions?: MessageSearchSourceFilterBuilderOptions<TContexts>);
53
+ protected query(searchQuery: string, queryOptions?: SearchQueryOptions): Promise<{
43
54
  items: never[];
44
55
  next?: undefined;
45
56
  } | {
@@ -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';
@@ -7,6 +7,12 @@ type CustomContext = Record<string, unknown>;
7
7
  export type UserSearchSourceFilterBuilderContext<C extends CustomContext = CustomContext> = {
8
8
  searchQuery?: string;
9
9
  } & C;
10
+ export type UserSearchSourceOptions = SearchSourceOptions & {
11
+ /** Static base filters merged under the dynamically generated ones. */
12
+ filters?: UserFilters;
13
+ sort?: UserSort;
14
+ searchOptions?: Omit<UserOptions, 'limit' | 'offset'>;
15
+ };
10
16
  export declare class UserSearchSource<TFilterContext extends CustomContext = CustomContext> extends BaseSearchSource<UserResponse> {
11
17
  readonly type = "users";
12
18
  client: StreamChat;
@@ -14,8 +20,8 @@ export declare class UserSearchSource<TFilterContext extends CustomContext = Cus
14
20
  sort: UserSort | undefined;
15
21
  searchOptions: Omit<UserOptions, 'limit' | 'offset'> | undefined;
16
22
  filterBuilder: FilterBuilder<UserFilters, UserSearchSourceFilterBuilderContext<TFilterContext>>;
17
- constructor(client: StreamChat, options?: SearchSourceOptions, filterBuilderOptions?: FilterBuilderOptions<UserFilters, UserSearchSourceFilterBuilderContext<TFilterContext>>);
18
- protected query(searchQuery: string): Promise<{
23
+ constructor(client: StreamChat, options?: UserSearchSourceOptions, filterBuilderOptions?: FilterBuilderOptions<UserFilters, UserSearchSourceFilterBuilderContext<TFilterContext>>);
24
+ protected query(searchQuery: string, queryOptions?: SearchQueryOptions): Promise<{
19
25
  items: UserResponse[];
20
26
  }>;
21
27
  protected filterQueryResults(items: UserResponse[]): UserResponse[];
@@ -9,8 +9,21 @@ export type SearchSourceState<T = any> = {
9
9
  offset?: number;
10
10
  };
11
11
  export type SearchSourceOptions = {
12
- /** The number of milliseconds to debounce the search query. The default interval is 300ms. */
12
+ /**
13
+ * Legacy single debounce interval. When set, it applies to both short and long queries,
14
+ * unless overridden by `shortQueryDebounceMs` / `longQueryDebounceMs`.
15
+ */
13
16
  debounceMs?: number;
17
+ /**
18
+ * Debounce interval for queries no longer than `shortQueryMaxLength`. Such queries have
19
+ * low selectivity and are expensive server-side, so they are debounced harder.
20
+ * Defaults to 500ms.
21
+ */
22
+ shortQueryDebounceMs?: number;
23
+ /** Debounce interval for queries longer than `shortQueryMaxLength`. Defaults to 300ms. */
24
+ longQueryDebounceMs?: number;
25
+ /** Query length (inclusive) that still counts as short. Defaults to 2. */
26
+ shortQueryMaxLength?: number;
14
27
  pageSize?: number;
15
28
  /** When true, the source can execute queries with an empty search string. Defaults to false. */
16
29
  allowEmptySearchString?: boolean;
@@ -1008,7 +1008,13 @@ export type ChannelQueryOptions = {
1008
1008
  watch?: boolean;
1009
1009
  watchers?: PaginationOptions;
1010
1010
  };
1011
- export type ChannelStateOptions = {
1011
+ /**
1012
+ * Composed with RequestOptions because `queryChannels` carries the abort signal in this
1013
+ * bag; neither the state flags nor the signal are serialized into the request. Declaring
1014
+ * the composition keeps passing a RequestOptions here intentional rather than incidental
1015
+ * structural compatibility.
1016
+ */
1017
+ export type ChannelStateOptions = RequestOptions & {
1012
1018
  offlineMode?: boolean;
1013
1019
  skipInitialization?: string[];
1014
1020
  skipHydration?: boolean;
@@ -1353,6 +1359,11 @@ export type SearchOptions = {
1353
1359
  offset?: number;
1354
1360
  sort?: SearchMessageSort;
1355
1361
  };
1362
+ /** Per-request options that are never part of the serialized request payload. */
1363
+ export type RequestOptions = {
1364
+ /** Aborts the request. See AbortController. */
1365
+ signal?: AbortSignal;
1366
+ };
1356
1367
  export type StreamChatOptions = AxiosRequestConfig & {
1357
1368
  /**
1358
1369
  * Used to disable warnings that are triggered by using connectUser or connectAnonymousUser server-side.
@@ -1,8 +1,38 @@
1
1
  import type { StreamChat } from './client';
2
2
  import { StateStore } from './store';
3
3
  import type { AttachmentManager } from '.';
4
+ /**
5
+ * Whether an upload ended because it was aborted rather than because it failed.
6
+ *
7
+ * {@link UploadManager.deleteUploadRecord} and {@link UploadManager.cancelAllUploads} abort the
8
+ * request through its `AbortController`, which is what happens when an attachment is removed
9
+ * from the composer mid-upload or the client disconnects. Axios reports that as a
10
+ * `CanceledError` (`axios.isCancel`); a custom `doUploadRequest` on another transport
11
+ * conventionally throws a `DOMException` named `AbortError`.
12
+ *
13
+ * Callers use this to keep a deliberate cancellation from being reported as an error.
14
+ */
15
+ export declare const isUploadCancellation: (error: unknown) => boolean;
4
16
  export type UploadRecord = {
5
17
  id: string;
18
+ /**
19
+ * `true` once every byte has been handed to the transport but the server has not responded
20
+ * yet.
21
+ *
22
+ * Upload progress measures bytes *written to the connection*, not bytes the server
23
+ * acknowledged, so it reaches 100% the moment the request body is flushed — then the
24
+ * connection sits idle while the CDN ingests the file and the response travels back. On a
25
+ * large file over a slow link that window is long, and a UI that renders 100% as "done"
26
+ * claims the upload is confirmed while it is not.
27
+ *
28
+ * Nothing measurable happens during that window, so it is the point at which a determinate
29
+ * progress bar should hand over to an indeterminate one. Confirmation is the record being
30
+ * removed, not progress reaching 100.
31
+ *
32
+ * Requires `AttachmentManagerConfig.trackUploadProgress`; without progress reporting there is
33
+ * no way to tell when the flush happened, and this stays `false`.
34
+ */
35
+ uploadConfirmationPending?: boolean;
6
36
  uploadProgress?: number;
7
37
  };
8
38
  export type UploadManagerState = {
@@ -1,5 +1,5 @@
1
1
  import FormData from 'form-data';
2
- import type { AscDesc, ChannelFilters, ChannelQueryOptions, ChannelSort, ChannelSortBase, LocalMessage, LocalMessageBase, Logger, Message, MessagePaginationOptions, MessageResponse, MessageResponseBase, MessageSet, OwnUserResponse, PromoteChannelParams, ReactionGroupResponse, UpdatedMessage, UserResponse } from './types';
2
+ import type { AscDesc, Attachment, ChannelFilters, ChannelQueryOptions, ChannelSort, ChannelSortBase, LocalMessage, LocalMessageBase, Logger, Message, MessagePaginationOptions, MessageResponse, MessageResponseBase, MessageSet, OwnUserResponse, PromoteChannelParams, ReactionGroupResponse, UpdatedMessage, UserResponse } from './types';
3
3
  import type { StreamChat } from './client';
4
4
  import type { Channel } from './channel';
5
5
  import type { AxiosRequestConfig } from 'axios';
@@ -74,6 +74,25 @@ export declare function formatMessage(message: MessageResponse | MessageResponse
74
74
  * @param {MessageResponse} message `MessageResponse` object
75
75
  */
76
76
  export declare function unformatMessage(message: LocalMessage): MessageResponse;
77
+ /**
78
+ * Strips composer-internal state from a message's attachments, and drops any whose upload never
79
+ * resolved.
80
+ *
81
+ * `localMetadata` is how `AttachmentManager` tracks an upload (its id, the `File`, the local
82
+ * preview); it must never reach the API. An attachment that still carries it and has no remote
83
+ * URL was never settled — which is what happens when
84
+ * `createSendWithPendingUploadsAttachmentsMiddleware` is installed by a UI SDK that does not
85
+ * implement the rest of the flow (awaiting those uploads before sending). Sending it would store
86
+ * an attachment pointing at nothing, so it is dropped and logged instead.
87
+ *
88
+ * Returns the same message object when there was nothing to change.
89
+ */
90
+ export declare const sanitizeOutgoingAttachments: <T extends {
91
+ attachments?: Attachment[];
92
+ }>({ client, message, }: {
93
+ client: StreamChat;
94
+ message: T;
95
+ }) => T;
77
96
  export declare const localMessageToNewMessagePayload: (localMessage: LocalMessage) => Message;
78
97
  export declare const toUpdatedMessagePayload: (message: LocalMessage | Partial<MessageResponse>) => UpdatedMessage;
79
98
  export declare const toDeletedMessage: ({ message, deletedAt, hardDelete, }: {
@@ -227,7 +246,7 @@ export interface DebouncedFunc<T extends (...args: any[]) => any> {
227
246
  */
228
247
  flush(): ReturnType<T> | undefined;
229
248
  }
230
- export declare const debounce: <T extends (...args: any[]) => any>(fn: T, timeout?: number, { leading, trailing }?: {
249
+ export declare const debounce: <T extends (...args: any[]) => any>(fn: T, timeout?: number | ((...args: Parameters<T>) => number), { leading, trailing }?: {
231
250
  leading?: boolean;
232
251
  trailing?: boolean;
233
252
  }) => DebouncedFunc<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stream-chat",
3
- "version": "9.50.3",
3
+ "version": "9.52.0",
4
4
  "description": "JS SDK for the Stream Chat API",
5
5
  "homepage": "https://getstream.io/chat/",
6
6
  "author": {
@@ -52,9 +52,9 @@
52
52
  "dependencies": {
53
53
  "@types/jsonwebtoken": "^9.0.8",
54
54
  "@types/ws": "^8.18.1",
55
- "axios": "^1.16.1",
55
+ "axios": "^1.19.0",
56
56
  "base64-js": "^1.5.1",
57
- "form-data": "^4.0.5",
57
+ "form-data": "^4.0.6",
58
58
  "isomorphic-ws": "^5.0.0",
59
59
  "jsonwebtoken": "^9.0.3",
60
60
  "linkifyjs": "^4.3.3",
@@ -68,22 +68,22 @@
68
68
  "@semantic-release/git": "^10.0.1",
69
69
  "@types/node": "^22.19.19",
70
70
  "@types/sinon": "^10.0.6",
71
- "@vitest/coverage-v8": "^4.1.7",
71
+ "@vitest/coverage-v8": "^4.1.10",
72
72
  "concurrently": "^9.2.1",
73
73
  "conventional-changelog-conventionalcommits": "^9.3.1",
74
74
  "dotenv": "^17.4.2",
75
- "esbuild": "^0.28.0",
75
+ "esbuild": "^0.28.2",
76
76
  "eslint": "^9.39.4",
77
77
  "eslint-plugin-import": "^2.32.0",
78
78
  "globals": "^17.6.0",
79
79
  "husky": "^9.1.7",
80
80
  "lint-staged": "^17.0.5",
81
81
  "prettier": "^3.8.3",
82
- "semantic-release": "^25.0.3",
82
+ "semantic-release": "^25.0.9",
83
83
  "sinon": "^12.0.1",
84
84
  "typescript": "^6.0.3",
85
85
  "typescript-eslint": "^8.59.4",
86
- "vitest": "^4.1.7"
86
+ "vitest": "^4.1.10"
87
87
  },
88
88
  "scripts": {
89
89
  "build": "rm -rf dist && concurrently 'tsc' './scripts/bundle.mjs'",
package/src/channel.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  logChatPromiseExecution,
10
10
  messageSetPagination,
11
11
  normalizeQuerySort,
12
+ sanitizeOutgoingAttachments,
12
13
  } from './utils';
13
14
  import type { StreamChat } from './client';
14
15
  import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants';
@@ -62,6 +63,7 @@ import type {
62
63
  QueryMembersOptions,
63
64
  Reaction,
64
65
  ReactionAPIResponse,
66
+ RequestOptions,
65
67
  SearchAPIResponse,
66
68
  SearchMessageSortBase,
67
69
  SearchOptions,
@@ -211,14 +213,17 @@ export class Channel {
211
213
  *
212
214
  * @return {Promise<SendMessageAPIResponse>} The Server Response
213
215
  */
214
- async _sendMessage(message: Message, options?: SendMessageOptions) {
215
- return await this.getClient().post<SendMessageAPIResponse>(
216
- this._channelURL() + '/message',
217
- {
218
- message,
219
- ...options,
220
- },
221
- );
216
+ async _sendMessage(rawMessage: Message, options?: SendMessageOptions) {
217
+ const client = this.getClient();
218
+ const message = sanitizeOutgoingAttachments({
219
+ client,
220
+ message: rawMessage,
221
+ });
222
+
223
+ return await client.post<SendMessageAPIResponse>(this._channelURL() + '/message', {
224
+ message,
225
+ ...options,
226
+ });
222
227
  }
223
228
 
224
229
  async sendMessage(message: Message, options?: SendMessageOptions) {
@@ -377,6 +382,7 @@ export class Channel {
377
382
  * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}].
378
383
  * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}]
379
384
  * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10}
385
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
380
386
  *
381
387
  * @return {Promise<ChannelMemberAPIResponse>} Query Members response
382
388
  */
@@ -384,6 +390,7 @@ export class Channel {
384
390
  filterConditions: MemberFilters,
385
391
  sort: MemberSort = [],
386
392
  options: QueryMembersOptions = {},
393
+ requestOptions: RequestOptions = {},
387
394
  ) {
388
395
  let id: string | undefined;
389
396
  const type = this.type;
@@ -406,6 +413,7 @@ export class Channel {
406
413
  ...options,
407
414
  },
408
415
  },
416
+ requestOptions,
409
417
  );
410
418
  }
411
419
 
@@ -2003,6 +2011,10 @@ export class Channel {
2003
2011
  // delivery-report network sync below is skipped for it.
2004
2012
  case 'message.read_locally':
2005
2013
  case 'message.read':
2014
+ // Thread read events are handled indiscriminately within the reactive `thread` object, we can
2015
+ // skip them here in order to ensure the channel does not get read incidentally when it should
2016
+ // not.
2017
+ if (event.thread) break;
2006
2018
  if (event.user?.id && event.created_at) {
2007
2019
  const previousReadState = channelState.read[event.user.id];
2008
2020
  channelState.read[event.user.id] = {
package/src/client.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  normalizeQuerySort,
37
37
  randomId,
38
38
  retryInterval,
39
+ sanitizeOutgoingAttachments,
39
40
  sleep,
40
41
  toUpdatedMessagePayload,
41
42
  } from './utils';
@@ -219,6 +220,7 @@ import type {
219
220
  ReminderAPIResponse,
220
221
  RemoveUserGroupMembersOptions,
221
222
  RemoveUserGroupMembersResponse,
223
+ RequestOptions,
222
224
  ReviewFlagReportOptions,
223
225
  ReviewFlagReportResponse,
224
226
  SdkIdentifier,
@@ -1312,6 +1314,9 @@ export class StreamChat {
1312
1314
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1313
1315
  } catch (e: any /**TODO: generalize error types */) {
1314
1316
  e.client_request_id = requestConfig.headers?.['x-client-request-id'];
1317
+ // An aborted request is a deliberate cancellation, not a failure - logging it
1318
+ // and counting it towards the token-refresh backoff would be misleading.
1319
+ if (axios.isCancel(e)) throw e;
1315
1320
  this._logApiError(type, url, e);
1316
1321
  this.consecutiveFailures += 1;
1317
1322
  if (e.response) {
@@ -1333,16 +1338,22 @@ export class StreamChat {
1333
1338
  }
1334
1339
  };
1335
1340
 
1336
- get<T>(url: string, params?: AxiosRequestConfig['params']) {
1337
- return this.doAxiosRequest<T>('get', url, null, { params });
1341
+ get<T>(
1342
+ url: string,
1343
+ params?: AxiosRequestConfig['params'],
1344
+ // `params` is omitted deliberately: _enrichAxiosOptions spreads `config` after the
1345
+ // enriched params, so a `config.params` would drop api_key/user_id/connection_id.
1346
+ config?: Omit<AxiosRequestConfig, 'params'>,
1347
+ ) {
1348
+ return this.doAxiosRequest<T>('get', url, null, { params, config });
1338
1349
  }
1339
1350
 
1340
1351
  put<T>(url: string, data?: unknown) {
1341
1352
  return this.doAxiosRequest<T>('put', url, data);
1342
1353
  }
1343
1354
 
1344
- post<T>(url: string, data?: unknown) {
1345
- return this.doAxiosRequest<T>('post', url, data);
1355
+ post<T>(url: string, data?: unknown, config?: Omit<AxiosRequestConfig, 'params'>) {
1356
+ return this.doAxiosRequest<T>('post', url, data, { config });
1346
1357
  }
1347
1358
 
1348
1359
  patch<T>(url: string, data?: unknown) {
@@ -1837,6 +1848,7 @@ export class StreamChat {
1837
1848
  * @param {UserSort} sort Sort options, for instance [{last_active: -1}].
1838
1849
  * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}]
1839
1850
  * @param {UserOptions} options Option object, {presence: true}
1851
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
1840
1852
  *
1841
1853
  * @return {Promise<{ users: Array<UserResponse> }>} User Query Response
1842
1854
  */
@@ -1844,6 +1856,7 @@ export class StreamChat {
1844
1856
  filterConditions: UserFilters,
1845
1857
  sort: UserSort = [],
1846
1858
  options: UserOptions = {},
1859
+ requestOptions: RequestOptions = {},
1847
1860
  ) {
1848
1861
  const defaultOptions = {
1849
1862
  presence: false,
@@ -1867,6 +1880,7 @@ export class StreamChat {
1867
1880
  ...options,
1868
1881
  },
1869
1882
  },
1883
+ requestOptions,
1870
1884
  );
1871
1885
 
1872
1886
  this.state.updateUsers(data.users);
@@ -1918,13 +1932,17 @@ export class StreamChat {
1918
1932
  * searchUserGroups - Search user groups by prefix for autocomplete
1919
1933
  *
1920
1934
  * @param {SearchUserGroupsOptions} options The search options
1921
- *
1935
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
1922
1936
  * @return {Promise<SearchUserGroupsResponse>} User Group Search Response
1923
1937
  */
1924
- async searchUserGroups(options: SearchUserGroupsOptions) {
1938
+ async searchUserGroups(
1939
+ options: SearchUserGroupsOptions,
1940
+ requestOptions: RequestOptions = {},
1941
+ ) {
1925
1942
  return await this.get<SearchUserGroupsResponse>(
1926
1943
  this.baseURL + '/usergroups/search',
1927
1944
  options,
1945
+ requestOptions,
1928
1946
  );
1929
1947
  }
1930
1948
 
@@ -2061,6 +2079,7 @@ export class StreamChat {
2061
2079
  * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}.
2062
2080
  * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}]
2063
2081
  * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters.
2082
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
2064
2083
  *
2065
2084
  * @return {Promise<QueryChannelsAPIResponse>} full search channels response
2066
2085
  */
@@ -2068,6 +2087,7 @@ export class StreamChat {
2068
2087
  filterConditions: ChannelFilters,
2069
2088
  sort: ChannelSort = [],
2070
2089
  options: ChannelOptions = {},
2090
+ requestOptions: RequestOptions = {},
2071
2091
  ): Promise<QueryChannelsAPIResponse> {
2072
2092
  const defaultOptions: ChannelOptions = {
2073
2093
  state: true,
@@ -2101,7 +2121,11 @@ export class StreamChat {
2101
2121
  ...restOptions,
2102
2122
  };
2103
2123
 
2104
- return await this.post<QueryChannelsAPIResponse>(this.baseURL + '/channels', payload);
2124
+ return await this.post<QueryChannelsAPIResponse>(
2125
+ this.baseURL + '/channels',
2126
+ payload,
2127
+ requestOptions,
2128
+ );
2105
2129
  }
2106
2130
 
2107
2131
  /**
@@ -2147,6 +2171,7 @@ export class StreamChat {
2147
2171
  * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list.
2148
2172
  * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response.
2149
2173
  * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`.
2174
+ * - stateOptions.signal - Aborts the request. See AbortController.
2150
2175
  *
2151
2176
  * @return {Promise<Array<Channel>>} search channels response
2152
2177
  */
@@ -2172,6 +2197,8 @@ export class StreamChat {
2172
2197
  filterConditions,
2173
2198
  sort,
2174
2199
  options,
2200
+ // stateOptions is never serialized, so it carries the abort signal
2201
+ { signal: stateOptions.signal },
2175
2202
  );
2176
2203
  const channels = queryChannelsResponse.channels;
2177
2204
 
@@ -2317,6 +2344,7 @@ export class StreamChat {
2317
2344
  * @param {ChannelFilters} filterConditions MongoDB style filter conditions
2318
2345
  * @param {MessageFilters | string} query search query or object MongoDB style filters
2319
2346
  * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'}
2347
+ * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request.
2320
2348
  *
2321
2349
  * @return {Promise<SearchAPIResponse>} search messages response
2322
2350
  */
@@ -2324,6 +2352,7 @@ export class StreamChat {
2324
2352
  filterConditions: ChannelFilters,
2325
2353
  query: string | MessageFilters,
2326
2354
  options: SearchOptions = {},
2355
+ requestOptions: RequestOptions = {},
2327
2356
  ) {
2328
2357
  if (options.offset && options.next) {
2329
2358
  throw Error(`Cannot specify offset with next`);
@@ -2346,7 +2375,11 @@ export class StreamChat {
2346
2375
  // Make sure we wait for the connect promise if there is a pending one
2347
2376
  await this.wsPromise;
2348
2377
 
2349
- return await this.get<SearchAPIResponse>(this.baseURL + '/search', { payload });
2378
+ return await this.get<SearchAPIResponse>(
2379
+ this.baseURL + '/search',
2380
+ { payload },
2381
+ requestOptions,
2382
+ );
2350
2383
  }
2351
2384
 
2352
2385
  /**
@@ -3019,6 +3052,11 @@ export class StreamChat {
3019
3052
 
3020
3053
  /**
3021
3054
  * unflagMessage - unflag a message
3055
+ *
3056
+ * @deprecated The `/moderation/unflag` endpoint is deprecated and is a no-op on
3057
+ * the backend - calling this resolves successfully but does not remove the flag.
3058
+ * Use `client.moderation.submitAction()` against the review queue item instead.
3059
+ *
3022
3060
  * @param {string} targetMessageID
3023
3061
  * @param {string} [options.user_id] currentUserID, only used with serverside auth
3024
3062
  * @returns {Promise<APIResponse>}
@@ -3032,6 +3070,11 @@ export class StreamChat {
3032
3070
 
3033
3071
  /**
3034
3072
  * unflagUser - unflag a user
3073
+ *
3074
+ * @deprecated The `/moderation/unflag` endpoint is deprecated and is a no-op on
3075
+ * the backend - calling this resolves successfully but does not remove the flag.
3076
+ * Use `client.moderation.submitAction()` against the review queue item instead.
3077
+ *
3035
3078
  * @param {string} targetID
3036
3079
  * @param {string} [options.user_id] currentUserID, only used with serverside auth
3037
3080
  * @returns {Promise<APIResponse>}
@@ -3388,7 +3431,12 @@ export class StreamChat {
3388
3431
  }
3389
3432
 
3390
3433
  // should not include user object
3391
- const payload = toUpdatedMessagePayload(message);
3434
+ // Sanitized at the point of sending, which is the only place every path converges: the
3435
+ // offline replay of a queued `update-message` task calls this method directly.
3436
+ const payload = sanitizeOutgoingAttachments({
3437
+ client: this,
3438
+ message: toUpdatedMessagePayload(message),
3439
+ });
3392
3440
 
3393
3441
  // add user_id (if exists)
3394
3442
  if (typeof partialUserOrUserId === 'string') {
@@ -3806,6 +3854,12 @@ export class StreamChat {
3806
3854
  ...axiosRequestConfigRest
3807
3855
  } = this.options.axiosRequestConfig || {};
3808
3856
 
3857
+ // Most specific wins, and it is spread last so that a config carrying an explicit
3858
+ // `signal: undefined` cannot clobber a client-wide one or an armed
3859
+ // nextRequestAbortController.
3860
+ const resolvedSignal =
3861
+ options.config?.signal ?? axiosRequestConfigRest.signal ?? signal;
3862
+
3809
3863
  return {
3810
3864
  params: {
3811
3865
  user_id: this.userID,
@@ -3821,9 +3875,9 @@ export class StreamChat {
3821
3875
  ...options.headers,
3822
3876
  ...(axiosRequestConfigHeaders || {}),
3823
3877
  },
3824
- ...(signal ? { signal } : {}),
3825
3878
  ...options.config,
3826
- ...(axiosRequestConfigRest || {}),
3879
+ ...axiosRequestConfigRest,
3880
+ ...(resolvedSignal ? { signal: resolvedSignal } : {}),
3827
3881
  };
3828
3882
  }
3829
3883
 
@@ -3993,8 +4047,12 @@ export class StreamChat {
3993
4047
  *
3994
4048
  * @returns {Promise<APIResponse>}
3995
4049
  */
3996
- searchRoles(options: SearchRolesOptions) {
3997
- return this.get<SearchRolesAPIResponse>(`${this.baseURL}/roles/search`, options);
4050
+ searchRoles(options: SearchRolesOptions, requestOptions: RequestOptions = {}) {
4051
+ return this.get<SearchRolesAPIResponse>(
4052
+ `${this.baseURL}/roles/search`,
4053
+ options,
4054
+ requestOptions,
4055
+ );
3998
4056
  }
3999
4057
 
4000
4058
  /** deleteRole - deletes a custom role
package/src/index.ts CHANGED
@@ -62,3 +62,15 @@ export {
62
62
  promoteChannel,
63
63
  } from './utils';
64
64
  export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache';
65
+ /**
66
+ * Tag-keyed async runners. Exported so the UI SDKs (and integrators) serialize their own
67
+ * actions with the same primitive the SDK uses internally instead of hand-rolling promise
68
+ * chains. Tags share one process-wide map, so namespace yours (`my-app/thing/${id}`) to avoid
69
+ * colliding with another caller's.
70
+ */
71
+ export {
72
+ hasPending,
73
+ settled,
74
+ withCancellation,
75
+ withoutConcurrency,
76
+ } from './utils/concurrency';
@@ -1,5 +1,6 @@
1
1
  import type { Attachment, SharedLocationResponse } from '../types';
2
2
  import type {
3
+ AttachmentLoadingState,
3
4
  AudioAttachment,
4
5
  FileAttachment,
5
6
  GiphyAttachment,
@@ -27,6 +28,32 @@ export const isLocalUploadAttachment = (
27
28
  ): attachment is LocalUploadAttachment =>
28
29
  !!(attachment as LocalAttachment)?.localMetadata?.uploadState;
29
30
 
31
+ /**
32
+ * Upload states meaning "the bytes are not on the CDN yet, but they are on their way".
33
+ * `blocked` and `failed` are excluded - those never resolve on their own.
34
+ *
35
+ * Typed against {@link AttachmentLoadingState} rather than `string`, so renaming a state fails
36
+ * the build instead of silently making this list match nothing - it decides what gets sent.
37
+ */
38
+ const PENDING_UPLOAD_STATES: ReadonlyArray<AttachmentLoadingState> = [
39
+ 'pending',
40
+ 'uploading',
41
+ ];
42
+
43
+ /** Whether an upload for this attachment is still expected to resolve. */
44
+ export const isPendingUpload = (
45
+ attachment: unknown,
46
+ ): attachment is LocalUploadAttachment =>
47
+ isLocalUploadAttachment(attachment) &&
48
+ PENDING_UPLOAD_STATES.includes(attachment.localMetadata.uploadState);
49
+
50
+ /** Whether this attachment's upload already resolved to a URL. */
51
+ export const isFinishedUpload = (
52
+ attachment: unknown,
53
+ ): attachment is LocalUploadAttachment =>
54
+ isLocalUploadAttachment(attachment) &&
55
+ attachment.localMetadata.uploadState === 'finished';
56
+
30
57
  export const isFileAttachment = (
31
58
  attachment: Attachment | LocalAttachment,
32
59
  supportedVideoFormat: string[] = [],