-
Notifications
You must be signed in to change notification settings - Fork 383
feat(llc): Implement local unread counts #2804
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. ' | ||
|
|
@@ -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. ' | ||
|
|
@@ -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); | ||
| return EmptyResponse(); | ||
| } | ||
|
|
||
| if (!canUseReadReceipts) { | ||
| throw const StreamChatError( | ||
| 'Cannot mark as unread: Channel does not support read events. ' | ||
|
|
@@ -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. | ||
|
|
@@ -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)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| }), | ||
| ); | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Does the |
||
| lastReadMessageId: lastReadMessageId, | ||
| lastDeliveredAt: existingUserRead?.lastDeliveredAt, | ||
| lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId, | ||
| ), | ||
| ]); | ||
| } | ||
|
Comment on lines
+3615
to
+3637
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.dartRepository: 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 -SRepository: 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 -SRepository: GetStream/stream-chat-flutter Length of output: 4513 🌐 Web query:
💡 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.dartRepository: GetStream/stream-chat-flutter Length of output: 10239 🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n "markReadLocally\\(" packages/stream_chat/lib -SRepository: GetStream/stream-chat-flutter Length of output: 373
🤖 Prompt for AI Agents |
||
|
|
||
| /// 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 | ||
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Will we always get an initial |
||
|
|
||
| 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. | ||
|
|
@@ -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); | ||
|
|
||
There was a problem hiding this comment.
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 callmarkUnreadLocallywith a-1msof the message timestamp -> but here we call themarkUnreadLocallywith the exact timestamp. I am curious whether we might be missing some edge-case here, because this method will usually be called with the exactcreatedAttimestamp of a specific message. So if we call:Would result in calling
markUnreadLocallywith a different timestamp. Probably not a big deal, but I wanted to flag it nonetheless!