stream-chat 9.48.0 → 9.50.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.
@@ -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
  */
@@ -8,7 +8,7 @@ import { WSConnectionFallback } from './connection_fallback';
8
8
  import { Campaign } from './campaign';
9
9
  import { ChannelBatchUpdater } from './channel_batch_updater';
10
10
  import { Segment } from './segment';
11
- import type { ActiveLiveLocationsAPIResponse, AddUserGroupMembersOptions, AddUserGroupMembersResponse, APIErrorResponse, APIResponse, AppSettings, AppSettingsAPIResponse, BannedUsersFilters, BannedUsersPaginationOptions, BannedUsersResponse, BannedUsersSort, BanUserOptions, BaseDeviceFields, BlockList, BlockListResponse, BlockUserAPIResponse, CampaignData, CampaignFilters, CampaignQueryOptions, CampaignResponse, CampaignSort, CastVoteAPIResponse, ChannelAPIResponse, ChannelData, ChannelFilters, ChannelMute, ChannelOptions, ChannelResponse, ChannelSort, ChannelStateOptions, CheckPushResponse, CheckSNSResponse, CheckSQSResponse, Configs, ConnectAPIResponse, CreateChannelOptions, CreateChannelResponse, CreateCommandOptions, CreateCommandResponse, CreateImportOptions, CreateImportResponse, CreateImportURLResponse, CreatePollAPIResponse, CreatePollData, CreatePollOptionAPIResponse, CreatePredefinedFilterOptions, CreateReminderOptions, CreateRoleAPIResponse, CreateUserGroupOptions, CreateUserGroupResponse, CustomPermissionOptions, DeactivateUsersOptions, DeleteCommandResponse, DeleteMessageOptions, DeleteUserGroupOptions, DeleteUserOptions, Device, DeviceIdentifier, DraftFilters, DraftSort, EndpointName, Event, EventAPIResponse, EventHandler, ExportChannelOptions, ExportChannelRequest, ExportChannelResponse, ExportChannelStatusResponse, ExportUsersRequest, ExportUsersResponse, FlagMessageResponse, FlagReportsFilters, FlagReportsPaginationOptions, FlagReportsResponse, FlagsFilters, FlagsPaginationOptions, FlagsResponse, FlagUserResponse, FutureChannelBansResponse, GetBlockedUsersAPIResponse, GetCampaignOptions, GetChannelTypeResponse, GetCommandResponse, GetHookEventsResponse, GetImportResponse, GetMessageOptions, GetPollAPIResponse, GetRateLimitsResponse, GetRetentionPolicyResponse, GetRetentionPolicyRunsOptions, GetRetentionPolicyRunsResponse, GetThreadAPIResponse, GetThreadOptions, GetUnreadCountAPIResponse, GetUnreadCountBatchAPIResponse, GetUserGroupOptions, GetUserGroupResponse, ListChannelResponse, ListCommandsResponse, ListImportsPaginationOptions, ListImportsResponse, ListPredefinedFiltersOptions, ListPredefinedFiltersResponse, ListRolesAPIResponse, LocalMessage, Logger, MarkChannelsReadOptions, MarkDeliveredOptions, MessageFilters, MessageFlagsFilters, MessageFlagsPaginationOptions, MessageFlagsResponse, MessageResponse, Mute, MuteUserOptions, MuteUserResponse, OGAttachment, OwnUserResponse, Pager, PartialMessageUpdate, PartialPollUpdate, PartialThreadUpdate, PartialUserUpdate, PermissionAPIResponse, PermissionsAPIResponse, PollAnswersAPIResponse, PollData, PollOptionData, PollSort, PollVote, PollVoteData, PollVotesAPIResponse, PredefinedFilterResponse, Product, PushPreference, PushProvider, PushProviderConfig, PushProviderID, PushProviderListResponse, PushProviderUpsertResponse, QueryChannelsAPIResponse, QueryDraftsResponse, QueryFutureChannelBansOptions, QueryMessageHistoryFilters, QueryMessageHistoryOptions, QueryMessageHistoryResponse, QueryMessageHistorySort, QueryPollsFilters, QueryPollsOptions, QueryPollsResponse, QueryReactionsAPIResponse, QueryReactionsOptions, QueryRemindersOptions, QueryRemindersResponse, QuerySegmentsOptions, QuerySegmentTargetsFilter, QueryTeamUsageStatsOptions, QueryTeamUsageStatsResponse, QueryThreadsOptions, QueryUserGroupsOptions, QueryUserGroupsResponse, QueryVotesFilters, QueryVotesOptions, ReactionFilters, ReactionResponse, ReactionSort, ReactivateUserOptions, ReactivateUsersOptions, ReminderAPIResponse, RemoveUserGroupMembersOptions, RemoveUserGroupMembersResponse, ReviewFlagReportOptions, ReviewFlagReportResponse, SdkIdentifier, SearchAPIResponse, SearchOptions, SearchRolesAPIResponse, SearchRolesOptions, SearchUserGroupsOptions, SearchUserGroupsResponse, SegmentData, SegmentResponse, SegmentTargetsResponse, SegmentType, SendFileAPIResponse, SetRetentionPolicyResponse, SharedLocationResponse, SortParam, StreamChatOptions, SyncOptions, SyncResponse, TaskResponse, TaskStatus, TestPushDataInput, TestSNSDataInput, TestSQSDataInput, TokenOrProvider, TranslateResponse, UnBanUserOptions, UpdateChannelsBatchOptions, UpdateChannelTypeRequest, UpdateChannelTypeResponse, UpdateCommandOptions, UpdateCommandResponse, UpdateLocationPayload, UpdateMessageAPIResponse, UpdateMessageOptions, UpdatePollAPIResponse, UpdatePredefinedFilterOptions, UpdateReminderOptions, UpdateSegmentData, UpdateUserGroupOptions, UpdateUserGroupResponse, UpdateUsersAPIResponse, UpsertPushPreferencesResponse, UserCustomEvent, UserFilters, UserOptions, UserResponse, UserSort, VoteSort } from './types';
11
+ import type { ActiveLiveLocationsAPIResponse, AddUserGroupMembersOptions, AddUserGroupMembersResponse, APIErrorResponse, APIResponse, AppIdentifier, AppSettings, AppSettingsAPIResponse, BannedUsersFilters, BannedUsersPaginationOptions, BannedUsersResponse, BannedUsersSort, BanUserOptions, BaseDeviceFields, BlockList, BlockListResponse, BlockUserAPIResponse, CampaignData, CampaignFilters, CampaignQueryOptions, CampaignResponse, CampaignSort, CastVoteAPIResponse, ChannelAPIResponse, ChannelData, ChannelFilters, ChannelMute, ChannelOptions, ChannelResponse, ChannelSort, ChannelStateOptions, CheckPushResponse, CheckSNSResponse, CheckSQSResponse, Configs, ConnectAPIResponse, CreateChannelOptions, CreateChannelResponse, CreateCommandOptions, CreateCommandResponse, CreateImportOptions, CreateImportResponse, CreateImportURLResponse, CreatePollAPIResponse, CreatePollData, CreatePollOptionAPIResponse, CreatePredefinedFilterOptions, CreateReminderOptions, CreateRoleAPIResponse, CreateUserGroupOptions, CreateUserGroupResponse, CustomPermissionOptions, DeactivateUsersOptions, DeleteCommandResponse, DeleteMessageOptions, DeleteUserGroupOptions, DeleteUserOptions, Device, DeviceIdentifier, DraftFilters, DraftSort, EndpointName, Event, EventAPIResponse, EventHandler, ExportChannelOptions, ExportChannelRequest, ExportChannelResponse, ExportChannelStatusResponse, ExportUsersRequest, ExportUsersResponse, FlagMessageResponse, FlagReportsFilters, FlagReportsPaginationOptions, FlagReportsResponse, FlagsFilters, FlagsPaginationOptions, FlagsResponse, FlagUserResponse, FutureChannelBansResponse, GetBlockedUsersAPIResponse, GetCampaignOptions, GetChannelTypeResponse, GetCommandResponse, GetHookEventsResponse, GetImportResponse, GetMessageOptions, GetPollAPIResponse, GetRateLimitsResponse, GetRetentionPolicyResponse, GetRetentionPolicyRunsOptions, GetRetentionPolicyRunsResponse, GetThreadAPIResponse, GetThreadOptions, GetUnreadCountAPIResponse, GetUnreadCountBatchAPIResponse, GetUserGroupOptions, GetUserGroupResponse, ListChannelResponse, ListCommandsResponse, ListImportsPaginationOptions, ListImportsResponse, ListPredefinedFiltersOptions, ListPredefinedFiltersResponse, ListRolesAPIResponse, LocalMessage, Logger, MarkChannelsReadOptions, MarkDeliveredOptions, MessageFilters, MessageFlagsFilters, MessageFlagsPaginationOptions, MessageFlagsResponse, MessageResponse, Mute, MuteUserOptions, MuteUserResponse, OGAttachment, OwnUserResponse, Pager, PartialMessageUpdate, PartialPollUpdate, PartialThreadUpdate, PartialUserUpdate, PermissionAPIResponse, PermissionsAPIResponse, PollAnswersAPIResponse, PollData, PollOptionData, PollSort, PollVote, PollVoteData, PollVotesAPIResponse, PredefinedFilterResponse, Product, PushPreference, PushProvider, PushProviderConfig, PushProviderID, PushProviderListResponse, PushProviderUpsertResponse, QueryChannelsAPIResponse, QueryDraftsResponse, QueryFutureChannelBansOptions, QueryMessageHistoryFilters, QueryMessageHistoryOptions, QueryMessageHistoryResponse, QueryMessageHistorySort, QueryPollsFilters, QueryPollsOptions, QueryPollsResponse, QueryReactionsAPIResponse, QueryReactionsOptions, QueryRemindersOptions, QueryRemindersResponse, QuerySegmentsOptions, QuerySegmentTargetsFilter, QueryTeamUsageStatsOptions, QueryTeamUsageStatsResponse, QueryThreadsOptions, QueryUserGroupsOptions, QueryUserGroupsResponse, QueryVotesFilters, QueryVotesOptions, ReactionFilters, ReactionResponse, ReactionSort, ReactivateUserOptions, ReactivateUsersOptions, ReminderAPIResponse, RemoveUserGroupMembersOptions, RemoveUserGroupMembersResponse, ReviewFlagReportOptions, ReviewFlagReportResponse, SdkIdentifier, SearchAPIResponse, SearchOptions, SearchRolesAPIResponse, SearchRolesOptions, SearchUserGroupsOptions, SearchUserGroupsResponse, SegmentData, SegmentResponse, SegmentTargetsResponse, SegmentType, SendFileAPIResponse, SetRetentionPolicyResponse, SharedLocationResponse, SortParam, StreamChatOptions, SyncOptions, SyncResponse, TaskResponse, TaskStatus, TestPushDataInput, TestSNSDataInput, TestSQSDataInput, TokenOrProvider, TranslateResponse, UnBanUserOptions, UpdateChannelsBatchOptions, UpdateChannelTypeRequest, UpdateChannelTypeResponse, UpdateCommandOptions, UpdateCommandResponse, UpdateLocationPayload, UpdateMessageAPIResponse, UpdateMessageOptions, UpdatePollAPIResponse, UpdatePredefinedFilterOptions, UpdateReminderOptions, UpdateSegmentData, UpdateUserGroupOptions, UpdateUserGroupResponse, UpdateUsersAPIResponse, UpsertPushPreferencesResponse, UserCustomEvent, UserFilters, UserOptions, UserResponse, UserSort, VoteSort } from './types';
12
12
  import { ErrorFromResponse } from './types';
13
13
  import { InsightMetrics } from './insights';
14
14
  import { Thread } from './thread';
@@ -111,6 +111,8 @@ export declare class StreamChat {
111
111
  defaultWSTimeout: number;
112
112
  sdkIdentifier?: SdkIdentifier;
113
113
  deviceIdentifier?: DeviceIdentifier;
114
+ appIdentifier?: AppIdentifier;
115
+ private cachedUserAgent?;
114
116
  readonly messageComposerCache: FixedSizeQueueCache<string, MessageComposer>;
115
117
  private nextRequestAbortController;
116
118
  /**
@@ -1319,7 +1321,7 @@ export declare class StreamChat {
1319
1321
  * @returns {GetThreadAPIResponse} Returns the updated thread.
1320
1322
  */
1321
1323
  partialUpdateThread(messageId: string, partialThreadObject: PartialThreadUpdate): Promise<GetThreadAPIResponse>;
1322
- getUserAgent(): string;
1324
+ getUserAgent: () => string;
1323
1325
  /**
1324
1326
  * @deprecated use sdkIdentifier instead
1325
1327
  * @param userAgent
@@ -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;
@@ -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;
@@ -3389,6 +3395,16 @@ export type DeviceIdentifier = {
3389
3395
  os: string;
3390
3396
  model?: string;
3391
3397
  };
3398
+ /**
3399
+ * An identifier containing information about the downstream application integrating
3400
+ * stream-chat, if available. `name` is reported as `app` and `version` as `app_version`
3401
+ * in the user agent. Distinct from the SDK ({@link SdkIdentifier}) and device
3402
+ * ({@link DeviceIdentifier}) identifiers.
3403
+ */
3404
+ export type AppIdentifier = {
3405
+ name: string;
3406
+ version?: string;
3407
+ };
3392
3408
  export type DraftResponse = {
3393
3409
  channel_cid: string;
3394
3410
  created_at: string;
@@ -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.48.0",
3
+ "version": "9.50.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
@@ -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
@@ -46,6 +46,7 @@ import type {
46
46
  AddUserGroupMembersResponse,
47
47
  APIErrorResponse,
48
48
  APIResponse,
49
+ AppIdentifier,
49
50
  AppSettings,
50
51
  AppSettingsAPIResponse,
51
52
  BannedUsersFilters,
@@ -394,6 +395,8 @@ export class StreamChat {
394
395
  defaultWSTimeout: number;
395
396
  sdkIdentifier?: SdkIdentifier;
396
397
  deviceIdentifier?: DeviceIdentifier;
398
+ appIdentifier?: AppIdentifier;
399
+ private cachedUserAgent?: string;
397
400
  readonly messageComposerCache: FixedSizeQueueCache<string, MessageComposer>;
398
401
  private nextRequestAbortController: AbortController | null = null;
399
402
  /**
@@ -467,6 +470,7 @@ export class StreamChat {
467
470
  warmUp: false,
468
471
  recoverStateOnReconnect: true,
469
472
  disableCache: false,
473
+ isLocalUnreadCountEnabled: false,
470
474
  wsUrlParams: new URLSearchParams({}),
471
475
  ...inputOptions,
472
476
  };
@@ -1641,6 +1645,19 @@ export class StreamChat {
1641
1645
  });
1642
1646
  }
1643
1647
 
1648
+ // Current user removed from a channel: evict it like channel.deleted so
1649
+ // recoverState won't re-watch it and no further events reach it (#2599).
1650
+ // Type-agnostic. We skip deleteAllChannelReference (unlike deletion) since
1651
+ // the channel still exists for its remaining members.
1652
+ if (event.type === 'notification.removed_from_channel' && event.cid) {
1653
+ const { cid } = event;
1654
+ this.activeChannels[cid]?._disconnect();
1655
+
1656
+ postListenerCallbacks.push(() => {
1657
+ delete this.activeChannels[cid];
1658
+ });
1659
+ }
1660
+
1644
1661
  return postListenerCallbacks;
1645
1662
  }
1646
1663
 
@@ -3709,40 +3726,42 @@ export class StreamChat {
3709
3726
  );
3710
3727
  }
3711
3728
 
3712
- getUserAgent() {
3729
+ getUserAgent = (): string => {
3730
+ // An explicit override (deprecated `setUserAgent`) always wins and is never cached.
3713
3731
  if (this.userAgent) {
3714
3732
  return this.userAgent;
3715
3733
  }
3716
3734
 
3717
- const version = process.env.PKG_VERSION;
3718
- const clientBundle = process.env.CLIENT_BUNDLE;
3719
-
3720
- let userAgentString = '';
3721
- if (this.sdkIdentifier) {
3722
- userAgentString = `stream-chat-${this.sdkIdentifier.name}-v${this.sdkIdentifier.version}-llc-v${version}`;
3723
- } else {
3724
- userAgentString = `stream-chat-js-v${version}-${this.node ? 'node' : 'browser'}`;
3735
+ // Computed once, then memoized for the client's lifetime - inputs read on
3736
+ // the first call (sdk/device identifiers, build-time env) are not re-read.
3737
+ if (!this.cachedUserAgent) {
3738
+ const version = process.env.PKG_VERSION;
3739
+ const { name: sdkName, version: sdkVersion } = this.sdkIdentifier ?? {};
3740
+ const { name: appName, version: appVersion } = this.appIdentifier ?? {};
3741
+ const { os, model: deviceModel } = this.deviceIdentifier ?? {};
3742
+
3743
+ const head = sdkName
3744
+ ? `stream-chat-${sdkName}-v${sdkVersion}-llc-v${version}`
3745
+ : `stream-chat-js-v${version}-${this.node ? 'node' : 'browser'}`;
3746
+
3747
+ this.cachedUserAgent = [
3748
+ head,
3749
+ // reports the host app, the device OS, the device model, and the picked
3750
+ // exports bundle, each only when a value is present
3751
+ ...Object.entries({
3752
+ app: appName,
3753
+ app_version: appVersion,
3754
+ os,
3755
+ device_model: deviceModel,
3756
+ client_bundle: process.env.CLIENT_BUNDLE,
3757
+ })
3758
+ .filter(([, value]) => value && value.length > 0)
3759
+ .map(([key, value]) => `${key}=${value}`),
3760
+ ].join('|');
3725
3761
  }
3726
3762
 
3727
- const { os, model } = this.deviceIdentifier ?? {};
3728
-
3729
- return (
3730
- [
3731
- // reports the device OS, if provided
3732
- ['os', os],
3733
- // reports the device model, if provided
3734
- ['device_model', model],
3735
- // reports which bundle is being picked from the exports
3736
- ['client_bundle', clientBundle],
3737
- ] as const
3738
- ).reduce(
3739
- (withArguments, [key, value]) =>
3740
- value && value.length > 0
3741
- ? withArguments.concat(`|${key}=${value}`)
3742
- : withArguments,
3743
- userAgentString,
3744
- );
3745
- }
3763
+ return this.cachedUserAgent;
3764
+ };
3746
3765
 
3747
3766
  /**
3748
3767
  * @deprecated use sdkIdentifier instead
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
  }
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;
@@ -4537,6 +4543,14 @@ export type SdkIdentifier = {
4537
4543
  */
4538
4544
  export type DeviceIdentifier = { os: string; model?: string };
4539
4545
 
4546
+ /**
4547
+ * An identifier containing information about the downstream application integrating
4548
+ * stream-chat, if available. `name` is reported as `app` and `version` as `app_version`
4549
+ * in the user agent. Distinct from the SDK ({@link SdkIdentifier}) and device
4550
+ * ({@link DeviceIdentifier}) identifiers.
4551
+ */
4552
+ export type AppIdentifier = { name: string; version?: string };
4553
+
4540
4554
  export type DraftResponse = {
4541
4555
  channel_cid: string;
4542
4556
  created_at: string;
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,