Skip to content

Commit 0a40196

Browse files
authored
feat: localized unread count (#1787)
## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? Spec: https://stream-wiki.notion.site/Local-Unread-Count-specification-38b6a5d7f9f68073a5e1c73298fe0d71 Adds an optin, client side unread count for channels that have read events disabled (e.g. livestreams). Those channels normally don't track unread at all and so `_countMessageAsUnread` bails out whenever `own_capabilities` lacks `read-events`, so integrators have no "N new messages" affordance to show while scrolled up. With the new `isLocalUnreadCountEnabled` client option (default `false`), the SDK maintains a purely local unread count for those channels. It increments on incoming messages and is reset via the new `channel.markReadLocally()` method, which dispatches a client only `message.local_read` event. That event reuses the exact `message.read` state logic in `_handleChannelEvent` (so read state updates stay in one place) but skips `syncDeliveredCandidates()`, so the count is never sent to the backend. `hasReadEvents()` centralizes the capability check. When offline support is enabled, the local read/unread state is persisted for channels with disabled `read-events` so the count survives a cold start. The offline DB handles `message.local_read` (guarded so it can only affect channels with disabled `read-events` channels with the option on) and falls back gracefully when `state.read[userId]` is absent, since the server never sends a read for these channels. Behavior is fully gated behind the flag, so default off means no change for existing integrations. ## Changelog - Add `isLocalUnreadCountEnabled` client option for a client side unread count on channels with disabled `read-events` (e.g. livestreams) - Add `Channel.markReadLocally()` to reset the local unread count with no backend call - Add `Channel.hasReadEvents()` helper - Add client only `message.local_read` event - Persist local unread state to the offline DB so it survives app restarts
1 parent 77fb9c8 commit 0a40196

11 files changed

Lines changed: 537 additions & 30 deletions

File tree

src/channel.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CooldownTimer } from './CooldownTimer';
44
import { MessageComposer } from './messageComposer';
55
import { MessageReceiptsTracker } from './messageDelivery';
66
import {
7+
channelHasReadEvents,
78
generateChannelTempCid,
89
logChatPromiseExecution,
910
messageSetPagination,
@@ -1247,6 +1248,36 @@ export class Channel {
12471248
});
12481249
}
12491250

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+
12501281
/**
12511282
* clean - Cleans the channel state and fires stop typing if needed
12521283
*/
@@ -1429,10 +1460,11 @@ export class Channel {
14291460
if (message.user?.id && this.getClient().userMuteStatus(message.user.id))
14301461
return false;
14311462

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`.
14331465
if (
1434-
Array.isArray(this.data?.own_capabilities) &&
1435-
!this.data?.own_capabilities.includes('read-events')
1466+
!this.getClient().options.isLocalUnreadCountEnabled &&
1467+
!channelHasReadEvents(this)
14361468
) {
14371469
return false;
14381470
}
@@ -1965,6 +1997,11 @@ export class Channel {
19651997
delete channelState.typing[event.user.id];
19661998
}
19671999
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':
19682005
case 'message.read':
19692006
if (event.user?.id && event.created_at) {
19702007
const previousReadState = channelState.read[event.user.id];
@@ -1987,7 +2024,11 @@ export class Channel {
19872024

19882025
if (isOwnEvent) {
19892026
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+
}
19912032
}
19922033
}
19932034
break;

src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ export class StreamChat {
470470
warmUp: false,
471471
recoverStateOnReconnect: true,
472472
disableCache: false,
473+
isLocalUnreadCountEnabled: false,
473474
wsUrlParams: new URLSearchParams({}),
474475
...inputOptions,
475476
};

src/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const EVENT_MAP = {
6363
'ai_indicator.clear': true,
6464

6565
// local events
66+
'message.read_locally': true,
6667
'channels.queried': true,
6768
'offline_reactions.queried': true,
6869
'connection.changed': true,

src/messageDelivery/MessageDeliveryReporter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
MarkReadOptions,
1111
} from '../types';
1212
import { type APIErrorResponse } from '../types';
13-
import { throttle } from '../utils';
13+
import { throttle, userHasReadReceipts } from '../utils';
1414
import { isAPIError, isErrorRetryable } from '../errors';
1515

1616
const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const;
@@ -279,6 +279,8 @@ export class MessageDeliveryReporter {
279279
* @param options
280280
*/
281281
public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => {
282+
if (!userHasReadReceipts(this.client)) return null;
283+
282284
let result: EventAPIResponse | null = null;
283285
if (isChannel(collection)) {
284286
result = await collection.markAsReadRequest(options);

src/offline-support/offline_support_api.ts

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import type { StreamChat } from '../client';
1818
import type { AxiosError } from 'axios';
1919
import { OfflineDBSyncManager } from './offline_sync_manager';
2020
import { StateStore } from '../store';
21-
import { localMessageToNewMessagePayload, runDetached } from '../utils';
21+
import {
22+
channelHasReadEvents,
23+
channelTracksReadLocally,
24+
localMessageToNewMessagePayload,
25+
runDetached,
26+
} from '../utils';
2227
import { isMessageUpdateReplayable } from './util';
2328

2429
/**
@@ -609,16 +614,22 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
609614
if (cid && client.user && client.user.id !== user?.id) {
610615
const userId = client.user.id;
611616
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)) {
613624
const ownReads = channel.state.read[userId];
614625
const unreadCount = channel.countUnread();
615626
const upsertReadsQueries = await this.upsertReads({
616627
cid,
617628
execute: false,
618629
reads: [
619630
{
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,
622633
unread_messages: unreadCount,
623634
user: client.user,
624635
},
@@ -867,32 +878,38 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
867878
execute: false,
868879
});
869880

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.
870887
const userId = ownUser.id;
871888
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];
873892

874-
let unreadCount = 0;
893+
let unreadCount = 0;
894+
if (truncated_at) {
895+
unreadCount = activeChannel.countUnread(new Date(truncated_at));
896+
}
875897

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];
879911
}
880912

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-
896913
if (execute) {
897914
await this.executeSqlBatch(finalQueries);
898915
}
@@ -1021,6 +1038,18 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
10211038
return this.handleRead({ event, unreadMessages: 0, execute });
10221039
}
10231040

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+
10241053
if (type === 'notification.mark_unread') {
10251054
return this.handleRead({ event, execute });
10261055
}

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,12 @@ export type StreamChatOptions = AxiosRequestConfig & {
15911591
*/
15921592
disableCache?: boolean;
15931593
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;
15941600
/** experimental feature, please contact support if you want this feature enabled for you */
15951601
enableWSFallback?: boolean;
15961602
logger?: Logger;

src/utils.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,28 @@ export function isOwnUserBaseProperty(property: string) {
110110
return ownUserBaseProperties[property as keyof OwnUserBase];
111111
}
112112

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+
113135
export function addFileToFormData(
114136
uri: string | NodeJS.ReadableStream | Buffer | File,
115137
name?: string,

0 commit comments

Comments
 (0)