stream-chat 9.49.0 → 9.50.1

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.
@@ -503,6 +503,18 @@ export declare class Channel {
503
503
  * @return {APIResponse} An API response
504
504
  */
505
505
  markUnread(data: MarkUnreadOptions): Promise<APIResponse | null>;
506
+ /**
507
+ * markReadLocally - Resets this user's unread count locally, without any backend call. Intended for
508
+ * channels that have read events disabled (e.g. livestreams) when the client is created with the
509
+ * `isLocalUnreadCountEnabled` option. Dispatches a dedicated, client-only `message.read_locally` event
510
+ * that runs through the same `_handleChannelEvent` read logic as a real `message.read` (minus the
511
+ * delivery-report network sync), so the read-state update lives in one place. When offline support
512
+ * is enabled, the offline DB persists the reset for read-events-disabled channels, so the local
513
+ * count stays consistent across app restarts.
514
+ *
515
+ * @return {Event | undefined} The dispatched `message.read_locally` event, or `undefined` if there is no connected user.
516
+ */
517
+ markReadLocally(): Event | undefined;
506
518
  /**
507
519
  * clean - Cleans the channel state and fires stop typing if needed
508
520
  */
@@ -60,6 +60,7 @@ export declare const EVENT_MAP: {
60
60
  'ai_indicator.update': boolean;
61
61
  'ai_indicator.stop': boolean;
62
62
  'ai_indicator.clear': boolean;
63
+ 'message.read_locally': boolean;
63
64
  'channels.queried': boolean;
64
65
  'offline_reactions.queried': boolean;
65
66
  'connection.changed': boolean;
@@ -5,6 +5,7 @@ import type { QueryThreadsOptions } from './types';
5
5
  import { WithSubscriptions } from './utils/WithSubscriptions';
6
6
  export declare const THREAD_MANAGER_INITIAL_STATE: {
7
7
  active: boolean;
8
+ wasActivatedAtLeastOnce: boolean;
8
9
  isThreadOrderStale: boolean;
9
10
  threads: never[];
10
11
  unreadThreadCount: number;
@@ -19,6 +20,12 @@ export declare const THREAD_MANAGER_INITIAL_STATE: {
19
20
  };
20
21
  export type ThreadManagerState = {
21
22
  active: boolean;
23
+ /**
24
+ * Whether the thread manager has been activated at least once in the current
25
+ * session (i.e. `activate()` was called). Used to avoid requerying threads
26
+ * on connection recovery for consumers that never actually activate the manager.
27
+ */
28
+ wasActivatedAtLeastOnce: boolean;
22
29
  isThreadOrderStale: boolean;
23
30
  lastConnectionDropAt: Date | null;
24
31
  pagination: ThreadManagerPagination;
@@ -1371,6 +1371,12 @@ export type StreamChatOptions = AxiosRequestConfig & {
1371
1371
  */
1372
1372
  disableCache?: boolean;
1373
1373
  enableInsights?: boolean;
1374
+ /**
1375
+ * When true, maintains a client-local unread count on channels that have read events disabled
1376
+ * (e.g. livestreams). The count increments on incoming messages and is reset via
1377
+ * `channel.markReadLocally()`. It is never sent to the backend, but is persisted to the offline DB.
1378
+ */
1379
+ isLocalUnreadCountEnabled?: boolean;
1374
1380
  /** experimental feature, please contact support if you want this feature enabled for you */
1375
1381
  enableWSFallback?: boolean;
1376
1382
  logger?: Logger;
@@ -20,6 +20,19 @@ export declare const chatCodes: {
20
20
  };
21
21
  export declare function isOwnUser(user?: OwnUserResponse | UserResponse): user is OwnUserResponse;
22
22
  export declare function isOwnUserBaseProperty(property: string): boolean;
23
+ /**
24
+ * channelHasReadEvents - Whether read events are enabled for the current user on a channel.
25
+ */
26
+ export declare const channelHasReadEvents: (channel?: Channel) => boolean;
27
+ /**
28
+ * channelTracksReadLocally - Whether a channel maintains a client local unread count.
29
+ */
30
+ 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;
23
36
  export declare function addFileToFormData(uri: string | NodeJS.ReadableStream | Buffer | File, name?: string, contentType?: string): FormData;
24
37
  export declare function normalizeQuerySort<T extends Record<string, AscDesc | undefined>>(sort: T | T[]): {
25
38
  direction: AscDesc;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stream-chat",
3
- "version": "9.49.0",
3
+ "version": "9.50.1",
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
@@ -4,6 +4,7 @@ import { CooldownTimer } from './CooldownTimer';
4
4
  import { MessageComposer } from './messageComposer';
5
5
  import { MessageReceiptsTracker } from './messageDelivery';
6
6
  import {
7
+ channelHasReadEvents,
7
8
  generateChannelTempCid,
8
9
  logChatPromiseExecution,
9
10
  messageSetPagination,
@@ -1247,6 +1248,36 @@ export class Channel {
1247
1248
  });
1248
1249
  }
1249
1250
 
1251
+ /**
1252
+ * markReadLocally - Resets this user's unread count locally, without any backend call. Intended for
1253
+ * channels that have read events disabled (e.g. livestreams) when the client is created with the
1254
+ * `isLocalUnreadCountEnabled` option. Dispatches a dedicated, client-only `message.read_locally` event
1255
+ * that runs through the same `_handleChannelEvent` read logic as a real `message.read` (minus the
1256
+ * delivery-report network sync), so the read-state update lives in one place. When offline support
1257
+ * is enabled, the offline DB persists the reset for read-events-disabled channels, so the local
1258
+ * count stays consistent across app restarts.
1259
+ *
1260
+ * @return {Event | undefined} The dispatched `message.read_locally` event, or `undefined` if there is no connected user.
1261
+ */
1262
+ markReadLocally() {
1263
+ const client = this.getClient();
1264
+ if (!client.userID) return;
1265
+
1266
+ const event: Event = {
1267
+ channel_id: this.id,
1268
+ channel_type: this.type,
1269
+ cid: this.cid,
1270
+ created_at: new Date().toISOString(),
1271
+ last_read_message_id: this.lastMessage()?.id,
1272
+ team: this.data?.team,
1273
+ type: 'message.read_locally',
1274
+ user: client.user,
1275
+ };
1276
+ client.dispatchEvent(event);
1277
+
1278
+ return event;
1279
+ }
1280
+
1250
1281
  /**
1251
1282
  * clean - Cleans the channel state and fires stop typing if needed
1252
1283
  */
@@ -1429,10 +1460,11 @@ export class Channel {
1429
1460
  if (message.user?.id && this.getClient().userMuteStatus(message.user.id))
1430
1461
  return false;
1431
1462
 
1432
- // Return false if channel doesn't allow read events.
1463
+ // Return false if channel doesn't allow read events, unless the client opted into a local
1464
+ // unread count (e.g. livestreams where read events are disabled). See `isLocalUnreadCountEnabled`.
1433
1465
  if (
1434
- Array.isArray(this.data?.own_capabilities) &&
1435
- !this.data?.own_capabilities.includes('read-events')
1466
+ !this.getClient().options.isLocalUnreadCountEnabled &&
1467
+ !channelHasReadEvents(this)
1436
1468
  ) {
1437
1469
  return false;
1438
1470
  }
@@ -1965,6 +1997,11 @@ export class Channel {
1965
1997
  delete channelState.typing[event.user.id];
1966
1998
  }
1967
1999
  break;
2000
+ // `message.read_locally` is the client-only event dispatched by `markReadLocally()` when read
2001
+ // events are disabled (e.g. livestreams with `isLocalUnreadCountEnabled`). It reuses the exact
2002
+ // `message.read` state logic so the read-state update lives in one place — only the
2003
+ // delivery-report network sync below is skipped for it.
2004
+ case 'message.read_locally':
1968
2005
  case 'message.read':
1969
2006
  if (event.user?.id && event.created_at) {
1970
2007
  const previousReadState = channelState.read[event.user.id];
@@ -1987,7 +2024,11 @@ export class Channel {
1987
2024
 
1988
2025
  if (isOwnEvent) {
1989
2026
  channelState.unreadCount = 0;
1990
- client.syncDeliveredCandidates([this]);
2027
+ // Delivery reporting buffers a `markChannelsDelivered` network request; the local read
2028
+ // must not hit the backend, so only sync for the real server `message.read`.
2029
+ if (event.type === 'message.read') {
2030
+ client.syncDeliveredCandidates([this]);
2031
+ }
1991
2032
  }
1992
2033
  }
1993
2034
  break;
package/src/client.ts CHANGED
@@ -470,6 +470,7 @@ export class StreamChat {
470
470
  warmUp: false,
471
471
  recoverStateOnReconnect: true,
472
472
  disableCache: false,
473
+ isLocalUnreadCountEnabled: false,
473
474
  wsUrlParams: new URLSearchParams({}),
474
475
  ...inputOptions,
475
476
  };
package/src/events.ts CHANGED
@@ -63,6 +63,7 @@ export const EVENT_MAP = {
63
63
  'ai_indicator.clear': true,
64
64
 
65
65
  // local events
66
+ 'message.read_locally': true,
66
67
  'channels.queried': true,
67
68
  'offline_reactions.queried': true,
68
69
  'connection.changed': true,
@@ -10,7 +10,7 @@ import type {
10
10
  MarkReadOptions,
11
11
  } from '../types';
12
12
  import { type APIErrorResponse } from '../types';
13
- import { throttle } from '../utils';
13
+ import { throttle, userHasReadReceipts } from '../utils';
14
14
  import { isAPIError, isErrorRetryable } from '../errors';
15
15
 
16
16
  const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const;
@@ -279,6 +279,8 @@ 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
+
282
284
  let result: EventAPIResponse | null = null;
283
285
  if (isChannel(collection)) {
284
286
  result = await collection.markAsReadRequest(options);
@@ -18,7 +18,12 @@ import type { StreamChat } from '../client';
18
18
  import type { AxiosError } from 'axios';
19
19
  import { OfflineDBSyncManager } from './offline_sync_manager';
20
20
  import { StateStore } from '../store';
21
- import { localMessageToNewMessagePayload, runDetached } from '../utils';
21
+ import {
22
+ channelHasReadEvents,
23
+ channelTracksReadLocally,
24
+ localMessageToNewMessagePayload,
25
+ runDetached,
26
+ } from '../utils';
22
27
  import { isMessageUpdateReplayable } from './util';
23
28
 
24
29
  /**
@@ -609,7 +614,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
609
614
  if (cid && client.user && client.user.id !== user?.id) {
610
615
  const userId = client.user.id;
611
616
  const channel = client.activeChannels[cid];
612
- if (channel) {
617
+ // Persist the current user's read state for channels that track reads server-side. When the
618
+ // client opted into a local unread count, also persist it for read-events-disabled channels
619
+ // (e.g. livestreams) so the client-local count survives a cold start. The server never sends
620
+ // a read for those, so state.read[userId] may be absent here; fall back accordingly and rely
621
+ // on countUnread() (the aggregate the local count maintains) for the value.
622
+ const tracksReadLocally = channelTracksReadLocally(channel);
623
+ if (channel && (channelHasReadEvents(channel) || tracksReadLocally)) {
613
624
  const ownReads = channel.state.read[userId];
614
625
  const unreadCount = channel.countUnread();
615
626
  const upsertReadsQueries = await this.upsertReads({
@@ -617,8 +628,8 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
617
628
  execute: false,
618
629
  reads: [
619
630
  {
620
- last_read: ownReads.last_read.toISOString() as string,
621
- last_read_message_id: ownReads.last_read_message_id,
631
+ last_read: (ownReads?.last_read ?? new Date(0)).toISOString() as string,
632
+ last_read_message_id: ownReads?.last_read_message_id,
622
633
  unread_messages: unreadCount,
623
634
  user: client.user,
624
635
  },
@@ -867,32 +878,38 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
867
878
  execute: false,
868
879
  });
869
880
 
881
+ let finalQueries = [...truncateQueries];
882
+
883
+ // Persist read state for channels that track reads server-side. When the client opted into a
884
+ // local unread count, also persist it for read-events-disabled channels (e.g. livestreams) so
885
+ // the client-local count survives a cold start. state.read[userId] may be absent for those, so
886
+ // fall back accordingly.
870
887
  const userId = ownUser.id;
871
888
  const activeChannel = this.client.activeChannels[cid];
872
- const ownReads = activeChannel.state.read[userId];
889
+ const tracksReadLocally = channelTracksReadLocally(activeChannel);
890
+ if (activeChannel && (channelHasReadEvents(activeChannel) || tracksReadLocally)) {
891
+ const ownReads = activeChannel.state.read[userId];
873
892
 
874
- let unreadCount = 0;
893
+ let unreadCount = 0;
894
+ if (truncated_at) {
895
+ unreadCount = activeChannel.countUnread(new Date(truncated_at));
896
+ }
875
897
 
876
- if (truncated_at) {
877
- const truncatedAt = new Date(truncated_at);
878
- unreadCount = activeChannel.countUnread(truncatedAt);
898
+ const upsertReadQueries = await this.upsertReads({
899
+ cid,
900
+ execute: false,
901
+ reads: [
902
+ {
903
+ last_read: (ownReads?.last_read ?? new Date(0)).toString() as string,
904
+ last_read_message_id: ownReads?.last_read_message_id,
905
+ unread_messages: unreadCount,
906
+ user: ownUser,
907
+ },
908
+ ],
909
+ });
910
+ finalQueries = [...finalQueries, ...upsertReadQueries];
879
911
  }
880
912
 
881
- const upsertReadQueries = await this.upsertReads({
882
- cid,
883
- execute: false,
884
- reads: [
885
- {
886
- last_read: ownReads.last_read.toString() as string,
887
- last_read_message_id: ownReads.last_read_message_id,
888
- unread_messages: unreadCount,
889
- user: ownUser,
890
- },
891
- ],
892
- });
893
-
894
- const finalQueries = [...truncateQueries, ...upsertReadQueries];
895
-
896
913
  if (execute) {
897
914
  await this.executeSqlBatch(finalQueries);
898
915
  }
@@ -1021,6 +1038,18 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
1021
1038
  return this.handleRead({ event, unreadMessages: 0, execute });
1022
1039
  }
1023
1040
 
1041
+ // `message.read_locally` is the client-only reset dispatched by `Channel.markReadLocally()`. Only
1042
+ // persist it for read-events-disabled channels with the local unread count opted in (the only
1043
+ // situation it is ever dispatched), so it can never touch channels that track reads server-side.
1044
+ if (type === 'message.read_locally') {
1045
+ const localChannel = event.cid ? this.client.activeChannels[event.cid] : undefined;
1046
+ // channelTracksReadLocally returns false when localChannel is undefined, so no extra guard.
1047
+ if (channelTracksReadLocally(localChannel)) {
1048
+ return this.handleRead({ event, unreadMessages: 0, execute });
1049
+ }
1050
+ return [];
1051
+ }
1052
+
1024
1053
  if (type === 'notification.mark_unread') {
1025
1054
  return this.handleRead({ event, execute });
1026
1055
  }
@@ -10,6 +10,7 @@ const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000;
10
10
  const MAX_QUERY_THREADS_LIMIT = 25;
11
11
  export const THREAD_MANAGER_INITIAL_STATE = {
12
12
  active: false,
13
+ wasActivatedAtLeastOnce: false,
13
14
  isThreadOrderStale: false,
14
15
  threads: [],
15
16
  unreadThreadCount: 0,
@@ -25,6 +26,12 @@ export const THREAD_MANAGER_INITIAL_STATE = {
25
26
 
26
27
  export type ThreadManagerState = {
27
28
  active: boolean;
29
+ /**
30
+ * Whether the thread manager has been activated at least once in the current
31
+ * session (i.e. `activate()` was called). Used to avoid requerying threads
32
+ * on connection recovery for consumers that never actually activate the manager.
33
+ */
34
+ wasActivatedAtLeastOnce: boolean;
28
35
  isThreadOrderStale: boolean;
29
36
  lastConnectionDropAt: Date | null;
30
37
  pagination: ThreadManagerPagination;
@@ -90,7 +97,7 @@ export class ThreadManager extends WithSubscriptions {
90
97
  };
91
98
 
92
99
  public activate = () => {
93
- this.state.partialNext({ active: true });
100
+ this.state.partialNext({ active: true, wasActivatedAtLeastOnce: true });
94
101
  };
95
102
 
96
103
  public deactivate = () => {
@@ -197,8 +204,9 @@ export class ThreadManager extends WithSubscriptions {
197
204
 
198
205
  const throttledHandleConnectionRecovered = throttle(
199
206
  () => {
200
- const { lastConnectionDropAt } = this.state.getLatestValue();
201
- if (!lastConnectionDropAt) return;
207
+ const { lastConnectionDropAt, wasActivatedAtLeastOnce } =
208
+ this.state.getLatestValue();
209
+ if (!lastConnectionDropAt || !wasActivatedAtLeastOnce) return;
202
210
  this.reload({ force: true });
203
211
  },
204
212
  DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION,
package/src/types.ts CHANGED
@@ -1591,6 +1591,12 @@ export type StreamChatOptions = AxiosRequestConfig & {
1591
1591
  */
1592
1592
  disableCache?: boolean;
1593
1593
  enableInsights?: boolean;
1594
+ /**
1595
+ * When true, maintains a client-local unread count on channels that have read events disabled
1596
+ * (e.g. livestreams). The count increments on incoming messages and is reset via
1597
+ * `channel.markReadLocally()`. It is never sent to the backend, but is persisted to the offline DB.
1598
+ */
1599
+ isLocalUnreadCountEnabled?: boolean;
1594
1600
  /** experimental feature, please contact support if you want this feature enabled for you */
1595
1601
  enableWSFallback?: boolean;
1596
1602
  logger?: Logger;
package/src/utils.ts CHANGED
@@ -110,6 +110,28 @@ export function isOwnUserBaseProperty(property: string) {
110
110
  return ownUserBaseProperties[property as keyof OwnUserBase];
111
111
  }
112
112
 
113
+ /**
114
+ * channelHasReadEvents - Whether read events are enabled for the current user on a channel.
115
+ */
116
+ export const channelHasReadEvents = (channel?: Channel) => {
117
+ const ownCapabilities = channel?.data?.own_capabilities;
118
+ return !(Array.isArray(ownCapabilities) && !ownCapabilities.includes('read-events'));
119
+ };
120
+
121
+ /**
122
+ * channelTracksReadLocally - Whether a channel maintains a client local unread count.
123
+ */
124
+ export const channelTracksReadLocally = (channel?: Channel) =>
125
+ !channelHasReadEvents(channel) &&
126
+ !!channel?.getClient().options.isLocalUnreadCountEnabled;
127
+
128
+ /**
129
+ * userHasReadReceipts - Whether the current user allows read receipts, per their privacy settings.
130
+ * Read receipts are treated as enabled unless the user has explicitly disabled them.
131
+ */
132
+ export const userHasReadReceipts = (client: StreamChat) =>
133
+ client.user?.privacy_settings?.read_receipts?.enabled ?? true;
134
+
113
135
  export function addFileToFormData(
114
136
  uri: string | NodeJS.ReadableStream | Buffer | File,
115
137
  name?: string,