stream-chat 9.52.1 → 9.54.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.
@@ -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
@@ -1140,9 +1153,7 @@ export declare class StreamChat {
1140
1153
  created_at?: string;
1141
1154
  deleted_at?: string;
1142
1155
  deleted_reply_count?: number;
1143
- i18n?: import("./types").RequireAtLeastOne<Record<`${import("./types").TranslationLanguages}_text`, string>> & {
1144
- language: import("./types").TranslationLanguages;
1145
- };
1156
+ i18n?: import("./types").TranslatedField;
1146
1157
  latest_reactions?: ReactionResponse[];
1147
1158
  member?: import("./types").ChannelMemberResponse;
1148
1159
  mentioned_users?: UserResponse[];
@@ -1878,9 +1889,7 @@ export declare class StreamChat {
1878
1889
  created_at?: string;
1879
1890
  deleted_at?: string;
1880
1891
  deleted_reply_count?: number;
1881
- i18n?: import("./types").RequireAtLeastOne<Record<`${import("./types").TranslationLanguages}_text`, string>> & {
1882
- language: import("./types").TranslationLanguages;
1883
- };
1892
+ i18n?: import("./types").TranslatedField;
1884
1893
  latest_reactions?: ReactionResponse[];
1885
1894
  member?: import("./types").ChannelMemberResponse;
1886
1895
  mentioned_users?: UserResponse[];
@@ -2183,6 +2192,10 @@ export declare class StreamChat {
2183
2192
  /**
2184
2193
  * Update Channels Batch
2185
2194
  *
2195
+ * For the `updateData` operation, `data.custom` replaces the channel's whole
2196
+ * custom object, while the root-level `custom_set` / `custom_unset` patch
2197
+ * individual keys and leave the rest untouched.
2198
+ *
2186
2199
  * @param {UpdateChannelsBatchOptions} payload for updating channels in batch
2187
2200
  * @return {Promise<APIResponse & UpdateChannelsBatchResponse>} The server response
2188
2201
  */
@@ -29,10 +29,13 @@ export declare class Poll {
29
29
  readonly state: StateStore<PollState>;
30
30
  id: string;
31
31
  private client;
32
+ private optimisticVotes;
33
+ private closeConfirmed;
32
34
  constructor({ client, poll }: PollInitOptions);
33
35
  private getInitialStateFromPollResponse;
34
36
  private upsertOfflineDb;
35
37
  reinitializeState: (poll: PollInitOptions["poll"]) => void;
38
+ private updateFromVoteEvent;
36
39
  get data(): PollState;
37
40
  handlePollUpdated: (event: Event) => void;
38
41
  handlePollClosed: (event: Event) => void;
@@ -42,15 +45,30 @@ export declare class Poll {
42
45
  query: (id: string) => Promise<PollResponse>;
43
46
  update: (data: Exclude<PollData, "id">) => Promise<import("./types").APIResponse & import("./types").UpdatePollAPIResponse>;
44
47
  partialUpdate: (partialPollObject: PartialPollUpdate) => Promise<import("./types").APIResponse & import("./types").UpdatePollAPIResponse>;
45
- close: () => Promise<import("./types").APIResponse & import("./types").UpdatePollAPIResponse>;
48
+ /**
49
+ * Closes the poll. The poll is marked as closed optimistically and the change is
50
+ * reverted if the request fails, unless the server confirmed the close meanwhile.
51
+ * Does nothing if the poll is already closed.
52
+ */
53
+ close: () => Promise<(import("./types").APIResponse & import("./types").UpdatePollAPIResponse) | undefined>;
46
54
  delete: () => Promise<import("./types").APIResponse>;
47
55
  createOption: (option: PollOptionData) => Promise<import("./types").APIResponse & import("./types").CreatePollOptionAPIResponse>;
48
56
  updateOption: (option: PollOptionData) => Promise<import("./types").APIResponse & import("./types").CreatePollOptionAPIResponse>;
49
57
  deleteOption: (optionId: string) => Promise<import("./types").APIResponse>;
58
+ /**
59
+ * Casts a vote for an option. The vote is shown right away through a local placeholder vote,
60
+ * which the server's vote replaces once the WS events of all pending vote changes arrived.
61
+ * If the request fails, the change is reverted.
62
+ */
50
63
  castVote: (optionId: string, messageId: string) => Promise<(import("./types").APIResponse & import("./types").CastVoteAPIResponse) | undefined>;
51
- removeVote: (voteId: string, messageId: string) => Promise<import("./types").APIResponse & {
64
+ /**
65
+ * Removes a vote. The vote is removed right away and restored if the request fails. Vote
66
+ * requests are sent in order, so removing a vote whose cast is not confirmed yet uses the vote
67
+ * id from the cast's response; if that cast failed, there is nothing to remove.
68
+ */
69
+ removeVote: (voteId: string, messageId: string) => Promise<(import("./types").APIResponse & {
52
70
  vote: PollVote;
53
- }>;
71
+ }) | undefined>;
54
72
  addAnswer: (answerText: string, messageId: string) => Promise<import("./types").APIResponse & import("./types").CastVoteAPIResponse>;
55
73
  removeAnswer: (answerId: string, messageId: string) => Promise<import("./types").APIResponse & {
56
74
  vote: PollVote;
@@ -59,6 +77,6 @@ export declare class Poll {
59
77
  queryOptionVotes: (params: PollOptionVotesQueryParams) => Promise<import("./types").APIResponse & import("./types").PollVotesAPIResponse>;
60
78
  }
61
79
  export declare function extractPollData(pollResponse: PollResponse): PollData;
62
- export declare function mapPollStateToResponse(poll: Poll): PollResponse;
80
+ export declare function mapPollStateToResponse(poll: Poll, state?: PollState): PollResponse;
63
81
  export declare function extractPollEnrichedData(pollResponse: PollResponse): Omit<PollEnrichData, 'own_votes' | 'latest_answers'>;
64
82
  export {};
@@ -0,0 +1,63 @@
1
+ import type { StreamChat } from './client';
2
+ import type { Poll, PollState } from './poll';
3
+ import type { PollResponse, PollVote } from './types';
4
+ type OptionId = string;
5
+ /**
6
+ * Optimistic updates for the current user's votes on a poll. Vote changes are shown right away.
7
+ * While any of them is not confirmed by its WS event, vote events are only stored, as the server
8
+ * state they carry does not contain the pending changes yet. Once all are confirmed (a failed
9
+ * request counts as confirmed), the server's vote state is shown, which also reverts failed changes.
10
+ * Vote requests are sent one after another, so the server applies them in the order they were made.
11
+ * A lost WS event is recovered from after CATCH_UP_TIMEOUT_MS (see applySucceededChanges).
12
+ */
13
+ export declare class OptimisticPollVotes {
14
+ private serverVoteState;
15
+ private pendingCount;
16
+ private succeededChanges;
17
+ private readonly requestTag;
18
+ private catchUpTimeout?;
19
+ private readonly client;
20
+ private readonly poll;
21
+ constructor({ client, poll }: {
22
+ client: StreamChat;
23
+ poll: Poll;
24
+ });
25
+ get serverOwnVotes(): Record<string, PollVote>;
26
+ withServerVotes: (pollState: PollState) => PollState;
27
+ /**
28
+ * Casts a vote for an option, shown right away through a placeholder vote. In polls with unique
29
+ * votes, it replaces the existing own votes.
30
+ */
31
+ castVote: (optionId: OptionId, messageId: string) => Promise<(import("./types").APIResponse & import("./types").CastVoteAPIResponse) | undefined>;
32
+ removeVote: (voteId: string, messageId: string) => Promise<(import("./types").APIResponse & {
33
+ vote: PollVote;
34
+ }) | undefined>;
35
+ private send;
36
+ handleVoteEvent: (poll: PollResponse, { confirmsOwnVote, ownVotesByOptionId, }: {
37
+ confirmsOwnVote: boolean;
38
+ ownVotesByOptionId: Record<OptionId, PollVote>;
39
+ }) => Partial<PollState>;
40
+ reset: (pollState: PollState) => void;
41
+ private track;
42
+ private settleWithoutEvent;
43
+ private createPlaceholderVote;
44
+ /**
45
+ * The server id of a vote to remove, once everything sent before the removal has finished. Casts
46
+ * and removals of an option alternate, so the option's last succeeded change tells: a cast is the
47
+ * vote being removed (e.g. a placeholder), a removal means the cast in between failed. Without
48
+ * one, the server's own vote of the option is the vote being removed.
49
+ */
50
+ private getServerVoteId;
51
+ private takeServerVoteState;
52
+ private scheduleCatchUp;
53
+ /**
54
+ * A WS event of an own vote change got lost, so the server's own votes are stale. All requests
55
+ * have finished (the timeout starts once nothing is left to send and a new request cancels it),
56
+ * so applying the succeeded changes in order gives the server's own votes (changes whose events
57
+ * did arrive end up the same). They are shown with the vote counts of the last WS event, which
58
+ * contain the other users' votes too.
59
+ */
60
+ private applySucceededChanges;
61
+ }
62
+ export declare function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']): string[];
63
+ export {};
@@ -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;
@@ -91,8 +92,10 @@ export type AppSettingsAPIResponse = APIResponse & {
91
92
  auto_translation_enabled?: boolean;
92
93
  before_message_send_hook_url?: string;
93
94
  before_message_send_hook_attempt_timeout_ms?: number;
95
+ before_message_send_hook_system_messages?: boolean;
94
96
  campaign_enabled?: boolean;
95
97
  cdn_expiration_seconds?: number;
98
+ chat_primary_use_case?: string;
96
99
  custom_action_handler_url?: string;
97
100
  datadog_info?: {
98
101
  api_key: string;
@@ -117,13 +120,18 @@ export type AppSettingsAPIResponse = APIResponse & {
117
120
  image_upload_config?: FileUploadConfig;
118
121
  allowed_flag_reasons?: string[];
119
122
  max_aggregated_activities_length?: number;
123
+ member_custom_on_mentioned_users_enabled?: boolean;
124
+ member_custom_on_messages_enabled?: boolean;
125
+ member_custom_on_typing_events_enabled?: boolean;
120
126
  moderation_bulk_submit_action_enabled?: boolean;
121
127
  moderation_dashboard_preferences?: Record<string, unknown> | null;
122
128
  moderation_audio_call_moderation_enabled?: boolean;
123
129
  moderation_enabled?: boolean;
124
130
  moderation_flood_rules_enabled?: boolean;
131
+ moderation_keyframe_video_enabled?: boolean;
125
132
  moderation_llm_configurability_enabled?: boolean;
126
133
  moderation_multitenant_blocklist_enabled?: boolean;
134
+ moderation_s3_image_access_role_arn?: string;
127
135
  moderation_video_call_moderation_enabled?: boolean;
128
136
  moderation_webhook_url?: string;
129
137
  multi_tenant_enabled?: boolean;
@@ -160,6 +168,7 @@ export type AppSettingsAPIResponse = APIResponse & {
160
168
  use_hook_v2?: boolean;
161
169
  user_response_time_enabled?: boolean;
162
170
  user_search_disallowed_roles?: string[] | null;
171
+ video_primary_use_case?: string;
163
172
  video_provider?: string;
164
173
  webhook_events?: Array<string>;
165
174
  webhook_url?: string;
@@ -659,9 +668,7 @@ export type MessageResponseBase = MessageBase & {
659
668
  created_at?: string;
660
669
  deleted_at?: string;
661
670
  deleted_reply_count?: number;
662
- i18n?: RequireAtLeastOne<Record<`${TranslationLanguages}_text`, string>> & {
663
- language: TranslationLanguages;
664
- };
671
+ i18n?: TranslatedField;
665
672
  latest_reactions?: ReactionResponse[];
666
673
  member?: ChannelMemberResponse;
667
674
  mentioned_users?: UserResponse[];
@@ -2415,6 +2422,9 @@ export type TestSNSDataInput = {
2415
2422
  export type TokenOrProvider = null | string | TokenProvider | undefined;
2416
2423
  export type TokenProvider = () => Promise<string>;
2417
2424
  export type TranslationLanguages = 'af' | 'am' | 'ar' | 'az' | 'bg' | 'bn' | 'bs' | 'cs' | 'da' | 'de' | 'el' | 'en' | 'es' | 'es-MX' | 'et' | 'fa' | 'fa-AF' | 'fi' | 'fr' | 'fr-CA' | 'ha' | 'he' | 'hi' | 'hr' | 'hu' | 'id' | 'it' | 'ja' | 'ka' | 'ko' | 'lt' | 'lv' | 'ms' | 'nl' | 'no' | 'pl' | 'ps' | 'pt' | 'ro' | 'ru' | 'sk' | 'sl' | 'so' | 'sq' | 'sr' | 'sv' | 'sw' | 'ta' | 'th' | 'tl' | 'tr' | 'uk' | 'ur' | 'vi' | 'zh' | 'zh-TW' | (string & {});
2425
+ export type TranslatedField = RequireAtLeastOne<Record<`${TranslationLanguages}_text`, string>> & {
2426
+ language: TranslationLanguages;
2427
+ };
2418
2428
  export type TypingStartEvent = Event;
2419
2429
  export type ReservedUpdatedMessageFields = keyof typeof RESERVED_UPDATED_MESSAGE_FIELDS;
2420
2430
  export type UpdatedMessage = Omit<MessageResponse, ReservedUpdatedMessageFields | 'mentioned_groups'> & {
@@ -2687,7 +2697,9 @@ export type PollResponse = CustomPollData & PollEnrichData & {
2687
2697
  allow_answers?: boolean;
2688
2698
  allow_user_suggested_options?: boolean;
2689
2699
  description?: string;
2700
+ description_i18n?: TranslatedField;
2690
2701
  is_closed?: boolean;
2702
+ name_i18n?: TranslatedField;
2691
2703
  voting_visibility?: VotingVisibility;
2692
2704
  };
2693
2705
  export type PollOption = {
@@ -2697,6 +2709,7 @@ export type PollOption = {
2697
2709
  text: string;
2698
2710
  updated_at: string;
2699
2711
  vote_count: number;
2712
+ text_i18n?: TranslatedField;
2700
2713
  votes?: PollVote[];
2701
2714
  };
2702
2715
  export declare enum VotingVisibility {
@@ -2760,6 +2773,7 @@ export type PollOptionResponse = CustomPollData & {
2760
2773
  text: string;
2761
2774
  updated_at: string;
2762
2775
  vote_count: number;
2776
+ text_i18n?: TranslatedField;
2763
2777
  votes?: PollVote[];
2764
2778
  };
2765
2779
  export type PollVote = {
@@ -2774,6 +2788,7 @@ export type PollVote = {
2774
2788
  export type PollAnswer = Exclude<PollVote, 'option_id'> & {
2775
2789
  answer_text: string;
2776
2790
  is_answer: boolean;
2791
+ answer_text_i18n?: TranslatedField;
2777
2792
  };
2778
2793
  export type PollVotesAPIResponse = {
2779
2794
  votes: (PollVote | PollAnswer)[];
@@ -3713,7 +3728,32 @@ export type UpdateChannelsBatchOptions = {
3713
3728
  filter: UpdateChannelsBatchFilters;
3714
3729
  members?: string[] | Array<NewMemberPayload>;
3715
3730
  data?: BatchChannelDataUpdate;
3731
+ /**
3732
+ * `updateData` only. Merges these keys into each matched channel's existing
3733
+ * custom object, leaving every other custom key untouched — unlike
3734
+ * `data.custom`, which replaces the whole object. Keys are dot-paths, so
3735
+ * `a.b` sets key `b` inside object `a` (the parent object must already
3736
+ * exist). Cannot be combined with `data.custom`.
3737
+ *
3738
+ * Lives at the request root, not inside `data`.
3739
+ */
3740
+ custom_set?: Record<string, unknown>;
3741
+ /**
3742
+ * `updateData` only. Deletes these keys from each matched channel's existing
3743
+ * custom object, leaving every other custom key untouched. Keys are
3744
+ * dot-paths; deleting a key that does not exist is a no-op. Cannot be
3745
+ * combined with `data.custom`.
3746
+ *
3747
+ * Lives at the request root, not inside `data`.
3748
+ */
3749
+ custom_unset?: string[];
3716
3750
  };
3751
+ /**
3752
+ * Options for {@link ChannelBatchUpdater.updateData}. `custom_set` and
3753
+ * `custom_unset` are sent at the request root, while `data` contains the
3754
+ * channel fields to update.
3755
+ */
3756
+ export type ChannelBatchDataUpdateOptions = Pick<UpdateChannelsBatchOptions, 'data' | 'custom_set' | 'custom_unset'>;
3717
3757
  export type UpdateChannelsBatchFilters = QueryFilters<{
3718
3758
  cids?: RequireOnlyOne<Pick<QueryFilter<string>, '$in' | '$eq'>> | PrimitiveFilter<string[]>;
3719
3759
  types?: RequireOnlyOne<Pick<QueryFilter<string>, '$in' | '$eq'>> | PrimitiveFilter<string[]>;
@@ -134,9 +134,7 @@ export declare const toDeletedMessage: ({ message, deletedAt, hardDelete, }: {
134
134
  [key: string]: number;
135
135
  } | null | undefined;
136
136
  reply_count?: number | undefined;
137
- i18n?: (import("./types").RequireAtLeastOne<Record<`${import("./types").TranslationLanguages}_text`, string>> & {
138
- language: import("./types").TranslationLanguages;
139
- }) | undefined;
137
+ i18n?: import("./types").TranslatedField | undefined;
140
138
  html?: string | undefined;
141
139
  user?: (UserResponse | null) | undefined;
142
140
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stream-chat",
3
- "version": "9.52.1",
3
+ "version": "9.54.0",
4
4
  "description": "JS SDK for the Stream Chat API",
5
5
  "homepage": "https://getstream.io/chat/",
6
6
  "author": {
@@ -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
@@ -393,6 +393,10 @@ export class StreamChat {
393
393
  wsConnection: StableWSConnection | null;
394
394
  wsFallback?: WSConnectionFallback;
395
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;
396
400
  consecutiveFailures: number;
397
401
  insightMetrics: InsightMetrics;
398
402
  defaultWSTimeoutWithFallback: number;
@@ -713,18 +717,25 @@ export class StreamChat {
713
717
 
714
718
  const wsPromise = this.openConnection();
715
719
 
716
- this.setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
720
+ const setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
717
721
  (result) => result[1], // We only return connection promise;
718
722
  );
723
+ this.setUserPromise = setUserPromise;
719
724
 
720
725
  try {
721
- return await this.setUserPromise;
726
+ return await setUserPromise;
722
727
  } catch (err) {
723
- if (this.persistUserOnConnectionFailure) {
724
- // cleanup client to allow the user to retry connectUser again
725
- this.closeConnection();
726
- } else {
727
- 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
+ }
728
739
  }
729
740
  throw err;
730
741
  }
@@ -852,9 +863,58 @@ export class StreamChat {
852
863
  }
853
864
 
854
865
  this.clientID = `${this.userID}--${randomId()}`;
855
- this.wsPromise = this.connect();
866
+ const wsPromise = this._bindWsPromise(this.connect());
856
867
  this._startCleaning();
857
- 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;
858
918
  };
859
919
 
860
920
  /**
@@ -1059,6 +1119,11 @@ export class StreamChat {
1059
1119
 
1060
1120
  this.anonymous = false;
1061
1121
 
1122
+ this._rejectPendingWsPromise(
1123
+ new Error('Connection was closed because disconnectUser() was called'),
1124
+ );
1125
+ this.wsPromise = null;
1126
+
1062
1127
  const closePromise = this.closeConnection(timeout);
1063
1128
 
1064
1129
  for (const channel of Object.values(this.activeChannels)) {
@@ -1751,7 +1816,12 @@ export class StreamChat {
1751
1816
  } as Event);
1752
1817
  }
1753
1818
 
1754
- 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
+ }
1755
1825
  this.setUserPromise = Promise.resolve();
1756
1826
  };
1757
1827
 
@@ -5306,6 +5376,10 @@ export class StreamChat {
5306
5376
  /**
5307
5377
  * Update Channels Batch
5308
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
+ *
5309
5383
  * @param {UpdateChannelsBatchOptions} payload for updating channels in batch
5310
5384
  * @return {Promise<APIResponse & UpdateChannelsBatchResponse>} The server response
5311
5385
  */
@@ -32,12 +32,15 @@ export const createLinkPreviewsCompositionMiddleware = (
32
32
  LinkPreviewsManager.getPreviewData(preview),
33
33
  );
34
34
 
35
- const attachments: Attachment[] = (state.message.attachments ?? []).concat(
35
+ const messageAttachments: Attachment[] = (state.message.attachments ?? []).concat(
36
36
  linkPreviews,
37
37
  );
38
+ const localAttachments: Attachment[] = (
39
+ state.localMessage.attachments ?? []
40
+ ).concat(linkPreviews);
38
41
 
39
42
  // prevent introducing attachments array into the payload sent to the server
40
- if (!attachments.length) return forward();
43
+ if (!messageAttachments.length && !localAttachments.length) return forward();
41
44
 
42
45
  const sendOptions = { ...state.sendOptions };
43
46
  const skip_enrich_url =
@@ -47,16 +50,23 @@ export const createLinkPreviewsCompositionMiddleware = (
47
50
  sendOptions.skip_enrich_url = true;
48
51
  }
49
52
 
53
+ // Each payload gets the `attachments` key only when there is something to put in it,
54
+ // matching the attachments middleware. An empty array is not "nothing to say": on an edit
55
+ // the API reads it as "remove every attachment".
50
56
  return next({
51
57
  ...state,
52
- message: {
53
- ...state.message,
54
- attachments,
55
- },
56
- localMessage: {
57
- ...state.localMessage,
58
- attachments,
59
- },
58
+ message: messageAttachments.length
59
+ ? {
60
+ ...state.message,
61
+ attachments: messageAttachments,
62
+ }
63
+ : state.message,
64
+ localMessage: localAttachments.length
65
+ ? {
66
+ ...state.localMessage,
67
+ attachments: localAttachments,
68
+ }
69
+ : state.localMessage,
60
70
  sendOptions,
61
71
  });
62
72
  },