stream-chat 9.52.0 → 9.53.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.
@@ -491,14 +491,16 @@ export declare class Channel {
491
491
  */
492
492
  markRead(data?: MarkReadOptions): Promise<EventAPIResponse | null>;
493
493
  /**
494
- * markReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled
494
+ * markReadRequest - Send the mark read event for this user, only works if the `read_events` setting is
495
+ * enabled and the user holds the `read-events` capability on this channel
495
496
  *
496
497
  * @param {MarkReadOptions} data
497
498
  * @return {Promise<EventAPIResponse | null>} Description
498
499
  */
499
500
  markAsReadRequest(data?: MarkReadOptions): Promise<EventAPIResponse | null>;
500
501
  /**
501
- * markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is enabled
502
+ * markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is
503
+ * enabled and the user holds the `read-events` capability on this channel
502
504
  *
503
505
  * @param {MarkUnreadOptions} data
504
506
  * @return {APIResponse} An API response
@@ -1,5 +1,5 @@
1
1
  import type { StreamChat } from './client';
2
- import type { APIResponse, BatchChannelDataUpdate, NewMemberPayload, UpdateChannelsBatchFilters, UpdateChannelsBatchResponse } from './types';
2
+ import type { APIResponse, BatchChannelDataUpdate, ChannelBatchDataUpdateOptions, NewMemberPayload, UpdateChannelsBatchFilters, UpdateChannelsBatchResponse } from './types';
3
3
  /**
4
4
  * ChannelBatchUpdater - A class that provides convenience methods for batch channel operations
5
5
  */
@@ -85,9 +85,16 @@ export declare class ChannelBatchUpdater {
85
85
  /**
86
86
  * updateData - Update data on channels matching the filter
87
87
  *
88
+ * `data.custom` replaces the channel's whole custom object. To patch
89
+ * individual custom keys, pass an options object with `custom_set` or
90
+ * `custom_unset`. The client sends those fields at the request root rather
91
+ * than inside `data`. The options object can also include `data` to update
92
+ * channel fields in the same request.
93
+ *
88
94
  * @param {UpdateChannelsBatchFilters} filter Filter to select channels
89
- * @param {BatchChannelDataUpdate} data Data to update
95
+ * @param {BatchChannelDataUpdate | ChannelBatchDataUpdateOptions} update Data or update options
90
96
  * @return {Promise<APIResponse & UpdateChannelsBatchResponse>} The server response
91
97
  */
92
98
  updateData(filter: UpdateChannelsBatchFilters, data: BatchChannelDataUpdate): Promise<APIResponse & UpdateChannelsBatchResponse>;
99
+ updateData(filter: UpdateChannelsBatchFilters, options: ChannelBatchDataUpdateOptions): Promise<APIResponse & UpdateChannelsBatchResponse>;
93
100
  }
@@ -105,6 +105,10 @@ export declare class StreamChat {
105
105
  wsConnection: StableWSConnection | null;
106
106
  wsFallback?: WSConnectionFallback;
107
107
  wsPromise: ConnectAPIResponse | null;
108
+ private _wsPromiseSettled;
109
+ private _wsConnectId;
110
+ private _resolveWsPromise?;
111
+ private _rejectWsPromise?;
108
112
  consecutiveFailures: number;
109
113
  insightMetrics: InsightMetrics;
110
114
  defaultWSTimeoutWithFallback: number;
@@ -224,6 +228,15 @@ export declare class StreamChat {
224
228
  * Creates a new WebSocket connection with the current user. Returns empty promise, if there is an active connection
225
229
  */
226
230
  openConnection: () => ConnectAPIResponse | undefined;
231
+ /**
232
+ * Rejects the shared `wsPromise`, invalidates any in-flight connect attempt
233
+ */
234
+ private _rejectPendingWsPromise;
235
+ /**
236
+ * Keep a single pending `wsPromise` across close/reopen so callers that already
237
+ * `await this.wsPromise` are not stranded when `openConnection` starts a new connect.
238
+ */
239
+ private _bindWsPromise;
227
240
  /**
228
241
  * @deprecated Please use client.openConnction instead.
229
242
  * @private
@@ -2183,6 +2196,10 @@ export declare class StreamChat {
2183
2196
  /**
2184
2197
  * Update Channels Batch
2185
2198
  *
2199
+ * For the `updateData` operation, `data.custom` replaces the channel's whole
2200
+ * custom object, while the root-level `custom_set` / `custom_unset` patch
2201
+ * individual keys and leave the rest untouched.
2202
+ *
2186
2203
  * @param {UpdateChannelsBatchOptions} payload for updating channels in batch
2187
2204
  * @return {Promise<APIResponse & UpdateChannelsBatchResponse>} The server response
2188
2205
  */
@@ -48,6 +48,7 @@ export type TranslateResponse = {
48
48
  export type AppSettingsAPIResponse = APIResponse & {
49
49
  app?: {
50
50
  id?: string | number;
51
+ activity_metrics_config?: Record<string, number>;
51
52
  allow_multi_user_devices?: boolean;
52
53
  feed_audit_logs_enabled?: boolean;
53
54
  moderation_onboarding_complete?: boolean | null;
@@ -93,6 +94,7 @@ export type AppSettingsAPIResponse = APIResponse & {
93
94
  before_message_send_hook_attempt_timeout_ms?: number;
94
95
  campaign_enabled?: boolean;
95
96
  cdn_expiration_seconds?: number;
97
+ chat_primary_use_case?: string;
96
98
  custom_action_handler_url?: string;
97
99
  datadog_info?: {
98
100
  api_key: string;
@@ -117,13 +119,18 @@ export type AppSettingsAPIResponse = APIResponse & {
117
119
  image_upload_config?: FileUploadConfig;
118
120
  allowed_flag_reasons?: string[];
119
121
  max_aggregated_activities_length?: number;
122
+ member_custom_on_mentioned_users_enabled?: boolean;
123
+ member_custom_on_messages_enabled?: boolean;
124
+ member_custom_on_typing_events_enabled?: boolean;
120
125
  moderation_bulk_submit_action_enabled?: boolean;
121
126
  moderation_dashboard_preferences?: Record<string, unknown> | null;
122
127
  moderation_audio_call_moderation_enabled?: boolean;
123
128
  moderation_enabled?: boolean;
124
129
  moderation_flood_rules_enabled?: boolean;
130
+ moderation_keyframe_video_enabled?: boolean;
125
131
  moderation_llm_configurability_enabled?: boolean;
126
132
  moderation_multitenant_blocklist_enabled?: boolean;
133
+ moderation_s3_image_access_role_arn?: string;
127
134
  moderation_video_call_moderation_enabled?: boolean;
128
135
  moderation_webhook_url?: string;
129
136
  multi_tenant_enabled?: boolean;
@@ -160,6 +167,7 @@ export type AppSettingsAPIResponse = APIResponse & {
160
167
  use_hook_v2?: boolean;
161
168
  user_response_time_enabled?: boolean;
162
169
  user_search_disallowed_roles?: string[] | null;
170
+ video_primary_use_case?: string;
163
171
  video_provider?: string;
164
172
  webhook_events?: Array<string>;
165
173
  webhook_url?: string;
@@ -3713,7 +3721,32 @@ export type UpdateChannelsBatchOptions = {
3713
3721
  filter: UpdateChannelsBatchFilters;
3714
3722
  members?: string[] | Array<NewMemberPayload>;
3715
3723
  data?: BatchChannelDataUpdate;
3724
+ /**
3725
+ * `updateData` only. Merges these keys into each matched channel's existing
3726
+ * custom object, leaving every other custom key untouched — unlike
3727
+ * `data.custom`, which replaces the whole object. Keys are dot-paths, so
3728
+ * `a.b` sets key `b` inside object `a` (the parent object must already
3729
+ * exist). Cannot be combined with `data.custom`.
3730
+ *
3731
+ * Lives at the request root, not inside `data`.
3732
+ */
3733
+ custom_set?: Record<string, unknown>;
3734
+ /**
3735
+ * `updateData` only. Deletes these keys from each matched channel's existing
3736
+ * custom object, leaving every other custom key untouched. Keys are
3737
+ * dot-paths; deleting a key that does not exist is a no-op. Cannot be
3738
+ * combined with `data.custom`.
3739
+ *
3740
+ * Lives at the request root, not inside `data`.
3741
+ */
3742
+ custom_unset?: string[];
3716
3743
  };
3744
+ /**
3745
+ * Options for {@link ChannelBatchUpdater.updateData}. `custom_set` and
3746
+ * `custom_unset` are sent at the request root, while `data` contains the
3747
+ * channel fields to update.
3748
+ */
3749
+ export type ChannelBatchDataUpdateOptions = Pick<UpdateChannelsBatchOptions, 'data' | 'custom_set' | 'custom_unset'>;
3717
3750
  export type UpdateChannelsBatchFilters = QueryFilters<{
3718
3751
  cids?: RequireOnlyOne<Pick<QueryFilter<string>, '$in' | '$eq'>> | PrimitiveFilter<string[]>;
3719
3752
  types?: RequireOnlyOne<Pick<QueryFilter<string>, '$in' | '$eq'>> | PrimitiveFilter<string[]>;
@@ -12,6 +12,15 @@ import type { AxiosRequestConfig } from 'axios';
12
12
  *
13
13
  */
14
14
  export declare function logChatPromiseExecution<T>(promise: Promise<T>, name: string): void;
15
+ /**
16
+ * The default no-op client logger.
17
+ */
18
+ export declare const noopLogger: Logger;
19
+ /**
20
+ * Invokes a single event listener in isolation, so that a faulty listener cannot
21
+ * abort the dispatch loop or the bookkeeping that follows it.
22
+ */
23
+ export declare const invokeEventListener: <T>(listener: (event: T) => unknown, event: T, logger: Logger) => void;
15
24
  export declare const sleep: (m: number) => Promise<void>;
16
25
  export declare function isFunction(value: unknown): value is (...args: unknown[]) => unknown;
17
26
  export declare const chatCodes: {
@@ -28,11 +37,6 @@ export declare const channelHasReadEvents: (channel?: Channel) => boolean;
28
37
  * channelTracksReadLocally - Whether a channel maintains a client local unread count.
29
38
  */
30
39
  export declare const channelTracksReadLocally: (channel?: Channel) => boolean;
31
- /**
32
- * userHasReadReceipts - Whether the current user allows read receipts, per their privacy settings.
33
- * Read receipts are treated as enabled unless the user has explicitly disabled them.
34
- */
35
- export declare const userHasReadReceipts: (client: StreamChat) => boolean;
36
40
  export declare function addFileToFormData(uri: string | NodeJS.ReadableStream | Buffer | File, name?: string, contentType?: string): FormData;
37
41
  export declare function normalizeQuerySort<T extends Record<string, AscDesc | undefined>>(sort: T | T[]): {
38
42
  direction: AscDesc;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stream-chat",
3
- "version": "9.52.0",
3
+ "version": "9.53.0",
4
4
  "description": "JS SDK for the Stream Chat API",
5
5
  "homepage": "https://getstream.io/chat/",
6
6
  "author": {
package/src/channel.ts CHANGED
@@ -6,6 +6,7 @@ import { MessageReceiptsTracker } from './messageDelivery';
6
6
  import {
7
7
  channelHasReadEvents,
8
8
  generateChannelTempCid,
9
+ invokeEventListener,
9
10
  logChatPromiseExecution,
10
11
  messageSetPagination,
11
12
  normalizeQuerySort,
@@ -1221,7 +1222,8 @@ export class Channel {
1221
1222
  }
1222
1223
 
1223
1224
  /**
1224
- * markReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled
1225
+ * markReadRequest - Send the mark read event for this user, only works if the `read_events` setting is
1226
+ * enabled and the user holds the `read-events` capability on this channel
1225
1227
  *
1226
1228
  * @param {MarkReadOptions} data
1227
1229
  * @return {Promise<EventAPIResponse | null>} Description
@@ -1229,7 +1231,13 @@ export class Channel {
1229
1231
  async markAsReadRequest(data: MarkReadOptions = {}) {
1230
1232
  this._checkInitialized();
1231
1233
 
1232
- if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) {
1234
+ // Server-side auth acts on behalf of a user with no own capabilities, so only the client-side
1235
+ // flow is gated. Channels the user has no `read-events` capability on do not track read state
1236
+ // on the backend at all - see `markReadLocally` for the client-local unread count.
1237
+ if (
1238
+ !this.getClient()._isUsingServerAuth() &&
1239
+ (!this.getConfig()?.read_events || !channelHasReadEvents(this))
1240
+ ) {
1233
1241
  return null;
1234
1242
  }
1235
1243
 
@@ -1239,7 +1247,8 @@ export class Channel {
1239
1247
  }
1240
1248
 
1241
1249
  /**
1242
- * markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is enabled
1250
+ * markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is
1251
+ * enabled and the user holds the `read-events` capability on this channel
1243
1252
  *
1244
1253
  * @param {MarkUnreadOptions} data
1245
1254
  * @return {APIResponse} An API response
@@ -1247,7 +1256,11 @@ export class Channel {
1247
1256
  async markUnread(data: MarkUnreadOptions) {
1248
1257
  this._checkInitialized();
1249
1258
 
1250
- if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) {
1259
+ // Gated the same way as `markAsReadRequest` - see the comment there.
1260
+ if (
1261
+ !this.getClient()._isUsingServerAuth() &&
1262
+ (!this.getConfig()?.read_events || !channelHasReadEvents(this))
1263
+ ) {
1251
1264
  return Promise.resolve(null);
1252
1265
  }
1253
1266
 
@@ -2354,22 +2367,17 @@ export class Channel {
2354
2367
  }
2355
2368
 
2356
2369
  _callChannelListeners = (event: Event) => {
2357
- // eslint-disable-next-line @typescript-eslint/no-this-alias
2358
- const channel = this;
2359
- // gather and call the listeners
2360
- const listeners = [];
2361
- if (channel.listeners.all) {
2362
- listeners.push(...channel.listeners.all);
2363
- }
2364
- if (channel.listeners[event.type]) {
2365
- listeners.push(...channel.listeners[event.type]);
2366
- }
2370
+ // snapshot before dispatching: `on` pushes into these arrays in place, so a
2371
+ // listener that subscribes while handling an event must not be invoked for it
2372
+ const listeners = [
2373
+ ...(this.listeners.all ?? []),
2374
+ ...(this.listeners[event.type] ?? []),
2375
+ ];
2367
2376
 
2368
- // call the event and send it to the listeners
2377
+ const { logger } = this._client;
2369
2378
  for (const listener of listeners) {
2370
- if (typeof listener !== 'string') {
2371
- listener(event);
2372
- }
2379
+ if (typeof listener === 'string') continue;
2380
+ invokeEventListener(listener, event, logger);
2373
2381
  }
2374
2382
  };
2375
2383
 
@@ -2,11 +2,17 @@ import type { StreamChat } from './client';
2
2
  import type {
3
3
  APIResponse,
4
4
  BatchChannelDataUpdate,
5
+ ChannelBatchDataUpdateOptions,
5
6
  NewMemberPayload,
6
7
  UpdateChannelsBatchFilters,
7
8
  UpdateChannelsBatchResponse,
8
9
  } from './types';
9
10
 
11
+ const isChannelBatchDataUpdateOptions = (
12
+ update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions,
13
+ ): update is ChannelBatchDataUpdateOptions =>
14
+ 'data' in update || 'custom_set' in update || 'custom_unset' in update;
15
+
10
16
  /**
11
17
  * ChannelBatchUpdater - A class that provides convenience methods for batch channel operations
12
18
  */
@@ -194,18 +200,38 @@ export class ChannelBatchUpdater {
194
200
  /**
195
201
  * updateData - Update data on channels matching the filter
196
202
  *
203
+ * `data.custom` replaces the channel's whole custom object. To patch
204
+ * individual custom keys, pass an options object with `custom_set` or
205
+ * `custom_unset`. The client sends those fields at the request root rather
206
+ * than inside `data`. The options object can also include `data` to update
207
+ * channel fields in the same request.
208
+ *
197
209
  * @param {UpdateChannelsBatchFilters} filter Filter to select channels
198
- * @param {BatchChannelDataUpdate} data Data to update
210
+ * @param {BatchChannelDataUpdate | ChannelBatchDataUpdateOptions} update Data or update options
199
211
  * @return {Promise<APIResponse & UpdateChannelsBatchResponse>} The server response
200
212
  */
201
213
  async updateData(
202
214
  filter: UpdateChannelsBatchFilters,
203
215
  data: BatchChannelDataUpdate,
216
+ ): Promise<APIResponse & UpdateChannelsBatchResponse>;
217
+ async updateData(
218
+ filter: UpdateChannelsBatchFilters,
219
+ options: ChannelBatchDataUpdateOptions,
220
+ ): Promise<APIResponse & UpdateChannelsBatchResponse>;
221
+ async updateData(
222
+ filter: UpdateChannelsBatchFilters,
223
+ update: BatchChannelDataUpdate | ChannelBatchDataUpdateOptions,
204
224
  ): Promise<APIResponse & UpdateChannelsBatchResponse> {
225
+ const { data, custom_set, custom_unset } = isChannelBatchDataUpdateOptions(update)
226
+ ? update
227
+ : { data: update };
228
+
205
229
  return await this.client.updateChannelsBatch({
206
230
  operation: 'updateData',
207
231
  filter,
208
- data,
232
+ ...(data && { data }),
233
+ ...(custom_set && { custom_set }),
234
+ ...(custom_unset && { custom_unset }),
209
235
  });
210
236
  }
211
237
  }
package/src/client.ts CHANGED
@@ -29,10 +29,12 @@ import {
29
29
  axiosParamsSerializer,
30
30
  chatCodes,
31
31
  generateChannelTempCid,
32
+ invokeEventListener,
32
33
  isFunction,
33
34
  isOnline,
34
35
  isOwnUserBaseProperty,
35
36
  messageSetPagination,
37
+ noopLogger,
36
38
  normalizeQuerySort,
37
39
  randomId,
38
40
  retryInterval,
@@ -391,6 +393,10 @@ export class StreamChat {
391
393
  wsConnection: StableWSConnection | null;
392
394
  wsFallback?: WSConnectionFallback;
393
395
  wsPromise: ConnectAPIResponse | null;
396
+ private _wsPromiseSettled = true;
397
+ private _wsConnectId = 0;
398
+ private _resolveWsPromise?: (value: Awaited<ConnectAPIResponse>) => void;
399
+ private _rejectWsPromise?: (reason?: unknown) => void;
394
400
  consecutiveFailures: number;
395
401
  insightMetrics: InsightMetrics;
396
402
  defaultWSTimeoutWithFallback: number;
@@ -575,7 +581,7 @@ export class StreamChat {
575
581
  * channel: object
576
582
  * }
577
583
  */
578
- this.logger = isFunction(inputOptions.logger) ? inputOptions.logger : () => null;
584
+ this.logger = isFunction(inputOptions.logger) ? inputOptions.logger : noopLogger;
579
585
  this.recoverStateOnReconnect = this.options.recoverStateOnReconnect;
580
586
  this.threads = new ThreadManager({ client: this });
581
587
  this.polls = new PollManager({ client: this });
@@ -711,18 +717,25 @@ export class StreamChat {
711
717
 
712
718
  const wsPromise = this.openConnection();
713
719
 
714
- this.setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
720
+ const setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
715
721
  (result) => result[1], // We only return connection promise;
716
722
  );
723
+ this.setUserPromise = setUserPromise;
717
724
 
718
725
  try {
719
- return await this.setUserPromise;
726
+ return await setUserPromise;
720
727
  } catch (err) {
721
- if (this.persistUserOnConnectionFailure) {
722
- // cleanup client to allow the user to retry connectUser again
723
- this.closeConnection();
724
- } else {
725
- this.disconnectUser();
728
+ // disconnectUser() rejects an in-flight handshake so this catch can run *after* something else has taken over.
729
+ // Cleaning up then would tear down whatever replaced us. setUserPromise catches a
730
+ // newer connectUser(); userID additionally catches connectAnonymousUser(), which
731
+ // never sets setUserPromise, and a bare disconnectUser() that already cleaned up.
732
+ if (this.setUserPromise === setUserPromise && this.userID === user.id) {
733
+ if (this.persistUserOnConnectionFailure) {
734
+ // cleanup client to allow the user to retry connectUser again
735
+ this.closeConnection();
736
+ } else {
737
+ this.disconnectUser();
738
+ }
726
739
  }
727
740
  throw err;
728
741
  }
@@ -850,9 +863,58 @@ export class StreamChat {
850
863
  }
851
864
 
852
865
  this.clientID = `${this.userID}--${randomId()}`;
853
- this.wsPromise = this.connect();
866
+ const wsPromise = this._bindWsPromise(this.connect());
854
867
  this._startCleaning();
855
- return this.wsPromise;
868
+ return wsPromise;
869
+ };
870
+
871
+ /**
872
+ * Rejects the shared `wsPromise`, invalidates any in-flight connect attempt
873
+ */
874
+ private _rejectPendingWsPromise = (reason: Error) => {
875
+ if (!this._wsPromiseSettled) {
876
+ // invalidate in-flight attempts so a late settle cannot revive this promise
877
+ this._wsConnectId += 1;
878
+ this._wsPromiseSettled = true;
879
+ // Avoid unhandled promise rejection errors
880
+ this.wsPromise?.catch(() => {
881
+ // noop - real awaiters still observe the rejection
882
+ });
883
+ this._rejectWsPromise?.(reason);
884
+ }
885
+ };
886
+
887
+ /**
888
+ * Keep a single pending `wsPromise` across close/reopen so callers that already
889
+ * `await this.wsPromise` are not stranded when `openConnection` starts a new connect.
890
+ */
891
+ private _bindWsPromise = (connectPromise: ConnectAPIResponse): ConnectAPIResponse => {
892
+ const connectId = ++this._wsConnectId;
893
+
894
+ let pending = this.wsPromise;
895
+ if (this._wsPromiseSettled || !pending) {
896
+ this._wsPromiseSettled = false;
897
+ pending = new Promise((resolve, reject) => {
898
+ this._resolveWsPromise = resolve;
899
+ this._rejectWsPromise = reject;
900
+ });
901
+ this.wsPromise = pending;
902
+ }
903
+
904
+ connectPromise.then(
905
+ (value) => {
906
+ if (connectId !== this._wsConnectId) return;
907
+ this._wsPromiseSettled = true;
908
+ this._resolveWsPromise?.(value);
909
+ },
910
+ (error) => {
911
+ if (connectId !== this._wsConnectId) return;
912
+ this._wsPromiseSettled = true;
913
+ this._rejectWsPromise?.(error);
914
+ },
915
+ );
916
+
917
+ return pending;
856
918
  };
857
919
 
858
920
  /**
@@ -1057,6 +1119,11 @@ export class StreamChat {
1057
1119
 
1058
1120
  this.anonymous = false;
1059
1121
 
1122
+ this._rejectPendingWsPromise(
1123
+ new Error('Connection was closed because disconnectUser() was called'),
1124
+ );
1125
+ this.wsPromise = null;
1126
+
1060
1127
  const closePromise = this.closeConnection(timeout);
1061
1128
 
1062
1129
  for (const channel of Object.values(this.activeChannels)) {
@@ -1700,20 +1767,15 @@ export class StreamChat {
1700
1767
  }
1701
1768
 
1702
1769
  _callClientListeners = (event: Event) => {
1703
- // eslint-disable-next-line @typescript-eslint/no-this-alias
1704
- const client = this;
1705
- // gather and call the listeners
1706
- const listeners: Array<(event: Event) => void> = [];
1707
- if (client.listeners.all) {
1708
- listeners.push(...client.listeners.all);
1709
- }
1710
- if (client.listeners[event.type]) {
1711
- listeners.push(...client.listeners[event.type]);
1712
- }
1770
+ // snapshot before dispatching: `on` pushes into these arrays in place, so a
1771
+ // listener that subscribes while handling an event must not be invoked for it
1772
+ const listeners = [
1773
+ ...(this.listeners.all ?? []),
1774
+ ...(this.listeners[event.type] ?? []),
1775
+ ];
1713
1776
 
1714
- // call the event and send it to the listeners
1715
1777
  for (const listener of listeners) {
1716
- listener(event);
1778
+ invokeEventListener(listener, event, this.logger);
1717
1779
  }
1718
1780
  };
1719
1781
 
@@ -1754,7 +1816,12 @@ export class StreamChat {
1754
1816
  } as Event);
1755
1817
  }
1756
1818
 
1757
- this.wsPromise = Promise.resolve();
1819
+ // If state recovery happens afer a failed connect (for example on persistUserOnConnectionFailure: true) we need to flip the wsPromise from rejected to resolved
1820
+ // Otherwise all API calls that wait for the promise will fail
1821
+ // If promise is not yet settled - we leave it for the connect sequence to resolve the promise
1822
+ if (this._wsPromiseSettled) {
1823
+ this.wsPromise = Promise.resolve();
1824
+ }
1758
1825
  this.setUserPromise = Promise.resolve();
1759
1826
  };
1760
1827
 
@@ -5309,6 +5376,10 @@ export class StreamChat {
5309
5376
  /**
5310
5377
  * Update Channels Batch
5311
5378
  *
5379
+ * For the `updateData` operation, `data.custom` replaces the channel's whole
5380
+ * custom object, while the root-level `custom_set` / `custom_unset` patch
5381
+ * individual keys and leave the rest untouched.
5382
+ *
5312
5383
  * @param {UpdateChannelsBatchOptions} payload for updating channels in batch
5313
5384
  * @return {Promise<APIResponse & UpdateChannelsBatchResponse>} The server response
5314
5385
  */
@@ -10,7 +10,7 @@ import type {
10
10
  MarkReadOptions,
11
11
  } from '../types';
12
12
  import { type APIErrorResponse } from '../types';
13
- import { throttle, userHasReadReceipts } from '../utils';
13
+ import { throttle } from '../utils';
14
14
  import { isAPIError, isErrorRetryable } from '../errors';
15
15
 
16
16
  const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const;
@@ -279,8 +279,6 @@ export class MessageDeliveryReporter {
279
279
  * @param options
280
280
  */
281
281
  public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => {
282
- if (!userHasReadReceipts(this.client)) return null;
283
-
284
282
  let result: EventAPIResponse | null = null;
285
283
  if (isChannel(collection)) {
286
284
  result = await collection.markAsReadRequest(options);
@@ -291,7 +289,10 @@ export class MessageDeliveryReporter {
291
289
  });
292
290
  }
293
291
 
294
- this.removeCandidateFor(collection);
292
+ // A read implies delivery, so the candidate is only dropped once the request actually went
293
+ // out. `markAsReadRequest` returns null for channels that do not track read state on the
294
+ // backend, and those candidates must survive for the next delivery report.
295
+ if (result) this.removeCandidateFor(collection);
295
296
  return result;
296
297
  };
297
298
 
package/src/types.ts CHANGED
@@ -83,6 +83,7 @@ export type TranslateResponse = {
83
83
  export type AppSettingsAPIResponse = APIResponse & {
84
84
  app?: {
85
85
  id?: string | number;
86
+ activity_metrics_config?: Record<string, number>;
86
87
  allow_multi_user_devices?: boolean;
87
88
  feed_audit_logs_enabled?: boolean;
88
89
  moderation_onboarding_complete?: boolean | null;
@@ -139,6 +140,7 @@ export type AppSettingsAPIResponse = APIResponse & {
139
140
  before_message_send_hook_attempt_timeout_ms?: number;
140
141
  campaign_enabled?: boolean;
141
142
  cdn_expiration_seconds?: number;
143
+ chat_primary_use_case?: string;
142
144
  custom_action_handler_url?: string;
143
145
  datadog_info?: {
144
146
  api_key: string;
@@ -163,13 +165,18 @@ export type AppSettingsAPIResponse = APIResponse & {
163
165
  image_upload_config?: FileUploadConfig;
164
166
  allowed_flag_reasons?: string[];
165
167
  max_aggregated_activities_length?: number;
168
+ member_custom_on_mentioned_users_enabled?: boolean;
169
+ member_custom_on_messages_enabled?: boolean;
170
+ member_custom_on_typing_events_enabled?: boolean;
166
171
  moderation_bulk_submit_action_enabled?: boolean;
167
172
  moderation_dashboard_preferences?: Record<string, unknown> | null;
168
173
  moderation_audio_call_moderation_enabled?: boolean;
169
174
  moderation_enabled?: boolean;
170
175
  moderation_flood_rules_enabled?: boolean;
176
+ moderation_keyframe_video_enabled?: boolean;
171
177
  moderation_llm_configurability_enabled?: boolean;
172
178
  moderation_multitenant_blocklist_enabled?: boolean;
179
+ moderation_s3_image_access_role_arn?: string;
173
180
  moderation_video_call_moderation_enabled?: boolean;
174
181
  moderation_webhook_url?: string;
175
182
  multi_tenant_enabled?: boolean;
@@ -206,6 +213,7 @@ export type AppSettingsAPIResponse = APIResponse & {
206
213
  use_hook_v2?: boolean;
207
214
  user_response_time_enabled?: boolean;
208
215
  user_search_disallowed_roles?: string[] | null;
216
+ video_primary_use_case?: string;
209
217
  video_provider?: string;
210
218
  webhook_events?: Array<string>;
211
219
  webhook_url?: string;
@@ -4990,8 +4998,37 @@ export type UpdateChannelsBatchOptions = {
4990
4998
  filter: UpdateChannelsBatchFilters;
4991
4999
  members?: string[] | Array<NewMemberPayload>;
4992
5000
  data?: BatchChannelDataUpdate;
5001
+ /**
5002
+ * `updateData` only. Merges these keys into each matched channel's existing
5003
+ * custom object, leaving every other custom key untouched — unlike
5004
+ * `data.custom`, which replaces the whole object. Keys are dot-paths, so
5005
+ * `a.b` sets key `b` inside object `a` (the parent object must already
5006
+ * exist). Cannot be combined with `data.custom`.
5007
+ *
5008
+ * Lives at the request root, not inside `data`.
5009
+ */
5010
+ custom_set?: Record<string, unknown>;
5011
+ /**
5012
+ * `updateData` only. Deletes these keys from each matched channel's existing
5013
+ * custom object, leaving every other custom key untouched. Keys are
5014
+ * dot-paths; deleting a key that does not exist is a no-op. Cannot be
5015
+ * combined with `data.custom`.
5016
+ *
5017
+ * Lives at the request root, not inside `data`.
5018
+ */
5019
+ custom_unset?: string[];
4993
5020
  };
4994
5021
 
5022
+ /**
5023
+ * Options for {@link ChannelBatchUpdater.updateData}. `custom_set` and
5024
+ * `custom_unset` are sent at the request root, while `data` contains the
5025
+ * channel fields to update.
5026
+ */
5027
+ export type ChannelBatchDataUpdateOptions = Pick<
5028
+ UpdateChannelsBatchOptions,
5029
+ 'data' | 'custom_set' | 'custom_unset'
5030
+ >;
5031
+
4995
5032
  export type UpdateChannelsBatchFilters = QueryFilters<{
4996
5033
  cids?:
4997
5034
  | RequireOnlyOne<Pick<QueryFilter<string>, '$in' | '$eq'>>