diff --git a/docs/docs_screenshots/test/src/mocks.dart b/docs/docs_screenshots/test/src/mocks.dart index 1274584355..585390d631 100644 --- a/docs/docs_screenshots/test/src/mocks.dart +++ b/docs/docs_screenshots/test/src/mocks.dart @@ -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')); diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 09b5f0cf2d..38d95152e1 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -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. diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 1432600a88..4b0df9f239 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -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 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 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 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)) { + 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, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: existingUserRead?.lastDeliveredAt, + lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId, + ), + ]); + } + + /// 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; + + 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); diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 4f6e026049..32967c2423 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -101,6 +101,7 @@ class StreamChatClient { Iterable? chatApiInterceptors, HttpClientAdapter? httpClientAdapter, bool recoverStateOnReconnect = true, + this.isLocalUnreadCountEnabled = false, }) : _recoverStateOnReconnect = recoverStateOnReconnect { logger.info('Initiating new StreamChatClient'); @@ -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 { @@ -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); diff --git a/packages/stream_chat/lib/src/core/util/message_rules.dart b/packages/stream_chat/lib/src/core/util/message_rules.dart index d3ac674eb0..34ed1549ba 100644 --- a/packages/stream_chat/lib/src/core/util/message_rules.dart +++ b/packages/stream_chat/lib/src/core/util/message_rules.dart @@ -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, @@ -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; diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 4a9b1ef09f..2765ac81eb 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -9807,6 +9807,292 @@ void main() { ); }); + group('Local unread count', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final currentUser = OwnUser(id: 'current-user-id'); + + late final client = MockStreamChatClient(); + + setUpAll(() { + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + when(() => client.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, __, ___) => false, delayFactor: Duration.zero), + ); + when(() => client.state).thenReturn(FakeClientState(currentUser: currentUser)); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + client.isLocalUnreadCountEnabled = true; + }); + + // A "livestream-like" channel: read events are disabled, both via the + // channel-type config and the current user's own capabilities. + Channel _createLivestreamChannel({ + StreamChatClient? overrideClient, + List? messages, + List? reads, + }) { + final channelState = ChannelState( + channel: ChannelModel( + id: channelId, + type: channelType, + config: ChannelConfig(readEvents: false), + ownCapabilities: const [], // No readEvents capability. + ), + messages: messages, + read: reads, + ); + + final channel = Channel.fromState(overrideClient ?? client, channelState); + addTearDown(channel.dispose); + return channel; + } + + test( + 'increments unreadCount locally for new messages when the channel has ' + 'no read events capability', + () async { + final channel = _createLivestreamChannel(); + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + + client.addEvent( + Event(cid: channel.cid, type: EventType.messageNew, message: message), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(1)); + }, + ); + + test( + 'does not increment unreadCount when local unread count tracking is ' + 'disabled', + () async { + final disabledClient = MockStreamChatClient(); + when(() => disabledClient.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + when(() => disabledClient.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, __, ___) => false), + ); + when(() => disabledClient.state).thenReturn(FakeClientState(currentUser: currentUser)); + when(() => disabledClient.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => disabledClient.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + // `isLocalUnreadCountEnabled` defaults to `false` on the mock. + + final channel = _createLivestreamChannel(overrideClient: disabledClient); + + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + + disabledClient.addEvent( + Event(cid: channel.cid, type: EventType.messageNew, message: message), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test('decrements unreadCount when a counted message is hard-deleted', () async { + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + final channel = _createLivestreamChannel( + messages: [message], + reads: [ + Read( + user: currentUser, + lastRead: message.createdAt.subtract(const Duration(days: 1)), + ), + ], + ); + channel.state!.unreadCount = 1; + expect(channel.state?.unreadCount, equals(1)); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: message, + hardDelete: true, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }); + + test('does not decrement unreadCount when a message is soft-deleted', () async { + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + final channel = _createLivestreamChannel( + messages: [message], + reads: [ + Read( + user: currentUser, + lastRead: message.createdAt.subtract(const Duration(days: 1)), + ), + ], + ); + channel.state!.unreadCount = 1; + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: message, + hardDelete: false, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(1)); + }); + + test( + 'markRead resets unreadCount locally without making a network request', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 3; + expect(channel.state?.unreadCount, equals(3)); + + await expectLater(channel.markRead(), completes); + + expect(channel.state?.unreadCount, equals(0)); + verifyNever( + () => client.markChannelRead( + any(), + any(), + messageId: any(named: 'messageId'), + ), + ); + }, + ); + + test( + 'markUnreadByTimestamp recomputes unreadCount locally without making a ' + 'network request', + () async { + final now = DateTime(2024, 1, 1); + final messages = [ + Message( + id: 'm1', + text: '1', + user: User(id: 'other-user'), + createdAt: now, + ), + Message( + id: 'm2', + text: '2', + user: User(id: 'other-user'), + createdAt: now.add(const Duration(minutes: 1)), + ), + Message( + id: 'm3', + text: '3', + user: User(id: 'other-user'), + createdAt: now.add(const Duration(minutes: 2)), + ), + ]; + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: now.add(const Duration(minutes: 5))), + ], + ); + expect(channel.state?.unreadCount, equals(0)); + + await expectLater( + channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), + completes, + ); + + // Only m2 and m3 were created after the given timestamp. + expect(channel.state?.unreadCount, equals(2)); + verifyNever( + () => client.markChannelUnreadByTimestamp(any(), any(), any()), + ); + }, + ); + + test( + 'markUnread throws when the message is not locally known', + () async { + final channel = _createLivestreamChannel(); + + await expectLater( + channel.markUnread('unknown-message-id'), + throwsA(isA()), + ); + verifyNever( + () => client.markChannelUnread(any(), any(), any()), + ); + }, + ); + + test( + 'server payloads do not clobber the locally-tracked read state', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 5; + + final serverRead = Read( + user: currentUser, + lastRead: DateTime.now(), + unreadMessages: 0, + ); + channel.state!.updateChannelStateFromServer( + channel.state!.channelState.copyWith(read: [serverRead]), + ); + + expect(channel.state?.unreadCount, equals(5)); + }, + ); + + test( + 'local (non-remote) state updates are not affected by the server-merge ' + 'guard', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 5; + + // A plain local mutation (via updateChannelState, not + // updateChannelStateFromServer) should still be able to change the + // locally-tracked read state. + await expectLater(channel.markRead(), completes); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + }); + group('updateChannelState identity guard', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/core/util/message_rules_test.dart b/packages/stream_chat/test/src/core/util/message_rules_test.dart index 8f84f2183b..115f35ec21 100644 --- a/packages/stream_chat/test/src/core/util/message_rules_test.dart +++ b/packages/stream_chat/test/src/core/util/message_rules_test.dart @@ -238,6 +238,39 @@ void main() { expect(MessageRules.canCountAsUnread(message, channel), isFalse); }); + test( + 'should return true when channel lacks read capability but local ' + 'unread count tracking is enabled', + () { + final localClient = _createMockClient( + currentUser: client.state.currentUser, + isLocalUnreadCountEnabled: true, + ); + final message = _createMessage(); + final channel = _createChannel(localClient, hasReadEvents: false); + + expect(MessageRules.canCountAsUnread(message, channel), isTrue); + }, + ); + + test( + 'should behave as before for channels that already support read ' + 'receipts, even when local unread count tracking is enabled', + () { + // Local unread tracking only changes behavior for channels that + // lack read receipts; channels that already have them are + // unaffected by the flag. + final localClient = _createMockClient( + currentUser: client.state.currentUser, + isLocalUnreadCountEnabled: true, + ); + final message = _createMessage(); + final channel = _createChannel(localClient, hasReadEvents: true); + + expect(MessageRules.canCountAsUnread(message, channel), isTrue); + }, + ); + test('should return false when channel is muted', () { final message = _createMessage(); final originalUser = client.state.currentUser; @@ -661,7 +694,10 @@ Logger _createLogger(String name) { return logger; } -StreamChatClient _createMockClient({OwnUser? currentUser}) { +StreamChatClient _createMockClient({ + OwnUser? currentUser, + bool isLocalUnreadCountEnabled = false, +}) { final client = MockStreamChatClient(); final clientState = FakeClientState(currentUser: currentUser); @@ -673,6 +709,7 @@ StreamChatClient _createMockClient({OwnUser? currentUser}) { when(() => client.retryPolicy).thenReturn( RetryPolicy(shouldRetry: (_, __, ___) => false), ); + client.isLocalUnreadCountEnabled = isLocalUnreadCountEnabled; return client; } diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index b9bb1b464f..848f26a48d 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -102,6 +102,15 @@ class MockStreamChatClient extends Mock implements StreamChatClient { @override bool get persistenceEnabled => false; + // A plain settable field (not a `when(...)` stub) so tests can flip it + // with a direct assignment, e.g. `client.isLocalUnreadCountEnabled = true`. + // Stubbing it via `when()` in this constructor would be re-entrant: this + // mock is often stored in a `late final` and lazily constructed as a side + // effect of evaluating another `when(() => client....)` call already in + // progress, which corrupts mocktail's global stubbing state. + @override + bool isLocalUnreadCountEnabled = false; + ChannelDeliveryReporter? _deliveryReporter; @override