Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs_screenshots/test/src/mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ void setupMockChannel({
final allMembers = members.isNotEmpty ? members : _defaultMembers(channel.id);

when(() => client.state).thenReturn(clientState);
when(() => client.isLocalUnreadCountEnabled).thenReturn(false);
when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00'));
when(() => channel.lastMessageAtStream).thenAnswer((_) => Stream.value(DateTime.parse('2020-06-22 12:00:00')));
when(() => channel.currentUserLastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00'));
Expand Down
4 changes: 4 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Upcoming

✅ Added

- Added `StreamChatClient.isLocalUnreadCountEnabled` (default `false`). When enabled, channels that have read events disabled (e.g. livestream channel types) track their unread count locally, on-device: incoming messages increment it, hard-deleted messages decrement it, and `Channel.markRead` / `markUnread` / `markUnreadByTimestamp` update it locally without a network request. Channels that support read receipts are unaffected and keep relying on server-driven unread counts.

🔄 Changed

- `StreamChatClient.updateSystemEnvironment` now sanitizes the passed `SystemEnvironment`: `sdkName`, `sdkVersion`, and `osName` are locked to internal defaults, and `sdkIdentifier` only accepts the `dart` → `flutter` promotion (other values, including a `flutter` → `dart` demotion, are ignored). `appName`, `appVersion`, `osVersion`, and `deviceModel` continue to pass through as-is.
Expand Down
163 changes: 162 additions & 1 deletion packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1852,9 +1852,17 @@ class Channel {
///
/// Optionally provide a [messageId] if you want to mark channel as
/// read from a particular message onwards.
///
/// If [usesLocalUnreadCount] is `true` for this channel, this updates the
/// unread count locally, on-device, without making a network request.
Future<EmptyResponse> markRead({String? messageId}) async {
_checkInitialized();

if (usesLocalUnreadCount) {
state!.markReadLocally(messageId: messageId);
return EmptyResponse();
}

if (!canUseReadReceipts) {
throw const StreamChatError(
'Cannot mark as read: Channel does not support read events. '
Expand All @@ -1868,9 +1876,31 @@ class Channel {
/// Marks the channel as unread by a given [messageId].
///
/// All messages from the provided message onwards will be marked as unread.
///
/// If [usesLocalUnreadCount] is `true` for this channel, this updates the
/// unread count locally, on-device, without making a network request. The
/// message must be part of the locally-known messages ([Channel.messages])
/// for the count to be recomputed.
Future<EmptyResponse> markUnread(String messageId) async {
_checkInitialized();

if (usesLocalUnreadCount) {
final anchor = state!.messages.firstWhereOrNull((it) => it.id == messageId);
if (anchor == null) {
throw StreamChatError(
'Cannot mark as unread: Message "$messageId" was not found in the '
'locally-known messages for this channel.',
);
}

// Subtract a microsecond so the anchor message itself is treated as
// "after" the new read boundary, matching the "from the provided
// message onwards" semantics described above.
final lastRead = anchor.createdAt.subtract(const Duration(microseconds: 1));
state!.markUnreadLocally(lastRead: lastRead);
return EmptyResponse();
}

if (!canUseReadReceipts) {
throw const StreamChatError(
'Cannot mark as unread: Channel does not support read events. '
Expand All @@ -1884,9 +1914,17 @@ class Channel {
/// Marks the channel as unread by a given [timestamp].
///
/// All messages after the provided timestamp will be marked as unread.
///
/// If [usesLocalUnreadCount] is `true` for this channel, this updates the
/// unread count locally, on-device, without making a network request.
Future<EmptyResponse> markUnreadByTimestamp(DateTime timestamp) async {
_checkInitialized();

if (usesLocalUnreadCount) {
state!.markUnreadLocally(lastRead: timestamp);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the markUnread(String messageId) method, we call markUnreadLocally with a -1ms of the message timestamp -> but here we call the markUnreadLocally with the exact timestamp. I am curious whether we might be missing some edge-case here, because this method will usually be called with the exact createdAt timestamp of a specific message. So if we call:

final message = targetMessage;

channel.markUnread(message.id);
channel.markUnreadByTimestamp(message.createdAt);

Would result in calling markUnreadLocally with a different timestamp. Probably not a big deal, but I wanted to flag it nonetheless!

return EmptyResponse();
}

if (!canUseReadReceipts) {
throw const StreamChatError(
'Cannot mark as unread: Channel does not support read events. '
Expand Down Expand Up @@ -2096,7 +2134,7 @@ class Channel {
};

if (isQueryingAround) this.state?.truncate();
this.state?.updateChannelState(channelState);
this.state?.updateChannelStateFromServer(channelState);
}

// Submit for delivery reporting only when fetching the latest messages.
Expand Down Expand Up @@ -3209,6 +3247,15 @@ class ChannelClientState {
deletedForMe: event.deletedForMe,
);

// Decrement the locally-tracked unread count for hard-deleted
// messages that would have counted as unread. Soft-deleted messages
// keep their slot. Only applies to channels that track unread counts
// locally (see [Channel.usesLocalUnreadCount]) — server-driven
// channels get corrected counts from server read events instead.
if (hardDelete && _channel.usesLocalUnreadCount && MessageRules.canCountAsUnread(message, _channel)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: Don't soft-deletes also decrement the unread count?

unreadCount = math.max(0, unreadCount - 1);
}

return deleteMessage(message, hardDelete: hardDelete);
}),
);
Expand Down Expand Up @@ -3563,6 +3610,68 @@ class ChannelClientState {
return updateRead([existingUserRead.copyWith(unreadMessages: count)]);
}

/// Marks the channel as read locally, without making a network request.
///
/// Used for channels that track unread counts locally (see
/// [Channel.usesLocalUnreadCount]), since the server rejects the mark-read
/// endpoint for channels that have read events disabled.
void markReadLocally({String? messageId}) {
final currentUser = _client.state.currentUser;
if (currentUser == null) return;

final now = DateTime.now();
final lastReadMessageId = messageId ?? messages.lastOrNull?.id;

final existingUserRead = currentUserRead;
updateRead([
Read(
user: currentUser,
lastRead: now,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Does the lastRead mark the moment the channel was read, or the timestamp of the last read message? (I think we should align this with how the server populates this field)

lastReadMessageId: lastReadMessageId,
lastDeliveredAt: existingUserRead?.lastDeliveredAt,
lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId,
),
]);
}
Comment on lines +3615 to +3637

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant symbols and surrounding code
rg -n "_listenReadEvents|reconcileDelivery|markReadLocally|markRead\\(" packages/stream_chat/lib/src/client/channel.dart packages/stream_chat/lib/src -S

# Show the relevant slice around markReadLocally
sed -n '3570,3665p' packages/stream_chat/lib/src/client/channel.dart

# Show the read event listener slice
sed -n '3000,3095p' packages/stream_chat/lib/src/client/channel.dart

Repository: GetStream/stream-chat-flutter

Length of output: 8499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "canUseDeliveryReceipts|canUseReadReceipts|delivery receipts|read receipts" packages/stream_chat/lib -S

Repository: GetStream/stream-chat-flutter

Length of output: 3601


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "void updateRead|updateRead\\(|currentUserRead|channelDeliveryReporter|reconcileDelivery" packages/stream_chat/lib/src -S

Repository: GetStream/stream-chat-flutter

Length of output: 4513


🌐 Web query:

stream_chat ChannelDeliveryReporter reconcileDelivery markReadLocally read receipts delivery receipts

💡 Result:

In the Stream Chat SDK (specifically the Dart/Flutter implementation), ChannelDeliveryReporter is a utility class responsible for managing and throttling delivery receipt reporting to the server [1][2][3]. Core Components and Functionality: ChannelDeliveryReporter: This class collects channels requiring delivery acknowledgments and reports them to the server efficiently [1][2]. It includes a throttle mechanism to prevent excessive API calls [2][3]. - reconcileDelivery(Iterable channels): Reconciles the delivery reporting state for a set of channels with their current internal state [1][2]. - submitForDelivery(Iterable channels): Submits channels to the reporter queue for delivery receipt processing [1][2]. - cancelDelivery(Iterable channels): Cancels pending delivery reports for specific channels [1][2]. Delivery Receipts vs. Read Receipts: - Delivery Receipts track when a message has been successfully delivered to a recipient's device [4][5]. This is confirmed via the message.delivered WebSocket event [4]. It is disabled by default and may require specific configuration or support contact [4][5]. - Read Receipts track when a user has actively viewed a message or marked a channel as read [4][5]. This is confirmed via the message.read WebSocket event [4]. Implementation Notes: - The SDK handles delivery confirmation automatically, including throttling and duplicate prevention [4][5]. - Developers can access the full read and delivery state (e.g., lastDeliveredAt, lastDeliveredMessageId) via the channel.state.read object, which provides a list of members and their respective read/delivery statuses [6]. - While markReadLocally is often used in local state management to update UI immediately before the server confirms the action, the SDK provides explicit methods like markChannelRead, markAllRead, and markChannelsDelivered to sync state with the Stream server [7]. The MarkChannelsDelivered typedef is the callback function used to transmit these delivery acknowledgments to the backend [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1848,1875p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '3288,3335p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '3398,3492p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '4698,4768p' packages/stream_chat/lib/src/client/channel.dart
printf '\n----\n'
sed -n '96,145p' packages/stream_chat/lib/src/client/channel_delivery_reporter.dart

Repository: GetStream/stream-chat-flutter

Length of output: 10239


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "markReadLocally\\(" packages/stream_chat/lib -S

Repository: GetStream/stream-chat-flutter

Length of output: 373


markReadLocally should reconcile delivery receipts too.
It only updates read state; for channels with delivery receipts enabled, skipping _client.channelDeliveryReporter.reconcileDelivery([_channel]) leaves pending delivery state stale after a local mark-read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stream_chat/lib/src/client/channel.dart` around lines 3615 - 3637,
The markReadLocally method does not reconcile pending delivery receipts after
updating read state. Add
_client.channelDeliveryReporter.reconcileDelivery([_channel]) to markReadLocally
after updateRead, matching the delivery reconciliation behavior used by related
mark-read flows.


/// Marks the channel as unread locally, without making a network request.
///
/// [lastRead] and [lastReadMessageId] define the new read boundary: any
/// locally-known message that is still eligible per
/// [MessageRules.canCountAsUnread] once this boundary is applied is counted
/// as unread.
///
/// Used for channels that track unread counts locally (see
/// [Channel.usesLocalUnreadCount]), since the server rejects the
/// mark-unread endpoint for channels that have read events disabled.
void markUnreadLocally({
required DateTime lastRead,
String? lastReadMessageId,
}) {
final currentUser = _client.state.currentUser;
if (currentUser == null) return;

final existingUserRead = currentUserRead;

// Apply the new read boundary first so `MessageRules.canCountAsUnread`
// (which reads `channel.state?.currentUserRead`) evaluates against it.
updateRead([
Read(
user: currentUser,
lastRead: lastRead,
lastReadMessageId: lastReadMessageId,
lastDeliveredAt: existingUserRead?.lastDeliveredAt,
lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId,
),
]);

// Recompute the unread count from the locally-known messages now that
// the boundary above is in effect.
final unread = messages.where((it) => MessageRules.canCountAsUnread(it, _channel)).length;

unreadCount = unread;
}

/// Counts the number of unread messages mentioning the current user.
///
/// **NOTE**: The method relies on the [Channel.messages] list and doesn't do
Expand Down Expand Up @@ -3645,6 +3754,47 @@ class ChannelClientState {
);
}

/// Applies a [remoteState] received from the server or offline storage
/// (e.g. a `query`/`watch` response), merging it into local state.
///
/// Unlike [updateChannelState], this preserves the current user's
/// locally-tracked read state for channels that track unread counts
/// on-device (see [Channel.usesLocalUnreadCount]) — their `lastRead`,
/// `lastReadMessageId`, and `unreadMessages` are kept as-is instead of
/// being overwritten by the remote payload; only delivery fields are
/// still applied from it.
///
/// Call this instead of [updateChannelState] whenever [remoteState]
/// genuinely comes from the network or offline storage.
void updateChannelStateFromServer(ChannelState remoteState) {
updateChannelState(_preserveLocalUnreadState(remoteState));
}

/// Rewrites the current user's [Read] in [remoteState], if present, to
/// keep the locally-tracked `lastRead` / `lastReadMessageId` /
/// `unreadMessages` while still adopting the remote delivery fields.
///
/// No-op unless [Channel.usesLocalUnreadCount] is enabled and a local read
/// already exists for the current user.
ChannelState _preserveLocalUnreadState(ChannelState remoteState) {
if (!_channel.usesLocalUnreadCount) return remoteState;

final localRead = currentUserRead;
final remoteReads = remoteState.read;
if (localRead == null || remoteReads == null) return remoteState;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Will we always get an initial Read object in the response for a channel which has read/delivery receipts disabled? I am worried that if we do not get an initial object, we won't have a baseline to update on message.new/message.deleted events.


final currentUserId = localRead.user.id;
final preservedReads = remoteReads.map((read) {
if (read.user.id != currentUserId) return read;
return localRead.copyWith(
lastDeliveredAt: read.lastDeliveredAt,
lastDeliveredMessageId: read.lastDeliveredMessageId,
);
});

return remoteState.copyWith(read: preservedReads.toList());
}

int _sortByCreatedAt(Message a, Message b) => a.createdAt.compareTo(b.createdAt);

/// The channel state related to this client.
Expand Down Expand Up @@ -4554,6 +4704,17 @@ extension ChannelCapabilityCheck on Channel {
return ownCapabilities.contains(ChannelCapability.readEvents);
}

/// True, if unread counts for this channel should be tracked locally,
/// on-device, rather than relying on the server.
///
/// This is the case when [StreamChatClient.isLocalUnreadCountEnabled] is
/// enabled and the channel doesn't support read receipts (for example,
/// livestream channel types that disable read events). Channels that
/// support read receipts always rely on server-driven unread counts.
bool get usesLocalUnreadCount {
return _client.isLocalUnreadCountEnabled && !canUseReadReceipts;
}

/// True, if the current user has connect events capability.
bool get canReceiveConnectEvents {
return ownCapabilities.contains(ChannelCapability.connectEvents);
Expand Down
18 changes: 17 additions & 1 deletion packages/stream_chat/lib/src/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class StreamChatClient {
Iterable<Interceptor>? chatApiInterceptors,
HttpClientAdapter? httpClientAdapter,
bool recoverStateOnReconnect = true,
this.isLocalUnreadCountEnabled = false,
}) : _recoverStateOnReconnect = recoverStateOnReconnect {
logger.info('Initiating new StreamChatClient');

Expand Down Expand Up @@ -212,6 +213,21 @@ class StreamChatClient {
/// Chat persistence client
ChatPersistenceClient? chatPersistenceClient;

/// Whether the SDK should track unread counts locally, on-device, for
/// channels that have read events disabled (e.g. livestream channel
/// types).
///
/// Channels with read events disabled never receive `message.read` /
/// `notification.mark_*` events from the server, and reject the mark-read
/// endpoint, so their unread count is always `0` by default.
///
/// When this is `true`, [Channel.unreadCount] is instead incremented
/// locally as new messages arrive and reset locally (with no network
/// request) when [Channel.markRead] is called, for those channels only.
/// Channels with read events enabled are unaffected and keep relying on
/// server-driven unread counts.
final bool isLocalUnreadCountEnabled;

/// Returns `True` if the [chatPersistenceClient] is available and connected.
/// Otherwise, returns `False`.
bool get persistenceEnabled {
Expand Down Expand Up @@ -1014,7 +1030,7 @@ class StreamChatClient {
for (final channelState in channelStates) {
final channel = channels[channelState.channel!.cid];
if (channel != null) {
channel.state?.updateChannelState(channelState);
channel.state?.updateChannelStateFromServer(channelState);
newChannels.add(channel);
} else {
final newChannel = Channel.fromState(this, channelState);
Expand Down
11 changes: 8 additions & 3 deletions packages/stream_chat/lib/src/core/util/message_rules.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ class MessageRules {
/// Returns `false` for the current user's own messages, messages from muted
/// users, silent/shadowed/ephemeral messages, thread-only replies, restricted
/// messages, and messages already read. Also returns `false` if the channel
/// is muted or doesn't support read events.
/// is muted, or doesn't support read events unless
/// [Channel.usesLocalUnreadCount] is enabled for the channel.
static bool canCountAsUnread(
Message message,
Channel channel,
Expand All @@ -80,8 +81,12 @@ class MessageRules {
// Don't count if the user has disabled read receipts.
if (!currentUser.isReadReceiptsEnabled) return false;

// Don't count if the channel doesn't support read receipts.
if (!channel.canUseReadReceipts) return false;
// Don't count if the channel doesn't support read receipts, unless local
// unread tracking owns the count for this channel (see
// [Channel.usesLocalUnreadCount]).
if (!channel.canUseReadReceipts && !channel.usesLocalUnreadCount) {
return false;
}

// Don't count if the channel is muted.
if (channel.isMuted) return false;
Expand Down
Loading
Loading