diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 35fe2c6931..4d32b273e9 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -7,6 +7,7 @@ 🐞 Fixed - Fixed `StreamChatClient.sync` letting a failure from its final `updateLastSyncAt` write escape to the caller; it is now handled by the method's own error handling, like every other sync failure. +- Fixed `ChannelClientState` no longer handling `notification.mark_read` (regression since 9.20.0), which left `unreadCount` stale after `Channel.markRead` on channels the user isn't watching. ## 9.27.0 diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index de0d4df28b..c624dc7404 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3116,7 +3116,9 @@ class ChannelClientState { void _listenReadEvents() { _subscriptions ..add( - _channel.on(EventType.messageRead).listen( + _channel + .on(EventType.messageRead, EventType.notificationMarkRead) + .listen( (event) { // Skip handling the event if delivered for a thread if (event.thread != null) return; diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 6504547537..4ec423f686 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -5477,6 +5477,178 @@ void main() { }, ); + test( + 'should reset unread count on notification mark read event', + () async { + final currentUser = client.state.currentUser!; + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + // Verify initial state + expect(channel.state?.unreadCount, 10); + + // notification.mark_read is delivered on the reading user's own + // connection, so it reaches non-watched channels as well. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, currentUser.id); + expect(channel.state?.unreadCount, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + }, + ); + + test( + 'should preserve delivery info on notification mark read event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastDeliveredAt: DateTime(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated but delivery info is preserved + final updatedRead = channel.state?.read.first; + expect(updatedRead?.unreadMessages, 0); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + test( + 'should not update channel read state on thread notification mark ' + 'read event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastReadMessageId: 'channel-msg-1', + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'thread-reply-99', + thread: Thread( + channelCid: channel.cid!, + parentMessageId: 'parent-msg-1', + createdByUserId: currentUser.id, + replyCount: 3, + participantCount: 2, + ), + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Channel read state must be untouched — thread reads + // must not clobber the channel-level Read. + final after = channel.state?.read.first; + expect(after?.unreadMessages, 10); + expect(after?.lastReadMessageId, 'channel-msg-1'); + expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + }, + ); + + test( + 'should reconcile delivery when notification mark read event is from ' + 'current user', + () async { + final currentUser = client.state.currentUser; + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + test('should update read state on message delivered event', () async { final currentUser = User(id: 'test-user'); final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);