From 47c9cf2bd0ad74c656ccaf771070c2c28e69261c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 26 Aug 2026 15:38:10 +0200 Subject: [PATCH 1/4] refactor(llc): move the channel extensions into their own files --- .../stream_chat/lib/src/client/channel.dart | 290 +-------- .../src/client/channel_capability_check.dart | 235 +++++++ .../lib/src/client/channel_read_helper.dart | 52 ++ .../client/channel_capability_check_test.dart | 407 ++++++++++++ .../src/client/channel_read_helper_test.dart | 319 ++++++++++ .../test/src/client/channel_test.dart | 588 ------------------ 6 files changed, 1018 insertions(+), 873 deletions(-) create mode 100644 packages/stream_chat/lib/src/client/channel_capability_check.dart create mode 100644 packages/stream_chat/lib/src/client/channel_read_helper.dart create mode 100644 packages/stream_chat/test/src/client/channel_capability_check_test.dart create mode 100644 packages/stream_chat/test/src/client/channel_read_helper_test.dart diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 5cfae938aa..cbc708a85b 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -10,6 +10,11 @@ import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:synchronized/synchronized.dart'; +// Re-exported so that importing this file directly keeps resolving the +// extensions that used to be declared here. +export 'channel_capability_check.dart'; +export 'channel_read_helper.dart'; + /// The maximum time the incoming [Event.typingStart] event is valid before a /// [Event.typingStop] event is emitted automatically. const incomingTypingStartEventTimeout = 7; @@ -4597,288 +4602,3 @@ bool _pinIsValid(Message message) { // If there's an expiration, check if it's still valid. return pinExpires.isAfter(DateTime.now()); } - -/// Extension methods for reading related operations on a ChannelClientState. -extension ChannelReadHelper on ChannelClientState { - /// Get the [Read] object for a specific user identified by [userId]. - Read? userReadOf({String? userId}) => read.userReadOf(userId: userId); - - /// Stream of [Read] object for a specific user identified by [userId]. - Stream userReadStreamOf({String? userId}) { - return readStream.map((read) => read.userReadOf(userId: userId)); - } - - /// Returns the list of [Read]s that have marked the given [msg] as read. - /// - /// The [Read] is considered to have read the message if: - /// - The read user is not the sender of the message. - /// - The read's lastRead is after or equal to the message's createdAt. - List readsOf({required Message message}) { - return read.readsOf(message: message); - } - - /// Stream of list of [Read]s that have marked the given [msg] as read. - /// - /// The [Read] is considered to have read the message if: - /// - The read user is not the sender of the message. - /// - The read's lastRead is after or equal to the message's createdAt. - Stream> readsOfStream({required Message message}) { - return readStream.map((read) => read.readsOf(message: message)); - } - - /// Returns the list of [Read]s that have marked the given [message] as - /// delivered. - /// - /// The [Read] is considered to have delivered the message if: - /// - The read user is not the sender of the message. - /// - The read contains a non-null lastDeliveredAt. - /// - The read's lastDeliveredAt is after or equal to the message's createdAt. - List deliveriesOf({required Message message}) { - return read.deliveriesOf(message: message); - } - - /// Stream of list of [Read]s that have marked the given [message] as - /// delivered. - /// - /// The [Read] is considered to have delivered the message if: - /// - The read user is not the sender of the message. - /// - The read contains a non-null lastDeliveredAt. - /// - The read's lastDeliveredAt is after or equal to the message's createdAt. - Stream> deliveriesOfStream({required Message message}) { - return readStream.map((read) => read.deliveriesOf(message: message)); - } -} - -/// Extension methods for checking channel capabilities on a Channel instance. -/// -/// These methods provide a convenient way to check if the current user has -/// specific capabilities in a channel. -extension ChannelCapabilityCheck on Channel { - /// True, if the current user can send a message to this channel. - bool get canSendMessage { - return ownCapabilities.contains(ChannelCapability.sendMessage); - } - - /// True, if the current user can send a reply to this channel. - bool get canSendReply { - return ownCapabilities.contains(ChannelCapability.sendReply); - } - - /// True, if the current user can send a message with restricted visibility. - bool get canSendRestrictedVisibilityMessage { - return ownCapabilities.contains( - ChannelCapability.sendRestrictedVisibilityMessage, - ); - } - - /// True, if the current user can send reactions. - bool get canSendReaction { - return ownCapabilities.contains(ChannelCapability.sendReaction); - } - - /// True, if the current user can attach links to messages. - bool get canSendLinks { - return ownCapabilities.contains(ChannelCapability.sendLinks); - } - - /// True, if the current user can attach files to messages. - bool get canCreateAttachment { - return ownCapabilities.contains(ChannelCapability.createAttachment); - } - - /// True, if the current user can freeze or unfreeze channel. - bool get canFreezeChannel { - return ownCapabilities.contains(ChannelCapability.freezeChannel); - } - - /// True, if the current user can enable or disable slow mode. - bool get canSetChannelCooldown { - return ownCapabilities.contains(ChannelCapability.setChannelCooldown); - } - - /// True, if the current user can leave channel (remove own membership). - bool get canLeaveChannel { - return ownCapabilities.contains(ChannelCapability.leaveChannel); - } - - /// True, if the current user can join channel (add own membership). - bool get canJoinChannel { - return ownCapabilities.contains(ChannelCapability.joinChannel); - } - - /// True, if the current user can pin a message. - bool get canPinMessage { - return ownCapabilities.contains(ChannelCapability.pinMessage); - } - - /// True, if the current user can delete any message from the channel. - bool get canDeleteAnyMessage { - return ownCapabilities.contains(ChannelCapability.deleteAnyMessage); - } - - /// True, if the current user can delete own messages from the channel. - bool get canDeleteOwnMessage { - return ownCapabilities.contains(ChannelCapability.deleteOwnMessage); - } - - /// True, if the current user can update any message in the channel. - bool get canUpdateAnyMessage { - return ownCapabilities.contains(ChannelCapability.updateAnyMessage); - } - - /// True, if the current user can update own messages in the channel. - bool get canUpdateOwnMessage { - return ownCapabilities.contains(ChannelCapability.updateOwnMessage); - } - - /// True, if the current user can use message search. - bool get canSearchMessages { - return ownCapabilities.contains(ChannelCapability.searchMessages); - } - - /// True, if the current user can send typing events. - @Deprecated('Use canUseTypingEvents instead') - bool get canSendTypingEvents { - if (canUseTypingEvents) return true; - return ownCapabilities.contains(ChannelCapability.sendTypingEvents); - } - - /// True, if the current user can upload message attachments. - bool get canUploadFile { - return ownCapabilities.contains(ChannelCapability.uploadFile); - } - - /// True, if the current user can delete channel. - bool get canDeleteChannel { - return ownCapabilities.contains(ChannelCapability.deleteChannel); - } - - /// True, if the current user can update channel data. - bool get canUpdateChannel { - return ownCapabilities.contains(ChannelCapability.updateChannel); - } - - /// True, if the current user can update channel members. - bool get canUpdateChannelMembers { - return ownCapabilities.contains(ChannelCapability.updateChannelMembers); - } - - /// True, if the current user can update thread data. - bool get canUpdateThread { - return ownCapabilities.contains(ChannelCapability.updateThread); - } - - /// True, if the current user can quote a message. - bool get canQuoteMessage { - return ownCapabilities.contains(ChannelCapability.quoteMessage); - } - - /// True, if the current user can ban channel members. - bool get canBanChannelMembers { - return ownCapabilities.contains(ChannelCapability.banChannelMembers); - } - - /// True, if the current user can flag a message. - bool get canFlagMessage { - return ownCapabilities.contains(ChannelCapability.flagMessage); - } - - /// True, if the current user can mute a channel. - bool get canMuteChannel { - return ownCapabilities.contains(ChannelCapability.muteChannel); - } - - /// True, if the current user can send custom events. - bool get canSendCustomEvents { - return ownCapabilities.contains(ChannelCapability.sendCustomEvents); - } - - /// True, if the current user has read events capability. - @Deprecated('Use canUseReadReceipts instead') - bool get canReceiveReadEvents => canUseReadReceipts; - - /// True, if the current user has read events capability. - bool get canUseReadReceipts { - 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); - } - - /// True, if the current user can send and receive typing events. - bool get canUseTypingEvents { - return ownCapabilities.contains(ChannelCapability.typingEvents); - } - - /// True, if channel slow mode is active. - bool get isInSlowMode { - return ownCapabilities.contains(ChannelCapability.slowMode); - } - - /// True, if the current user is allowed to post messages as usual even if the - /// channel is in slow mode. - bool get canSkipSlowMode { - return ownCapabilities.contains(ChannelCapability.skipSlowMode); - } - - /// True, if the current user can create a poll. - bool get canSendPoll { - return ownCapabilities.contains(ChannelCapability.sendPoll); - } - - /// True, if the current user can vote in a poll. - bool get canCastPollVote { - return ownCapabilities.contains(ChannelCapability.castPollVote); - } - - /// True, if the current user can query poll votes. - bool get canQueryPollVotes { - return ownCapabilities.contains(ChannelCapability.queryPollVotes); - } - - /// True, if the current user has delivery events capability. - bool get canUseDeliveryReceipts { - return ownCapabilities.contains(ChannelCapability.deliveryEvents); - } - - /// True, if the current user can share location in the channel. - bool get canShareLocation { - return ownCapabilities.contains(ChannelCapability.shareLocation); - } - - /// True, if the current user can send an "@channel" mention that notifies - /// all channel members. - bool get canNotifyChannel { - return ownCapabilities.contains(ChannelCapability.notifyChannel); - } - - /// True, if the current user can send an "@here" mention that notifies all - /// online channel members. - bool get canNotifyHere { - return ownCapabilities.contains(ChannelCapability.notifyHere); - } - - /// True, if the current user can mention one or more roles in a message. - bool get canNotifyRole { - return ownCapabilities.contains(ChannelCapability.notifyRole); - } - - /// True, if the current user can mention one or more user groups in a - /// message. - bool get canNotifyGroup { - return ownCapabilities.contains(ChannelCapability.notifyGroup); - } -} diff --git a/packages/stream_chat/lib/src/client/channel_capability_check.dart b/packages/stream_chat/lib/src/client/channel_capability_check.dart new file mode 100644 index 0000000000..c3e9e44935 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel_capability_check.dart @@ -0,0 +1,235 @@ +import 'package:stream_chat/stream_chat.dart'; + +/// Extension methods for checking channel capabilities on a Channel instance. +/// +/// These methods provide a convenient way to check if the current user has +/// specific capabilities in a channel. +extension ChannelCapabilityCheck on Channel { + /// True, if the current user can send a message to this channel. + bool get canSendMessage { + return ownCapabilities.contains(ChannelCapability.sendMessage); + } + + /// True, if the current user can send a reply to this channel. + bool get canSendReply { + return ownCapabilities.contains(ChannelCapability.sendReply); + } + + /// True, if the current user can send a message with restricted visibility. + bool get canSendRestrictedVisibilityMessage { + return ownCapabilities.contains( + ChannelCapability.sendRestrictedVisibilityMessage, + ); + } + + /// True, if the current user can send reactions. + bool get canSendReaction { + return ownCapabilities.contains(ChannelCapability.sendReaction); + } + + /// True, if the current user can attach links to messages. + bool get canSendLinks { + return ownCapabilities.contains(ChannelCapability.sendLinks); + } + + /// True, if the current user can attach files to messages. + bool get canCreateAttachment { + return ownCapabilities.contains(ChannelCapability.createAttachment); + } + + /// True, if the current user can freeze or unfreeze channel. + bool get canFreezeChannel { + return ownCapabilities.contains(ChannelCapability.freezeChannel); + } + + /// True, if the current user can enable or disable slow mode. + bool get canSetChannelCooldown { + return ownCapabilities.contains(ChannelCapability.setChannelCooldown); + } + + /// True, if the current user can leave channel (remove own membership). + bool get canLeaveChannel { + return ownCapabilities.contains(ChannelCapability.leaveChannel); + } + + /// True, if the current user can join channel (add own membership). + bool get canJoinChannel { + return ownCapabilities.contains(ChannelCapability.joinChannel); + } + + /// True, if the current user can pin a message. + bool get canPinMessage { + return ownCapabilities.contains(ChannelCapability.pinMessage); + } + + /// True, if the current user can delete any message from the channel. + bool get canDeleteAnyMessage { + return ownCapabilities.contains(ChannelCapability.deleteAnyMessage); + } + + /// True, if the current user can delete own messages from the channel. + bool get canDeleteOwnMessage { + return ownCapabilities.contains(ChannelCapability.deleteOwnMessage); + } + + /// True, if the current user can update any message in the channel. + bool get canUpdateAnyMessage { + return ownCapabilities.contains(ChannelCapability.updateAnyMessage); + } + + /// True, if the current user can update own messages in the channel. + bool get canUpdateOwnMessage { + return ownCapabilities.contains(ChannelCapability.updateOwnMessage); + } + + /// True, if the current user can use message search. + bool get canSearchMessages { + return ownCapabilities.contains(ChannelCapability.searchMessages); + } + + /// True, if the current user can send typing events. + @Deprecated('Use canUseTypingEvents instead') + bool get canSendTypingEvents { + if (canUseTypingEvents) return true; + return ownCapabilities.contains(ChannelCapability.sendTypingEvents); + } + + /// True, if the current user can upload message attachments. + bool get canUploadFile { + return ownCapabilities.contains(ChannelCapability.uploadFile); + } + + /// True, if the current user can delete channel. + bool get canDeleteChannel { + return ownCapabilities.contains(ChannelCapability.deleteChannel); + } + + /// True, if the current user can update channel data. + bool get canUpdateChannel { + return ownCapabilities.contains(ChannelCapability.updateChannel); + } + + /// True, if the current user can update channel members. + bool get canUpdateChannelMembers { + return ownCapabilities.contains(ChannelCapability.updateChannelMembers); + } + + /// True, if the current user can update thread data. + bool get canUpdateThread { + return ownCapabilities.contains(ChannelCapability.updateThread); + } + + /// True, if the current user can quote a message. + bool get canQuoteMessage { + return ownCapabilities.contains(ChannelCapability.quoteMessage); + } + + /// True, if the current user can ban channel members. + bool get canBanChannelMembers { + return ownCapabilities.contains(ChannelCapability.banChannelMembers); + } + + /// True, if the current user can flag a message. + bool get canFlagMessage { + return ownCapabilities.contains(ChannelCapability.flagMessage); + } + + /// True, if the current user can mute a channel. + bool get canMuteChannel { + return ownCapabilities.contains(ChannelCapability.muteChannel); + } + + /// True, if the current user can send custom events. + bool get canSendCustomEvents { + return ownCapabilities.contains(ChannelCapability.sendCustomEvents); + } + + /// True, if the current user has read events capability. + @Deprecated('Use canUseReadReceipts instead') + bool get canReceiveReadEvents => canUseReadReceipts; + + /// True, if the current user has read events capability. + bool get canUseReadReceipts { + 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); + } + + /// True, if the current user can send and receive typing events. + bool get canUseTypingEvents { + return ownCapabilities.contains(ChannelCapability.typingEvents); + } + + /// True, if channel slow mode is active. + bool get isInSlowMode { + return ownCapabilities.contains(ChannelCapability.slowMode); + } + + /// True, if the current user is allowed to post messages as usual even if the + /// channel is in slow mode. + bool get canSkipSlowMode { + return ownCapabilities.contains(ChannelCapability.skipSlowMode); + } + + /// True, if the current user can create a poll. + bool get canSendPoll { + return ownCapabilities.contains(ChannelCapability.sendPoll); + } + + /// True, if the current user can vote in a poll. + bool get canCastPollVote { + return ownCapabilities.contains(ChannelCapability.castPollVote); + } + + /// True, if the current user can query poll votes. + bool get canQueryPollVotes { + return ownCapabilities.contains(ChannelCapability.queryPollVotes); + } + + /// True, if the current user has delivery events capability. + bool get canUseDeliveryReceipts { + return ownCapabilities.contains(ChannelCapability.deliveryEvents); + } + + /// True, if the current user can share location in the channel. + bool get canShareLocation { + return ownCapabilities.contains(ChannelCapability.shareLocation); + } + + /// True, if the current user can send an "@channel" mention that notifies + /// all channel members. + bool get canNotifyChannel { + return ownCapabilities.contains(ChannelCapability.notifyChannel); + } + + /// True, if the current user can send an "@here" mention that notifies all + /// online channel members. + bool get canNotifyHere { + return ownCapabilities.contains(ChannelCapability.notifyHere); + } + + /// True, if the current user can mention one or more roles in a message. + bool get canNotifyRole { + return ownCapabilities.contains(ChannelCapability.notifyRole); + } + + /// True, if the current user can mention one or more user groups in a + /// message. + bool get canNotifyGroup { + return ownCapabilities.contains(ChannelCapability.notifyGroup); + } +} diff --git a/packages/stream_chat/lib/src/client/channel_read_helper.dart b/packages/stream_chat/lib/src/client/channel_read_helper.dart new file mode 100644 index 0000000000..a7366cd409 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel_read_helper.dart @@ -0,0 +1,52 @@ +import 'package:stream_chat/stream_chat.dart'; + +/// Extension methods for reading related operations on a ChannelClientState. +extension ChannelReadHelper on ChannelClientState { + /// Get the [Read] object for a specific user identified by [userId]. + Read? userReadOf({String? userId}) => read.userReadOf(userId: userId); + + /// Stream of [Read] object for a specific user identified by [userId]. + Stream userReadStreamOf({String? userId}) { + return readStream.map((read) => read.userReadOf(userId: userId)); + } + + /// Returns the list of [Read]s that have marked the given [msg] as read. + /// + /// The [Read] is considered to have read the message if: + /// - The read user is not the sender of the message. + /// - The read's lastRead is after or equal to the message's createdAt. + List readsOf({required Message message}) { + return read.readsOf(message: message); + } + + /// Stream of list of [Read]s that have marked the given [msg] as read. + /// + /// The [Read] is considered to have read the message if: + /// - The read user is not the sender of the message. + /// - The read's lastRead is after or equal to the message's createdAt. + Stream> readsOfStream({required Message message}) { + return readStream.map((read) => read.readsOf(message: message)); + } + + /// Returns the list of [Read]s that have marked the given [message] as + /// delivered. + /// + /// The [Read] is considered to have delivered the message if: + /// - The read user is not the sender of the message. + /// - The read contains a non-null lastDeliveredAt. + /// - The read's lastDeliveredAt is after or equal to the message's createdAt. + List deliveriesOf({required Message message}) { + return read.deliveriesOf(message: message); + } + + /// Stream of list of [Read]s that have marked the given [message] as + /// delivered. + /// + /// The [Read] is considered to have delivered the message if: + /// - The read user is not the sender of the message. + /// - The read contains a non-null lastDeliveredAt. + /// - The read's lastDeliveredAt is after or equal to the message's createdAt. + Stream> deliveriesOfStream({required Message message}) { + return readStream.map((read) => read.deliveriesOf(message: message)); + } +} diff --git a/packages/stream_chat/test/src/client/channel_capability_check_test.dart b/packages/stream_chat/test/src/client/channel_capability_check_test.dart new file mode 100644 index 0000000000..5dba632192 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel_capability_check_test.dart @@ -0,0 +1,407 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + group('ChannelCapabilityCheck', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); + + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + /// Parameterized test for channel capability extension properties + void testCapability( + String capabilityName, + ChannelCapability capability, + bool Function(Channel) getterMethod, + ) { + test('can$capabilityName returns false when capability is absent', () { + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + expect(getterMethod(channel), false); + }); + + test('can$capabilityName returns true when capability is present', () { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [capability], + ); + final channel = Channel.fromState(client, channelState); + expect(getterMethod(channel), true); + }); + } + + // Test all channel capabilities using the parameterized function + testCapability( + 'SendMessage', + ChannelCapability.sendMessage, + (channel) => channel.canSendMessage, + ); + + testCapability( + 'SendReply', + ChannelCapability.sendReply, + (channel) => channel.canSendReply, + ); + + testCapability( + 'SendRestrictedVisibilityMessage', + ChannelCapability.sendRestrictedVisibilityMessage, + (channel) => channel.canSendRestrictedVisibilityMessage, + ); + + testCapability( + 'SendReaction', + ChannelCapability.sendReaction, + (channel) => channel.canSendReaction, + ); + + testCapability( + 'SendLinks', + ChannelCapability.sendLinks, + (channel) => channel.canSendLinks, + ); + + testCapability( + 'CreateAttachment', + ChannelCapability.createAttachment, + (channel) => channel.canCreateAttachment, + ); + + testCapability( + 'FreezeChannel', + ChannelCapability.freezeChannel, + (channel) => channel.canFreezeChannel, + ); + + testCapability( + 'SetChannelCooldown', + ChannelCapability.setChannelCooldown, + (channel) => channel.canSetChannelCooldown, + ); + + testCapability( + 'LeaveChannel', + ChannelCapability.leaveChannel, + (channel) => channel.canLeaveChannel, + ); + + testCapability( + 'JoinChannel', + ChannelCapability.joinChannel, + (channel) => channel.canJoinChannel, + ); + + testCapability( + 'PinMessage', + ChannelCapability.pinMessage, + (channel) => channel.canPinMessage, + ); + + testCapability( + 'DeleteAnyMessage', + ChannelCapability.deleteAnyMessage, + (channel) => channel.canDeleteAnyMessage, + ); + + testCapability( + 'DeleteOwnMessage', + ChannelCapability.deleteOwnMessage, + (channel) => channel.canDeleteOwnMessage, + ); + + testCapability( + 'UpdateAnyMessage', + ChannelCapability.updateAnyMessage, + (channel) => channel.canUpdateAnyMessage, + ); + + testCapability( + 'UpdateOwnMessage', + ChannelCapability.updateOwnMessage, + (channel) => channel.canUpdateOwnMessage, + ); + + testCapability( + 'SearchMessages', + ChannelCapability.searchMessages, + (channel) => channel.canSearchMessages, + ); + + testCapability( + 'SendTypingEvents', + ChannelCapability.sendTypingEvents, + (channel) => channel.canSendTypingEvents, + ); + + testCapability( + 'UploadFile', + ChannelCapability.uploadFile, + (channel) => channel.canUploadFile, + ); + + testCapability( + 'DeleteChannel', + ChannelCapability.deleteChannel, + (channel) => channel.canDeleteChannel, + ); + + testCapability( + 'UpdateChannel', + ChannelCapability.updateChannel, + (channel) => channel.canUpdateChannel, + ); + + testCapability( + 'UpdateChannelMembers', + ChannelCapability.updateChannelMembers, + (channel) => channel.canUpdateChannelMembers, + ); + + testCapability( + 'UpdateThread', + ChannelCapability.updateThread, + (channel) => channel.canUpdateThread, + ); + + testCapability( + 'QuoteMessage', + ChannelCapability.quoteMessage, + (channel) => channel.canQuoteMessage, + ); + + testCapability( + 'BanChannelMembers', + ChannelCapability.banChannelMembers, + (channel) => channel.canBanChannelMembers, + ); + + testCapability( + 'FlagMessage', + ChannelCapability.flagMessage, + (channel) => channel.canFlagMessage, + ); + + testCapability( + 'MuteChannel', + ChannelCapability.muteChannel, + (channel) => channel.canMuteChannel, + ); + + testCapability( + 'SendCustomEvents', + ChannelCapability.sendCustomEvents, + (channel) => channel.canSendCustomEvents, + ); + + testCapability( + 'ReceiveReadEvents', + ChannelCapability.readEvents, + (channel) => channel.canReceiveReadEvents, + ); + + testCapability( + 'UseReadReceipts', + ChannelCapability.readEvents, + (channel) => channel.canUseReadReceipts, + ); + + testCapability( + 'ReceiveConnectEvents', + ChannelCapability.connectEvents, + (channel) => channel.canReceiveConnectEvents, + ); + + testCapability( + 'UseTypingEvents', + ChannelCapability.typingEvents, + (channel) => channel.canUseTypingEvents, + ); + + testCapability( + 'InSlowMode', + ChannelCapability.slowMode, + (channel) => channel.isInSlowMode, + ); + + testCapability( + 'SkipSlowMode', + ChannelCapability.skipSlowMode, + (channel) => channel.canSkipSlowMode, + ); + + testCapability( + 'SendPoll', + ChannelCapability.sendPoll, + (channel) => channel.canSendPoll, + ); + + testCapability( + 'CastPollVote', + ChannelCapability.castPollVote, + (channel) => channel.canCastPollVote, + ); + + testCapability( + 'QueryPollVotes', + ChannelCapability.queryPollVotes, + (channel) => channel.canQueryPollVotes, + ); + + testCapability( + 'UseDeliveryReceipts', + ChannelCapability.deliveryEvents, + (channel) => channel.canUseDeliveryReceipts, + ); + + testCapability( + 'ShareLocation', + ChannelCapability.shareLocation, + (channel) => channel.canShareLocation, + ); + + testCapability( + 'NotifyChannel', + ChannelCapability.notifyChannel, + (channel) => channel.canNotifyChannel, + ); + + testCapability( + 'NotifyHere', + ChannelCapability.notifyHere, + (channel) => channel.canNotifyHere, + ); + + testCapability( + 'NotifyRole', + ChannelCapability.notifyRole, + (channel) => channel.canNotifyRole, + ); + + testCapability( + 'NotifyGroup', + ChannelCapability.notifyGroup, + (channel) => channel.canNotifyGroup, + ); + + test('returns correct values with multiple capabilities', () { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.deleteOwnMessage, + ], + ); + + final channel = Channel.fromState(client, channelState); + expect(channel.canSendMessage, true); + expect(channel.canSendReply, true); + expect(channel.canDeleteOwnMessage, true); + expect(channel.canDeleteAnyMessage, false); + expect(channel.canUpdateChannel, false); + }); + + group('usesLocalUnreadCount', () { + // `isLocalUnreadCountEnabled` is a settable field on the mock and the + // client is shared across the group, so reset it between tests. + tearDown(() => client.isLocalUnreadCountEnabled = false); + + Channel channelWithReadEvents({required bool available}) { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ + if (available) ChannelCapability.readEvents, + ], + ); + + return Channel.fromState(client, channelState); + } + + test('is false when disabled and read receipts are unavailable', () { + client.isLocalUnreadCountEnabled = false; + final channel = channelWithReadEvents(available: false); + expect(channel.usesLocalUnreadCount, false); + }); + + test('is false when disabled and read receipts are available', () { + client.isLocalUnreadCountEnabled = false; + final channel = channelWithReadEvents(available: true); + expect(channel.usesLocalUnreadCount, false); + }); + + test('is false when enabled but the channel supports read receipts', () { + client.isLocalUnreadCountEnabled = true; + final channel = channelWithReadEvents(available: true); + expect(channel.usesLocalUnreadCount, false); + }); + + test('is true when enabled and read receipts are unavailable', () { + client.isLocalUnreadCountEnabled = true; + final channel = channelWithReadEvents(available: false); + expect(channel.usesLocalUnreadCount, true); + }); + }); + }); +} + +// region Test Helpers + +ChannelState _generateChannelState( + String channelId, + String channelType, { + DateTime? lastMessageAt, + List? ownCapabilities, + bool mockChannelConfig = false, +}) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ownCapabilities: ownCapabilities, + lastMessageAt: lastMessageAt, + ); + return ChannelState(channel: channel); +} + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +// endregion diff --git a/packages/stream_chat/test/src/client/channel_read_helper_test.dart b/packages/stream_chat/test/src/client/channel_read_helper_test.dart new file mode 100644 index 0000000000..8c8467cfc6 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel_read_helper_test.dart @@ -0,0 +1,319 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + group('ChannelReadHelper', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); + + // A date in the distant past (Unix epoch), useful for representing old dates + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + test('userReadOf should return read for specific user', () { + final now = DateTime.now(); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final reads = [ + Read(user: user1, lastRead: now), + Read(user: user2, lastRead: now.add(const Duration(minutes: 1))), + ]; + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); + + final user1Read = channel.state!.userReadOf(userId: 'user-1'); + expect(user1Read, isNotNull); + expect(user1Read!.user.id, 'user-1'); + expect(user1Read.lastRead, now); + + final user2Read = channel.state!.userReadOf(userId: 'user-2'); + expect(user2Read, isNotNull); + expect(user2Read!.user.id, 'user-2'); + + final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); + expect(nonExistentRead, isNull); + }); + + test('userReadOf should return null when userId is null', () { + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final read = channel.state!.userReadOf(userId: null); + expect(read, isNull); + }); + + test( + 'userReadStreamOf should emit read updates for specific user', + () async { + final now = DateTime.now(); + final user1 = User(id: 'user-1', name: 'User 1'); + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); + + expectLater( + readStream, + emitsInOrder([ + isNull, // initial state + isA().having((r) => r.user.id, 'userId', 'user-1'), + ]), + ); + + // Update with read + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [Read(user: user1, lastRead: now)], + ), + ); + }, + ); + + test('readsOf should return reads that have marked message as read', () { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + final user3 = User(id: 'user-3', name: 'User 3'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final reads = [ + // user1 has read the message + Read(user: user1, lastRead: now.add(const Duration(seconds: 1))), + // user2 has not read the message yet + Read(user: user2, lastRead: distantPast), + // user3 has read the message + Read(user: user3, lastRead: now.add(const Duration(seconds: 2))), + // sender should be excluded + Read(user: sender, lastRead: now.add(const Duration(seconds: 10))), + ]; + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); + + final messageReads = channel.state!.readsOf(message: message); + expect(messageReads.length, 2); + expect(messageReads.map((r) => r.user.id), containsAll(['user-1', 'user-3'])); + expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); + expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); + }); + + test('readsOfStream should emit read updates for a message', () async { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final readsStream = channel.state!.readsOfStream(message: message); + + expectLater( + readsStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding read + ]), + ); + + // Update with read + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [Read(user: user1, lastRead: now.add(const Duration(seconds: 1)))], + ), + ); + }); + + test('deliveriesOf should return reads that have delivered the message', () { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + final user3 = User(id: 'user-3', name: 'User 3'); + final user4 = User(id: 'user-4', name: 'User 4'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final reads = [ + // user1 has delivered the message + Read( + user: user1, + lastRead: distantPast, + lastDeliveredAt: now.add(const Duration(seconds: 1)), + ), + // user2 has not delivered the message yet (lastDeliveredAt is before message) + Read( + user: user2, + lastRead: distantPast, + lastDeliveredAt: distantPast, + ), + // user3 has no lastDeliveredAt + Read( + user: user3, + lastRead: distantPast, + ), + // user4 has read the message (implicitly delivered) + Read( + user: user4, + lastRead: now.add(const Duration(seconds: 1)), + ), + // sender should be excluded + Read( + user: sender, + lastRead: now.add(const Duration(seconds: 10)), + lastDeliveredAt: now.add(const Duration(seconds: 10)), + ), + ]; + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); + + final deliveries = channel.state!.deliveriesOf(message: message); + expect(deliveries.length, 2); + expect(deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); + }); + + test('deliveriesOfStream should emit delivery updates for a message', () async { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final deliveriesStream = channel.state!.deliveriesOfStream(message: message); + + expectLater( + deliveriesStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding delivery + ]), + ); + + // Update with delivery + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [ + Read( + user: user1, + lastRead: distantPast, + lastDeliveredAt: now.add(const Duration(seconds: 1)), + ), + ], + ), + ); + }); + }); +} + +// region Test Helpers + +ChannelState _generateChannelState( + String channelId, + String channelType, { + DateTime? lastMessageAt, + List? ownCapabilities, + bool mockChannelConfig = false, +}) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ownCapabilities: ownCapabilities, + lastMessageAt: lastMessageAt, + ); + return ChannelState(channel: channel); +} + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +// endregion diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 86b6f2677f..b0959ee164 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -8915,594 +8915,6 @@ void main() { }); }); - group('ChannelReadHelper', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - // A date in the distant past (Unix epoch), useful for representing old dates - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test('userReadOf should return read for specific user', () { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final reads = [ - Read(user: user1, lastRead: now), - Read(user: user2, lastRead: now.add(const Duration(minutes: 1))), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final user1Read = channel.state!.userReadOf(userId: 'user-1'); - expect(user1Read, isNotNull); - expect(user1Read!.user.id, 'user-1'); - expect(user1Read.lastRead, now); - - final user2Read = channel.state!.userReadOf(userId: 'user-2'); - expect(user2Read, isNotNull); - expect(user2Read!.user.id, 'user-2'); - - final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); - expect(nonExistentRead, isNull); - }); - - test('userReadOf should return null when userId is null', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final read = channel.state!.userReadOf(userId: null); - expect(read, isNull); - }); - - test( - 'userReadStreamOf should emit read updates for specific user', - () async { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); - - expectLater( - readStream, - emitsInOrder([ - isNull, // initial state - isA().having((r) => r.user.id, 'userId', 'user-1'), - ]), - ); - - // Update with read - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [Read(user: user1, lastRead: now)], - ), - ); - }, - ); - - test('readsOf should return reads that have marked message as read', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final reads = [ - // user1 has read the message - Read(user: user1, lastRead: now.add(const Duration(seconds: 1))), - // user2 has not read the message yet - Read(user: user2, lastRead: distantPast), - // user3 has read the message - Read(user: user3, lastRead: now.add(const Duration(seconds: 2))), - // sender should be excluded - Read(user: sender, lastRead: now.add(const Duration(seconds: 10))), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final messageReads = channel.state!.readsOf(message: message); - expect(messageReads.length, 2); - expect(messageReads.map((r) => r.user.id), containsAll(['user-1', 'user-3'])); - expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); - expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); - }); - - test('readsOfStream should emit read updates for a message', () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final readsStream = channel.state!.readsOfStream(message: message); - - expectLater( - readsStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding read - ]), - ); - - // Update with read - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [Read(user: user1, lastRead: now.add(const Duration(seconds: 1)))], - ), - ); - }); - - test('deliveriesOf should return reads that have delivered the message', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); - final user4 = User(id: 'user-4', name: 'User 4'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final reads = [ - // user1 has delivered the message - Read( - user: user1, - lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), - ), - // user2 has not delivered the message yet (lastDeliveredAt is before message) - Read( - user: user2, - lastRead: distantPast, - lastDeliveredAt: distantPast, - ), - // user3 has no lastDeliveredAt - Read( - user: user3, - lastRead: distantPast, - ), - // user4 has read the message (implicitly delivered) - Read( - user: user4, - lastRead: now.add(const Duration(seconds: 1)), - ), - // sender should be excluded - Read( - user: sender, - lastRead: now.add(const Duration(seconds: 10)), - lastDeliveredAt: now.add(const Duration(seconds: 10)), - ), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final deliveries = channel.state!.deliveriesOf(message: message); - expect(deliveries.length, 2); - expect(deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); - }); - - test('deliveriesOfStream should emit delivery updates for a message', () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final deliveriesStream = channel.state!.deliveriesOfStream(message: message); - - expectLater( - deliveriesStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding delivery - ]), - ); - - // Update with delivery - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [ - Read( - user: user1, - lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), - ), - ], - ), - ); - }); - }); - - group('ChannelCapabilityCheck', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - /// Parameterized test for channel capability extension properties - void testCapability( - String capabilityName, - ChannelCapability capability, - bool Function(Channel) getterMethod, - ) { - test('can$capabilityName returns false when capability is absent', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), false); - }); - - test('can$capabilityName returns true when capability is present', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [capability], - ); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), true); - }); - } - - // Test all channel capabilities using the parameterized function - testCapability( - 'SendMessage', - ChannelCapability.sendMessage, - (channel) => channel.canSendMessage, - ); - - testCapability( - 'SendReply', - ChannelCapability.sendReply, - (channel) => channel.canSendReply, - ); - - testCapability( - 'SendRestrictedVisibilityMessage', - ChannelCapability.sendRestrictedVisibilityMessage, - (channel) => channel.canSendRestrictedVisibilityMessage, - ); - - testCapability( - 'SendReaction', - ChannelCapability.sendReaction, - (channel) => channel.canSendReaction, - ); - - testCapability( - 'SendLinks', - ChannelCapability.sendLinks, - (channel) => channel.canSendLinks, - ); - - testCapability( - 'CreateAttachment', - ChannelCapability.createAttachment, - (channel) => channel.canCreateAttachment, - ); - - testCapability( - 'FreezeChannel', - ChannelCapability.freezeChannel, - (channel) => channel.canFreezeChannel, - ); - - testCapability( - 'SetChannelCooldown', - ChannelCapability.setChannelCooldown, - (channel) => channel.canSetChannelCooldown, - ); - - testCapability( - 'LeaveChannel', - ChannelCapability.leaveChannel, - (channel) => channel.canLeaveChannel, - ); - - testCapability( - 'JoinChannel', - ChannelCapability.joinChannel, - (channel) => channel.canJoinChannel, - ); - - testCapability( - 'PinMessage', - ChannelCapability.pinMessage, - (channel) => channel.canPinMessage, - ); - - testCapability( - 'DeleteAnyMessage', - ChannelCapability.deleteAnyMessage, - (channel) => channel.canDeleteAnyMessage, - ); - - testCapability( - 'DeleteOwnMessage', - ChannelCapability.deleteOwnMessage, - (channel) => channel.canDeleteOwnMessage, - ); - - testCapability( - 'UpdateAnyMessage', - ChannelCapability.updateAnyMessage, - (channel) => channel.canUpdateAnyMessage, - ); - - testCapability( - 'UpdateOwnMessage', - ChannelCapability.updateOwnMessage, - (channel) => channel.canUpdateOwnMessage, - ); - - testCapability( - 'SearchMessages', - ChannelCapability.searchMessages, - (channel) => channel.canSearchMessages, - ); - - testCapability( - 'SendTypingEvents', - ChannelCapability.sendTypingEvents, - (channel) => channel.canSendTypingEvents, - ); - - testCapability( - 'UploadFile', - ChannelCapability.uploadFile, - (channel) => channel.canUploadFile, - ); - - testCapability( - 'DeleteChannel', - ChannelCapability.deleteChannel, - (channel) => channel.canDeleteChannel, - ); - - testCapability( - 'UpdateChannel', - ChannelCapability.updateChannel, - (channel) => channel.canUpdateChannel, - ); - - testCapability( - 'UpdateChannelMembers', - ChannelCapability.updateChannelMembers, - (channel) => channel.canUpdateChannelMembers, - ); - - testCapability( - 'UpdateThread', - ChannelCapability.updateThread, - (channel) => channel.canUpdateThread, - ); - - testCapability( - 'QuoteMessage', - ChannelCapability.quoteMessage, - (channel) => channel.canQuoteMessage, - ); - - testCapability( - 'BanChannelMembers', - ChannelCapability.banChannelMembers, - (channel) => channel.canBanChannelMembers, - ); - - testCapability( - 'FlagMessage', - ChannelCapability.flagMessage, - (channel) => channel.canFlagMessage, - ); - - testCapability( - 'MuteChannel', - ChannelCapability.muteChannel, - (channel) => channel.canMuteChannel, - ); - - testCapability( - 'SendCustomEvents', - ChannelCapability.sendCustomEvents, - (channel) => channel.canSendCustomEvents, - ); - - testCapability( - 'ReceiveReadEvents', - ChannelCapability.readEvents, - (channel) => channel.canReceiveReadEvents, - ); - - testCapability( - 'ReceiveConnectEvents', - ChannelCapability.connectEvents, - (channel) => channel.canReceiveConnectEvents, - ); - - testCapability( - 'UseTypingEvents', - ChannelCapability.typingEvents, - (channel) => channel.canUseTypingEvents, - ); - - testCapability( - 'InSlowMode', - ChannelCapability.slowMode, - (channel) => channel.isInSlowMode, - ); - - testCapability( - 'SkipSlowMode', - ChannelCapability.skipSlowMode, - (channel) => channel.canSkipSlowMode, - ); - - testCapability( - 'SendPoll', - ChannelCapability.sendPoll, - (channel) => channel.canSendPoll, - ); - - testCapability( - 'CastPollVote', - ChannelCapability.castPollVote, - (channel) => channel.canCastPollVote, - ); - - testCapability( - 'QueryPollVotes', - ChannelCapability.queryPollVotes, - (channel) => channel.canQueryPollVotes, - ); - - testCapability( - 'ShareLocation', - ChannelCapability.shareLocation, - (channel) => channel.canShareLocation, - ); - - testCapability( - 'NotifyChannel', - ChannelCapability.notifyChannel, - (channel) => channel.canNotifyChannel, - ); - - testCapability( - 'NotifyHere', - ChannelCapability.notifyHere, - (channel) => channel.canNotifyHere, - ); - - testCapability( - 'NotifyRole', - ChannelCapability.notifyRole, - (channel) => channel.canNotifyRole, - ); - - testCapability( - 'NotifyGroup', - ChannelCapability.notifyGroup, - (channel) => channel.canNotifyGroup, - ); - - test('returns correct values with multiple capabilities', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ - ChannelCapability.sendMessage, - ChannelCapability.sendReply, - ChannelCapability.deleteOwnMessage, - ], - ); - - final channel = Channel.fromState(client, channelState); - expect(channel.canSendMessage, true); - expect(channel.canSendReply, true); - expect(channel.canDeleteOwnMessage, true); - expect(channel.canDeleteAnyMessage, false); - expect(channel.canUpdateChannel, false); - }); - }); - group('Channel State Validation and Cooldown', () { late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; From afb670de0f4b14f93a860882da7ad6481e57a319 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 26 Aug 2026 18:36:49 +0200 Subject: [PATCH 2/4] refactor(llc): move ChannelClientState into its own file --- .../stream_chat/lib/src/client/channel.dart | 2188 +----- .../lib/src/client/channel_client_state.dart | 2148 ++++++ .../src/client/channel_client_state_test.dart | 5124 +++++++++++++ .../test/src/client/channel_test.dart | 6787 +++-------------- 4 files changed, 8151 insertions(+), 8096 deletions(-) create mode 100644 packages/stream_chat/lib/src/client/channel_client_state.dart create mode 100644 packages/stream_chat/test/src/client/channel_client_state_test.dart diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index cbc708a85b..4d5c372375 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -5,14 +5,13 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/client/retry_queue.dart'; -import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:synchronized/synchronized.dart'; // Re-exported so that importing this file directly keeps resolving the // extensions that used to be declared here. export 'channel_capability_check.dart'; +export 'channel_client_state.dart'; export 'channel_read_helper.dart'; /// The maximum time the incoming [Event.typingStart] event is valid before a @@ -203,7 +202,7 @@ class Channel { /// Channel configuration. ChannelConfig? get config { _checkInitialized(); - return state!._channelState.channel?.config; + return state!.channelState.channel?.config; } /// Channel configuration as a stream. @@ -215,7 +214,7 @@ class Channel { /// Relationship of the current user to this channel. Member? get membership { _checkInitialized(); - return state!._channelState.membership; + return state!.channelState.membership; } /// Relationship of the current user to this channel as a stream. @@ -227,7 +226,7 @@ class Channel { /// Channel user creator. User? get createdBy { _checkInitialized(); - return state!._channelState.channel?.createdBy; + return state!.channelState.channel?.createdBy; } /// Channel user creator as a stream. @@ -239,7 +238,7 @@ class Channel { /// Channel frozen status. bool get frozen { _checkInitialized(); - return state!._channelState.channel?.frozen == true; + return state!.channelState.channel?.frozen == true; } /// Channel frozen status as a stream. @@ -251,7 +250,7 @@ class Channel { /// Channel disabled status. bool get disabled { _checkInitialized(); - return state!._channelState.channel?.disabled == true; + return state!.channelState.channel?.disabled == true; } /// Channel disabled status as a stream. @@ -263,7 +262,7 @@ class Channel { /// Channel hidden status. bool get hidden { _checkInitialized(); - return state!._channelState.channel?.hidden == true; + return state!.channelState.channel?.hidden == true; } /// Channel hidden status as a stream. @@ -301,7 +300,7 @@ class Channel { /// The last date at which the channel got truncated. DateTime? get truncatedAt { _checkInitialized(); - return state!._channelState.channel?.truncatedAt; + return state!.channelState.channel?.truncatedAt; } /// The last date at which the channel got truncated as a stream. @@ -313,7 +312,7 @@ class Channel { /// Cooldown count int get cooldown { _checkInitialized(); - return state!._channelState.channel?.cooldown ?? 0; + return state!.channelState.channel?.cooldown ?? 0; } /// Cooldown count as a stream @@ -348,7 +347,7 @@ class Channel { /// Channel creation date. DateTime? get createdAt { _checkInitialized(); - return state!._channelState.channel?.createdAt; + return state!.channelState.channel?.createdAt; } /// Channel creation date as a stream. @@ -360,7 +359,7 @@ class Channel { /// Channel last message date. DateTime? get lastMessageAt { _checkInitialized(); - return state!._channelState.channel?.lastMessageAt; + return state!.channelState.channel?.lastMessageAt; } /// Channel last message date as a stream. @@ -440,7 +439,7 @@ class Channel { /// Channel updated date. DateTime? get updatedAt { _checkInitialized(); - return state!._channelState.channel?.updatedAt; + return state!.channelState.channel?.updatedAt; } /// Channel updated date as a stream. @@ -452,7 +451,7 @@ class Channel { /// Channel deletion date. DateTime? get deletedAt { _checkInitialized(); - return state!._channelState.channel?.deletedAt; + return state!.channelState.channel?.deletedAt; } /// Channel deletion date as a stream. @@ -464,7 +463,7 @@ class Channel { /// Channel member count. int? get memberCount { _checkInitialized(); - return state!._channelState.channel?.memberCount; + return state!.channelState.channel?.memberCount; } /// Channel member count as a stream. @@ -479,7 +478,7 @@ class Channel { /// enabled for your app. int? get messageCount { _checkInitialized(); - return state!._channelState.channel?.messageCount; + return state!.channelState.channel?.messageCount; } /// Channel message count as a stream. @@ -496,27 +495,27 @@ class Channel { /// Generally used for filtering channels while querying. List? get filterTags { _checkInitialized(); - return state!._channelState.channel?.filterTags; + return state!.channelState.channel?.filterTags; } /// Channel id. - String? get id => state?._channelState.channel?.id ?? _id; + String? get id => state?.channelState.channel?.id ?? _id; /// Channel type. - String get type => state?._channelState.channel?.type ?? _type; + String get type => state?.channelState.channel?.type ?? _type; /// Channel cid. - String? get cid => state?._channelState.channel?.cid ?? _cid; + String? get cid => state?.channelState.channel?.cid ?? _cid; /// Channel team. String? get team { _checkInitialized(); - return state!._channelState.channel?.team; + return state!.channelState.channel?.team; } /// Channel extra data. Map get extraData { - var data = state?._channelState.channel?.extraData; + var data = state?.channelState.channel?.extraData; if (data == null || data.isEmpty) { data = _extraData; } @@ -524,7 +523,7 @@ class Channel { } /// List of user permissions on this channel - List get ownCapabilities => state?._channelState.channel?.ownCapabilities ?? []; + List get ownCapabilities => state?.channelState.channel?.ownCapabilities ?? []; /// List of user permissions on this channel Stream> get ownCapabilitiesStream { @@ -854,7 +853,7 @@ class Channel { state?.updateMessage(failedMessage); // If the error is retriable, add it to the retry queue. if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); + state?.scheduleRetry(failedMessage); } rethrow; @@ -943,7 +942,7 @@ class Channel { state?.updateMessage(failedMessage); // If the error is retriable, add it to the retry queue. if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); + state?.scheduleRetry(failedMessage); } rethrow; @@ -1010,7 +1009,7 @@ class Channel { state?.updateMessage(failedMessage); // If the error is retriable, add it to the retry queue. if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); + state?.scheduleRetry(failedMessage); } rethrow; @@ -1105,7 +1104,7 @@ class Channel { state?.deleteMessage(failedMessage, hardDelete: scope.hard); // If the error is retriable, add it to the retry queue. if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); + state?.scheduleRetry(failedMessage); } rethrow; @@ -2467,2138 +2466,3 @@ class Channel { ); } } - -/// The class that handles the state of the channel listening to the events. -class ChannelClientState { - /// Creates a new instance listening to events and updating the state. - ChannelClientState( - this._channel, - ChannelState channelState, - ) { - _retryQueue = RetryQueue( - channel: _channel, - logger: _client.detachedLogger( - '🔄 (${generateHash([_channel.cid])})', - ), - ); - - _channelStateController = BehaviorSubject.seeded(channelState); - // Update the persistence storage with the seeded channel state. - _debouncedUpdatePersistenceChannelState.call([channelState]); - - // region TYPING EVENTS - _listenTypingEvents(); - // endregion - - // region MESSAGE EVENTS - _listenMessageNew(); - _listenMessageDeleted(); - _listenMessageUpdated(); - // endregion - - // region DRAFT EVENTS - _listenDraftUpdated(); - _listenDraftDeleted(); - // endregion - - // region REACTION EVENTS - _listenReactionNew(); - _listenReactionUpdated(); - _listenReactionDeleted(); - // endregion - - // region POLL EVENTS - _listenPollCreated(); - _listenPollUpdated(); - _listenPollClosed(); - _listenPollAnswerCasted(); - _listenPollVoteCasted(); - _listenPollVoteChanged(); - _listenPollAnswerRemoved(); - _listenPollVoteRemoved(); - // endregion - - // region READ EVENTS - _listenReadEvents(); - // endregion - - // region CHANNEL EVENTS - _listenChannelTruncated(); - _listenChannelUpdated(); - _listenChannelMessageCount(); - // endregion - - // region MEMBER EVENTS - _listenMemberAdded(); - _listenMemberRemoved(); - _listenMemberUpdated(); - _listenMemberBanned(); - _listenMemberUnbanned(); - _listenUserMessagesDeleted(); - // endregion - - // region USER WATCHING EVENTS - _listenUserStartWatching(); - _listenUserStopWatching(); - // endregion - - // region REMINDER EVENTS - _listenReminderCreated(); - _listenReminderUpdated(); - _listenReminderDeleted(); - // endregion - - // region LOCATION EVENTS - _listenLocationShared(); - _listenLocationUpdated(); - _listenLocationExpired(); - // endregion - - _startCleaningStaleTypingEvents(); - - _startCleaningStalePinnedMessages(); - - _startCleaningExpiredLocations(); - - _listenChannelPushPreferenceUpdated(); - - final persistenceClient = _client.chatPersistenceClient; - persistenceClient - ?.getChannelThreads(_channel.cid!) - .then((threads) { - // Load all the threads for the channel from the offline storage. - if (threads.isNotEmpty) _threads = threads; - }) - .then((_) => retryFailedMessages()); - } - - final Channel _channel; - StreamChatClient get _client => _channel._client; - final _subscriptions = CompositeSubscription(); - - void _listenMemberAdded() { - _subscriptions.add( - _channel.on(EventType.memberAdded).listen((Event e) { - final member = e.member!; - final existingMembers = channelState.members ?? []; - - updateChannelState( - channelState.copyWith( - members: [...existingMembers, member], - ), - ); - }), - ); - } - - void _listenMemberRemoved() { - _subscriptions.add( - _channel.on(EventType.memberRemoved).listen((Event e) { - final user = e.user!; - final existingRead = channelState.read ?? []; - final existingMembers = channelState.members ?? []; - - updateChannelState( - channelState.copyWith( - read: [...existingRead.where((r) => r.user.id != user.id)], - members: [...existingMembers.where((m) => m.userId != user.id)], - ), - ); - }), - ); - } - - void _listenMemberUpdated() { - _subscriptions - // Listen to events containing member users - ..add( - _channel.on().listen( - (event) { - final user = event.user; - if (user == null) return; - - final existingMembers = [...?channelState.members]; - final existingMembership = channelState.membership; - - // Return if the user is not a existing member of the channel. - if (!existingMembers.any((m) => m.userId == user.id)) return; - - Member? maybeUpdateMemberUser(Member? existingMember) { - if (existingMember == null) return null; - if (existingMember.userId == user.id) { - return existingMember.copyWith(user: user); - } - return existingMember; - } - - updateChannelState( - channelState.copyWith( - membership: maybeUpdateMemberUser(existingMembership), - members: [...existingMembers.map(maybeUpdateMemberUser).nonNulls], - ), - ); - }, - ), - ) - // Listen to member updated events. - ..add( - _channel.on(EventType.memberUpdated).listen( - (Event e) { - final member = e.member!; - final existingMembers = channelState.members ?? []; - final existingMembership = channelState.membership; - - Member? maybeUpdateMember(Member? existingMember) { - if (existingMember == null) return null; - if (existingMember.userId == member.userId) return member; - return existingMember; - } - - updateChannelState( - channelState.copyWith( - membership: maybeUpdateMember(existingMembership), - members: [...existingMembers.map(maybeUpdateMember).nonNulls], - ), - ); - }, - ), - ); - } - - void _listenChannelUpdated() { - _subscriptions.add( - _channel.on(EventType.channelUpdated).listen((Event e) { - final channel = e.channel!; - updateChannelState( - channelState.copyWith( - channel: channelState.channel?.merge(channel), - members: channel.members, - ), - ); - }), - ); - } - - void _listenChannelMessageCount() { - _subscriptions.add( - _channel.on().listen( - (Event e) { - final messageCount = e.channelMessageCount; - if (messageCount == null) return; - - updateChannelState( - channelState.copyWith( - channel: channelState.channel?.copyWith( - messageCount: messageCount, - ), - ), - ); - }, - ), - ); - } - - void _listenChannelTruncated() { - _subscriptions.add( - _channel.on(EventType.channelTruncated, EventType.notificationChannelTruncated).listen((event) async { - final channel = event.channel!; - await _client.chatPersistenceClient?.deleteMessageByCid(channel.cid); - truncate(); - if (event.message != null) { - updateMessage(event.message!); - } - }), - ); - } - - void _listenMemberBanned() { - _subscriptions.add( - _channel - .on(EventType.userBanned) - .where((it) => it.cid != null) // filters channel ban from app ban - .listen( - (event) async { - final user = event.user!; - final member = await _channel - .queryMembers(filter: Filter.equal('id', user.id)) - .then((it) => it.members.first); - - _updateMember(member); - }, - ), - ); - } - - void _listenUserStartWatching() { - _subscriptions.add( - _channel.on(EventType.userWatchingStart).listen((event) { - final watcher = event.user; - if (watcher != null) { - final existingWatchers = channelState.watchers; - updateChannelState( - channelState.copyWith( - watchers: [ - watcher, - ...?existingWatchers?.where((user) => user.id != watcher.id), - ], - watcherCount: event.watcherCount, - ), - ); - } - }), - ); - } - - void _listenUserStopWatching() { - _subscriptions.add( - _channel.on(EventType.userWatchingStop).listen((event) { - final watcher = event.user; - if (watcher != null) { - final existingWatchers = channelState.watchers ?? const []; - _channelState = channelState.copyWith( - watchers: existingWatchers.where((user) => user.id != watcher.id).toList(), - watcherCount: event.watcherCount, - ); - } - }), - ); - } - - void _listenMemberUnbanned() { - _subscriptions.add( - _channel - .on(EventType.userUnbanned) - .where((it) => it.cid != null) // filters channel ban from app ban - .listen( - (event) async { - final user = event.user!; - final member = await _channel - .queryMembers(filter: Filter.equal('id', user.id)) - .then((it) => it.members.first); - - _updateMember(member); - }, - ), - ); - } - - void _updateMember(Member member) { - final currentMembers = [...members]; - final memberIndex = currentMembers.indexWhere( - (m) => m.userId == member.userId, - ); - - if (memberIndex == -1) return; - currentMembers[memberIndex] = member; - - updateChannelState( - channelState.copyWith( - members: currentMembers, - ), - ); - } - - /// Flag which indicates if [ChannelClientState] contain latest/recent messages or not. - /// - /// This flag should be managed by UI sdks. - /// - /// When false, any new message received by WebSocket event - /// [EventType.messageNew] will not be pushed on to message list. - bool get isUpToDate => _isUpToDateController.value; - - set isUpToDate(bool isUpToDate) => _isUpToDateController.safeAdd(isUpToDate); - - /// [isUpToDate] flag count as a stream. - Stream get isUpToDateStream => _isUpToDateController.stream; - final _isUpToDateController = BehaviorSubject.seeded(true); - - /// The retry queue associated to this channel. - late final RetryQueue _retryQueue; - - /// Retry failed message. - Future retryFailedMessages() async { - final allMessages = [...messages, ...threads.values.flattened]; - final failedMessages = allMessages.where((it) => it.state.isFailed); - - if (failedMessages.isEmpty) return; - _retryQueue.add(failedMessages); - } - - Message? _findPollMessage(String pollId) { - final message = messages.firstWhereOrNull((it) => it.pollId == pollId); - if (message != null) return message; - - final threadMessage = threads.values.flattened.firstWhereOrNull((it) { - return it.pollId == pollId; - }); - - return threadMessage; - } - - void _listenPollCreated() { - _subscriptions.add( - _channel.on(EventType.pollCreated).listen((event) { - final message = event.message; - if (message == null || message.poll == null) return; - - return addNewMessage(message); - }), - ); - } - - void _listenPollUpdated() { - _subscriptions.add( - _channel.on(EventType.pollUpdated).listen((event) { - final eventPoll = event.poll; - if (eventPoll == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final ownVotesAndAnswers = oldPoll?.ownVotesAndAnswers ?? eventPoll.ownVotesAndAnswers; - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollClosed() { - _subscriptions.add( - _channel.on(EventType.pollClosed).listen((event) { - final eventPoll = event.poll; - if (eventPoll == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - final poll = oldPoll?.copyWith(isClosed: true) ?? eventPoll; - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollAnswerCasted() { - _subscriptions.add( - _channel.on(EventType.pollAnswerCasted).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = { - for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, - eventPollVote.id!: eventPollVote, - }; - - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, - }; - - final poll = eventPoll.copyWith( - latestAnswers: [...latestAnswers.values], - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollVoteCasted() { - _subscriptions.add( - _channel.on(EventType.pollVoteCasted).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, - }; - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollAnswerRemoved() { - _subscriptions.add( - _channel.on(EventType.pollAnswerRemoved).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = { - for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, - }..remove(eventPollVote.id); - - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - }..remove(eventPollVote.id); - - final poll = eventPoll.copyWith( - latestAnswers: [...latestAnswers.values], - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollVoteRemoved() { - _subscriptions.add( - _channel.on(EventType.pollVoteRemoved).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - }..remove(eventPollVote.id); - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollVoteChanged() { - _subscriptions.add( - _channel.on(EventType.pollVoteChanged).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, - }; - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenDraftUpdated() { - _subscriptions.add( - _channel.on(EventType.draftUpdated).listen((event) { - final draft = event.draft; - if (draft == null) return; - - return updateDraft(draft); - }), - ); - } - - void _listenDraftDeleted() { - _subscriptions.add( - _channel.on(EventType.draftDeleted).listen((event) { - final draft = event.draft; - if (draft == null) return; - - return deleteDraft(draft); - }), - ); - } - - void _listenReminderCreated() { - _subscriptions.add( - _channel.on(EventType.reminderCreated).listen((event) { - final reminder = event.reminder; - if (reminder == null) return; - - updateReminder(reminder); - }), - ); - } - - void _listenReminderUpdated() { - _subscriptions.add( - _channel.on(EventType.reminderUpdated).listen((event) { - final reminder = event.reminder; - if (reminder == null) return; - - updateReminder(reminder); - }), - ); - } - - void _listenReminderDeleted() { - _subscriptions.add( - _channel.on(EventType.reminderDeleted).listen((event) { - final reminder = event.reminder; - if (reminder == null) return; - - deleteReminder(reminder); - }), - ); - } - - /// Updates the [reminder] of the message if it exists. - void updateReminder(MessageReminder reminder) { - final messageId = reminder.messageId; - // TODO: Improve once we have support for parentId in reminders. - for (final message in [...messages, ...threads.values.flattened]) { - if (message.id == messageId) { - return updateMessage( - message.copyWith(reminder: reminder), - ); - } - } - } - - /// Deletes the [reminder] of the message if it exists. - void deleteReminder(MessageReminder reminder) { - final messageId = reminder.messageId; - // TODO: Improve once we have support for parentId in reminders. - for (final message in [...messages, ...threads.values.flattened]) { - if (message.id == messageId) { - return updateMessage( - message.copyWith(reminder: null), - ); - } - } - } - - Message? _findLocationMessage(String id) { - final message = messages.firstWhereOrNull((it) { - return it.sharedLocation?.messageId == id; - }); - - if (message != null) return message; - - final threadMessage = threads.values.flattened.firstWhereOrNull((it) { - return it.sharedLocation?.messageId == id; - }); - - return threadMessage; - } - - void _listenLocationShared() { - _subscriptions.add( - _channel.on(EventType.locationShared).listen((event) { - final message = event.message; - if (message == null || message.sharedLocation == null) return; - - return addNewMessage(message); - }), - ); - } - - void _listenLocationUpdated() { - _subscriptions.add( - _channel.on(EventType.locationUpdated).listen((event) { - final location = event.message?.sharedLocation; - if (location == null) return; - - final messageId = location.messageId; - if (messageId == null) return; - - final oldMessage = _findLocationMessage(messageId); - if (oldMessage == null) return; - - final updatedMessage = oldMessage.copyWith(sharedLocation: location); - return updateMessage(updatedMessage); - }), - ); - } - - void _listenLocationExpired() { - _subscriptions.add( - _channel.on(EventType.locationExpired).listen((event) { - final location = event.message?.sharedLocation; - if (location == null) return; - - final messageId = location.messageId; - if (messageId == null) return; - - final oldMessage = _findLocationMessage(messageId); - if (oldMessage == null) return; - - final updatedMessage = oldMessage.copyWith(sharedLocation: location); - return updateMessage(updatedMessage); - }), - ); - } - - void _listenReactionDeleted() { - _subscriptions.add( - _channel.on(EventType.reactionDeleted).listen((event) { - final (eventReaction, eventMessage) = (event.reaction, event.message); - if (eventReaction == null || eventMessage == null) return; - - final messageId = eventMessage.id; - final parentId = eventMessage.parentId; - - for (final message in [...messages, ...?threads[parentId]]) { - if (message.id == messageId) { - final currentUserId = _channel.client.state.currentUser?.id; - - final currentMessage = switch (currentUserId) { - final userId? when userId == eventReaction.userId => message.deleteMyReaction( - reactionType: eventReaction.type, - ), - _ => message, - }; - - return updateMessage( - eventMessage.copyWith( - ownReactions: currentMessage.ownReactions, - ), - ); - } - } - }), - ); - } - - void _listenReactionNew() { - _subscriptions.add( - _channel.on(EventType.reactionNew).listen((event) { - final (eventReaction, eventMessage) = (event.reaction, event.message); - if (eventReaction == null || eventMessage == null) return; - - final messageId = eventMessage.id; - final parentId = eventMessage.parentId; - - for (final message in [...messages, ...?threads[parentId]]) { - if (message.id == messageId) { - final currentUserId = _channel.client.state.currentUser?.id; - - final currentMessage = switch (currentUserId) { - final userId? when userId == eventReaction.userId => message.addMyReaction(eventReaction), - _ => message, - }; - - return updateMessage( - eventMessage.copyWith( - ownReactions: currentMessage.ownReactions, - ), - ); - } - } - }), - ); - } - - void _listenReactionUpdated() { - _subscriptions.add( - _channel.on(EventType.reactionUpdated).listen((event) { - final (eventReaction, eventMessage) = (event.reaction, event.message); - if (eventReaction == null || eventMessage == null) return; - - final messageId = eventMessage.id; - final parentId = eventMessage.parentId; - - for (final message in [...messages, ...?threads[parentId]]) { - if (message.id == messageId) { - final currentUserId = _channel.client.state.currentUser?.id; - - final currentMessage = switch (currentUserId) { - final userId? when userId == eventReaction.userId => - // reaction.updated is only called if enforce_unique is true - message.addMyReaction(eventReaction, enforceUnique: true), - _ => message, - }; - - return updateMessage( - eventMessage.copyWith( - ownReactions: currentMessage.ownReactions, - ), - ); - } - } - }), - ); - } - - void _listenMessageUpdated() { - _subscriptions.add( - _channel.on(EventType.messageUpdated).listen((event) { - final message = event.message; - if (message == null) return; - - return updateMessage(message, upsert: false); - }), - ); - } - - void _listenMessageDeleted() { - _subscriptions.add( - _channel.on(EventType.messageDeleted).listen((event) { - final hardDelete = event.hardDelete ?? false; - - final message = event.message!.copyWith( - // TODO: Remove once deletedForMe is properly enriched on the backend. - 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); - }), - ); - } - - void _listenMessageNew() { - _subscriptions.add( - _channel - .on( - EventType.messageNew, - EventType.notificationMessageNew, - ) - .listen((event) { - final message = event.message; - if (message == null) return; - - addNewMessage(message); - - // Only message.new carries a reliable watcher count; - // notification.message_new targets non-watchers and reports 0. - if (event.watcherCount case final watcherCount? when event.type == EventType.messageNew) { - updateChannelState( - channelState.copyWith(watcherCount: watcherCount), - ); - } - }), - ); - } - - /// Adds a new message to the channel state and updates the unread count. - void addNewMessage(Message message) { - final isThreadMessage = message.parentId != null; - final isNotShownInChannel = message.showInChannel != true; - final isThreadOnlyMessage = isThreadMessage && isNotShownInChannel; - - // Only add the message if the channel is upToDate or if the message is - // a thread-only message. - if (isUpToDate || isThreadOnlyMessage) updateMessage(message); - - // Otherwise, check if we can count the message as unread. - if (MessageRules.canCountAsUnread(message, _channel)) { - unreadCount += 1; // Increment unread count - } - - _client.channelDeliveryReporter.submitForDelivery([_channel]); - } - - /// Updates the [read] in the state if it exists. Adds it otherwise. - void updateRead([Iterable? read]) { - final existingReads = channelState.read ?? const []; - final updatedReads = existingReads.merge( - read, - key: (read) => read.user.id, - ); - - updateChannelState( - channelState.copyWith( - read: updatedReads.toList(), - ), - ); - } - - /// Updates the [draft] in the channel state or the message if it exists. - void updateDraft(Draft draft) { - if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage(message.copyWith(draft: draft)); - } - } - } - - updateChannelState( - channelState.copyWith( - draft: draft, - ), - ); - } - - /// Deletes the [draft] from the state if it exists. - void deleteDraft(Draft draft) async { - // Delete the draft from the persistence client. - await _client.chatPersistenceClient?.deleteDraftMessageByCid( - draft.channelCid, - parentId: draft.parentId, - ); - - if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage( - message.copyWith(draft: null), - ); - } - } - } - - updateChannelState( - channelState.copyWith( - draft: null, - ), - ); - } - - /// Updates the [message] in the state. - /// - /// Reconciles via `Message.updateWith`, so locally-known enrichment - /// (poll, sharedLocation, ownReactions, nested quotedMessage) is - /// preserved when [message] omits those fields. Use [replaceMessage] - /// for paths that need a strict overwrite. - /// - /// When [upsert] is `true` (the default) and [message] isn't already in - /// the state, it's added. When `false`, an unknown [message] is skipped - /// and the state is left unchanged; only a message already loaded in the - /// state is updated. - void updateMessage(Message message, {bool upsert = true}) => _updateMessages([message], upsert: upsert); - - /// Replaces the [message] in the state if it exists, no-op otherwise. - /// - /// Unlike [updateMessage], this does **not** merge with the existing - /// state — [message] is used as-is. Useful for local rollbacks of an - /// optimistic update, where the caller has the full prior snapshot and - /// doesn't want the merge falling back to the optimistic values. - void replaceMessage(Message message) => _updateMessages([message], update: _replaceUpdate); - - // Default `update` for [_updateMessages]: merge incoming with the - // locally-known message via `Message.updateWith`, preserving enrichment - // the server may strip on partial payloads. - static Message _mergeUpdate(Message original, Message updated) => original.updateWith(updated); - - // Replace `update` for [_updateMessages]: take the incoming as-is. Used - // by local rollback paths. - static Message _replaceUpdate(Message _, Message updated) => updated; - - /// Cleans up all the stale error messages which requires no action. - void cleanUpStaleErrorMessages() { - final errorMessages = messages.where((message) { - return message.isError && !message.isBounced; - }); - - if (errorMessages.isEmpty) return; - return _removeMessages(errorMessages); - } - - /// Remove a [message] from this [channelState]. - void removeMessage(Message message) => _removeMessages([message]); - - /// Removes/Updates the [message] based on the [hardDelete] value. - void deleteMessage(Message message, {bool hardDelete = false}) { - return _deleteMessages([message], hardDelete: hardDelete); - } - - void _listenReadEvents() { - _subscriptions - ..add( - _channel.on(EventType.messageRead, EventType.notificationMarkRead).listen( - (event) { - // Skip handling the event if delivered for a thread - if (event.thread != null) return; - - final user = event.user; - if (user == null) return; - - final currentRead = userReadOf(userId: user.id); - - final updatedRead = Read( - user: user, - lastRead: event.createdAt, - unreadMessages: 0, // Reset unread count - lastReadMessageId: event.lastReadMessageId, - // Preserve delivery info as it's not part of the read event. - lastDeliveredAt: currentRead?.lastDeliveredAt, - lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, - ); - - updateRead([updatedRead]); - - // If the read event is from the current user, reconcile the - // channel delivery status with the updated read state. - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) { - _client.channelDeliveryReporter.reconcileDelivery([_channel]); - } - }, - ), - ) - ..add( - _channel.on(EventType.notificationMarkUnread).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentRead = userReadOf(userId: user.id); - - final updatedRead = Read( - user: user, - lastRead: event.lastReadAt!, - unreadMessages: event.unreadMessages, - lastReadMessageId: event.lastReadMessageId, - // Preserve delivery info as it's not part of the read event. - lastDeliveredAt: currentRead?.lastDeliveredAt, - lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, - ); - - return updateRead([updatedRead]); - }, - ), - ) - ..add( - _channel.on(EventType.messageDelivered).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentRead = userReadOf(userId: user.id); - final never = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - final updatedRead = Read( - user: user, - lastDeliveredAt: event.lastDeliveredAt, - lastDeliveredMessageId: event.lastDeliveredMessageId, - // Preserve read info as it's not part of the delivery event. - lastRead: currentRead?.lastRead ?? never, - unreadMessages: currentRead?.unreadMessages, - lastReadMessageId: currentRead?.lastReadMessageId, - ); - - updateRead([updatedRead]); - - // If the delivered event is from the current user, reconcile - // the channel delivery with the updated read state. - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) { - _client.channelDeliveryReporter.reconcileDelivery([_channel]); - } - }, - ), - ); - } - - /// Channel message list. - List get messages => _channelState.messages ?? []; - - /// Channel message list as a stream. - Stream> get messagesStream => - channelStateStream.map((cs) => cs.messages ?? []).distinct(const ListEquality().equals); - - /// Channel pinned message list. - List get pinnedMessages => _channelState.pinnedMessages ?? []; - - /// Channel pinned message list as a stream. - Stream> get pinnedMessagesStream => - channelStateStream.map((cs) => cs.pinnedMessages ?? []).distinct(const ListEquality().equals); - - /// Channel pending message list. - List get pendingMessages => _channelState.pendingMessages ?? []; - - /// Channel pending message list as a stream. - Stream> get pendingMessagesStream => - channelStateStream.map((cs) => cs.pendingMessages ?? []).distinct(const ListEquality().equals); - - /// Get channel last message. - Message? get lastMessage => messages.lastOrNull; - - /// Get channel last message as a stream. - Stream get lastMessageStream { - return messagesStream.map((messages) => messages.lastOrNull); - } - - /// Channel members list. - List get members => - (_channelState.members ?? []).map((e) => e.copyWith(user: _client.state.users[e.user!.id])).toList(); - - /// Channel members list as a stream. - Stream> get membersStream => - CombineLatestStream.combine2?, Map, List>( - channelStateStream.map((cs) => cs.members), - _client.state.usersStream, - (members, users) => [...?members?.map((e) => e!.copyWith(user: users[e.user!.id]))], - ).distinct(const ListEquality().equals); - - /// Channel watcher count. - int? get watcherCount => _channelState.watcherCount; - - /// Channel watcher count as a stream. - Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount); - - /// Channel watchers list. - List get watchers => (_channelState.watchers ?? []).map((e) => _client.state.users[e.id] ?? e).toList(); - - /// Channel watchers list as a stream. - Stream> get watchersStream => CombineLatestStream.combine2?, Map, List>( - channelStateStream.map((cs) => cs.watchers), - _client.state.usersStream, - (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], - ).distinct(const ListEquality().equals); - - /// Channel active live locations. - List get activeLiveLocations { - return _channelState.activeLiveLocations ?? []; - } - - /// Channel active live locations as a stream. - Stream> get activeLiveLocationsStream => - channelStateStream.map((cs) => cs.activeLiveLocations ?? []).distinct(const ListEquality().equals); - - /// Channel draft. - Draft? get draft => _channelState.draft; - - /// Channel draft as a stream. - Stream get draftStream { - return channelStateStream.map((cs) => cs.draft).distinct(); - } - - /// Channel member for the current user. - Member? get currentUserMember => members.firstWhereOrNull( - (m) => m.user?.id == _client.state.currentUser?.id, - ); - - /// Channel role for the current user - String? get currentUserChannelRole => currentUserMember?.channelRole; - - /// Channel read list. - List get read => _channelState.read ?? []; - - /// Channel read list as a stream. - Stream> get readStream => - channelStateStream.map((cs) => cs.read ?? []).distinct(const ListEquality().equals); - - /// Channel read for the logged in user. - Read? get currentUserRead { - final currentUser = _client.state.currentUser; - return userReadOf(userId: currentUser?.id); - } - - /// Channel read for the logged in user as a stream. - /// - /// Re-subscribes only when the user id actually changes; null still - /// propagates downstream so consumers see the logged-out transition. - Stream get currentUserReadStream { - final currentUserId = _client.state.currentUserStream.map((it) => it?.id).distinct(); - return currentUserId.switchMap((id) => userReadStreamOf(userId: id)).distinct(); - } - - /// Unread count getter as a stream. - Stream get unreadCountStream => currentUserReadStream.map((read) => read?.unreadMessages ?? 0).distinct(); - - /// Unread count getter. - int get unreadCount => currentUserRead?.unreadMessages ?? 0; - - /// Setter for unread count. - set unreadCount(int count) { - final currentUser = _client.state.currentUser; - if (currentUser == null) return; - - var existingUserRead = currentUserRead; - if (existingUserRead == null) { - final lastMessageAt = _channelState.channel?.lastMessageAt; - existingUserRead = Read( - user: currentUser, - lastRead: lastMessageAt ?? DateTime.now(), - ); - } - - 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. - /// - /// [messageId] only sets the resulting [Read.lastReadMessageId]; it does not - /// narrow which messages stay unread. The count always drops to zero and - /// [Read.lastRead] is always `now`, so messages newer than [messageId] are - /// marked read as well. This differs from the server, which recomputes the - /// count as the number of messages after [messageId], and from - /// [markUnreadLocally], which does recompute from the locally-known - /// messages. Callers that need a partial boundary should use - /// [markUnreadLocally] instead. - 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, - ), - ]); - - // Read supersedes delivered, so drop any pending delivery candidate the - // new read boundary just made ineligible. `delivery_events` is configured - // independently of `read_events`, so a channel tracking unread counts - // locally can still have delivery receipts enabled. Mirrors what the - // `message.read` event listener does for server-driven channels. - _client.channelDeliveryReporter.reconcileDelivery([_channel]); - } - - /// 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 - /// any API call. Therefore, the count might be not reliable as it relies on - /// the local data. - int countUnreadMentions() { - final currentUserId = _client.state.currentUser?.id; - - var count = 0; - for (final message in messages) { - if (!MessageRules.canCountAsUnread(message, _channel)) continue; - if (!message.mentionedUsers.any((it) => it.id == currentUserId)) continue; - - count++; - } - - return count; - } - - /// Delete all channel messages. - void truncate() { - _channelState = _channelState.copyWith( - messages: [], - ); - } - - /// Drops the oldest messages, keeping at most [maxMessages]. - /// - /// No-op when [maxMessages] is non-positive, when the current count is - /// already within the limit, or when [isUpToDate] is `false`. - /// - /// Prefer `StreamChannel.pruneOldest` when a [StreamChannel] is present: - /// it also resets the widget-layer "top reached" marker so top-pagination - /// can resume. Calling this directly leaves that marker untouched. - void pruneOldest(int maxMessages) { - if (maxMessages <= 0) return; - if (!isUpToDate) return; - - final current = messages; - if (current.length <= maxMessages) return; - - final pruned = current.sublist(current.length - maxMessages); - _channelState = _channelState.copyWith(messages: pruned); - } - - /// Update channelState with updated information. - void updateChannelState(ChannelState updatedState) { - final newMessages = messages.mergeSorted( - updatedState.messages, - key: (message) => message.id, - update: _mergeUpdate, - compare: _sortByCreatedAt, - ); - - final watchers = _channelState.watchers ?? const []; - final newWatchers = watchers.merge( - updatedState.watchers, - key: (watcher) => watcher.id, - ); - - final reads = _channelState.read ?? const []; - final newReads = reads.merge( - updatedState.read, - key: (read) => read.user.id, - ); - - _channelState = _channelState.copyWith( - messages: newMessages, - channel: _channelState.channel?.merge(updatedState.channel), - watchers: newWatchers.toList(), - watcherCount: updatedState.watcherCount, - members: updatedState.members, - membership: updatedState.membership, - read: newReads.toList(), - draft: updatedState.draft, - pinnedMessages: updatedState.pinnedMessages, - pendingMessages: updatedState.pendingMessages, - pushPreferences: updatedState.pushPreferences, - activeLiveLocations: updatedState.activeLiveLocations, - ); - } - - /// 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. - ChannelState get _channelState => _channelStateController.value; - - /// The channel state related to this client as a stream. - Stream get channelStateStream => _channelStateController.stream; - - /// The channel state related to this client. - ChannelState get channelState => _channelStateController.value; - late BehaviorSubject _channelStateController; - - late final _debouncedUpdatePersistenceChannelState = debounce( - (ChannelState state) { - final persistenceClient = _client.chatPersistenceClient; - return persistenceClient?.updateChannelState(state); - }, - const Duration(seconds: 1), - ); - - set _channelState(ChannelState v) { - _channelStateController.safeAdd(v); - _debouncedUpdatePersistenceChannelState.call([v]); - } - - late final _debouncedUpdatePersistenceChannelThreads = debounce( - (Map> threads) async { - final channelCid = _channel.cid; - if (channelCid == null) return; - - final persistenceClient = _client.chatPersistenceClient; - return persistenceClient?.updateChannelThreads(channelCid, threads); - }, - const Duration(seconds: 1), - ); - - /// The channel threads related to this channel. - Map> get threads => {..._threadsController.value}; - - /// The channel threads related to this channel as a stream. - Stream>> get threadsStream => _threadsController; - final _threadsController = BehaviorSubject.seeded(>{}); - set _threads(Map> threads) { - _threadsController.safeAdd(threads); - _debouncedUpdatePersistenceChannelThreads.call([threads]); - } - - /// Clears all the replies in the thread identified by [parentId]. - void clearThread(String parentId) { - final updatedThreads = { - ...threads, - parentId: [], - }; - - _threads = updatedThreads; - } - - /// Update threads with updated information about messages. - void updateThreadInfo(String parentId, List messages) { - final updatedThreads = {...threads}; - - final threadMessages = updatedThreads[parentId] ?? []; - final updatedThreadMessages = _mergeMessagesIntoExisting( - existing: threadMessages, - toMerge: messages.where((it) => it.id != parentId), - ); - - // Update the thread with the modified message list. - updatedThreads[parentId] = updatedThreadMessages.toList(); - - _threads = updatedThreads; - } - - Draft? _getThreadDraft(String parentId, List? messages) { - return messages?.firstWhereOrNull((it) => it.id == parentId)?.draft; - } - - /// Draft for a specific thread identified by [parentId]. - Draft? threadDraft(String parentId) => _getThreadDraft(parentId, messages); - - /// Stream of draft for a specific thread identified by [parentId]. - /// - /// This stream emits a new value whenever the draft associated with the - /// specified thread is updated or removed. - Stream threadDraftStream(String parentId) => - channelStateStream.map((cs) => _getThreadDraft(parentId, cs.messages)).distinct(); - - /// Channel related typing users stream. - Stream> get typingEventsStream => _typingEventsController.stream; - - /// Channel related typing users last value. - Map get typingEvents => _typingEventsController.value; - final _typingEventsController = BehaviorSubject.seeded({}); - - void _listenTypingEvents() { - _subscriptions - ..add( - _channel.on(EventType.typingStart).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) return; - - final events = {...typingEvents, user: event}; - _typingEventsController.safeAdd(events); - }, - ), - ) - ..add( - _channel.on(EventType.typingStop).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) return; - - final events = {...typingEvents}..remove(user); - _typingEventsController.safeAdd(events); - }, - ), - ); - } - - Timer? _staleTypingEventsCleanerTimer; - - // Checks and removes stale typing events that were not explicitly stopped by - // the sender due to technical difficulties. e.g. process death, loss of - // Internet connection or custom implementation. - void _startCleaningStaleTypingEvents() { - _staleTypingEventsCleanerTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - final now = DateTime.now(); - typingEvents.forEach((user, event) { - if (now.difference(event.createdAt).inSeconds > incomingTypingStartEventTimeout) { - _client.handleEvent( - Event( - type: EventType.typingStop, - user: user, - cid: _channel.cid, - parentId: event.parentId, - ), - ); - } - }); - }, - ); - } - - Timer? _stalePinnedMessagesCleanerTimer; - - // Checks and removes stale pinned messages that are not valid anymore. - void _startCleaningStalePinnedMessages() { - _stalePinnedMessagesCleanerTimer = Timer.periodic( - const Duration(seconds: 30), - (_) { - final now = DateTime.now(); - var expiredMessages = channelState.pinnedMessages?.where((m) => m.pinExpires?.isBefore(now) == true).toList(); - if (expiredMessages != null && expiredMessages.isNotEmpty) { - expiredMessages = expiredMessages - .map( - (m) => m.copyWith( - pinExpires: null, - pinned: false, - ), - ) - .toList(); - - updateChannelState( - _channelState.copyWith( - pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), - messages: expiredMessages, - ), - ); - } - }, - ); - } - - Timer? _staleLiveLocationsCleanerTimer; - void _startCleaningExpiredLocations() { - _staleLiveLocationsCleanerTimer?.cancel(); - _staleLiveLocationsCleanerTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - final currentUserId = _channel._client.state.currentUser?.id; - if (currentUserId == null) return; - - final expired = activeLiveLocations.where((it) => it.isExpired); - if (expired.isEmpty) return; - - for (final sharedLocation in expired) { - // Skip if the location is shared by the current user, - // as we are already handling them in the client. - if (sharedLocation.userId == currentUserId) continue; - - final lastUpdatedAt = DateTime.timestamp(); - final locationExpiredEvent = Event( - type: EventType.locationExpired, - cid: sharedLocation.channelCid, - message: Message( - id: sharedLocation.messageId, - updatedAt: lastUpdatedAt, - sharedLocation: sharedLocation.copyWith( - updatedAt: lastUpdatedAt, - ), - ), - ); - - _channel._client.handleEvent(locationExpiredEvent); - } - }, - ); - } - - // Listens to channel push preference update events and updates the state - void _listenChannelPushPreferenceUpdated() { - _subscriptions.add( - _channel.on(EventType.channelPushPreferenceUpdated).listen( - (event) { - final pushPreferences = event.channelPushPreference; - if (pushPreferences == null) return; - - updateChannelState( - channelState.copyWith( - pushPreferences: pushPreferences, - ), - ); - }, - ), - ); - } - - Future _deleteMessagesFromUser({ - required String userId, - bool hardDelete = false, - DateTime? deletedAt, - }) async { - // Delete messages from persistence. - // - // Note: We perform this operation separately even though [_removeMessages] - // already handles it as we need to delete all messages from the user, not - // only the ones present in the current state. - final persistence = _channel.client.chatPersistenceClient; - await persistence?.deleteMessagesFromUser( - userId: userId, - cid: _channel.cid, - hardDelete: hardDelete, - deletedAt: deletedAt, - ); - - // Gather messages to delete from state. - final userMessages = {}; - for (final message in [...messages, ...threads.values.flattened]) { - if (message.user?.id != userId) continue; - userMessages[message.id] = message.copyWith( - type: MessageType.deleted, - deletedAt: deletedAt ?? DateTime.now(), - state: switch (hardDelete) { - true => MessageState.hardDeleted, - false => MessageState.softDeleted, - }, - ); - } - - final messagesToDelete = userMessages.values; - return _deleteMessages(messagesToDelete, hardDelete: hardDelete); - } - - void _deleteMessages( - Iterable messages, { - bool hardDelete = false, - }) { - if (messages.isEmpty) return; - - if (hardDelete) return _removeMessages(messages); - return _updateMessages(messages, upsert: false); - } - - void _updateMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (messages.isEmpty) return; - - _updateThreadMessages(messages, update: update, upsert: upsert); - _updateChannelMessages(messages, update: update, upsert: upsert); - _updatePinnedMessages(messages, update: update); - _updateActiveLiveLocations(messages); - } - - void _updateThreadMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (messages.isEmpty) return; - - // Group messages by parentId so each thread merge only sees its own - // replies — passing the full batch to every thread would leak replies - // across thread boundaries (the merge dedups by id, not by parentId). - final messagesByThread = >{}; - for (final m in messages) { - if (m.parentId case final parentId?) (messagesByThread[parentId] ??= []).add(m); - } - - // If there are no affected threads, return early. - if (messagesByThread.isEmpty) return; - - final updatedThreads = {...threads}; - for (final MapEntry(key: thread, :value) in messagesByThread.entries) { - final existingThreadMessages = updatedThreads[thread]; - - // Don't create a phantom entry for a thread that wasn't loaded: with - // `upsert: false` an out-of-window reply is dropped, so there's nothing - // to merge. Writing it back would make `threads.containsKey(parentId)` - // report a thread that was never paged in. - if (existingThreadMessages == null && !upsert) continue; - - final threadMessages = existingThreadMessages ?? []; - final updatedThreadMessages = _mergeMessagesIntoExisting( - existing: threadMessages, - toMerge: value, - update: update, - upsert: upsert, - ); - - // Update the thread with the modified message list. - updatedThreads[thread] = updatedThreadMessages.toList(); - } - - // Update the threads map. - _threads = updatedThreads; - } - - void _updateChannelMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (messages.isEmpty) return; - - final affectedMessages = messages.map((it) { - // If it's not a thread message, consider it affected. - if (it.parentId == null) return it; - // If it's a thread message shown in channel, consider it affected. - if (it.showInChannel == true) return it; - - return null; // Thread message not shown in channel, ignore it. - }).nonNulls; - - // If there are no affected messages, return early. - if (affectedMessages.isEmpty) return; - - final channelMessages = [...this.messages]; - final updatedChannelMessages = _mergeMessagesIntoExisting( - existing: channelMessages, - toMerge: affectedMessages, - update: update, - upsert: upsert, - ); - - // Calculate the new last message at time. - var lastMessageAt = _channelState.channel?.lastMessageAt; - for (final message in affectedMessages) { - if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) { - lastMessageAt = [lastMessageAt, message.createdAt].nonNulls.max; - } - } - - _channelState = _channelState.copyWith( - messages: updatedChannelMessages.toList(), - channel: _channelState.channel?.copyWith(lastMessageAt: lastMessageAt), - ); - } - - void _updatePinnedMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - }) { - if (messages.isEmpty) return; - - // No-op fast path: nothing was pinned, and nothing in the batch is - // becoming pinned — skip the merge/copyWith churn that would otherwise - // land right back on an empty `pinnedMessages` list. - if (pinnedMessages.isEmpty && messages.every((m) => !m.pinned)) return; - - final updatedPinnedMessages = _mergePinnedMessagesIntoExisting( - existing: pinnedMessages, - toMerge: messages, - update: update, - ); - - _channelState = _channelState.copyWith( - pinnedMessages: updatedPinnedMessages.toList(), - ); - } - - void _updateActiveLiveLocations(Iterable messages) { - if (messages.isEmpty) return; - - final activeLiveLocations = [...this.activeLiveLocations]; - final updatedActiveLiveLocations = _mergeActiveLocationsIntoExisting( - existing: activeLiveLocations, - toMerge: messages, - ); - - _channelState = _channelState.copyWith( - activeLiveLocations: updatedActiveLiveLocations.toList(), - ); - } - - Iterable _mergeActiveLocationsIntoExisting({ - required Iterable existing, - required Iterable toMerge, - }) { - if (toMerge.isEmpty) return existing; - - final mergedLocations = existing.mergeFrom( - toMerge, - key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), - value: (message) => message.sharedLocation, - update: (original, updated) => updated, - ); - - final toUpdateMap = {for (final m in toMerge) m.id: m}; - final updatedLocations = mergedLocations.where((it) { - // Remove the location if it's expired. - if (it.isExpired) return false; - - final updatedMessage = toUpdateMap[it.messageId]; - // Remove the location if the attached message is deleted. - if (updatedMessage?.isDeleted == true) return false; - - return true; - }); - - return updatedLocations; - } - - Iterable _mergePinnedMessagesIntoExisting({ - required Iterable existing, - required Iterable toMerge, - Message Function(Message original, Message updated) update = _mergeUpdate, - }) { - return _mergeMessagesIntoExisting( - existing: existing, - toMerge: toMerge, - update: update, - ).where(_pinIsValid); - } - - Iterable _mergeMessagesIntoExisting({ - required Iterable existing, - required Iterable toMerge, - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (toMerge.isEmpty) return existing; - - // [update] decides whether each pair is reconciled (default — see - // `_mergeUpdate`) or replaced (`_replaceUpdate`, used by local rollback - // paths that don't want enrichment fallback to keep optimistic values). - // - // [upsert] controls whether ids not already in [existing] are inserted. - // Event-driven paths (`message.updated`, `message.deleted` soft) pass - // `upsert: false` so an out-of-window message isn't dropped into a gap - // between the loaded slice and history the client hasn't paged in yet. - final existingList = existing is List ? existing : existing.toList(); - var toMergeList = toMerge is List ? toMerge : toMerge.toList(); - - // Single-message fast path. The hot ingest path (server echoes, edits, - // reactions, read receipts) always lands here, and `lastIndexWhere` + - // `sortedUpsertAt` skips the O(N) keymap build that the two-pointer - // merge would otherwise do up front. - if (toMergeList.length == 1) { - final message = toMergeList.first; - final oldIndex = existingList.lastIndexWhere((it) => it.id == message.id); - - // upsert: false — skip update if message is not loaded - if (oldIndex == -1 && !upsert) return existingList; - - final resolved = oldIndex == -1 ? message : update(existingList[oldIndex], message); - - final mergedMessages = existingList.sortedUpsertAt( - oldIndex, - resolved, - update: update, - compare: _sortByCreatedAt, - ); - - // Non-delete updates can't change what embedded quotedMessage copies - // should display, so we can skip the rewrite entirely. - if (!resolved.isDeleted) return mergedMessages; - - return mergedMessages.updateIf( - (it) => it.quotedMessageId == resolved.id, - (it) => it.copyWith(quotedMessage: resolved), - ); - } - - // upsert: false - skip messages not loaded in the window - if (!upsert) { - final existingIds = {for (final m in existingList) m.id}; - toMergeList = toMergeList.where((m) => existingIds.contains(m.id)).toList(); - if (toMergeList.isEmpty) return existingList; - } - - // Batch path: receiver (`existingList`) is maintained sorted as a - // state invariant; `mergeSorted` sorts `toMergeList` internally and - // returns a sorted result. - final mergedMessages = existingList.mergeSorted( - toMergeList, - key: (message) => message.id, - update: update, - compare: _sortByCreatedAt, - ); - - // Refresh embedded `quotedMessage` refs only for messages quoting an - // incoming message that is now deleted. `updateIf` returns the same - // list reference when nothing matches, so steady-state allocates - // nothing for this step. - final deletedIds = toMergeList.where((m) => m.isDeleted).map((m) => m.id).toSet(); - if (deletedIds.isEmpty) return mergedMessages; - - final mergedById = {for (final m in mergedMessages) m.id: m}; - return mergedMessages.updateIf( - (it) => deletedIds.contains(it.quotedMessageId), - (it) => it.copyWith(quotedMessage: mergedById[it.quotedMessageId]), - ); - } - - void _removeMessages(Iterable messages) { - if (messages.isEmpty) return; - - final messageIds = messages.map((m) => m.id).toSet().toList(); - final persistenceClient = _channel.client.chatPersistenceClient; - // Remove the messages from the persistence client. - persistenceClient?.deleteMessageByIds(messageIds); - persistenceClient?.deletePinnedMessageByIds(messageIds); - - _removeThreadMessages(messages); - _removeChannelMessages(messages); - _removePinnedMessages(messages); - _removeActiveLiveLocations(messages); - } - - void _removeThreadMessages(Iterable messages) { - if (messages.isEmpty) return; - - final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; - // If there are no affected threads, return early. - if (affectedThreads.isEmpty) return; - - final updatedThreads = {...threads}; - for (final thread in affectedThreads) { - final threadMessages = updatedThreads[thread]; - // Continue if the thread doesn't exist. - if (threadMessages == null) continue; - - // Remove the deleted message from the thread messages and reference from - // other messages quoting it. - final updatedThreadMessages = _removeMessagesFromExisting( - existing: threadMessages, - toRemove: messages, - ); - - // If there are no more messages in the thread, remove the thread entry. - if (updatedThreadMessages.isEmpty) { - updatedThreads.remove(thread); - continue; - } - - // Otherwise, update the thread with the modified message list. - updatedThreads[thread] = updatedThreadMessages.toList(); - } - - // Update the threads map. - _threads = updatedThreads; - } - - void _removeChannelMessages(Iterable messages) { - if (messages.isEmpty) return; - - final affectedMessages = messages.map((it) { - // If it's not a thread message, consider it affected. - if (it.parentId == null) return it; - // If it's a thread message shown in channel, consider it affected. - if (it.showInChannel == true) return it; - - return null; // Thread message not shown in channel, ignore it. - }).nonNulls; - - // If there are no affected messages, return early. - if (affectedMessages.isEmpty) return; - - final channelMessages = [...this.messages]; - final updatedChannelMessages = _removeMessagesFromExisting( - existing: channelMessages, - toRemove: affectedMessages, - ); - - _channelState = _channelState.copyWith( - messages: updatedChannelMessages.toList(), - ); - } - - void _removePinnedMessages(Iterable messages) { - if (messages.isEmpty) return; - - final pinnedMessages = [...this.pinnedMessages]; - final updatedPinnedMessages = _removePinnedMessagesFromExisting( - existing: pinnedMessages, - toRemove: messages, - ); - - _channelState = _channelState.copyWith( - pinnedMessages: updatedPinnedMessages.toList(), - ); - } - - void _removeActiveLiveLocations(Iterable messages) { - if (messages.isEmpty) return; - - final activeLiveLocations = [...this.activeLiveLocations]; - final updatedActiveLiveLocations = _removeActiveLocationsFromExisting( - existing: activeLiveLocations, - toRemove: messages, - ); - - _channelState = _channelState.copyWith( - activeLiveLocations: updatedActiveLiveLocations.toList(), - ); - } - - Iterable _removeActiveLocationsFromExisting({ - required Iterable existing, - required Iterable toRemove, - }) { - if (toRemove.isEmpty) return existing; - - final toRemoveIds = toRemove.map((m) => m.id).toSet(); - final updatedLocations = existing.where( - // Remove the location if its attached message is in the toRemove list. - (it) => !toRemoveIds.contains(it.messageId), - ); - - return updatedLocations; - } - - Iterable _removePinnedMessagesFromExisting({ - required Iterable existing, - required Iterable toRemove, - }) { - return _removeMessagesFromExisting( - existing: existing, - toRemove: toRemove, - ).where(_pinIsValid); - } - - Iterable _removeMessagesFromExisting({ - required Iterable existing, - required Iterable toRemove, - }) { - if (toRemove.isEmpty) return existing; - - final toRemoveIds = toRemove.map((m) => m.id).toSet(); - final updatedMessages = existing - .where((it) { - // Remove the message if it's in the toRemove list. - return !toRemoveIds.contains(it.id); - }) - .map((it) { - // Continue if the message doesn't quote any of the deleted messages. - if (!toRemoveIds.contains(it.quotedMessageId)) return it; - - // Setting it to null will remove the quoted message from the message. - return it.copyWith(quotedMessageId: null, quotedMessage: null); - }); - - return updatedMessages; - } - - // Listens to user message deleted events and marks messages from that user - // as either soft or hard deleted based on the event data. - void _listenUserMessagesDeleted() { - _subscriptions.add( - _channel.on(EventType.userMessagesDeleted).listen((event) async { - final user = event.user; - if (user == null) return; - - return _deleteMessagesFromUser( - userId: user.id, - hardDelete: event.hardDelete ?? false, - deletedAt: event.createdAt, - ); - }), - ); - } - - /// Call this method to dispose this object. - void dispose() { - _debouncedUpdatePersistenceChannelThreads.cancel(); - _debouncedUpdatePersistenceChannelState.cancel(); - _retryQueue.dispose(); - _subscriptions.cancel(); - _channelStateController.close(); - _isUpToDateController.close(); - _threadsController.close(); - _staleTypingEventsCleanerTimer?.cancel(); - _stalePinnedMessagesCleanerTimer?.cancel(); - _staleLiveLocationsCleanerTimer?.cancel(); - _typingEventsController.close(); - } -} - -bool _pinIsValid(Message message) { - // If the message is deleted, the pin is not valid. - if (message.isDeleted) return false; - - // If the message is not pinned, it's not valid. - if (message.pinned != true) return false; - - // If there's no expiration, the pin is valid. - final pinExpires = message.pinExpires; - if (pinExpires == null) return true; - - // If there's an expiration, check if it's still valid. - return pinExpires.isAfter(DateTime.now()); -} diff --git a/packages/stream_chat/lib/src/client/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel_client_state.dart new file mode 100644 index 0000000000..99980ec744 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel_client_state.dart @@ -0,0 +1,2148 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/client/retry_queue.dart'; +import 'package:stream_chat/src/core/util/utils.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// The class that handles the state of the channel listening to the events. +class ChannelClientState { + /// Creates a new instance listening to events and updating the state. + ChannelClientState( + this._channel, + ChannelState channelState, + ) { + _retryQueue = RetryQueue( + channel: _channel, + logger: _client.detachedLogger( + '🔄 (${generateHash([_channel.cid])})', + ), + ); + + _channelStateController = BehaviorSubject.seeded(channelState); + // Update the persistence storage with the seeded channel state. + _debouncedUpdatePersistenceChannelState.call([channelState]); + + // region TYPING EVENTS + _listenTypingEvents(); + // endregion + + // region MESSAGE EVENTS + _listenMessageNew(); + _listenMessageDeleted(); + _listenMessageUpdated(); + // endregion + + // region DRAFT EVENTS + _listenDraftUpdated(); + _listenDraftDeleted(); + // endregion + + // region REACTION EVENTS + _listenReactionNew(); + _listenReactionUpdated(); + _listenReactionDeleted(); + // endregion + + // region POLL EVENTS + _listenPollCreated(); + _listenPollUpdated(); + _listenPollClosed(); + _listenPollAnswerCasted(); + _listenPollVoteCasted(); + _listenPollVoteChanged(); + _listenPollAnswerRemoved(); + _listenPollVoteRemoved(); + // endregion + + // region READ EVENTS + _listenReadEvents(); + // endregion + + // region CHANNEL EVENTS + _listenChannelTruncated(); + _listenChannelUpdated(); + _listenChannelMessageCount(); + // endregion + + // region MEMBER EVENTS + _listenMemberAdded(); + _listenMemberRemoved(); + _listenMemberUpdated(); + _listenMemberBanned(); + _listenMemberUnbanned(); + _listenUserMessagesDeleted(); + // endregion + + // region USER WATCHING EVENTS + _listenUserStartWatching(); + _listenUserStopWatching(); + // endregion + + // region REMINDER EVENTS + _listenReminderCreated(); + _listenReminderUpdated(); + _listenReminderDeleted(); + // endregion + + // region LOCATION EVENTS + _listenLocationShared(); + _listenLocationUpdated(); + _listenLocationExpired(); + // endregion + + _startCleaningStaleTypingEvents(); + + _startCleaningStalePinnedMessages(); + + _startCleaningExpiredLocations(); + + _listenChannelPushPreferenceUpdated(); + + final persistenceClient = _client.chatPersistenceClient; + persistenceClient + ?.getChannelThreads(_channel.cid!) + .then((threads) { + // Load all the threads for the channel from the offline storage. + if (threads.isNotEmpty) _threads = threads; + }) + .then((_) => retryFailedMessages()); + } + + final Channel _channel; + StreamChatClient get _client => _channel.client; + final _subscriptions = CompositeSubscription(); + + void _listenMemberAdded() { + _subscriptions.add( + _channel.on(EventType.memberAdded).listen((Event e) { + final member = e.member!; + final existingMembers = channelState.members ?? []; + + updateChannelState( + channelState.copyWith( + members: [...existingMembers, member], + ), + ); + }), + ); + } + + void _listenMemberRemoved() { + _subscriptions.add( + _channel.on(EventType.memberRemoved).listen((Event e) { + final user = e.user!; + final existingRead = channelState.read ?? []; + final existingMembers = channelState.members ?? []; + + updateChannelState( + channelState.copyWith( + read: [...existingRead.where((r) => r.user.id != user.id)], + members: [...existingMembers.where((m) => m.userId != user.id)], + ), + ); + }), + ); + } + + void _listenMemberUpdated() { + _subscriptions + // Listen to events containing member users + ..add( + _channel.on().listen( + (event) { + final user = event.user; + if (user == null) return; + + final existingMembers = [...?channelState.members]; + final existingMembership = channelState.membership; + + // Return if the user is not a existing member of the channel. + if (!existingMembers.any((m) => m.userId == user.id)) return; + + Member? maybeUpdateMemberUser(Member? existingMember) { + if (existingMember == null) return null; + if (existingMember.userId == user.id) { + return existingMember.copyWith(user: user); + } + return existingMember; + } + + updateChannelState( + channelState.copyWith( + membership: maybeUpdateMemberUser(existingMembership), + members: [...existingMembers.map(maybeUpdateMemberUser).nonNulls], + ), + ); + }, + ), + ) + // Listen to member updated events. + ..add( + _channel.on(EventType.memberUpdated).listen( + (Event e) { + final member = e.member!; + final existingMembers = channelState.members ?? []; + final existingMembership = channelState.membership; + + Member? maybeUpdateMember(Member? existingMember) { + if (existingMember == null) return null; + if (existingMember.userId == member.userId) return member; + return existingMember; + } + + updateChannelState( + channelState.copyWith( + membership: maybeUpdateMember(existingMembership), + members: [...existingMembers.map(maybeUpdateMember).nonNulls], + ), + ); + }, + ), + ); + } + + void _listenChannelUpdated() { + _subscriptions.add( + _channel.on(EventType.channelUpdated).listen((Event e) { + final channel = e.channel!; + updateChannelState( + channelState.copyWith( + channel: channelState.channel?.merge(channel), + members: channel.members, + ), + ); + }), + ); + } + + void _listenChannelMessageCount() { + _subscriptions.add( + _channel.on().listen( + (Event e) { + final messageCount = e.channelMessageCount; + if (messageCount == null) return; + + updateChannelState( + channelState.copyWith( + channel: channelState.channel?.copyWith( + messageCount: messageCount, + ), + ), + ); + }, + ), + ); + } + + void _listenChannelTruncated() { + _subscriptions.add( + _channel.on(EventType.channelTruncated, EventType.notificationChannelTruncated).listen((event) async { + final channel = event.channel!; + await _client.chatPersistenceClient?.deleteMessageByCid(channel.cid); + truncate(); + if (event.message != null) { + updateMessage(event.message!); + } + }), + ); + } + + void _listenMemberBanned() { + _subscriptions.add( + _channel + .on(EventType.userBanned) + .where((it) => it.cid != null) // filters channel ban from app ban + .listen( + (event) async { + final user = event.user!; + final member = await _channel + .queryMembers(filter: Filter.equal('id', user.id)) + .then((it) => it.members.first); + + _updateMember(member); + }, + ), + ); + } + + void _listenUserStartWatching() { + _subscriptions.add( + _channel.on(EventType.userWatchingStart).listen((event) { + final watcher = event.user; + if (watcher != null) { + final existingWatchers = channelState.watchers; + updateChannelState( + channelState.copyWith( + watchers: [ + watcher, + ...?existingWatchers?.where((user) => user.id != watcher.id), + ], + watcherCount: event.watcherCount, + ), + ); + } + }), + ); + } + + void _listenUserStopWatching() { + _subscriptions.add( + _channel.on(EventType.userWatchingStop).listen((event) { + final watcher = event.user; + if (watcher != null) { + final existingWatchers = channelState.watchers ?? const []; + _channelState = channelState.copyWith( + watchers: existingWatchers.where((user) => user.id != watcher.id).toList(), + watcherCount: event.watcherCount, + ); + } + }), + ); + } + + void _listenMemberUnbanned() { + _subscriptions.add( + _channel + .on(EventType.userUnbanned) + .where((it) => it.cid != null) // filters channel ban from app ban + .listen( + (event) async { + final user = event.user!; + final member = await _channel + .queryMembers(filter: Filter.equal('id', user.id)) + .then((it) => it.members.first); + + _updateMember(member); + }, + ), + ); + } + + void _updateMember(Member member) { + final currentMembers = [...members]; + final memberIndex = currentMembers.indexWhere( + (m) => m.userId == member.userId, + ); + + if (memberIndex == -1) return; + currentMembers[memberIndex] = member; + + updateChannelState( + channelState.copyWith( + members: currentMembers, + ), + ); + } + + /// Flag which indicates if [ChannelClientState] contain latest/recent messages or not. + /// + /// This flag should be managed by UI sdks. + /// + /// When false, any new message received by WebSocket event + /// [EventType.messageNew] will not be pushed on to message list. + bool get isUpToDate => _isUpToDateController.value; + + set isUpToDate(bool isUpToDate) => _isUpToDateController.safeAdd(isUpToDate); + + /// [isUpToDate] flag count as a stream. + Stream get isUpToDateStream => _isUpToDateController.stream; + final _isUpToDateController = BehaviorSubject.seeded(true); + + /// The retry queue associated to this channel. + late final RetryQueue _retryQueue; + + /// Queues [message] for another send attempt. + @internal + void scheduleRetry(Message message) => _retryQueue.add([message]); + + /// Retry failed message. + Future retryFailedMessages() async { + final allMessages = [...messages, ...threads.values.flattened]; + final failedMessages = allMessages.where((it) => it.state.isFailed); + + if (failedMessages.isEmpty) return; + _retryQueue.add(failedMessages); + } + + Message? _findPollMessage(String pollId) { + final message = messages.firstWhereOrNull((it) => it.pollId == pollId); + if (message != null) return message; + + final threadMessage = threads.values.flattened.firstWhereOrNull((it) { + return it.pollId == pollId; + }); + + return threadMessage; + } + + void _listenPollCreated() { + _subscriptions.add( + _channel.on(EventType.pollCreated).listen((event) { + final message = event.message; + if (message == null || message.poll == null) return; + + return addNewMessage(message); + }), + ); + } + + void _listenPollUpdated() { + _subscriptions.add( + _channel.on(EventType.pollUpdated).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final ownVotesAndAnswers = oldPoll?.ownVotesAndAnswers ?? eventPoll.ownVotesAndAnswers; + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollClosed() { + _subscriptions.add( + _channel.on(EventType.pollClosed).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + final poll = oldPoll?.copyWith(isClosed: true) ?? eventPoll; + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollAnswerCasted() { + _subscriptions.add( + _channel.on(EventType.pollAnswerCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = { + for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, + eventPollVote.id!: eventPollVote, + }; + + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + latestAnswers: [...latestAnswers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollVoteCasted() { + _subscriptions.add( + _channel.on(EventType.pollVoteCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollAnswerRemoved() { + _subscriptions.add( + _channel.on(EventType.pollAnswerRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = { + for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, + }..remove(eventPollVote.id); + + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); + + final poll = eventPoll.copyWith( + latestAnswers: [...latestAnswers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollVoteRemoved() { + _subscriptions.add( + _channel.on(EventType.pollVoteRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollVoteChanged() { + _subscriptions.add( + _channel.on(EventType.pollVoteChanged).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenDraftUpdated() { + _subscriptions.add( + _channel.on(EventType.draftUpdated).listen((event) { + final draft = event.draft; + if (draft == null) return; + + return updateDraft(draft); + }), + ); + } + + void _listenDraftDeleted() { + _subscriptions.add( + _channel.on(EventType.draftDeleted).listen((event) { + final draft = event.draft; + if (draft == null) return; + + return deleteDraft(draft); + }), + ); + } + + void _listenReminderCreated() { + _subscriptions.add( + _channel.on(EventType.reminderCreated).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + updateReminder(reminder); + }), + ); + } + + void _listenReminderUpdated() { + _subscriptions.add( + _channel.on(EventType.reminderUpdated).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + updateReminder(reminder); + }), + ); + } + + void _listenReminderDeleted() { + _subscriptions.add( + _channel.on(EventType.reminderDeleted).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + deleteReminder(reminder); + }), + ); + } + + /// Updates the [reminder] of the message if it exists. + void updateReminder(MessageReminder reminder) { + final messageId = reminder.messageId; + // TODO: Improve once we have support for parentId in reminders. + for (final message in [...messages, ...threads.values.flattened]) { + if (message.id == messageId) { + return updateMessage( + message.copyWith(reminder: reminder), + ); + } + } + } + + /// Deletes the [reminder] of the message if it exists. + void deleteReminder(MessageReminder reminder) { + final messageId = reminder.messageId; + // TODO: Improve once we have support for parentId in reminders. + for (final message in [...messages, ...threads.values.flattened]) { + if (message.id == messageId) { + return updateMessage( + message.copyWith(reminder: null), + ); + } + } + } + + Message? _findLocationMessage(String id) { + final message = messages.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + if (message != null) return message; + + final threadMessage = threads.values.flattened.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + return threadMessage; + } + + void _listenLocationShared() { + _subscriptions.add( + _channel.on(EventType.locationShared).listen((event) { + final message = event.message; + if (message == null || message.sharedLocation == null) return; + + return addNewMessage(message); + }), + ); + } + + void _listenLocationUpdated() { + _subscriptions.add( + _channel.on(EventType.locationUpdated).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + + void _listenLocationExpired() { + _subscriptions.add( + _channel.on(EventType.locationExpired).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + + void _listenReactionDeleted() { + _subscriptions.add( + _channel.on(EventType.reactionDeleted).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => message.deleteMyReaction( + reactionType: eventReaction.type, + ), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenReactionNew() { + _subscriptions.add( + _channel.on(EventType.reactionNew).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => message.addMyReaction(eventReaction), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenReactionUpdated() { + _subscriptions.add( + _channel.on(EventType.reactionUpdated).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => + // reaction.updated is only called if enforce_unique is true + message.addMyReaction(eventReaction, enforceUnique: true), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenMessageUpdated() { + _subscriptions.add( + _channel.on(EventType.messageUpdated).listen((event) { + final message = event.message; + if (message == null) return; + + return updateMessage(message, upsert: false); + }), + ); + } + + void _listenMessageDeleted() { + _subscriptions.add( + _channel.on(EventType.messageDeleted).listen((event) { + final hardDelete = event.hardDelete ?? false; + + final message = event.message!.copyWith( + // TODO: Remove once deletedForMe is properly enriched on the backend. + 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); + }), + ); + } + + void _listenMessageNew() { + _subscriptions.add( + _channel + .on( + EventType.messageNew, + EventType.notificationMessageNew, + ) + .listen((event) { + final message = event.message; + if (message == null) return; + + addNewMessage(message); + + // Only message.new carries a reliable watcher count; + // notification.message_new targets non-watchers and reports 0. + if (event.watcherCount case final watcherCount? when event.type == EventType.messageNew) { + updateChannelState( + channelState.copyWith(watcherCount: watcherCount), + ); + } + }), + ); + } + + /// Adds a new message to the channel state and updates the unread count. + void addNewMessage(Message message) { + final isThreadMessage = message.parentId != null; + final isNotShownInChannel = message.showInChannel != true; + final isThreadOnlyMessage = isThreadMessage && isNotShownInChannel; + + // Only add the message if the channel is upToDate or if the message is + // a thread-only message. + if (isUpToDate || isThreadOnlyMessage) updateMessage(message); + + // Otherwise, check if we can count the message as unread. + if (MessageRules.canCountAsUnread(message, _channel)) { + unreadCount += 1; // Increment unread count + } + + _client.channelDeliveryReporter.submitForDelivery([_channel]); + } + + /// Updates the [read] in the state if it exists. Adds it otherwise. + void updateRead([Iterable? read]) { + final existingReads = channelState.read ?? const []; + final updatedReads = existingReads.merge( + read, + key: (read) => read.user.id, + ); + + updateChannelState( + channelState.copyWith( + read: updatedReads.toList(), + ), + ); + } + + /// Updates the [draft] in the channel state or the message if it exists. + void updateDraft(Draft draft) { + if (draft.parentId case final parentId?) { + for (final message in messages) { + if (message.id == parentId) { + return updateMessage(message.copyWith(draft: draft)); + } + } + } + + updateChannelState( + channelState.copyWith( + draft: draft, + ), + ); + } + + /// Deletes the [draft] from the state if it exists. + void deleteDraft(Draft draft) async { + // Delete the draft from the persistence client. + await _client.chatPersistenceClient?.deleteDraftMessageByCid( + draft.channelCid, + parentId: draft.parentId, + ); + + if (draft.parentId case final parentId?) { + for (final message in messages) { + if (message.id == parentId) { + return updateMessage( + message.copyWith(draft: null), + ); + } + } + } + + updateChannelState( + channelState.copyWith( + draft: null, + ), + ); + } + + /// Updates the [message] in the state. + /// + /// Reconciles via `Message.updateWith`, so locally-known enrichment + /// (poll, sharedLocation, ownReactions, nested quotedMessage) is + /// preserved when [message] omits those fields. Use [replaceMessage] + /// for paths that need a strict overwrite. + /// + /// When [upsert] is `true` (the default) and [message] isn't already in + /// the state, it's added. When `false`, an unknown [message] is skipped + /// and the state is left unchanged; only a message already loaded in the + /// state is updated. + void updateMessage(Message message, {bool upsert = true}) => _updateMessages([message], upsert: upsert); + + /// Replaces the [message] in the state if it exists, no-op otherwise. + /// + /// Unlike [updateMessage], this does **not** merge with the existing + /// state — [message] is used as-is. Useful for local rollbacks of an + /// optimistic update, where the caller has the full prior snapshot and + /// doesn't want the merge falling back to the optimistic values. + void replaceMessage(Message message) => _updateMessages([message], update: _replaceUpdate); + + // Default `update` for [_updateMessages]: merge incoming with the + // locally-known message via `Message.updateWith`, preserving enrichment + // the server may strip on partial payloads. + static Message _mergeUpdate(Message original, Message updated) => original.updateWith(updated); + + // Replace `update` for [_updateMessages]: take the incoming as-is. Used + // by local rollback paths. + static Message _replaceUpdate(Message _, Message updated) => updated; + + /// Cleans up all the stale error messages which requires no action. + void cleanUpStaleErrorMessages() { + final errorMessages = messages.where((message) { + return message.isError && !message.isBounced; + }); + + if (errorMessages.isEmpty) return; + return _removeMessages(errorMessages); + } + + /// Remove a [message] from this [channelState]. + void removeMessage(Message message) => _removeMessages([message]); + + /// Removes/Updates the [message] based on the [hardDelete] value. + void deleteMessage(Message message, {bool hardDelete = false}) { + return _deleteMessages([message], hardDelete: hardDelete); + } + + void _listenReadEvents() { + _subscriptions + ..add( + _channel.on(EventType.messageRead, EventType.notificationMarkRead).listen( + (event) { + // Skip handling the event if delivered for a thread + if (event.thread != null) return; + + final user = event.user; + if (user == null) return; + + final currentRead = userReadOf(userId: user.id); + + final updatedRead = Read( + user: user, + lastRead: event.createdAt, + unreadMessages: 0, // Reset unread count + lastReadMessageId: event.lastReadMessageId, + // Preserve delivery info as it's not part of the read event. + lastDeliveredAt: currentRead?.lastDeliveredAt, + lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, + ); + + updateRead([updatedRead]); + + // If the read event is from the current user, reconcile the + // channel delivery status with the updated read state. + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) { + _client.channelDeliveryReporter.reconcileDelivery([_channel]); + } + }, + ), + ) + ..add( + _channel.on(EventType.notificationMarkUnread).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentRead = userReadOf(userId: user.id); + + final updatedRead = Read( + user: user, + lastRead: event.lastReadAt!, + unreadMessages: event.unreadMessages, + lastReadMessageId: event.lastReadMessageId, + // Preserve delivery info as it's not part of the read event. + lastDeliveredAt: currentRead?.lastDeliveredAt, + lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, + ); + + return updateRead([updatedRead]); + }, + ), + ) + ..add( + _channel.on(EventType.messageDelivered).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentRead = userReadOf(userId: user.id); + final never = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + final updatedRead = Read( + user: user, + lastDeliveredAt: event.lastDeliveredAt, + lastDeliveredMessageId: event.lastDeliveredMessageId, + // Preserve read info as it's not part of the delivery event. + lastRead: currentRead?.lastRead ?? never, + unreadMessages: currentRead?.unreadMessages, + lastReadMessageId: currentRead?.lastReadMessageId, + ); + + updateRead([updatedRead]); + + // If the delivered event is from the current user, reconcile + // the channel delivery with the updated read state. + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) { + _client.channelDeliveryReporter.reconcileDelivery([_channel]); + } + }, + ), + ); + } + + /// Channel message list. + List get messages => _channelState.messages ?? []; + + /// Channel message list as a stream. + Stream> get messagesStream => + channelStateStream.map((cs) => cs.messages ?? []).distinct(const ListEquality().equals); + + /// Channel pinned message list. + List get pinnedMessages => _channelState.pinnedMessages ?? []; + + /// Channel pinned message list as a stream. + Stream> get pinnedMessagesStream => + channelStateStream.map((cs) => cs.pinnedMessages ?? []).distinct(const ListEquality().equals); + + /// Channel pending message list. + List get pendingMessages => _channelState.pendingMessages ?? []; + + /// Channel pending message list as a stream. + Stream> get pendingMessagesStream => + channelStateStream.map((cs) => cs.pendingMessages ?? []).distinct(const ListEquality().equals); + + /// Get channel last message. + Message? get lastMessage => messages.lastOrNull; + + /// Get channel last message as a stream. + Stream get lastMessageStream { + return messagesStream.map((messages) => messages.lastOrNull); + } + + /// Channel members list. + List get members => + (_channelState.members ?? []).map((e) => e.copyWith(user: _client.state.users[e.user!.id])).toList(); + + /// Channel members list as a stream. + Stream> get membersStream => + CombineLatestStream.combine2?, Map, List>( + channelStateStream.map((cs) => cs.members), + _client.state.usersStream, + (members, users) => [...?members?.map((e) => e!.copyWith(user: users[e.user!.id]))], + ).distinct(const ListEquality().equals); + + /// Channel watcher count. + int? get watcherCount => _channelState.watcherCount; + + /// Channel watcher count as a stream. + Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount); + + /// Channel watchers list. + List get watchers => (_channelState.watchers ?? []).map((e) => _client.state.users[e.id] ?? e).toList(); + + /// Channel watchers list as a stream. + Stream> get watchersStream => CombineLatestStream.combine2?, Map, List>( + channelStateStream.map((cs) => cs.watchers), + _client.state.usersStream, + (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], + ).distinct(const ListEquality().equals); + + /// Channel active live locations. + List get activeLiveLocations { + return _channelState.activeLiveLocations ?? []; + } + + /// Channel active live locations as a stream. + Stream> get activeLiveLocationsStream => + channelStateStream.map((cs) => cs.activeLiveLocations ?? []).distinct(const ListEquality().equals); + + /// Channel draft. + Draft? get draft => _channelState.draft; + + /// Channel draft as a stream. + Stream get draftStream { + return channelStateStream.map((cs) => cs.draft).distinct(); + } + + /// Channel member for the current user. + Member? get currentUserMember => members.firstWhereOrNull( + (m) => m.user?.id == _client.state.currentUser?.id, + ); + + /// Channel role for the current user + String? get currentUserChannelRole => currentUserMember?.channelRole; + + /// Channel read list. + List get read => _channelState.read ?? []; + + /// Channel read list as a stream. + Stream> get readStream => + channelStateStream.map((cs) => cs.read ?? []).distinct(const ListEquality().equals); + + /// Channel read for the logged in user. + Read? get currentUserRead { + final currentUser = _client.state.currentUser; + return userReadOf(userId: currentUser?.id); + } + + /// Channel read for the logged in user as a stream. + /// + /// Re-subscribes only when the user id actually changes; null still + /// propagates downstream so consumers see the logged-out transition. + Stream get currentUserReadStream { + final currentUserId = _client.state.currentUserStream.map((it) => it?.id).distinct(); + return currentUserId.switchMap((id) => userReadStreamOf(userId: id)).distinct(); + } + + /// Unread count getter as a stream. + Stream get unreadCountStream => currentUserReadStream.map((read) => read?.unreadMessages ?? 0).distinct(); + + /// Unread count getter. + int get unreadCount => currentUserRead?.unreadMessages ?? 0; + + /// Setter for unread count. + set unreadCount(int count) { + final currentUser = _client.state.currentUser; + if (currentUser == null) return; + + var existingUserRead = currentUserRead; + if (existingUserRead == null) { + final lastMessageAt = _channelState.channel?.lastMessageAt; + existingUserRead = Read( + user: currentUser, + lastRead: lastMessageAt ?? DateTime.now(), + ); + } + + 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. + /// + /// [messageId] only sets the resulting [Read.lastReadMessageId]; it does not + /// narrow which messages stay unread. The count always drops to zero and + /// [Read.lastRead] is always `now`, so messages newer than [messageId] are + /// marked read as well. This differs from the server, which recomputes the + /// count as the number of messages after [messageId], and from + /// [markUnreadLocally], which does recompute from the locally-known + /// messages. Callers that need a partial boundary should use + /// [markUnreadLocally] instead. + 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, + ), + ]); + + // Read supersedes delivered, so drop any pending delivery candidate the + // new read boundary just made ineligible. `delivery_events` is configured + // independently of `read_events`, so a channel tracking unread counts + // locally can still have delivery receipts enabled. Mirrors what the + // `message.read` event listener does for server-driven channels. + _client.channelDeliveryReporter.reconcileDelivery([_channel]); + } + + /// 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 + /// any API call. Therefore, the count might be not reliable as it relies on + /// the local data. + int countUnreadMentions() { + final currentUserId = _client.state.currentUser?.id; + + var count = 0; + for (final message in messages) { + if (!MessageRules.canCountAsUnread(message, _channel)) continue; + if (!message.mentionedUsers.any((it) => it.id == currentUserId)) continue; + + count++; + } + + return count; + } + + /// Delete all channel messages. + void truncate() { + _channelState = _channelState.copyWith( + messages: [], + ); + } + + /// Drops the oldest messages, keeping at most [maxMessages]. + /// + /// No-op when [maxMessages] is non-positive, when the current count is + /// already within the limit, or when [isUpToDate] is `false`. + /// + /// Prefer `StreamChannel.pruneOldest` when a [StreamChannel] is present: + /// it also resets the widget-layer "top reached" marker so top-pagination + /// can resume. Calling this directly leaves that marker untouched. + void pruneOldest(int maxMessages) { + if (maxMessages <= 0) return; + if (!isUpToDate) return; + + final current = messages; + if (current.length <= maxMessages) return; + + final pruned = current.sublist(current.length - maxMessages); + _channelState = _channelState.copyWith(messages: pruned); + } + + /// Update channelState with updated information. + void updateChannelState(ChannelState updatedState) { + final newMessages = messages.mergeSorted( + updatedState.messages, + key: (message) => message.id, + update: _mergeUpdate, + compare: _sortByCreatedAt, + ); + + final watchers = _channelState.watchers ?? const []; + final newWatchers = watchers.merge( + updatedState.watchers, + key: (watcher) => watcher.id, + ); + + final reads = _channelState.read ?? const []; + final newReads = reads.merge( + updatedState.read, + key: (read) => read.user.id, + ); + + _channelState = _channelState.copyWith( + messages: newMessages, + channel: _channelState.channel?.merge(updatedState.channel), + watchers: newWatchers.toList(), + watcherCount: updatedState.watcherCount, + members: updatedState.members, + membership: updatedState.membership, + read: newReads.toList(), + draft: updatedState.draft, + pinnedMessages: updatedState.pinnedMessages, + pendingMessages: updatedState.pendingMessages, + pushPreferences: updatedState.pushPreferences, + activeLiveLocations: updatedState.activeLiveLocations, + ); + } + + /// 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. + ChannelState get _channelState => _channelStateController.value; + + /// The channel state related to this client as a stream. + Stream get channelStateStream => _channelStateController.stream; + + /// The channel state related to this client. + ChannelState get channelState => _channelStateController.value; + late BehaviorSubject _channelStateController; + + late final _debouncedUpdatePersistenceChannelState = debounce( + (ChannelState state) { + final persistenceClient = _client.chatPersistenceClient; + return persistenceClient?.updateChannelState(state); + }, + const Duration(seconds: 1), + ); + + set _channelState(ChannelState v) { + _channelStateController.safeAdd(v); + _debouncedUpdatePersistenceChannelState.call([v]); + } + + late final _debouncedUpdatePersistenceChannelThreads = debounce( + (Map> threads) async { + final channelCid = _channel.cid; + if (channelCid == null) return; + + final persistenceClient = _client.chatPersistenceClient; + return persistenceClient?.updateChannelThreads(channelCid, threads); + }, + const Duration(seconds: 1), + ); + + /// The channel threads related to this channel. + Map> get threads => {..._threadsController.value}; + + /// The channel threads related to this channel as a stream. + Stream>> get threadsStream => _threadsController; + final _threadsController = BehaviorSubject.seeded(>{}); + set _threads(Map> threads) { + _threadsController.safeAdd(threads); + _debouncedUpdatePersistenceChannelThreads.call([threads]); + } + + /// Clears all the replies in the thread identified by [parentId]. + void clearThread(String parentId) { + final updatedThreads = { + ...threads, + parentId: [], + }; + + _threads = updatedThreads; + } + + /// Update threads with updated information about messages. + void updateThreadInfo(String parentId, List messages) { + final updatedThreads = {...threads}; + + final threadMessages = updatedThreads[parentId] ?? []; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages.where((it) => it.id != parentId), + ); + + // Update the thread with the modified message list. + updatedThreads[parentId] = updatedThreadMessages.toList(); + + _threads = updatedThreads; + } + + Draft? _getThreadDraft(String parentId, List? messages) { + return messages?.firstWhereOrNull((it) => it.id == parentId)?.draft; + } + + /// Draft for a specific thread identified by [parentId]. + Draft? threadDraft(String parentId) => _getThreadDraft(parentId, messages); + + /// Stream of draft for a specific thread identified by [parentId]. + /// + /// This stream emits a new value whenever the draft associated with the + /// specified thread is updated or removed. + Stream threadDraftStream(String parentId) => + channelStateStream.map((cs) => _getThreadDraft(parentId, cs.messages)).distinct(); + + /// Channel related typing users stream. + Stream> get typingEventsStream => _typingEventsController.stream; + + /// Channel related typing users last value. + Map get typingEvents => _typingEventsController.value; + final _typingEventsController = BehaviorSubject.seeded({}); + + void _listenTypingEvents() { + _subscriptions + ..add( + _channel.on(EventType.typingStart).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) return; + + final events = {...typingEvents, user: event}; + _typingEventsController.safeAdd(events); + }, + ), + ) + ..add( + _channel.on(EventType.typingStop).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) return; + + final events = {...typingEvents}..remove(user); + _typingEventsController.safeAdd(events); + }, + ), + ); + } + + Timer? _staleTypingEventsCleanerTimer; + + // Checks and removes stale typing events that were not explicitly stopped by + // the sender due to technical difficulties. e.g. process death, loss of + // Internet connection or custom implementation. + void _startCleaningStaleTypingEvents() { + _staleTypingEventsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final now = DateTime.now(); + typingEvents.forEach((user, event) { + if (now.difference(event.createdAt).inSeconds > incomingTypingStartEventTimeout) { + _client.handleEvent( + Event( + type: EventType.typingStop, + user: user, + cid: _channel.cid, + parentId: event.parentId, + ), + ); + } + }); + }, + ); + } + + Timer? _stalePinnedMessagesCleanerTimer; + + // Checks and removes stale pinned messages that are not valid anymore. + void _startCleaningStalePinnedMessages() { + _stalePinnedMessagesCleanerTimer = Timer.periodic( + const Duration(seconds: 30), + (_) { + final now = DateTime.now(); + var expiredMessages = channelState.pinnedMessages?.where((m) => m.pinExpires?.isBefore(now) == true).toList(); + if (expiredMessages != null && expiredMessages.isNotEmpty) { + expiredMessages = expiredMessages + .map( + (m) => m.copyWith( + pinExpires: null, + pinned: false, + ), + ) + .toList(); + + updateChannelState( + _channelState.copyWith( + pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), + messages: expiredMessages, + ), + ); + } + }, + ); + } + + Timer? _staleLiveLocationsCleanerTimer; + void _startCleaningExpiredLocations() { + _staleLiveLocationsCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final currentUserId = _channel.client.state.currentUser?.id; + if (currentUserId == null) return; + + final expired = activeLiveLocations.where((it) => it.isExpired); + if (expired.isEmpty) return; + + for (final sharedLocation in expired) { + // Skip if the location is shared by the current user, + // as we are already handling them in the client. + if (sharedLocation.userId == currentUserId) continue; + + final lastUpdatedAt = DateTime.timestamp(); + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: sharedLocation.channelCid, + message: Message( + id: sharedLocation.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: sharedLocation.copyWith( + updatedAt: lastUpdatedAt, + ), + ), + ); + + _channel.client.handleEvent(locationExpiredEvent); + } + }, + ); + } + + // Listens to channel push preference update events and updates the state + void _listenChannelPushPreferenceUpdated() { + _subscriptions.add( + _channel.on(EventType.channelPushPreferenceUpdated).listen( + (event) { + final pushPreferences = event.channelPushPreference; + if (pushPreferences == null) return; + + updateChannelState( + channelState.copyWith( + pushPreferences: pushPreferences, + ), + ); + }, + ), + ); + } + + Future _deleteMessagesFromUser({ + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) async { + // Delete messages from persistence. + // + // Note: We perform this operation separately even though [_removeMessages] + // already handles it as we need to delete all messages from the user, not + // only the ones present in the current state. + final persistence = _channel.client.chatPersistenceClient; + await persistence?.deleteMessagesFromUser( + userId: userId, + cid: _channel.cid, + hardDelete: hardDelete, + deletedAt: deletedAt, + ); + + // Gather messages to delete from state. + final userMessages = {}; + for (final message in [...messages, ...threads.values.flattened]) { + if (message.user?.id != userId) continue; + userMessages[message.id] = message.copyWith( + type: MessageType.deleted, + deletedAt: deletedAt ?? DateTime.now(), + state: switch (hardDelete) { + true => MessageState.hardDeleted, + false => MessageState.softDeleted, + }, + ); + } + + final messagesToDelete = userMessages.values; + return _deleteMessages(messagesToDelete, hardDelete: hardDelete); + } + + void _deleteMessages( + Iterable messages, { + bool hardDelete = false, + }) { + if (messages.isEmpty) return; + + if (hardDelete) return _removeMessages(messages); + return _updateMessages(messages, upsert: false); + } + + void _updateMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (messages.isEmpty) return; + + _updateThreadMessages(messages, update: update, upsert: upsert); + _updateChannelMessages(messages, update: update, upsert: upsert); + _updatePinnedMessages(messages, update: update); + _updateActiveLiveLocations(messages); + } + + void _updateThreadMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (messages.isEmpty) return; + + // Group messages by parentId so each thread merge only sees its own + // replies — passing the full batch to every thread would leak replies + // across thread boundaries (the merge dedups by id, not by parentId). + final messagesByThread = >{}; + for (final m in messages) { + if (m.parentId case final parentId?) (messagesByThread[parentId] ??= []).add(m); + } + + // If there are no affected threads, return early. + if (messagesByThread.isEmpty) return; + + final updatedThreads = {...threads}; + for (final MapEntry(key: thread, :value) in messagesByThread.entries) { + final existingThreadMessages = updatedThreads[thread]; + + // Don't create a phantom entry for a thread that wasn't loaded: with + // `upsert: false` an out-of-window reply is dropped, so there's nothing + // to merge. Writing it back would make `threads.containsKey(parentId)` + // report a thread that was never paged in. + if (existingThreadMessages == null && !upsert) continue; + + final threadMessages = existingThreadMessages ?? []; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: value, + update: update, + upsert: upsert, + ); + + // Update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages.toList(); + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _updateChannelMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (messages.isEmpty) return; + + final affectedMessages = messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _mergeMessagesIntoExisting( + existing: channelMessages, + toMerge: affectedMessages, + update: update, + upsert: upsert, + ); + + // Calculate the new last message at time. + var lastMessageAt = _channelState.channel?.lastMessageAt; + for (final message in affectedMessages) { + if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) { + lastMessageAt = [lastMessageAt, message.createdAt].nonNulls.max; + } + } + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages.toList(), + channel: _channelState.channel?.copyWith(lastMessageAt: lastMessageAt), + ); + } + + void _updatePinnedMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + if (messages.isEmpty) return; + + // No-op fast path: nothing was pinned, and nothing in the batch is + // becoming pinned — skip the merge/copyWith churn that would otherwise + // land right back on an empty `pinnedMessages` list. + if (pinnedMessages.isEmpty && messages.every((m) => !m.pinned)) return; + + final updatedPinnedMessages = _mergePinnedMessagesIntoExisting( + existing: pinnedMessages, + toMerge: messages, + update: update, + ); + + _channelState = _channelState.copyWith( + pinnedMessages: updatedPinnedMessages.toList(), + ); + } + + void _updateActiveLiveLocations(Iterable messages) { + if (messages.isEmpty) return; + + final activeLiveLocations = [...this.activeLiveLocations]; + final updatedActiveLiveLocations = _mergeActiveLocationsIntoExisting( + existing: activeLiveLocations, + toMerge: messages, + ); + + _channelState = _channelState.copyWith( + activeLiveLocations: updatedActiveLiveLocations.toList(), + ); + } + + Iterable _mergeActiveLocationsIntoExisting({ + required Iterable existing, + required Iterable toMerge, + }) { + if (toMerge.isEmpty) return existing; + + final mergedLocations = existing.mergeFrom( + toMerge, + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + value: (message) => message.sharedLocation, + update: (original, updated) => updated, + ); + + final toUpdateMap = {for (final m in toMerge) m.id: m}; + final updatedLocations = mergedLocations.where((it) { + // Remove the location if it's expired. + if (it.isExpired) return false; + + final updatedMessage = toUpdateMap[it.messageId]; + // Remove the location if the attached message is deleted. + if (updatedMessage?.isDeleted == true) return false; + + return true; + }); + + return updatedLocations; + } + + Iterable _mergePinnedMessagesIntoExisting({ + required Iterable existing, + required Iterable toMerge, + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + return _mergeMessagesIntoExisting( + existing: existing, + toMerge: toMerge, + update: update, + ).where(_pinIsValid); + } + + Iterable _mergeMessagesIntoExisting({ + required Iterable existing, + required Iterable toMerge, + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (toMerge.isEmpty) return existing; + + // [update] decides whether each pair is reconciled (default — see + // `_mergeUpdate`) or replaced (`_replaceUpdate`, used by local rollback + // paths that don't want enrichment fallback to keep optimistic values). + // + // [upsert] controls whether ids not already in [existing] are inserted. + // Event-driven paths (`message.updated`, `message.deleted` soft) pass + // `upsert: false` so an out-of-window message isn't dropped into a gap + // between the loaded slice and history the client hasn't paged in yet. + final existingList = existing is List ? existing : existing.toList(); + var toMergeList = toMerge is List ? toMerge : toMerge.toList(); + + // Single-message fast path. The hot ingest path (server echoes, edits, + // reactions, read receipts) always lands here, and `lastIndexWhere` + + // `sortedUpsertAt` skips the O(N) keymap build that the two-pointer + // merge would otherwise do up front. + if (toMergeList.length == 1) { + final message = toMergeList.first; + final oldIndex = existingList.lastIndexWhere((it) => it.id == message.id); + + // upsert: false — skip update if message is not loaded + if (oldIndex == -1 && !upsert) return existingList; + + final resolved = oldIndex == -1 ? message : update(existingList[oldIndex], message); + + final mergedMessages = existingList.sortedUpsertAt( + oldIndex, + resolved, + update: update, + compare: _sortByCreatedAt, + ); + + // Non-delete updates can't change what embedded quotedMessage copies + // should display, so we can skip the rewrite entirely. + if (!resolved.isDeleted) return mergedMessages; + + return mergedMessages.updateIf( + (it) => it.quotedMessageId == resolved.id, + (it) => it.copyWith(quotedMessage: resolved), + ); + } + + // upsert: false - skip messages not loaded in the window + if (!upsert) { + final existingIds = {for (final m in existingList) m.id}; + toMergeList = toMergeList.where((m) => existingIds.contains(m.id)).toList(); + if (toMergeList.isEmpty) return existingList; + } + + // Batch path: receiver (`existingList`) is maintained sorted as a + // state invariant; `mergeSorted` sorts `toMergeList` internally and + // returns a sorted result. + final mergedMessages = existingList.mergeSorted( + toMergeList, + key: (message) => message.id, + update: update, + compare: _sortByCreatedAt, + ); + + // Refresh embedded `quotedMessage` refs only for messages quoting an + // incoming message that is now deleted. `updateIf` returns the same + // list reference when nothing matches, so steady-state allocates + // nothing for this step. + final deletedIds = toMergeList.where((m) => m.isDeleted).map((m) => m.id).toSet(); + if (deletedIds.isEmpty) return mergedMessages; + + final mergedById = {for (final m in mergedMessages) m.id: m}; + return mergedMessages.updateIf( + (it) => deletedIds.contains(it.quotedMessageId), + (it) => it.copyWith(quotedMessage: mergedById[it.quotedMessageId]), + ); + } + + void _removeMessages(Iterable messages) { + if (messages.isEmpty) return; + + final messageIds = messages.map((m) => m.id).toSet().toList(); + final persistenceClient = _channel.client.chatPersistenceClient; + // Remove the messages from the persistence client. + persistenceClient?.deleteMessageByIds(messageIds); + persistenceClient?.deletePinnedMessageByIds(messageIds); + + _removeThreadMessages(messages); + _removeChannelMessages(messages); + _removePinnedMessages(messages); + _removeActiveLiveLocations(messages); + } + + void _removeThreadMessages(Iterable messages) { + if (messages.isEmpty) return; + + final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; + // If there are no affected threads, return early. + if (affectedThreads.isEmpty) return; + + final updatedThreads = {...threads}; + for (final thread in affectedThreads) { + final threadMessages = updatedThreads[thread]; + // Continue if the thread doesn't exist. + if (threadMessages == null) continue; + + // Remove the deleted message from the thread messages and reference from + // other messages quoting it. + final updatedThreadMessages = _removeMessagesFromExisting( + existing: threadMessages, + toRemove: messages, + ); + + // If there are no more messages in the thread, remove the thread entry. + if (updatedThreadMessages.isEmpty) { + updatedThreads.remove(thread); + continue; + } + + // Otherwise, update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages.toList(); + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _removeChannelMessages(Iterable messages) { + if (messages.isEmpty) return; + + final affectedMessages = messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _removeMessagesFromExisting( + existing: channelMessages, + toRemove: affectedMessages, + ); + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages.toList(), + ); + } + + void _removePinnedMessages(Iterable messages) { + if (messages.isEmpty) return; + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _removePinnedMessagesFromExisting( + existing: pinnedMessages, + toRemove: messages, + ); + + _channelState = _channelState.copyWith( + pinnedMessages: updatedPinnedMessages.toList(), + ); + } + + void _removeActiveLiveLocations(Iterable messages) { + if (messages.isEmpty) return; + + final activeLiveLocations = [...this.activeLiveLocations]; + final updatedActiveLiveLocations = _removeActiveLocationsFromExisting( + existing: activeLiveLocations, + toRemove: messages, + ); + + _channelState = _channelState.copyWith( + activeLiveLocations: updatedActiveLiveLocations.toList(), + ); + } + + Iterable _removeActiveLocationsFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + if (toRemove.isEmpty) return existing; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedLocations = existing.where( + // Remove the location if its attached message is in the toRemove list. + (it) => !toRemoveIds.contains(it.messageId), + ); + + return updatedLocations; + } + + Iterable _removePinnedMessagesFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + return _removeMessagesFromExisting( + existing: existing, + toRemove: toRemove, + ).where(_pinIsValid); + } + + Iterable _removeMessagesFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + if (toRemove.isEmpty) return existing; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedMessages = existing + .where((it) { + // Remove the message if it's in the toRemove list. + return !toRemoveIds.contains(it.id); + }) + .map((it) { + // Continue if the message doesn't quote any of the deleted messages. + if (!toRemoveIds.contains(it.quotedMessageId)) return it; + + // Setting it to null will remove the quoted message from the message. + return it.copyWith(quotedMessageId: null, quotedMessage: null); + }); + + return updatedMessages; + } + + // Listens to user message deleted events and marks messages from that user + // as either soft or hard deleted based on the event data. + void _listenUserMessagesDeleted() { + _subscriptions.add( + _channel.on(EventType.userMessagesDeleted).listen((event) async { + final user = event.user; + if (user == null) return; + + return _deleteMessagesFromUser( + userId: user.id, + hardDelete: event.hardDelete ?? false, + deletedAt: event.createdAt, + ); + }), + ); + } + + /// Call this method to dispose this object. + void dispose() { + _debouncedUpdatePersistenceChannelThreads.cancel(); + _debouncedUpdatePersistenceChannelState.cancel(); + _retryQueue.dispose(); + _subscriptions.cancel(); + _channelStateController.close(); + _isUpToDateController.close(); + _threadsController.close(); + _staleTypingEventsCleanerTimer?.cancel(); + _stalePinnedMessagesCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer?.cancel(); + _typingEventsController.close(); + } +} + +bool _pinIsValid(Message message) { + // If the message is deleted, the pin is not valid. + if (message.isDeleted) return false; + + // If the message is not pinned, it's not valid. + if (message.pinned != true) return false; + + // If there's no expiration, the pin is valid. + final pinExpires = message.pinExpires; + if (pinExpires == null) return true; + + // If there's an expiration, check if it's still valid. + return pinExpires.isAfter(DateTime.now()); +} diff --git a/packages/stream_chat/test/src/client/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel_client_state_test.dart new file mode 100644 index 0000000000..119c5a8012 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel_client_state_test.dart @@ -0,0 +1,5124 @@ +// ignore_for_file: lines_longer_than_80_chars, cascade_invocations, deprecated_member_use_from_same_package, avoid_redundant_argument_values + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + group('WS events', () { + late final client = MockStreamChatClient(); + + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); + + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + // mock channel delivery reporter + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + }); + + group( + '${EventType.messageNew} or ${EventType.notificationMessageNew}', + () { + final initialLastMessageAt = DateTime.now(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + lastMessageAt: initialLastMessageAt, + ); + + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + Event createNewMessageEvent(Message message) { + return Event( + cid: channel.cid, + type: EventType.messageNew, + message: message, + ); + } + + test( + "should update 'channel.lastMessageAt'", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, equals(message.createdAt)); + expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + }, + ); + + test( + "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Message is visible to the current user. + restrictedVisibility: [client.state.currentUser!.id], + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, equals(message.createdAt)); + expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Older than the current 'channel.lastMessageAt'. + createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message is shadowed", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + shadowed: true, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message is ephemeral", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + type: MessageType.ephemeral, + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Message is only visible to user-1 not the current user. + restrictedVisibility: const ['user-1'], + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + when( + () => channel.config?.skipLastMsgUpdateForSystemMsgs, + ).thenReturn(true); + + final message = Message( + type: MessageType.system, + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test("should update 'unreadCount'", () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(1)); + + final message2 = Message( + id: 'test-message-id-2', + user: User(id: 'other-user'), + createdAt: message.createdAt.add(const Duration(seconds: 3)), + ); + + final newMessage2Event = createNewMessageEvent(message2); + client.addEvent(newMessage2Event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(2)); + }); + + group("should not update 'unreadCount'", () { + test( + 'when the message is silent', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + silent: true, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is shadowed', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + shadowed: true, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message type is ephemeral', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + type: MessageType.ephemeral, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is a thread reply', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is a thread reply', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is from the current user', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is not restricted for the current user', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + restrictedVisibility: const ['other-user-2'], + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + }); + + test( + 'should submit channel for delivery when message is received', + () async { + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Verify submitForDelivery was called + verify( + () => client.channelDeliveryReporter.submitForDelivery([channel]), + ).called(1); + }, + ); + + test( + 'should not duplicate when server echoes back an optimistically ' + 'inserted message with a later createdAt', + () async { + // Local message used as the input to `channel.sendMessage`. + final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 3)); + final localMessage = Message( + id: 'test-message-id', + text: 'Hello world!', + user: client.state.currentUser, + createdAt: localCreatedAt, + ); + + // Mock the network send to return the message unchanged so the + // optimistic insert + sent-state update both land on the same + // `createdAt`. The bug fires later, on the WS echo. + final sendMessageResponse = SendMessageResponse() + ..message = localMessage.copyWith(state: MessageState.sent); + when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + + await channel.sendMessage(localMessage); + + expect(channel.state!.messages, hasLength(1)); + + // Server then broadcasts the same message via a `message.new` + // event with a slightly later `createdAt` (server-assigned + // timestamp). + final serverMessage = localMessage.copyWith( + createdAt: localCreatedAt.add(const Duration(milliseconds: 50)), + ); + client.addEvent(createNewMessageEvent(serverMessage)); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // The state should contain exactly one message with that id, + // not a duplicate. + final matching = channel.state!.messages.where((it) => it.id == localMessage.id); + expect(matching, hasLength(1)); + expect(channel.state!.messages, hasLength(1)); + }, + ); + + test( + 'should not duplicate when the locally-sent message is no longer ' + 'the latest (retry-after-offline scenario)', + () async { + // Mirrors the offline-retry flow: a local message is sent, then + // another message arrives via WS while the local one is still + // pending. When the retry finally succeeds the server response's + // `createdAt` is later than the intervening message, so the + // locally-sent copy is no longer `messages.last`. + final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 1)); + final localMessage = Message( + id: 'local-message-id', + text: 'Hello world!', + user: client.state.currentUser, + createdAt: localCreatedAt, + ); + + final sendMessageResponse = SendMessageResponse() + ..message = localMessage.copyWith(state: MessageState.sent); + when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + + await channel.sendMessage(localMessage); + + // Another message arrives via WS with a later `createdAt`, + // pushing the locally-sent message off the tail. + final otherMessage = Message( + id: 'other-message-id', + user: User(id: 'other-user'), + createdAt: localCreatedAt.add(const Duration(seconds: 2)), + ); + client.addEvent(createNewMessageEvent(otherMessage)); + await Future.delayed(Duration.zero); + + // Server then broadcasts the locally-sent message via + // `message.new` with a `createdAt` that is later than the + // intervening message — exactly the shape produced by a + // successful retry after another message arrived in between. + final serverEcho = localMessage.copyWith( + createdAt: otherMessage.createdAt.add(const Duration(seconds: 1)), + ); + client.addEvent(createNewMessageEvent(serverEcho)); + await Future.delayed(Duration.zero); + + final localMatches = channel.state!.messages.where((it) => it.id == localMessage.id); + expect(localMatches, hasLength(1)); + expect(channel.state!.messages, hasLength(2)); + }, + ); + }, + ); + + group( + EventType.messageUpdated, + () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + Event createUpdateMessageEvent(Message message) { + return Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: message, + ); + } + + test( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); + + final newMessageEvent = createUpdateMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + }, + ); + + test( + 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', + () async { + const messageId = 'test-message-id'; + const oldText = 'Old text'; + const newText = 'New text'; + final message = Message( + id: messageId, + user: client.state.currentUser, + text: oldText, + pinned: true, + ); + + final firstUpdateEvent = createUpdateMessageEvent(message); + client.addEvent(firstUpdateEvent); + + // Wait for the first event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + expect(channel.state?.pinnedMessages.first.text, equals(oldText)); + + final updatedMessage = message.copyWith(text: newText); + final secondUpdateEvent = createUpdateMessageEvent(updatedMessage); + client.addEvent(secondUpdateEvent); + + // Wait for the second event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + expect(channel.state?.pinnedMessages.first.text, equals(newText)); + }, + ); + + test( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages " + 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', + () async { + const firstMessageId = 'first-test-message-id'; + const secondMessageId = 'second-test-message-id'; + final firstMessage = Message( + id: firstMessageId, + user: client.state.currentUser, + pinned: true, + ); + final secondMessage = firstMessage.copyWith(id: secondMessageId); + + final firstUpdateEvent = createUpdateMessageEvent(firstMessage); + client.addEvent(firstUpdateEvent); + + // Wait for the first event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect( + channel.state?.pinnedMessages.first.id, + equals(firstMessageId), + ); + + final secondUpdateEvent = createUpdateMessageEvent(secondMessage); + client.addEvent(secondUpdateEvent); + + // Wait for the second event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(2)); + expect( + channel.state?.pinnedMessages.first.id, + equals(firstMessageId), + ); + expect( + channel.state?.pinnedMessages[1].id, + equals(secondMessageId), + ); + }, + ); + + test( + "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", + () async { + const messageId = 'test-message-id'; + final pinnedMessage = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); + + final pinEvent = createUpdateMessageEvent(pinnedMessage); + client.addEvent(pinEvent); + + // Wait for the pin event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + + final unpinnedMessage = pinnedMessage.copyWith(pinned: false); + final unpinEvent = createUpdateMessageEvent(unpinnedMessage); + client.addEvent(unpinEvent); + + // Wait for the unpin event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages, isEmpty); + }, + ); + + // A `message.updated` event for a message outside the loaded window + // would otherwise upsert into the sorted list — creating a phantom + // entry with a gap. The guard is "id not in the loaded list", and + // is independent of `isUpToDate` — even at the latest page we may + // have paginated past older history and receive an event for a + // message no longer in memory. + group('when message is outside the loaded window', () { + test( + 'should NOT insert unknown message into `messages` list', + () async { + // Simulate "we have the latest page but not older history": + // seed the tail messages. + final tail = List.generate( + 3, + (i) => Message( + id: 'tail-$i', + user: client.state.currentUser, + text: 'tail $i', + createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), + ), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: tail), + ); + expect(channel.state!.messages, hasLength(3)); + + // Event for a message on an older page we don't have loaded. + final olderPageEdit = Message( + id: 'older-page-msg', + user: client.state.currentUser, + text: 'edited on older page', + createdAt: DateTime.utc(2025, 1, 1), + ); + client.addEvent(createUpdateMessageEvent(olderPageEdit)); + await Future.delayed(Duration.zero); + + // Tail is unchanged, no phantom entry inserted at position 0. + expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + expect(channel.state!.pinnedMessages, isEmpty); + }, + ); + + test( + 'should update message in place when it IS in the loaded window', + () async { + const messageId = 'known'; + final seeded = Message( + id: messageId, + user: client.state.currentUser, + text: 'old', + createdAt: DateTime.utc(2026), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: [seeded]), + ); + channel.state!.isUpToDate = false; + + final edited = seeded.copyWith(text: 'new'); + client.addEvent(createUpdateMessageEvent(edited)); + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); + expect(stored.text, equals('new')); + }, + ); + + test( + 'should still add to pinnedMessages when pinned:true even if not in loaded window', + () async { + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, isEmpty); + + const messageId = 'pin-me'; + final pinned = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); + client.addEvent(createUpdateMessageEvent(pinned)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages.length, equals(1)); + expect(channel.state!.pinnedMessages.first.id, equals(messageId)); + }, + ); + + test( + 'should NOT insert unknown reply into threads[parentId]', + () async { + const parentId = 'parent-1'; + final knownReply = Message( + id: 'known-reply', + parentId: parentId, + user: client.state.currentUser, + createdAt: DateTime.utc(2026), + ); + // Populate threads[parentId] via addNewMessage's thread-only path. + channel.state!.addNewMessage(knownReply); + await Future.delayed(Duration.zero); + expect(channel.state!.threads[parentId], hasLength(1)); + + channel.state!.isUpToDate = false; + + final phantomReply = Message( + id: 'other-reply', + parentId: parentId, + user: client.state.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + client.addEvent(createUpdateMessageEvent(phantomReply)); + await Future.delayed(Duration.zero); + + expect(channel.state!.threads[parentId]!.map((m) => m.id), ['known-reply']); + }, + ); + + test( + 'should NOT create phantom threads[parentId] entry for unloaded thread', + () async { + const parentId = 'unloaded-parent'; + // The thread was never paged in, so there's no entry for it. + expect(channel.state!.threads.containsKey(parentId), isFalse); + + channel.state!.isUpToDate = false; + + final phantomReply = Message( + id: 'phantom-reply', + parentId: parentId, + user: client.state.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + client.addEvent(createUpdateMessageEvent(phantomReply)); + await Future.delayed(Duration.zero); + + // The dropped reply must not leave behind an empty thread entry. + expect(channel.state!.threads.containsKey(parentId), isFalse); + }, + ); + + test( + 'should still expire activeLiveLocations for out-of-window message', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'loc-msg', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + // Seed only activeLiveLocations, keeping `messages` empty — + // the exact "message is outside the loaded window" scenario. + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + activeLiveLocations: [liveLocation], + ), + ); + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, hasLength(1)); + + // A message.updated that expires the live location. + final expiredMessage = Message( + id: 'loc-msg', + text: 'Live location shared', + sharedLocation: liveLocation.copyWith( + endAt: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + client.addEvent(createUpdateMessageEvent(expiredMessage)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, isEmpty); + }, + ); + }); + }, + ); + + // A reply with `show_in_channel = true` is mirrored into both `messages` + // and `threads[parentId]`. When the thread isn't loaded (fresh hydration, + // user never opened the thread) the channel-level copy is the only place + // locally-cached fields like `ownReactions`/`poll` survive — so reaction + // and message-update events for such replies must still find it. + group( + 'reply events with `show_in_channel = true` and unloaded thread', + () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const replyId = 'mirrored-reply-id'; + const parentId = 'parent-message-id'; + // Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. + final createdAt = DateTime.utc(2026, 1, 1); + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + // Seeds a single reply into the channel-level `messages` while leaving + // `threads[parentId]` empty — the exact regression scenario. + Message seedMirroredReply({ + List ownReactions = const [], + Poll? poll, + }) { + final reply = Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + ownReactions: ownReactions, + poll: poll, + pollId: poll?.id, + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: [reply]), + ); + return reply; + } + + test( + '`reaction.new` from another user preserves `ownReactions`', + () async { + final ownReaction = Reaction( + type: 'like', + messageId: replyId, + user: client.state.currentUser, + ); + seedMirroredReply(ownReactions: [ownReaction]); + // Pre-condition: thread is not loaded. + expect(channel.state!.threads, isEmpty); + + // Server reaction events don't echo back the recipient's own + // reactions, so the listener must pull them from the cached copy. + final otherUserReaction = Reaction( + type: 'love', + messageId: replyId, + user: User(id: 'other-user'), + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionNew, + reaction: otherUserReaction, + message: Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + latestReactions: [otherUserReaction], + ), + ), + ); + + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); + expect(stored.ownReactions, [ownReaction]); + }, + ); + + test( + '`reaction.deleted` strips only the removed reaction', + () async { + final kept = Reaction( + type: 'like', + messageId: replyId, + user: client.state.currentUser, + ); + final removed = Reaction( + type: 'love', + messageId: replyId, + user: client.state.currentUser, + ); + seedMirroredReply(ownReactions: [kept, removed]); + expect(channel.state!.threads, isEmpty); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionDeleted, + reaction: removed, + message: Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + ), + ), + ); + + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); + expect(stored.ownReactions, [kept]); + }, + ); + + test( + '`message.updated` preserves `poll`, `pollId`, and `ownReactions`', + () async { + final ownReaction = Reaction( + type: 'like', + messageId: replyId, + user: client.state.currentUser, + ); + // Partial server updates can omit poll/pollId/ownReactions; the + // cached copy is what backfills them. + final poll = Poll( + id: 'poll-1', + name: 'Pick one', + options: const [ + PollOption(text: 'A'), + PollOption(text: 'B'), + ], + ); + seedMirroredReply(ownReactions: [ownReaction], poll: poll); + expect(channel.state!.threads, isEmpty); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + text: 'edited', + ), + ), + ); + + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); + expect(stored.ownReactions, [ownReaction]); + expect(stored.poll?.id, poll.id); + expect(stored.pollId, poll.id); + }, + ); + }, + ); + + // A `message.deleted` event for a message outside the loaded window + // must not upsert a "deleted" record into the sorted list — that would + // create a phantom entry with a gap. Pinned + live-location + // side-effects must still fire. + group( + EventType.messageDeleted, + () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + Event createDeleteMessageEvent(Message message, {bool hardDelete = false}) { + return Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + hardDelete: hardDelete, + ); + } + + // Same design as the `messageUpdated` guards: the check is + // "message-in-loaded-window" and is independent of `isUpToDate` — + // an event for a message on an older, unloaded page must not be + // turned into a phantom "deleted" record inserted into the sorted + // list. + group('when message is outside the loaded window', () { + test( + 'soft delete does NOT insert phantom "deleted" record into messages', + () async { + final tail = List.generate( + 3, + (i) => Message( + id: 'tail-$i', + user: client.state.currentUser, + text: 'tail $i', + createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), + ), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: tail), + ); + expect(channel.state!.messages, hasLength(3)); + + final olderPage = Message( + id: 'older-page-msg', + user: client.state.currentUser, + text: 'gone', + createdAt: DateTime.utc(2025, 1, 1), + ); + client.addEvent(createDeleteMessageEvent(olderPage)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + }, + ); + + test( + 'soft delete marks message as deleted when it IS in the loaded window', + () async { + const messageId = 'known'; + final seeded = Message( + id: messageId, + user: client.state.currentUser, + text: 'hi', + createdAt: DateTime.utc(2026), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: [seeded]), + ); + channel.state!.isUpToDate = false; + + client.addEvent(createDeleteMessageEvent(seeded)); + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); + expect(stored.type, equals(MessageType.deleted)); + expect(stored.deletedAt, isNotNull); + }, + ); + + test( + 'soft delete unpins a pinned-but-not-in-window message via _pinIsValid', + () async { + const messageId = 'pinned-msg'; + final pinned = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + createdAt: DateTime.utc(2026), + ); + // Seed only the pinnedMessages list — message absent from + // the main `messages` window. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(pinnedMessages: [pinned]), + ); + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, hasLength(1)); + + client.addEvent(createDeleteMessageEvent(pinned)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, isEmpty); + }, + ); + + test( + 'soft delete still clears activeLiveLocations even when message not in window', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'loc-msg', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + // Seed only activeLiveLocations, keeping `messages` empty. + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + activeLiveLocations: [liveLocation], + ), + ); + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, hasLength(1)); + + final locationMessage = Message( + id: 'loc-msg', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + client.addEvent(createDeleteMessageEvent(locationMessage)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, isEmpty); + }, + ); + + test( + 'hard delete is a no-op when message is not in the loaded window', + () async { + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + + final phantom = Message( + id: 'phantom', + user: client.state.currentUser, + text: 'gone', + createdAt: DateTime.utc(2026), + ); + client.addEvent(createDeleteMessageEvent(phantom, hardDelete: true)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, isEmpty); + }, + ); + }); + }, + ); + + group('Member Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test( + 'should update membership when member is updated and is current user', + () async { + final currentUser = client.state.currentUser; + final currentMember = Member(user: currentUser); + final now = DateTime.now(); + + // Setup initial membership + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + expect(channel.membership, isNotNull); + expect(channel.membership?.channelRole, isNull); + expect(channel.membership?.isModerator, false); + expect(channel.isPinned, isFalse); + expect(channel.isArchived, isFalse); + + // Create updated member with same userId but updated properties + final updatedMember = currentMember.copyWith( + channelRole: 'moderator', + isModerator: true, + pinnedAt: now, + archivedAt: now, + ); + + // Create member updated event + final memberUpdatedEvent = Event( + cid: channel.cid, + type: EventType.memberUpdated, + user: currentUser, + member: updatedMember, + ); + + // Dispatch event + client.addEvent(memberUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify membership is updated with new properties + expect(channel.membership, isNotNull); + expect(channel.membership?.userId, equals(currentUser?.id)); + expect(channel.membership?.channelRole, equals('moderator')); + expect(channel.membership?.isModerator, isTrue); + expect(channel.isPinned, isTrue); + expect(channel.isArchived, isTrue); + }, + ); + + test( + 'should update membership user when any event containing user is updated', + () async { + final currentUser = client.state.currentUser; + final currentMember = Member(user: currentUser); + + // Setup initial membership + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + expect(channel.membership, isNotNull); + expect(channel.membership?.user?.id, equals(currentUser?.id)); + expect(channel.membership?.user?.role, equals(currentUser?.role)); + + // Create updated user with same userId but updated properties + final updatedUser = currentUser?.copyWith(role: 'moderator'); + + // Create any event with same updated user as membership. + final anyEvent = Event( + cid: channel.cid, + type: EventType.any, + user: updatedUser, + ); + + // Dispatch event + client.addEvent(anyEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify membership is updated with new properties + expect(channel.membership, isNotNull); + expect(channel.membership?.user?.id, equals(updatedUser?.id)); + expect(channel.membership?.user?.role, equals(updatedUser?.role)); + }, + ); + }); + + group('Watching Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + test( + '${EventType.userWatchingStart} adds the watcher and updates watcherCount', + () async { + final watcher = User(id: 'watcher-1'); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 3, + ), + ); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 3); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + }, + ); + + test( + '${EventType.userWatchingStop} removes the watcher and updates watcherCount', + () async { + final watcher = User(id: 'watcher-1'); + + // The watcher starts watching first (count = 2). + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 2, + ), + ); + await Future.delayed(Duration.zero); + expect(channel.state!.watcherCount, 2); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + + // Then stops watching (count = 1). + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStop, + user: watcher, + watcherCount: 1, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 1); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + isNot(contains('watcher-1')), + ); + }, + ); + + test( + 'watching event without watcherCount preserves the existing count', + () async { + // Seed an initial watcher count. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(watcherCount: 5), + ); + expect(channel.state!.watcherCount, 5); + + // A watching event that omits watcher_count must not wipe the count. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStart, + user: User(id: 'watcher-2'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 5); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + contains('watcher-2'), + ); + }, + ); + + test( + '${EventType.messageNew} updates watcherCount from the event', + () async { + expect(channel.state!.watcherCount, isNull); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: DateTime.now(), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageNew, + message: message, + watcherCount: 7, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 7); + }, + ); + + test( + '${EventType.messageNew} without watcherCount preserves the existing count', + () async { + // Seed an initial watcher count. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(watcherCount: 4), + ); + expect(channel.state!.watcherCount, 4); + + // A local/optimistic message.new without watcher_count must not + // reset the count. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'test-message-id-2', + user: client.state.currentUser, + createdAt: DateTime.now(), + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 4); + }, + ); + + test( + '${EventType.notificationMessageNew} does not overwrite watcherCount', + () async { + // Seed a known watcher count. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(watcherCount: 5), + ); + expect(channel.state!.watcherCount, 5); + + // notification.message_new is delivered to non-watchers and reports + // watcher_count: 0; it must not clobber the real count. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMessageNew, + message: Message( + id: 'notif-message-id', + user: User(id: 'other-user'), + createdAt: DateTime.now(), + ), + watcherCount: 0, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 5); + }, + ); + }); + + group('Read Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ); + + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should update read state on message read event', () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + + // Create message read event + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(messageReadEvent); + + // 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, 'test-user'); + expect(updatedRead?.unreadMessages, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); + }); + + test( + 'should add a new read state if not exist on message read event', + () async { + // Create the current read state + final currentUser = User(id: 'test-user'); + + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); + + // Create mark read notification event + final markReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(markReadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read list has not changed + final updated = channel.state?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); + }, + ); + + test( + 'should not update channel read state on thread message 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 channel read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, 'channel-msg-1'); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + + // Create a thread-scoped message.read event (thread != null) + final threadMessageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + 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, + ), + ); + + // Dispatch event + client.addEvent(threadMessageReadEvent); + + // 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 update read state on notification mark unread event', () async { + // Create the current read state + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + + // Create mark unread notification event + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: currentUser, + lastReadAt: DateTime(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); + + // Dispatch event + client.addEvent(markUnreadEvent); + + // 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, 'test-user'); + expect(updatedRead?.unreadMessages, 15); + expect(updatedRead?.lastReadMessageId, 'message-100'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2019)), isTrue); + }); + + test( + 'should add a new read state if not exist on notification mark unread', + () async { + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); + + // Create event for non-existing user + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: User(id: 'non-existing-user'), + lastReadAt: DateTime(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); + + // Dispatch event + client.addEvent(markUnreadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read list has not changed + final updated = channel.state?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); + }, + ); + + test( + 'should preserve delivery info on message 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 with delivery info + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.lastDeliveredAt, isNotNull); + expect( + read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); + + // Create message read event (doesn't include delivery info) + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(messageReadEvent); + + // 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?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + // Delivery info should be preserved + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + test( + 'should reconcile delivery when message read event is from current user', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith(id: 'current-user-id'); + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + // Create message read event from current user + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(messageReadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + + 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); + final currentRead = Read( + user: currentUser, + lastRead: distantPast, + unreadMessages: 5, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state has no delivery info + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.lastDeliveredAt, isNull); + expect(read?.lastDeliveredMessageId, isNull); + + // Create message delivered event + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify delivery state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'message-456'); + }); + + test( + 'should add a new read state if not exist on message delivered event', + () async { + final newUser = User(id: 'new-user'); + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); + + // Create message delivered event for new user + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: newUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-789', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state was created with delivery info + final updated = channel.state?.read; + expect(updated?.length, 1); + final newRead = updated?.first; + expect(newRead?.user.id, 'new-user'); + expect(newRead?.lastDeliveredAt, isNotNull); + expect( + newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(newRead?.lastDeliveredMessageId, 'message-789'); + // lastRead should default to distantPast + expect( + newRead?.lastRead.isAtSameMomentAs(distantPast), + isTrue, + ); + }, + ); + + test( + 'should preserve read info on message delivered event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastReadMessageId: 'read-msg-123', + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, 'read-msg-123'); + + // Create message delivered event (doesn't include read info) + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify delivery state is updated but read info is preserved + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + // Read info should be preserved + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), + isTrue, + ); + expect(updatedRead?.unreadMessages, 10); + expect(updatedRead?.lastReadMessageId, 'read-msg-123'); + }, + ); + + test( + 'should reconcile delivery when message delivered event is from current user', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith(id: 'current-user-id'); + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + // Create message delivered event from current user + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + }); + + group('Draft events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle draft.updated event for channel drafts', () async { + // Verify initial state + expect(channel.state?.draft, isNull); + + // Create Draft + final draft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: draft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel draft was updated + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'test message'); + }); + + test('should handle draft.updated event for thread drafts', () async { + const threadParentMessageId = 'thread-parent-id'; + + // Setup initial state with a regular message + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + ), + ); + + // Verify initial state + expect(channel.state?.threadDraft(threadParentMessageId), isNull); + + // Create thread Draft + final draft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: draft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread draft was updated + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + }); + + test('should handle draft.deleted event for channel drafts', () async { + // Setup initial state with a draft + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + draft: Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ), + ), + ); + + // Verify initial state + final draft = channel.state?.draft; + expect(draft, isNotNull); + expect(draft?.message.text, 'test message'); + + // Create draft.deleted event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftDeleted, + draft: draft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel draft was updated + expect(channel.state?.draft, isNull); + }); + + test('should handle draft.deleted event for thread drafts', () async { + const threadParentMessageId = 'thread-parent-id'; + + // Setup initial state with a thread draft + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + draft: Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ), + ), + ); + + // Verify initial state + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + + // Create draft.deleted event + final draftDeletedEvent = Event( + cid: channel.cid, + type: EventType.draftDeleted, + draft: threadDraft, + ); + + // Dispatch event + client.addEvent(draftDeletedEvent); + + // Allow event to be processed + await Future.delayed(Duration.zero); + + // Verify thread draft was removed + expect(channel.state?.threadDraft(threadParentMessageId), isNull); + }); + + test( + 'should update current channel draft if draft.updated event is emitted', + () async { + // Setup initial state with a draft + final initialDraft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + draft: initialDraft, + ), + ); + + // Verify initial state + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'test message'); + + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated message'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel draft was updated + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'updated message'); + }, + ); + + test( + 'should update current thread draft if draft.updated event is emitted', + () async { + const threadParentMessageId = 'thread-parent-id'; + + // Setup initial state with a thread draft + final initialDraft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + draft: initialDraft, + ), + ); + + // Verify initial state + final draft = channel.state?.threadDraft(threadParentMessageId); + expect(draft, isNotNull); + expect(draft?.message.text, 'thread reply'); + + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated thread reply'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread draft was updated + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'updated thread reply'); + }, + ); + }); + + group('Reminder events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle reminder.created event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message without reminder + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + ); + + channel.state?.updateMessage(message); + + // Verify initial state - no reminder + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: DateTime.now().add(const Duration(days: 30)), + ); + + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); + + // Dispatch event + client.addEvent(reminderCreatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was added + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }); + + test('should handle reminder.updated event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(message); + + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); + + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); + + // Create reminder.updated event + final reminderUpdatedEvent = Event( + cid: channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + // Dispatch event + client.addEvent(reminderUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was updated + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }); + + test('should handle reminder.deleted event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(message); + + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Create reminder.deleted event + final reminderDeletedEvent = Event( + cid: channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ); + + // Dispatch event + client.addEvent(reminderDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was removed + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }); + + test('should handle reminder.created event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message without reminder + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + // `Message.createdAt` falls back to `DateTime.now()` per call when + // not provided, which breaks merge/sort keyed on createdAt. + createdAt: DateTime.now(), + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state - no reminder + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); + + // Dispatch event + client.addEvent(reminderCreatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was added + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }); + + test('should handle reminder.updated event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, + // `Message.createdAt` falls back to `DateTime.now()` per call when + // not provided, which breaks merge/sort keyed on createdAt. + createdAt: DateTime.now(), + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); + + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); + + // Create reminder.updated event + final reminderUpdatedEvent = Event( + cid: channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + // Dispatch event + client.addEvent(reminderUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was updated + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }); + + test('should handle reminder.deleted event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, + // Explicit `createdAt` so `Message.createdAt` is deterministic + // across reads — without one it falls back to `DateTime.now()` + // on every call, which breaks any sort/merge keyed on createdAt. + createdAt: DateTime.now(), + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Create reminder.deleted event + final reminderDeletedEvent = Event( + cid: channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ); + + // Dispatch event + client.addEvent(reminderDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was removed + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }); + }); + + group('Location events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle location.shared event', () async { + // Verify initial state + expect(channel.state?.activeLiveLocations, isEmpty); + + // Create live location + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: locationMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message, isNotNull); + + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg1')); + }); + + test('should handle location.updated event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + channel.state?.addNewMessage(locationMessage); + + // Create updated location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + ); + + final updatedMessage = locationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Create location.updated event + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedMessage, + ); + + // Dispatch event + client.addEvent(locationUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.latitude, equals(40.7500)); + expect(message?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); + + test('should handle location.expired event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create expired location + final expiredLocation = liveLocation.copyWith( + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final expiredMessage = locationMessage.copyWith( + sharedLocation: expiredLocation, + ); + + // Create location.expired event + final locationExpiredEvent = Event( + cid: channel.cid, + type: EventType.locationExpired, + message: expiredMessage, + ); + + // Dispatch event + client.addEvent(locationExpiredEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.isExpired, isTrue); + + // Check if active live location was removed + expect(channel.state?.activeLiveLocations, isEmpty); + }); + + test('should not add static location to active locations', () async { + final staticLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + // No endAt - static location + ); + + final staticMessage = Message( + id: 'msg1', + text: 'Static location shared', + sharedLocation: staticLocation, + ); + + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: staticMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation, isNotNull); + + // Check if active live location was NOT updated (should remain empty) + expect(channel.state?.activeLiveLocations, isEmpty); + }); + + test( + 'should update active locations when location message is deleted', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Verify initial state + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + final messageDeletedEvent = Event( + type: EventType.messageDeleted, + cid: channel.cid, + message: locationMessage.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + ); + + // Dispatch event + client.addEvent(messageDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify active locations are updated + expect(channel.state?.activeLiveLocations, isEmpty); + }, + ); + + test('should merge locations with same key', () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create new location with same user, channel, and device + final newLocation = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', // Different message + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device1', // Same device + endAt: DateTime.now().add(const Duration(hours: 2)), + ); + + final newMessage = Message( + id: 'msg2', + text: 'Updated location', + sharedLocation: newLocation, + ); + + // Create location.shared event for the new message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: newMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Should still have only one active location (merged) + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg2')); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + }); + + test( + 'should handle multiple active locations from different devices', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add first location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create location from different device + final location2 = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device2', // Different device + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final message2 = Message( + id: 'msg2', + text: 'Location from device 2', + sharedLocation: location2, + ); + + // Create location.shared event for the second message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: message2, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Should have two active locations + expect(channel.state?.activeLiveLocations, hasLength(2)); + }, + ); + + test('should handle location messages in threads', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + // Add parent message first for setup + channel.state?.addNewMessage(parentMessage); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Create location.shared event for the thread message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: threadLocationMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was added + final thread = channel.state?.threads['parent1']; + expect(thread, contains(threadLocationMessage)); + + // Check if location was added to active locations + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); + }); + + test('should update thread location messages', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Add messages + channel.state?.addNewMessage(parentMessage); + channel.state?.addNewMessage(threadLocationMessage); + + // Update the location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, + longitude: -74.1000, + ); + + final updatedThreadMessage = threadLocationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Create location.updated event for the thread message + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedThreadMessage, + ); + + // Dispatch event + client.addEvent(locationUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was updated + final thread = channel.state?.threads['parent1']; + final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); + expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); + expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); + }); + + group('Channel push preference events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle channel.push_preference.updated event', () async { + // Verify initial state + expect(channel.state?.channelState.pushPreferences, isNull); + + // Create channel push preference + final channelPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.mentions, + disabledUntil: DateTime.now().add(const Duration(hours: 1)), + ); + + // Create channel.push_preference.updated event + final channelPushPreferenceUpdatedEvent = Event( + cid: channel.cid, + type: EventType.channelPushPreferenceUpdated, + channelPushPreference: channelPushPreference, + ); + + // Dispatch event + client.addEvent(channelPushPreferenceUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel push preferences were updated + final updatedPreferences = channel.state?.channelState.pushPreferences; + expect(updatedPreferences, isNotNull); + expect(updatedPreferences?.chatLevel, ChatLevel.mentions); + expect( + updatedPreferences?.disabledUntil, + channelPushPreference.disabledUntil, + ); + }); + + test('should update existing channel push preferences', () async { + // Set initial push preferences + const initialPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.all, + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + pushPreferences: initialPushPreference, + ), + ); + + // Verify initial state + final pushPreferences = channel.state?.channelState.pushPreferences; + expect(pushPreferences?.chatLevel, ChatLevel.all); + expect(pushPreferences?.disabledUntil, isNull); + + // Create updated channel push preference + final updatedPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.none, + disabledUntil: DateTime.now().add(const Duration(hours: 2)), + ); + + // Create channel.push_preference.updated event + final channelPushPreferenceUpdatedEvent = Event( + cid: channel.cid, + type: EventType.channelPushPreferenceUpdated, + channelPushPreference: updatedPushPreference, + ); + + // Dispatch event + client.addEvent(channelPushPreferenceUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel push preferences were updated + final updatedPreferences = channel.state?.channelState.pushPreferences; + expect(updatedPreferences?.chatLevel, ChatLevel.none); + expect( + updatedPreferences?.disabledUntil, + updatedPushPreference.disabledUntil, + ); + }); + }); + + group('User messages deleted event', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + late MockPersistenceClient persistenceClient; + + setUp(() { + persistenceClient = MockPersistenceClient(); + when(() => client.chatPersistenceClient).thenReturn(persistenceClient); + when( + () => persistenceClient.deleteMessagesFromUser( + cid: any(named: 'cid'), + userId: any(named: 'userId'), + hardDelete: any(named: 'hardDelete'), + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async {}); + when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => >{}); + + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test( + 'should soft delete all messages from user when hardDelete is false', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-1').length, + equals(2), + ); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-2').length, + equals(1), + ); + + // Create user.messages.deleted event (soft delete) + final deletedAt = DateTime.now(); + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + createdAt: deletedAt, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are soft deleted + expect(channel.state?.messages.length, equals(3)); + final deletedMessages = channel.state?.messages.where((m) => m.user?.id == 'user-1').toList(); + expect(deletedMessages?.length, equals(2)); + for (final message in deletedMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.deletedAt, isNotNull); + expect(message.state.isDeleted, isTrue); + } + + // Verify user2's message is unaffected + final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message?.type, isNot(MessageType.deleted)); + expect(user2Message?.deletedAt, isNull); + }, + ); + + test( + 'should hard delete all messages from user when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are removed + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify user2's message still exists + final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message, isNotNull); + expect(user2Message?.user?.id, equals('user-2')); + }, + ); + + test( + 'should handle thread messages from user', + () async { + // Setup: Add parent and thread messages + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentMessage = Message( + id: 'parent-msg', + text: 'Parent message', + user: user2, + ); + final threadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + final threadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + + channel.state?.addNewMessage(parentMessage); + channel.state?.addNewMessage(threadMessage1); + channel.state?.addNewMessage(threadMessage2); + + // Verify initial state + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.threads['parent-msg']?.length, equals(2)); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread messages are soft deleted + final threadMessages = channel.state?.threads['parent-msg']; + expect(threadMessages?.length, equals(2)); + for (final message in threadMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.state.isDeleted, isTrue); + } + + // Verify parent message is unaffected + final parent = channel.state?.messages.first; + expect(parent?.type, isNot(MessageType.deleted)); + }, + ); + + test( + 'should do nothing when user is null', + () async { + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + channel.state?.addNewMessage(message1); + + // Verify initial state + expect(channel.state?.messages.length, equals(1)); + + // Create user.messages.deleted event without user + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify messages are unaffected + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.first.type, + isNot(MessageType.deleted), + ); + }, + ); + + test( + 'should handle empty message list', + () async { + // Setup: Empty channel + expect(channel.state?.messages.length, equals(0)); + + // Create user.messages.deleted event + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: User(id: 'user-1'), + hardDelete: false, + ); + + // Dispatch event - should not throw + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify state is still empty + expect(channel.state?.messages.length, equals(0)); + }, + ); + + test( + 'should delete messages from persistence when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify messages are removed from persistence + verify( + () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), + ).called(1); + verify( + () => persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), + ).called(1); + + // Verify user1's messages are removed from state + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + }, + ); + + test( + 'should not delete from persistence when hardDelete is false', + () async { + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + channel.state?.addNewMessage(message1); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify persistence deletion methods were NOT called + verifyNever(() => persistenceClient.deleteMessageByIds(any())); + verifyNever(() => persistenceClient.deletePinnedMessageByIds(any())); + + // Verify message is soft deleted (still in state) + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.messages.first.type, equals(MessageType.deleted)); + }, + ); + + test( + 'should delete all user messages including those only in storage', + () async { + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final stateMessage1 = Message( + id: 'msg-1', + text: 'Message from user 1 in state', + user: user1, + pinned: true, + ); + final stateMessage2 = Message( + id: 'msg-2', + text: 'Message from user 2 in state', + user: user2, + ); + final stateThreadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1 in state', + user: user1, + parentId: 'msg-1', + ); + final stateThreadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 2 in state', + user: user2, + parentId: 'msg-1', + ); + + // Load the state with only 2 messages and 1 thread with 2 replies. + // Note: In reality, storage may contain many more user1 messages + // (e.g., older messages not loaded into state yet), but the delete + // operation should remove ALL of them from storage. + channel.state?.addNewMessage(stateMessage1); + channel.state?.addNewMessage(stateMessage2); + channel.state?.addNewMessage(stateThreadMessage1); + channel.state?.addNewMessage(stateThreadMessage2); + + // Verify initial state has only 2 messages and 1 thread with 2 replies + expect(channel.state?.messages.length, equals(2)); + expect(channel.state?.threads['msg-1']?.length, equals(2)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are removed from state + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.threads['msg-1']?.length, equals(1)); + + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + expect( + channel.state?.threads['msg-1']?.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify persistence delete was called - this handles ALL messages + // in storage (both those in state AND those only in storage) + verify( + () => persistenceClient.deleteMessagesFromUser( + cid: channel.cid, + userId: user1.id, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + + // Verify in-state messages were also removed from state's persistence + final capturedIds = + verify( + () => persistenceClient.deleteMessageByIds(captureAny()), + ).captured.first + as List; + + expect( + capturedIds, + containsAll([ + 'msg-1', // state message + 'thread-msg-1', // state thread message + ]), + ); + }, + ); + + test( + 'should delete every authored message across threads without ' + 'cross-thread leakage (regression: _updateThreadMessages)', + () async { + // user-1 authors a top-level message AND replies in two different + // threads (owned by user-2). The user.messages.deleted flow + // collects everything from user-1 across channel + threads and + // routes it through a single _updateMessages batch — historically + // this batch was passed unfiltered to every affected thread's + // merge, so replies to thread A leaked into thread B and v.v. + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); + final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); + + final topLevelFromUser1 = Message( + id: 'top-1', + text: 'user-1 top-level message', + user: user1, + ); + final replyA = Message( + id: 'reply-A', + text: 'user-1 reply in thread A', + user: user1, + parentId: 'parent-A', + ); + final replyB = Message( + id: 'reply-B', + text: 'user-1 reply in thread B', + user: user1, + parentId: 'parent-B', + ); + + channel.state?.addNewMessage(parentA); + channel.state?.addNewMessage(parentB); + channel.state?.addNewMessage(topLevelFromUser1); + channel.state?.addNewMessage(replyA); + channel.state?.addNewMessage(replyB); + + // Initial state: each thread has exactly its own reply. + expect( + channel.state?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + ); + expect( + channel.state?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + ); + + // Trigger the multi-thread batch via user.messages.deleted. + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + client.addEvent(userMessagesDeletedEvent); + await Future.delayed(Duration.zero); + + // 1) Thread membership is preserved — no cross-thread leakage. + // Without the fix, replyB would leak into thread A and v.v. + expect( + channel.state?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + reason: 'thread A must not contain replies from thread B', + ); + expect( + channel.state?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + reason: 'thread B must not contain replies from thread A', + ); + + // 2) Every message authored by user-1 is soft-deleted — top-level + // AND in both threads. The fix must not narrow this scope. + expect( + channel.state?.messages.firstWhere((m) => m.id == 'top-1').type, + equals(MessageType.deleted), + reason: 'top-level user-1 message must be deleted', + ); + expect( + channel.state?.threads['parent-A']?.first.type, + equals(MessageType.deleted), + reason: 'thread A reply from user-1 must be deleted', + ); + expect( + channel.state?.threads['parent-B']?.first.type, + equals(MessageType.deleted), + reason: 'thread B reply from user-1 must be deleted', + ); + + // 3) Other users' messages are unaffected. + expect( + channel.state?.messages.firstWhere((m) => m.id == 'parent-A').type, + isNot(MessageType.deleted), + ); + expect( + channel.state?.messages.firstWhere((m) => m.id == 'parent-B').type, + isNot(MessageType.deleted), + ); + }, + ); + }); + }); + + 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 {}); + when( + () => client.channelDeliveryReporter.reconcileDelivery(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( + 'markRead reconciles pending delivery receipts', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 2; + + await expectLater(channel.markRead(), completes); + + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + + group('local read boundary anchors', () { + final start = DateTime(2024, 1, 1); + final messages = [ + Message( + id: 'm1', + text: '1', + user: User(id: 'other-user'), + createdAt: start, + ), + Message( + id: 'm2', + text: '2', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 1)), + ), + Message( + id: 'm3', + text: '3', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 2)), + ), + ]; + + test( + 'markUnread is inclusive of the anchor and points lastReadMessageId at ' + 'the previous message', + () async { + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + await expectLater(channel.markUnread('m2'), completes); + + // m2 (the anchor) and m3 are unread; m1 stays read. + expect(channel.state?.unreadCount, equals(2)); + expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m1')); + verifyNever(() => client.markChannelUnread(any(), any(), any())); + }, + ); + + test( + 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' + 'known message', + () async { + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + await expectLater(channel.markUnread('m1'), completes); + + expect(channel.state?.unreadCount, equals(3)); + expect(channel.state?.currentUserRead?.lastReadMessageId, isNull); + }, + ); + + test( + 'markUnreadByTimestamp is exclusive of the boundary and points ' + 'lastReadMessageId at the newest message at or before it', + () async { + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + // Exactly m2's createdAt: m2 stays read, only m3 becomes unread. + await expectLater(channel.markUnreadByTimestamp(messages[1].createdAt), completes); + + expect(channel.state?.unreadCount, equals(1)); + expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m2')); + verifyNever(() => client.markChannelUnreadByTimestamp(any(), any(), any())); + }, + ); + + test( + 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' + 'differ by the anchor message', + () async { + final byId = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + final byTimestamp = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + await byId.markUnread('m2'); + await byTimestamp.markUnreadByTimestamp(messages[1].createdAt); + + // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. + expect(byId.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.unreadCount, equals(1)); + + // ...and they agree once the timestamp is nudged below the anchor. + await byTimestamp.markUnreadByTimestamp( + messages[1].createdAt.subtract(const Duration(microseconds: 1)), + ); + expect(byTimestamp.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); + }, + ); + }); + + 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'; + 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()); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + }); + + Channel _seededChannel() { + final base = _generateChannelState(channelId, channelType); + final now = DateTime.now(); + final seeded = base.copyWith( + messages: [ + Message(id: 'm1', text: '1', createdAt: now), + Message(id: 'm2', text: '2', createdAt: now.add(const Duration(seconds: 1))), + Message(id: 'm3', text: '3', createdAt: now.add(const Duration(seconds: 2))), + ], + ); + return Channel.fromState(client, seeded); + } + + test( + 'preserves messages reference when updatedState.messages is null', + () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final before = channel.state!.messages; + channel.state!.updateChannelState( + ChannelState(channel: channel.state!.channelState.channel), + ); + final after = channel.state!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + test( + 'preserves messages reference when updatedState.messages is identical', + () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final before = channel.state!.messages; + // copyWith without messages keeps the same `messages` reference, so + // updateChannelState should hit the identity-guard fast path. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + read: [ + Read( + user: User(id: 'me'), + lastRead: DateTime.now(), + unreadMessages: 1, + ), + ], + ), + ); + final after = channel.state!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + test( + 'still merges messages when updatedState.messages is a different list', + () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final newMessage = Message( + id: 'm4', + text: '4', + createdAt: DateTime.now().add(const Duration(seconds: 10)), + ); + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + messages: [newMessage], + ), + ); + + expect( + channel.state!.messages.map((m) => m.id), + ['m1', 'm2', 'm3', 'm4'], + ); + }, + ); + + test('cold-path merge interleaves new messages in sorted order', () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final base = channel.state!.messages.first.createdAt; + // Incoming list is sorted ascending by createdAt and slots between + // the existing m1, m2, m3. + final incoming = [ + Message( + id: 'm1.5', + text: 'between m1 and m2', + createdAt: base.add(const Duration(milliseconds: 500)), + ), + Message( + id: 'm2.5', + text: 'between m2 and m3', + createdAt: base.add(const Duration(milliseconds: 1500)), + ), + ]; + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + messages: incoming, + ), + ); + + expect( + channel.state!.messages.map((m) => m.id), + ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], + ); + }); + + test('cold-path merge runs syncWith on overlapping ids', () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final localStamp = DateTime.now(); + // Seed m2 with a localCreatedAt that the incoming version doesn't + // carry, so we can verify syncWith fired during the merge. + channel.state!.updateMessage( + Message( + id: 'm2', + text: '2', + createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ).copyWith(localCreatedAt: localStamp), + ); + + final incoming = [ + Message( + id: 'm2', + text: '2 (server)', + createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ), + ]; + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + messages: incoming, + ), + ); + + final m2 = channel.state!.messages.firstWhere((m) => m.id == 'm2'); + expect(m2.text, '2 (server)'); + // Local-only field carried over by syncWith during the merge. + expect(m2.localCreatedAt, localStamp); + }); + }); + + group('updateMessage quoted-rewrite', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + 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()); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + }); + + Channel _seededChannel({required List messages}) { + final base = _generateChannelState(channelId, channelType); + return Channel.fromState(client, base.copyWith(messages: messages)); + } + + test( + 'rewrites quotedMessage on every quoter when target is deleted', + () { + final now = DateTime.now(); + final target = Message(id: 'target', text: 'hi', createdAt: now); + final quoter1 = Message( + id: 'q1', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + final unrelated = Message( + id: 'u1', + text: 'other', + createdAt: now.add(const Duration(seconds: 2)), + ); + final quoter2 = Message( + id: 'q2', + text: 'reply2', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 3)), + ); + + final channel = _seededChannel(messages: [target, quoter1, unrelated, quoter2]); + addTearDown(channel.dispose); + + final unrelatedBefore = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + channel.state!.updateMessage(deleted); + + final after = channel.state!.messages; + final q1After = after.firstWhere((m) => m.id == 'q1'); + final q2After = after.firstWhere((m) => m.id == 'q2'); + final uAfter = after.firstWhere((m) => m.id == 'u1'); + + expect(q1After.quotedMessage?.deletedAt, isNotNull); + expect(q1After.quotedMessage?.type, MessageType.deleted); + expect(q2After.quotedMessage?.deletedAt, isNotNull); + expect(q2After.quotedMessage?.type, MessageType.deleted); + // Unrelated messages must not be rebuilt by the rewrite. + expect(identical(uAfter, unrelatedBefore), isTrue); + }, + ); + + test( + 'preserves messages reference when no message quotes the deleted one', + () { + final now = DateTime.now(); + final target = Message(id: 'target', text: 'hi', createdAt: now); + final unrelated = Message( + id: 'u1', + text: 'other', + createdAt: now.add(const Duration(seconds: 1)), + ); + + final channel = _seededChannel(messages: [target, unrelated]); + addTearDown(channel.dispose); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + channel.state!.updateMessage(deleted); + + // No message quotes `target`, so `updateIf` short-circuits and the + // remaining messages keep their identities (only `target` itself was + // replaced by `sortedUpsert`). + final unrelatedAfter = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + expect(identical(unrelatedAfter, unrelated), isTrue); + }, + ); + + test( + 'does not rewrite quotes when an existing quoted target is updated ' + 'without being deleted', + () { + final now = DateTime.now(); + final target = Message(id: 'target', text: 'original', createdAt: now); + final quoter = Message( + id: 'q1', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + + final channel = _seededChannel(messages: [target, quoter]); + addTearDown(channel.dispose); + + final quoterBefore = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + + // Plain text update — not a deletion. + channel.state!.updateMessage(target.copyWith(text: 'edited')); + + final quoterAfter = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + // `updateIf` is gated on `message.isDeleted`, so the quoter must keep + // its identity (no allocation, no quoted-message overwrite). + expect(identical(quoterAfter, quoterBefore), isTrue); + }, + ); + }); + + group('Message enrichment preservation on merge', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUpAll(() { + registerFallbackValue(FakeMessage()); + registerFallbackValue([]); + + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + }); + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + clearInteractions(client); + }); + + test( + 'preserves the `poll` on a quotedMessage when the server omits it during ' + 're-sync (regression: poll quote disappears after foregrounding)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-1', + name: 'Pizza or pasta?', + options: const [ + PollOption(id: 'opt-1', text: 'Pizza'), + PollOption(id: 'opt-2', text: 'Pasta'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-1', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-1', + text: 'Voting now', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + // Seed channel state with the fully-enriched messages (mirrors what + // the local DB load produces). + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate a re-sync from the API: the server echoes the reply with + // a `quoted_message` that has only `poll_id` (no `poll` object). + // Constructed directly (not via copyWith) because copyWith cannot + // clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.id, pollMessage.id); + expect(mergedReply.quotedMessage!.poll, isNotNull); + expect(mergedReply.quotedMessage!.poll!.id, poll.id); + expect(mergedReply.quotedMessage!.poll!.name, poll.name); + }, + ); + + test( + 'preserves a nested quotedMessage (poll) two levels deep when the ' + 'server omits it during re-sync (regression: quote-of-quote of a poll ' + 'disappears completely after foregrounding)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-2', + name: 'Coffee or tea?', + options: const [ + PollOption(id: 'opt-a', text: 'Coffee'), + PollOption(id: 'opt-b', text: 'Tea'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-2', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-A', + text: 'My pick', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'user-a'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + final replyToReply = Message( + id: 'reply-B', + text: 'Same here', + quotedMessageId: replyToPoll.id, + quotedMessage: replyToPoll, + user: User(id: 'user-b'), + createdAt: DateTime.utc(2026, 4, 29, 12), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll, replyToReply], + ), + ); + + // Simulate the server response where: + // - replyA's nested quoted poll is missing the `poll` object. + // - replyB's nested quoted replyA is missing its own `quoted_message` + // (the server typically does not nest two levels deep). + // Stripped poll snapshot is constructed directly because copyWith + // cannot clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); + + final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], + ), + ); + + final mergedReplyA = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + final mergedReplyB = channel.state?.messages.firstWhere((it) => it.id == replyToReply.id); + + // First-level quote (reply A's quote of the poll) must keep the poll. + expect(mergedReplyA?.quotedMessage?.poll, isNotNull); + expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); + + // Second-level quote (reply B's quote of reply A) must keep reply A's + // own nested quotedMessage so the poll preview still resolves. + expect(mergedReplyB?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); + }, + ); + + test( + 'still preserves quotedMessage when the updated payload has no ' + 'quoted_message at all (existing behavior should not regress)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-3', + name: 'Beach or mountains?', + options: const [ + PollOption(id: 'opt-x', text: 'Beach'), + PollOption(id: 'opt-y', text: 'Mountains'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-3', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-3', + text: 'Definitely beach', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate an update event that touches the reply but doesn't echo + // the nested quoted_message at all (only quotedMessageId is set). + final reSyncedReply = Message( + id: replyToPoll.id, + text: 'Definitely beach (edited)', + quotedMessageId: pollMessage.id, + user: replyToPoll.user, + createdAt: replyToPoll.createdAt, + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.text, 'Definitely beach (edited)'); + expect(mergedReply.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.poll?.id, poll.id); + }, + ); + + test( + 'preserves the top-level `poll` when the server emits a `message.updated`' + ' that omits the `poll` object (regression: poll disappears from the ' + 'parent message after a thread reply is added)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-thread', + name: 'What is for lunch?', + options: const [ + PollOption(id: 'opt-1', text: 'Burgers'), + PollOption(id: 'opt-2', text: 'Salads'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'parent-poll-msg', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + replyCount: 0, + ); + + // Seed channel state with the fully-enriched parent poll message. + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + // Simulate the `message.updated` event the backend fires for the + // parent after a thread reply is added: bookkeeping fields are bumped + // (`reply_count`, `updated_at`) but the `poll` object is omitted from + // the payload — only `pollId` is set. Constructed directly because + // copyWith cannot clear `poll` — see Message.copyWith. + final strippedParentUpdate = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + replyCount: 1, + updatedAt: DateTime.utc(2026, 4, 29, 11), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: strippedParentUpdate, + ), + ); + + // Wait for the event to be processed. + await Future.delayed(Duration.zero); + + final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + + // Parent poll message must remain in the channel state after a thread reply. + expect(merged, isNotNull); + // Bookkeeping fields from the event should still apply. + expect(merged!.replyCount, 1); + // Locally-known poll must be preserved when the server omits it from a + // `message.updated` payload (e.g. when a thread reply bumps reply_count). + expect(merged.poll, isNotNull); + expect(merged.poll!.id, poll.id); + expect(merged.poll!.name, poll.name); + expect(merged.pollId, poll.id); + }, + ); + + test( + 'still uses the updated `poll` when the server includes one in ' + '`message.updated` (poll edits should not be reverted to the locally ' + 'cached version)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-edit', + name: 'Initial name', + options: const [ + PollOption(id: 'opt-1', text: 'Original A'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'edit-parent', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + final updatedPoll = poll.copyWith(name: 'Edited name'); + final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: updatedParent, + ), + ); + + await Future.delayed(Duration.zero); + + final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + + // Server-echoed poll must override the locally cached one — poll edits + // should not be reverted by the local-fallback merge. + expect(merged?.poll, isNotNull); + expect(merged?.poll?.name, 'Edited name'); + }, + ); + }); +} + +// region Test Helpers + +ChannelState _generateChannelState( + String channelId, + String channelType, { + DateTime? lastMessageAt, + List? ownCapabilities, + bool mockChannelConfig = false, +}) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ownCapabilities: ownCapabilities, + lastMessageAt: lastMessageAt, + ); + return ChannelState(channel: channel); +} + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +// endregion diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index b0959ee164..2ae088ff30 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -4941,15 +4941,12 @@ void main() { }); }); - group('WS events', () { + group('Channel State Validation and Cooldown', () { late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); - // detached loggers when(() => client.detachedLogger(any())).thenAnswer((invocation) { final name = invocation.positionalArguments.first; @@ -4975,4852 +4972,488 @@ void main() { ).thenAnswer((_) async {}); }); - group( - '${EventType.messageNew} or ${EventType.notificationMessageNew}', - () { - final initialLastMessageAt = DateTime.now(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - lastMessageAt: initialLastMessageAt, - ); - - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - Event createNewMessageEvent(Message message) { - return Event( - cid: channel.cid, - type: EventType.messageNew, - message: message, - ); - } - - test( - "should update 'channel.lastMessageAt'", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); - - test( - "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Message is visible to the current user. - restrictedVisibility: [client.state.currentUser!.id], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Older than the current 'channel.lastMessageAt'. - createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when Message is shadowed", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - shadowed: true, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when Message is ephemeral", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - type: MessageType.ephemeral, - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + group('Non-initialized channel state validation', () { + test( + 'should throw StateError when accessing cooldown on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(() => channel.cooldown, throwsA(isA())); + }, + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test( + 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should throw StateError when accessing cooldownStream on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); + }); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); + group('Initialized channel cooldown functionality', () { + late Channel channel; - test( - "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Message is only visible to user-1 not the current user. - restrictedVisibility: const ['user-1'], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + tearDown(() => channel.dispose()); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should return default cooldown value of 0 for initialized channel', + () => expect(channel.cooldown, equals(0)), + ); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, + test('should return custom cooldown value when set in channel model', () { + final channelWithCooldown = ChannelModel( + id: channelId, + type: channelType, + cooldown: 30, ); - test( - "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + final stateWithCooldown = ChannelState(channel: channelWithCooldown); + final testChannel = Channel.fromState(client, stateWithCooldown); + addTearDown(testChannel.dispose); - when( - () => channel.config?.skipLastMsgUpdateForSystemMsgs, - ).thenReturn(true); + expect(testChannel.cooldown, equals(30)); + }); - final message = Message( - type: MessageType.system, - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test('should return 0 remaining cooldown when no cooldown is set', () { + expect(channel.getRemainingCooldown(), equals(0)); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test('should return cooldown stream with default value', () { + expectLater(channel.cooldownStream.take(1), emits(0)); + }); + }); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + group('Thread reply cooldown', () { + const currentUserId = 'test-user-id'; // matches FakeClientState default + const cooldownDuration = 30; // seconds - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, + Channel _buildChannelWithCooldown() { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + cooldown: cooldownDuration, + ownCapabilities: [ChannelCapability.slowMode], ); + final state = ChannelState(channel: channelModel); + final ch = Channel.fromState(client, state); + // isUpToDate is seeded true by default + return ch; + } - test("should update 'unreadCount'", () async { - expect(channel.state?.unreadCount, equals(0)); + test( + 'should return positive cooldown after current user sends a thread reply', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + // Simulate a thread reply by the current user sent just now. + final threadReply = Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), ); + ch.state!.updateThreadInfo('parent-msg-1', [threadReply]); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(ch.getRemainingCooldown(), greaterThan(0)); + }, + ); - expect(channel.state?.unreadCount, equals(1)); + test( + 'should return 0 cooldown when thread reply was sent outside the cooldown window', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final message2 = Message( - id: 'test-message-id-2', - user: User(id: 'other-user'), - createdAt: message.createdAt.add(const Duration(seconds: 3)), + // Reply sent cooldownDuration+5 seconds ago — outside the window. + final oldReply = Message( + id: 'thread-reply-old', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp().subtract( + const Duration(seconds: cooldownDuration + 5), + ), + user: User(id: currentUserId), ); + ch.state!.updateThreadInfo('parent-msg-1', [oldReply]); - final newMessage2Event = createNewMessageEvent(message2); - client.addEvent(newMessage2Event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(2)); - }); + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); - group("should not update 'unreadCount'", () { - test( - 'when the message is silent', - () async { - expect(channel.state?.unreadCount, equals(0)); + test( + 'should not trigger cooldown for a thread reply from another user', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final message = Message( - id: 'test-message-id', - silent: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + final otherUserReply = Message( + id: 'thread-reply-other', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: 'other-user-id'), + ); + ch.state!.updateThreadInfo('parent-msg-1', [otherUserReply]); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should clear cooldown when the most-recent own message is hard-deleted', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - expect(channel.state?.unreadCount, equals(0)); - }, + final ownMessage = Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), ); + ch.state!.updateMessage(ownMessage); + expect(ch.getRemainingCooldown(), greaterThan(0)); - test( - 'when the message is shadowed', - () async { - expect(channel.state?.unreadCount, equals(0)); + ch.state!.deleteMessage(ownMessage, hardDelete: true); + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); - final message = Message( - id: 'test-message-id', - shadowed: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test( + 'currentUserLastMessageAtStream emits a new timestamp when own message is added', + () async { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + final emissions = []; + final sub = ch.currentUserLastMessageAtStream.listen(emissions.add); + addTearDown(sub.cancel); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Let the seed emission settle. + await Future.delayed(Duration.zero); + final seededLast = emissions.last; - expect(channel.state?.unreadCount, equals(0)); - }, + ch.state!.updateMessage( + Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), + ), ); + await Future.delayed(Duration.zero); - test( - 'when the message type is ephemeral', - () async { - expect(channel.state?.unreadCount, equals(0)); + expect(emissions.last, isNotNull); + expect(emissions.last, isNot(equals(seededLast))); + }, + ); - final message = Message( - id: 'test-message-id', - type: MessageType.ephemeral, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test( + 'getRemainingCooldown uses the explicit [lastMessageAt] override', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // No messages in state, so the default path returns 0. + expect(ch.getRemainingCooldown(), equals(0)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Override pointing inside the cooldown window → positive remaining. + final recent = DateTime.timestamp().subtract(const Duration(seconds: 5)); + expect(ch.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); - expect(channel.state?.unreadCount, equals(0)); - }, + // Override pointing outside the window → 0. + final old = DateTime.timestamp().subtract( + const Duration(seconds: cooldownDuration + 5), ); + expect(ch.getRemainingCooldown(lastMessageAt: old), equals(0)); + }, + ); - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test( + 'currentUserLastMessageAt picks the latest across channel messages and threads', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + final older = DateTime.timestamp().subtract(const Duration(seconds: 20)); + final newer = DateTime.timestamp().subtract(const Duration(seconds: 5)); - expect(channel.state?.unreadCount, equals(0)); - }, + // Older message in the main channel. + ch.state!.updateMessage( + Message( + id: 'msg-1', + createdAt: older, + user: User(id: currentUserId), + ), ); + // Newer reply in a thread. + ch.state!.updateThreadInfo('parent-msg-1', [ + Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: newer, + user: User(id: currentUserId), + ), + ]); - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Should pick the newer thread reply, not the older channel message. + final result = ch.currentUserLastMessageAt; + expect(result, isNotNull); + expect(result!.isAtSameMomentAs(newer), isTrue); + }, + ); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + group('Disposed channel state validation', () { + late Channel channel; - // Wait for the event to get processed - await Future.delayed(Duration.zero); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + test( + 'should throw StateError when accessing cooldown after disposal', + () { + // First verify it works when initialized + expect(channel.cooldown, equals(0)); - test( - 'when the message is from the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Dispose the channel + channel.dispose(); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Now accessing cooldown should throw + expect(() => channel.cooldown, throwsA(isA())); + }, + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test( + 'should throw StateError when accessing getRemainingCooldown after disposal', + () { + // First verify it works when initialized + expect(channel.getRemainingCooldown(), equals(0)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Dispose the channel + channel.dispose(); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + // Now accessing getRemainingCooldown should throw + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); - test( - 'when the message is not restricted for the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + test( + 'should throw StateError when accessing cooldownStream after disposal', + () { + // First verify it works when initialized + expectLater(channel.cooldownStream.take(1), emits(0)); - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - restrictedVisibility: const ['other-user-2'], - ); + // Dispose the channel + channel.dispose(); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Now accessing cooldownStream should throw + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should handle race condition scenario - initialization then quick disposal', + () { + // This test simulates the race condition that was causing the production crash + final channelState = _generateChannelState(channelId, channelType); + final raceChannel = Channel.fromState(client, channelState); - expect(channel.state?.unreadCount, equals(0)); - }, - ); - }); + // Verify it works initially + expect(raceChannel.cooldown, equals(0)); - test( - 'should submit channel for delivery when message is received', - () async { - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Simulate quick disposal (like what happens with rapid navigation) + raceChannel.dispose(); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // This should throw StateError instead of crashing with null check operator + expect(() => raceChannel.cooldown, throwsA(isA())); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(raceChannel.getRemainingCooldown, throwsA(isA())); + }, + ); + }); - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); - }, - ); + group('Channel message count events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - test( - 'should not duplicate when server echoes back an optimistically ' - 'inserted message with a later createdAt', - () async { - // Local message used as the input to `channel.sendMessage`. - final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 3)); - final localMessage = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - createdAt: localCreatedAt, - ); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - // Mock the network send to return the message unchanged so the - // optimistic insert + sent-state update both land on the same - // `createdAt`. The bug fires later, on the WS echo. - final sendMessageResponse = SendMessageResponse() - ..message = localMessage.copyWith(state: MessageState.sent); - when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + tearDown(() { + channel.dispose(); + }); - await channel.sendMessage(localMessage); + test( + 'should update channel messageCount when event contains channelMessageCount', + () async { + // Verify initial state - no messageCount + expect(channel.messageCount, isNull); - expect(channel.state!.messages, hasLength(1)); + // Create event with channelMessageCount + final messageCountEvent = Event( + cid: channel.cid, + type: EventType.messageNew, + channelMessageCount: 42, + ); - // Server then broadcasts the same message via a `message.new` - // event with a slightly later `createdAt` (server-assigned - // timestamp). - final serverMessage = localMessage.copyWith( - createdAt: localCreatedAt.add(const Duration(milliseconds: 50)), - ); - client.addEvent(createNewMessageEvent(serverMessage)); + // Dispatch event + client.addEvent(messageCountEvent); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // The state should contain exactly one message with that id, - // not a duplicate. - final matching = channel.state!.messages.where((it) => it.id == localMessage.id); - expect(matching, hasLength(1)); - expect(channel.state!.messages, hasLength(1)); - }, - ); + // Verify channel messageCount was updated + expect(channel.messageCount, equals(42)); + }, + ); - test( - 'should not duplicate when the locally-sent message is no longer ' - 'the latest (retry-after-offline scenario)', - () async { - // Mirrors the offline-retry flow: a local message is sent, then - // another message arrives via WS while the local one is still - // pending. When the retry finally succeeds the server response's - // `createdAt` is later than the intervening message, so the - // locally-sent copy is no longer `messages.last`. - final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 1)); - final localMessage = Message( - id: 'local-message-id', + test( + 'should update channel messageCount from message.new and message.deleted events', + () async { + // Test with message.new event - count increases + final messageNewEvent = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'new-message-1', text: 'Hello world!', - user: client.state.currentUser, - createdAt: localCreatedAt, - ); - - final sendMessageResponse = SendMessageResponse() - ..message = localMessage.copyWith(state: MessageState.sent); - when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); - await channel.sendMessage(localMessage); + client.addEvent(messageNewEvent); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(1)); - // Another message arrives via WS with a later `createdAt`, - // pushing the locally-sent message off the tail. - final otherMessage = Message( - id: 'other-message-id', - user: User(id: 'other-user'), - createdAt: localCreatedAt.add(const Duration(seconds: 2)), - ); - client.addEvent(createNewMessageEvent(otherMessage)); - await Future.delayed(Duration.zero); + // Test with another message.new event - count increases + final messageNewEvent2 = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'new-message-2', + text: 'Second message', + user: User(id: 'user-2'), + ), + channelMessageCount: 2, + ); - // Server then broadcasts the locally-sent message via - // `message.new` with a `createdAt` that is later than the - // intervening message — exactly the shape produced by a - // successful retry after another message arrived in between. - final serverEcho = localMessage.copyWith( - createdAt: otherMessage.createdAt.add(const Duration(seconds: 1)), - ); - client.addEvent(createNewMessageEvent(serverEcho)); - await Future.delayed(Duration.zero); + client.addEvent(messageNewEvent2); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(2)); - final localMatches = channel.state!.messages.where((it) => it.id == localMessage.id); - expect(localMatches, hasLength(1)); - expect(channel.state!.messages, hasLength(2)); - }, - ); - }, - ); + // Test with message.deleted event - count decreases + final messageDeletedEvent = Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: Message( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); - group( - EventType.messageUpdated, - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + client.addEvent(messageDeletedEvent); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(1)); + }, + ); - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], + test( + 'should preserve other channel properties when updating messageCount', + () async { + // Set initial channel state with some properties + final initialChannel = channel.state?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + memberCount: 5, + frozen: true, ); - channel = Channel.fromState(client, channelState); - }); + if (initialChannel != null) { + channel.state?.updateChannelState( + channel.state!.channelState.copyWith(channel: initialChannel), + ); + } - tearDown(() => channel.dispose()); + // Verify initial state + expect(channel.name, 'Test Channel'); + expect(channel.memberCount, equals(5)); + expect(channel.frozen, equals(true)); + expect(channel.messageCount, isNull); - Event createUpdateMessageEvent(Message message) { - return Event( + // Update messageCount via event + final messageCountEvent = Event( cid: channel.cid, - type: EventType.messageUpdated, - message: message, + type: EventType.messageNew, + channelMessageCount: 100, ); - } - - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", - () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - final newMessageEvent = createUpdateMessageEvent(message); - client.addEvent(newMessageEvent); + client.addEvent(messageCountEvent); + await Future.delayed(Duration.zero); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Verify messageCount was updated while preserving other properties + expect(channel.messageCount, equals(100)); + expect(channel.name, 'Test Channel'); + expect(channel.memberCount, equals(5)); + expect(channel.frozen, equals(true)); + }, + ); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - }, - ); + test( + 'should provide messageCountStream for reactive updates', + () async { + expectLater( + channel.messageCountStream.distinct(), + emitsInOrder([null, 1, 5, 10]), + ); - test( - 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', - () async { - const messageId = 'test-message-id'; - const oldText = 'Old text'; - const newText = 'New text'; - final message = Message( - id: messageId, - user: client.state.currentUser, - text: oldText, - pinned: true, + // Update messageCount multiple times + final counts = [1, 5, 10]; + for (final count in counts) { + final event = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'msg-$count', + text: 'Message $count', + user: User(id: 'user-1'), + ), + channelMessageCount: count, ); - final firstUpdateEvent = createUpdateMessageEvent(message); - client.addEvent(firstUpdateEvent); - - // Wait for the first event to get processed + client.addEvent(event); await Future.delayed(Duration.zero); + } + }, + ); + }); + }); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(oldText)); - - final updatedMessage = message.copyWith(text: newText); - final secondUpdateEvent = createUpdateMessageEvent(updatedMessage); - client.addEvent(secondUpdateEvent); - - // Wait for the second event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(newText)); - }, - ); - - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages " - 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', - () async { - const firstMessageId = 'first-test-message-id'; - const secondMessageId = 'second-test-message-id'; - final firstMessage = Message( - id: firstMessageId, - user: client.state.currentUser, - pinned: true, - ); - final secondMessage = firstMessage.copyWith(id: secondMessageId); - - final firstUpdateEvent = createUpdateMessageEvent(firstMessage); - client.addEvent(firstUpdateEvent); - - // Wait for the first event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); - - final secondUpdateEvent = createUpdateMessageEvent(secondMessage); - client.addEvent(secondUpdateEvent); - - // Wait for the second event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(2)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); - expect( - channel.state?.pinnedMessages[1].id, - equals(secondMessageId), - ); - }, - ); - - test( - "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", - () async { - const messageId = 'test-message-id'; - final pinnedMessage = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - - final pinEvent = createUpdateMessageEvent(pinnedMessage); - client.addEvent(pinEvent); - - // Wait for the pin event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - - final unpinnedMessage = pinnedMessage.copyWith(pinned: false); - final unpinEvent = createUpdateMessageEvent(unpinnedMessage); - client.addEvent(unpinEvent); - - // Wait for the unpin event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages, isEmpty); - }, - ); - - // A `message.updated` event for a message outside the loaded window - // would otherwise upsert into the sorted list — creating a phantom - // entry with a gap. The guard is "id not in the loaded list", and - // is independent of `isUpToDate` — even at the latest page we may - // have paginated past older history and receive an event for a - // message no longer in memory. - group('when message is outside the loaded window', () { - test( - 'should NOT insert unknown message into `messages` list', - () async { - // Simulate "we have the latest page but not older history": - // seed the tail messages. - final tail = List.generate( - 3, - (i) => Message( - id: 'tail-$i', - user: client.state.currentUser, - text: 'tail $i', - createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), - ), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: tail), - ); - expect(channel.state!.messages, hasLength(3)); - - // Event for a message on an older page we don't have loaded. - final olderPageEdit = Message( - id: 'older-page-msg', - user: client.state.currentUser, - text: 'edited on older page', - createdAt: DateTime.utc(2025, 1, 1), - ); - client.addEvent(createUpdateMessageEvent(olderPageEdit)); - await Future.delayed(Duration.zero); - - // Tail is unchanged, no phantom entry inserted at position 0. - expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - - test( - 'should update message in place when it IS in the loaded window', - () async { - const messageId = 'known'; - final seeded = Message( - id: messageId, - user: client.state.currentUser, - text: 'old', - createdAt: DateTime.utc(2026), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [seeded]), - ); - channel.state!.isUpToDate = false; - - final edited = seeded.copyWith(text: 'new'); - client.addEvent(createUpdateMessageEvent(edited)); - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); - expect(stored.text, equals('new')); - }, - ); - - test( - 'should still add to pinnedMessages when pinned:true even if not in loaded window', - () async { - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - - const messageId = 'pin-me'; - final pinned = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - client.addEvent(createUpdateMessageEvent(pinned)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages.length, equals(1)); - expect(channel.state!.pinnedMessages.first.id, equals(messageId)); - }, - ); - - test( - 'should NOT insert unknown reply into threads[parentId]', - () async { - const parentId = 'parent-1'; - final knownReply = Message( - id: 'known-reply', - parentId: parentId, - user: client.state.currentUser, - createdAt: DateTime.utc(2026), - ); - // Populate threads[parentId] via addNewMessage's thread-only path. - channel.state!.addNewMessage(knownReply); - await Future.delayed(Duration.zero); - expect(channel.state!.threads[parentId], hasLength(1)); - - channel.state!.isUpToDate = false; - - final phantomReply = Message( - id: 'other-reply', - parentId: parentId, - user: client.state.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - client.addEvent(createUpdateMessageEvent(phantomReply)); - await Future.delayed(Duration.zero); - - expect(channel.state!.threads[parentId]!.map((m) => m.id), ['known-reply']); - }, - ); - - test( - 'should NOT create phantom threads[parentId] entry for unloaded thread', - () async { - const parentId = 'unloaded-parent'; - // The thread was never paged in, so there's no entry for it. - expect(channel.state!.threads.containsKey(parentId), isFalse); - - channel.state!.isUpToDate = false; - - final phantomReply = Message( - id: 'phantom-reply', - parentId: parentId, - user: client.state.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - client.addEvent(createUpdateMessageEvent(phantomReply)); - await Future.delayed(Duration.zero); - - // The dropped reply must not leave behind an empty thread entry. - expect(channel.state!.threads.containsKey(parentId), isFalse); - }, - ); - - test( - 'should still expire activeLiveLocations for out-of-window message', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'loc-msg', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - // Seed only activeLiveLocations, keeping `messages` empty — - // the exact "message is outside the loaded window" scenario. - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - activeLiveLocations: [liveLocation], - ), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, hasLength(1)); - - // A message.updated that expires the live location. - final expiredMessage = Message( - id: 'loc-msg', - text: 'Live location shared', - sharedLocation: liveLocation.copyWith( - endAt: DateTime.now().subtract(const Duration(minutes: 1)), - ), - ); - client.addEvent(createUpdateMessageEvent(expiredMessage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, isEmpty); - }, - ); - }); - }, - ); - - // A reply with `show_in_channel = true` is mirrored into both `messages` - // and `threads[parentId]`. When the thread isn't loaded (fresh hydration, - // user never opened the thread) the channel-level copy is the only place - // locally-cached fields like `ownReactions`/`poll` survive — so reaction - // and message-update events for such replies must still find it. - group( - 'reply events with `show_in_channel = true` and unloaded thread', - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const replyId = 'mirrored-reply-id'; - const parentId = 'parent-message-id'; - // Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. - final createdAt = DateTime.utc(2026, 1, 1); - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - // Seeds a single reply into the channel-level `messages` while leaving - // `threads[parentId]` empty — the exact regression scenario. - Message seedMirroredReply({ - List ownReactions = const [], - Poll? poll, - }) { - final reply = Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - ownReactions: ownReactions, - poll: poll, - pollId: poll?.id, - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [reply]), - ); - return reply; - } - - test( - '`reaction.new` from another user preserves `ownReactions`', - () async { - final ownReaction = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - seedMirroredReply(ownReactions: [ownReaction]); - // Pre-condition: thread is not loaded. - expect(channel.state!.threads, isEmpty); - - // Server reaction events don't echo back the recipient's own - // reactions, so the listener must pull them from the cached copy. - final otherUserReaction = Reaction( - type: 'love', - messageId: replyId, - user: User(id: 'other-user'), - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionNew, - reaction: otherUserReaction, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - latestReactions: [otherUserReaction], - ), - ), - ); - - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [ownReaction]); - }, - ); - - test( - '`reaction.deleted` strips only the removed reaction', - () async { - final kept = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - final removed = Reaction( - type: 'love', - messageId: replyId, - user: client.state.currentUser, - ); - seedMirroredReply(ownReactions: [kept, removed]); - expect(channel.state!.threads, isEmpty); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionDeleted, - reaction: removed, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - ), - ), - ); - - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [kept]); - }, - ); - - test( - '`message.updated` preserves `poll`, `pollId`, and `ownReactions`', - () async { - final ownReaction = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - // Partial server updates can omit poll/pollId/ownReactions; the - // cached copy is what backfills them. - final poll = Poll( - id: 'poll-1', - name: 'Pick one', - options: const [ - PollOption(text: 'A'), - PollOption(text: 'B'), - ], - ); - seedMirroredReply(ownReactions: [ownReaction], poll: poll); - expect(channel.state!.threads, isEmpty); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - text: 'edited', - ), - ), - ); - - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [ownReaction]); - expect(stored.poll?.id, poll.id); - expect(stored.pollId, poll.id); - }, - ); - }, - ); - - // A `message.deleted` event for a message outside the loaded window - // must not upsert a "deleted" record into the sorted list — that would - // create a phantom entry with a gap. Pinned + live-location - // side-effects must still fire. - group( - EventType.messageDeleted, - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - Event createDeleteMessageEvent(Message message, {bool hardDelete = false}) { - return Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: message.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), - ), - hardDelete: hardDelete, - ); - } - - // Same design as the `messageUpdated` guards: the check is - // "message-in-loaded-window" and is independent of `isUpToDate` — - // an event for a message on an older, unloaded page must not be - // turned into a phantom "deleted" record inserted into the sorted - // list. - group('when message is outside the loaded window', () { - test( - 'soft delete does NOT insert phantom "deleted" record into messages', - () async { - final tail = List.generate( - 3, - (i) => Message( - id: 'tail-$i', - user: client.state.currentUser, - text: 'tail $i', - createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), - ), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: tail), - ); - expect(channel.state!.messages, hasLength(3)); - - final olderPage = Message( - id: 'older-page-msg', - user: client.state.currentUser, - text: 'gone', - createdAt: DateTime.utc(2025, 1, 1), - ); - client.addEvent(createDeleteMessageEvent(olderPage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); - }, - ); - - test( - 'soft delete marks message as deleted when it IS in the loaded window', - () async { - const messageId = 'known'; - final seeded = Message( - id: messageId, - user: client.state.currentUser, - text: 'hi', - createdAt: DateTime.utc(2026), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [seeded]), - ); - channel.state!.isUpToDate = false; - - client.addEvent(createDeleteMessageEvent(seeded)); - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); - expect(stored.type, equals(MessageType.deleted)); - expect(stored.deletedAt, isNotNull); - }, - ); - - test( - 'soft delete unpins a pinned-but-not-in-window message via _pinIsValid', - () async { - const messageId = 'pinned-msg'; - final pinned = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - createdAt: DateTime.utc(2026), - ); - // Seed only the pinnedMessages list — message absent from - // the main `messages` window. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(pinnedMessages: [pinned]), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, hasLength(1)); - - client.addEvent(createDeleteMessageEvent(pinned)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - - test( - 'soft delete still clears activeLiveLocations even when message not in window', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'loc-msg', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - // Seed only activeLiveLocations, keeping `messages` empty. - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - activeLiveLocations: [liveLocation], - ), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, hasLength(1)); - - final locationMessage = Message( - id: 'loc-msg', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - client.addEvent(createDeleteMessageEvent(locationMessage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, isEmpty); - }, - ); - - test( - 'hard delete is a no-op when message is not in the loaded window', - () async { - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - - final phantom = Message( - id: 'phantom', - user: client.state.currentUser, - text: 'gone', - createdAt: DateTime.utc(2026), - ); - client.addEvent(createDeleteMessageEvent(phantom, hardDelete: true)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - }); - }, - ); - - group('Member Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( - 'should update membership when member is updated and is current user', - () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); - final now = DateTime.now(); - - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); - - // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.channelRole, isNull); - expect(channel.membership?.isModerator, false); - expect(channel.isPinned, isFalse); - expect(channel.isArchived, isFalse); - - // Create updated member with same userId but updated properties - final updatedMember = currentMember.copyWith( - channelRole: 'moderator', - isModerator: true, - pinnedAt: now, - archivedAt: now, - ); - - // Create member updated event - final memberUpdatedEvent = Event( - cid: channel.cid, - type: EventType.memberUpdated, - user: currentUser, - member: updatedMember, - ); - - // Dispatch event - client.addEvent(memberUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.userId, equals(currentUser?.id)); - expect(channel.membership?.channelRole, equals('moderator')); - expect(channel.membership?.isModerator, isTrue); - expect(channel.isPinned, isTrue); - expect(channel.isArchived, isTrue); - }, - ); - - test( - 'should update membership user when any event containing user is updated', - () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); - - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); - - // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(currentUser?.id)); - expect(channel.membership?.user?.role, equals(currentUser?.role)); - - // Create updated user with same userId but updated properties - final updatedUser = currentUser?.copyWith(role: 'moderator'); - - // Create any event with same updated user as membership. - final anyEvent = Event( - cid: channel.cid, - type: EventType.any, - user: updatedUser, - ); - - // Dispatch event - client.addEvent(anyEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(updatedUser?.id)); - expect(channel.membership?.user?.role, equals(updatedUser?.role)); - }, - ); - }); - - group('Watching Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - test( - '${EventType.userWatchingStart} adds the watcher and updates watcherCount', - () async { - final watcher = User(id: 'watcher-1'); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: watcher, - watcherCount: 3, - ), - ); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 3); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), - ); - }, - ); - - test( - '${EventType.userWatchingStop} removes the watcher and updates watcherCount', - () async { - final watcher = User(id: 'watcher-1'); - - // The watcher starts watching first (count = 2). - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: watcher, - watcherCount: 2, - ), - ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 2); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), - ); - - // Then stops watching (count = 1). - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStop, - user: watcher, - watcherCount: 1, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 1); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - isNot(contains('watcher-1')), - ); - }, - ); - - test( - 'watching event without watcherCount preserves the existing count', - () async { - // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), - ); - expect(channel.state!.watcherCount, 5); - - // A watching event that omits watcher_count must not wipe the count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: User(id: 'watcher-2'), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 5); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-2'), - ); - }, - ); - - test( - '${EventType.messageNew} updates watcherCount from the event', - () async { - expect(channel.state!.watcherCount, isNull); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: DateTime.now(), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageNew, - message: message, - watcherCount: 7, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 7); - }, - ); - - test( - '${EventType.messageNew} without watcherCount preserves the existing count', - () async { - // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 4), - ); - expect(channel.state!.watcherCount, 4); - - // A local/optimistic message.new without watcher_count must not - // reset the count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'test-message-id-2', - user: client.state.currentUser, - createdAt: DateTime.now(), - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 4); - }, - ); - - test( - '${EventType.notificationMessageNew} does not overwrite watcherCount', - () async { - // Seed a known watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), - ); - expect(channel.state!.watcherCount, 5); - - // notification.message_new is delivered to non-watchers and reports - // watcher_count: 0; it must not clobber the real count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMessageNew, - message: Message( - id: 'notif-message-id', - user: User(id: 'other-user'), - createdAt: DateTime.now(), - ), - watcherCount: 0, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 5); - }, - ); - }); - - group('Read Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ); - - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should update read state on message read event', () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create message read event - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // 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, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); - }); - - test( - 'should add a new read state if not exist on message read event', - () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create mark read notification event - final markReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(markReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); - }, - ); - - test( - 'should not update channel read state on thread message 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 channel read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, 'channel-msg-1'); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create a thread-scoped message.read event (thread != null) - final threadMessageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - 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, - ), - ); - - // Dispatch event - client.addEvent(threadMessageReadEvent); - - // 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 update read state on notification mark unread event', () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create mark unread notification event - final markUnreadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkUnread, - user: currentUser, - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); - - // Dispatch event - client.addEvent(markUnreadEvent); - - // 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, 'test-user'); - expect(updatedRead?.unreadMessages, 15); - expect(updatedRead?.lastReadMessageId, 'message-100'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2019)), isTrue); - }); - - test( - 'should add a new read state if not exist on notification mark unread', - () async { - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create event for non-existing user - final markUnreadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkUnread, - user: User(id: 'non-existing-user'), - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); - - // Dispatch event - client.addEvent(markUnreadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); - }, - ); - - test( - 'should preserve delivery info on message 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 with delivery info - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastDeliveredAt, isNotNull); - expect( - read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); - - // Create message read event (doesn't include delivery info) - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // 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?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - // Delivery info should be preserved - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, - ); - - test( - 'should reconcile delivery when message read event is from current user', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Create message read event from current user - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - - 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); - final currentRead = Read( - user: currentUser, - lastRead: distantPast, - unreadMessages: 5, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state has no delivery info - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.lastDeliveredAt, isNull); - expect(read?.lastDeliveredMessageId, isNull); - - // Create message delivered event - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify delivery state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'message-456'); - }); - - test( - 'should add a new read state if not exist on message delivered event', - () async { - final newUser = User(id: 'new-user'); - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create message delivered event for new user - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: newUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-789', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read state was created with delivery info - final updated = channel.state?.read; - expect(updated?.length, 1); - final newRead = updated?.first; - expect(newRead?.user.id, 'new-user'); - expect(newRead?.lastDeliveredAt, isNotNull); - expect( - newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(newRead?.lastDeliveredMessageId, 'message-789'); - // lastRead should default to distantPast - expect( - newRead?.lastRead.isAtSameMomentAs(distantPast), - isTrue, - ); - }, - ); - - test( - 'should preserve read info on message delivered event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'read-msg-123', - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, 'read-msg-123'); - - // Create message delivered event (doesn't include read info) - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify delivery state is updated but read info is preserved - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - // Read info should be preserved - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), - isTrue, - ); - expect(updatedRead?.unreadMessages, 10); - expect(updatedRead?.lastReadMessageId, 'read-msg-123'); - }, - ); - - test( - 'should reconcile delivery when message delivered event is from current user', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Create message delivered event from current user - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - }); - - group('Draft events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle draft.updated event for channel drafts', () async { - // Verify initial state - expect(channel.state?.draft, isNull); - - // Create Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); - }); - - test('should handle draft.updated event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a regular message - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - ), - ); - - // Verify initial state - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - - // Create thread Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - }); - - test('should handle draft.deleted event for channel drafts', () async { - // Setup initial state with a draft - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ), - ), - ); - - // Verify initial state - final draft = channel.state?.draft; - expect(draft, isNotNull); - expect(draft?.message.text, 'test message'); - - // Create draft.deleted event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: draft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel draft was updated - expect(channel.state?.draft, isNull); - }); - - test('should handle draft.deleted event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a thread draft - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ), - ), - ); - - // Verify initial state - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - - // Create draft.deleted event - final draftDeletedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: threadDraft, - ); - - // Dispatch event - client.addEvent(draftDeletedEvent); - - // Allow event to be processed - await Future.delayed(Duration.zero); - - // Verify thread draft was removed - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - }); - - test( - 'should update current channel draft if draft.updated event is emitted', - () async { - // Setup initial state with a draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: initialDraft, - ), - ); - - // Verify initial state - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); - - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated message'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'updated message'); - }, - ); - - test( - 'should update current thread draft if draft.updated event is emitted', - () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a thread draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: initialDraft, - ), - ); - - // Verify initial state - final draft = channel.state?.threadDraft(threadParentMessageId); - expect(draft, isNotNull); - expect(draft?.message.text, 'thread reply'); - - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated thread reply'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'updated thread reply'); - }, - ); - }); - - group('Reminder events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle reminder.created event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message without reminder - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - ); - - channel.state?.updateMessage(message); - - // Verify initial state - no reminder - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // Create reminder - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: DateTime.now().add(const Duration(days: 30)), - ); - - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); - - // Dispatch event - client.addEvent(reminderCreatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was added - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); - - test('should handle reminder.updated event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); - - channel.state?.updateMessage(message); - - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); - - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); - - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); - - // Dispatch event - client.addEvent(reminderUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was updated - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); - - test('should handle reminder.deleted event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); - - channel.state?.updateMessage(message); - - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); - - // Dispatch event - client.addEvent(reminderDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was removed - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); - - test('should handle reminder.created event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message without reminder - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - // `Message.createdAt` falls back to `DateTime.now()` per call when - // not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), - ); - - channel.state?.updateMessage(threadMessage); - - // Verify initial state - no reminder - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // Create reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); - - // Dispatch event - client.addEvent(reminderCreatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread message reminder was added - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); - - test('should handle reminder.updated event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - // `Message.createdAt` falls back to `DateTime.now()` per call when - // not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), - ); - - channel.state?.updateMessage(threadMessage); - - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); - - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); - - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); - - // Dispatch event - client.addEvent(reminderUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread message reminder was updated - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); - - test('should handle reminder.deleted event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - // Explicit `createdAt` so `Message.createdAt` is deterministic - // across reads — without one it falls back to `DateTime.now()` - // on every call, which breaks any sort/merge keyed on createdAt. - createdAt: DateTime.now(), - ); - - channel.state?.updateMessage(threadMessage); - - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); - - // Dispatch event - client.addEvent(reminderDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread message reminder was removed - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); - }); - - group('Location events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle location.shared event', () async { - // Verify initial state - expect(channel.state?.activeLiveLocations, isEmpty); - - // Create live location - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: locationMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was added - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message, isNotNull); - - // Check if active live location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg1')); - }); - - test('should handle location.updated event', () async { - // Setup initial state with location message - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - channel.state?.addNewMessage(locationMessage); - - // Create updated location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, // Updated latitude - longitude: -74.1000, // Updated longitude - ); - - final updatedMessage = locationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Create location.updated event - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedMessage, - ); - - // Dispatch event - client.addEvent(locationUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was updated - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.latitude, equals(40.7500)); - expect(message?.sharedLocation?.longitude, equals(-74.1000)); - - // Check if active live location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - expect(activeLiveLocations?.first.longitude, equals(-74.1000)); - }); - - test('should handle location.expired event', () async { - // Setup initial state with location message - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create expired location - final expiredLocation = liveLocation.copyWith( - endAt: DateTime.now().subtract(const Duration(hours: 1)), - ); - - final expiredMessage = locationMessage.copyWith( - sharedLocation: expiredLocation, - ); - - // Create location.expired event - final locationExpiredEvent = Event( - cid: channel.cid, - type: EventType.locationExpired, - message: expiredMessage, - ); - - // Dispatch event - client.addEvent(locationExpiredEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was updated - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.isExpired, isTrue); - - // Check if active live location was removed - expect(channel.state?.activeLiveLocations, isEmpty); - }); - - test('should not add static location to active locations', () async { - final staticLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - // No endAt - static location - ); - - final staticMessage = Message( - id: 'msg1', - text: 'Static location shared', - sharedLocation: staticLocation, - ); - - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: staticMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was added - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation, isNotNull); - - // Check if active live location was NOT updated (should remain empty) - expect(channel.state?.activeLiveLocations, isEmpty); - }); - - test( - 'should update active locations when location message is deleted', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Verify initial state - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - final messageDeletedEvent = Event( - type: EventType.messageDeleted, - cid: channel.cid, - message: locationMessage.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), - ), - ); - - // Dispatch event - client.addEvent(messageDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify active locations are updated - expect(channel.state?.activeLiveLocations, isEmpty); - }, - ); - - test('should merge locations with same key', () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial location for setup - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create new location with same user, channel, and device - final newLocation = Location( - channelCid: channel.cid, - userId: 'user1', // Same user - messageId: 'msg2', // Different message - latitude: 40.7500, - longitude: -74.1000, - createdByDeviceId: 'device1', // Same device - endAt: DateTime.now().add(const Duration(hours: 2)), - ); - - final newMessage = Message( - id: 'msg2', - text: 'Updated location', - sharedLocation: newLocation, - ); - - // Create location.shared event for the new message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: newMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Should still have only one active location (merged) - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg2')); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - }); - - test( - 'should handle multiple active locations from different devices', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add first location for setup - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create location from different device - final location2 = Location( - channelCid: channel.cid, - userId: 'user1', // Same user - messageId: 'msg2', - latitude: 34.0522, - longitude: -118.2437, - createdByDeviceId: 'device2', // Different device - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final message2 = Message( - id: 'msg2', - text: 'Location from device 2', - sharedLocation: location2, - ); - - // Create location.shared event for the second message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: message2, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Should have two active locations - expect(channel.state?.activeLiveLocations, hasLength(2)); - }, - ); - - test('should handle location messages in threads', () async { - final parentMessage = Message( - id: 'parent1', - text: 'Thread parent', - ); - - // Add parent message first for setup - channel.state?.addNewMessage(parentMessage); - - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'thread-msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Create location.shared event for the thread message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: threadLocationMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if thread message was added - final thread = channel.state?.threads['parent1']; - expect(thread, contains(threadLocationMessage)); - - // Check if location was added to active locations - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); - }); - - test('should update thread location messages', () async { - final parentMessage = Message( - id: 'parent1', - text: 'Thread parent', - ); - - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'thread-msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Add messages - channel.state?.addNewMessage(parentMessage); - channel.state?.addNewMessage(threadLocationMessage); - - // Update the location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, - longitude: -74.1000, - ); - - final updatedThreadMessage = threadLocationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Create location.updated event for the thread message - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedThreadMessage, - ); - - // Dispatch event - client.addEvent(locationUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if thread message was updated - final thread = channel.state?.threads['parent1']; - final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); - expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); - expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); - - // Check if active location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - expect(activeLiveLocations?.first.longitude, equals(-74.1000)); - }); - }); - - group('Channel push preference events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle channel.push_preference.updated event', () async { - // Verify initial state - expect(channel.state?.channelState.pushPreferences, isNull); - - // Create channel push preference - final channelPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.mentions, - disabledUntil: DateTime.now().add(const Duration(hours: 1)), - ); - - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: channelPushPreference, - ); - - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences, isNotNull); - expect(updatedPreferences?.chatLevel, ChatLevel.mentions); - expect( - updatedPreferences?.disabledUntil, - channelPushPreference.disabledUntil, - ); - }); - - test('should update existing channel push preferences', () async { - // Set initial push preferences - const initialPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.all, - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - pushPreferences: initialPushPreference, - ), - ); - - // Verify initial state - final pushPreferences = channel.state?.channelState.pushPreferences; - expect(pushPreferences?.chatLevel, ChatLevel.all); - expect(pushPreferences?.disabledUntil, isNull); - - // Create updated channel push preference - final updatedPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.none, - disabledUntil: DateTime.now().add(const Duration(hours: 2)), - ); - - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: updatedPushPreference, - ); - - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences?.chatLevel, ChatLevel.none); - expect( - updatedPreferences?.disabledUntil, - updatedPushPreference.disabledUntil, - ); - }); - }); - - group('User messages deleted event', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - late MockPersistenceClient persistenceClient; - - setUp(() { - persistenceClient = MockPersistenceClient(); - when(() => client.chatPersistenceClient).thenReturn(persistenceClient); - when( - () => persistenceClient.deleteMessagesFromUser( - cid: any(named: 'cid'), - userId: any(named: 'userId'), - hardDelete: any(named: 'hardDelete'), - deletedAt: any(named: 'deletedAt'), - ), - ).thenAnswer((_) async {}); - when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => >{}); - - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( - 'should soft delete all messages from user when hardDelete is false', - () async { - // Setup: Add messages from different users - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - expect( - channel.state?.messages.where((m) => m.user?.id == 'user-1').length, - equals(2), - ); - expect( - channel.state?.messages.where((m) => m.user?.id == 'user-2').length, - equals(1), - ); - - // Create user.messages.deleted event (soft delete) - final deletedAt = DateTime.now(); - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - createdAt: deletedAt, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify user1's messages are soft deleted - expect(channel.state?.messages.length, equals(3)); - final deletedMessages = channel.state?.messages.where((m) => m.user?.id == 'user-1').toList(); - expect(deletedMessages?.length, equals(2)); - for (final message in deletedMessages!) { - expect(message.type, equals(MessageType.deleted)); - expect(message.deletedAt, isNotNull); - expect(message.state.isDeleted, isTrue); - } - - // Verify user2's message is unaffected - final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(user2Message?.type, isNot(MessageType.deleted)); - expect(user2Message?.deletedAt, isNull); - }, - ); - - test( - 'should hard delete all messages from user when hardDelete is true', - () async { - // Setup: Add messages from different users - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify user1's messages are removed - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - // Verify user2's message still exists - final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(user2Message, isNotNull); - expect(user2Message?.user?.id, equals('user-2')); - }, - ); - - test( - 'should handle thread messages from user', - () async { - // Setup: Add parent and thread messages - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final parentMessage = Message( - id: 'parent-msg', - text: 'Parent message', - user: user2, - ); - final threadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - final threadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - - channel.state?.addNewMessage(parentMessage); - channel.state?.addNewMessage(threadMessage1); - channel.state?.addNewMessage(threadMessage2); - - // Verify initial state - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.threads['parent-msg']?.length, equals(2)); - - // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread messages are soft deleted - final threadMessages = channel.state?.threads['parent-msg']; - expect(threadMessages?.length, equals(2)); - for (final message in threadMessages!) { - expect(message.type, equals(MessageType.deleted)); - expect(message.state.isDeleted, isTrue); - } - - // Verify parent message is unaffected - final parent = channel.state?.messages.first; - expect(parent?.type, isNot(MessageType.deleted)); - }, - ); - - test( - 'should do nothing when user is null', - () async { - // Setup: Add messages - final user1 = User(id: 'user-1', name: 'User 1'); - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - channel.state?.addNewMessage(message1); - - // Verify initial state - expect(channel.state?.messages.length, equals(1)); - - // Create user.messages.deleted event without user - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - hardDelete: false, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify messages are unaffected - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.first.type, - isNot(MessageType.deleted), - ); - }, - ); - - test( - 'should handle empty message list', - () async { - // Setup: Empty channel - expect(channel.state?.messages.length, equals(0)); - - // Create user.messages.deleted event - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: User(id: 'user-1'), - hardDelete: false, - ); - - // Dispatch event - should not throw - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify state is still empty - expect(channel.state?.messages.length, equals(0)); - }, - ); - - test( - 'should delete messages from persistence when hardDelete is true', - () async { - // Setup: Add messages from different users - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify messages are removed from persistence - verify( - () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), - ).called(1); - verify( - () => persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), - ).called(1); - - // Verify user1's messages are removed from state - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - }, - ); - - test( - 'should not delete from persistence when hardDelete is false', - () async { - // Setup: Add messages - final user1 = User(id: 'user-1', name: 'User 1'); - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - channel.state?.addNewMessage(message1); - - // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify persistence deletion methods were NOT called - verifyNever(() => persistenceClient.deleteMessageByIds(any())); - verifyNever(() => persistenceClient.deletePinnedMessageByIds(any())); - - // Verify message is soft deleted (still in state) - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.messages.first.type, equals(MessageType.deleted)); - }, - ); - - test( - 'should delete all user messages including those only in storage', - () async { - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final stateMessage1 = Message( - id: 'msg-1', - text: 'Message from user 1 in state', - user: user1, - pinned: true, - ); - final stateMessage2 = Message( - id: 'msg-2', - text: 'Message from user 2 in state', - user: user2, - ); - final stateThreadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1 in state', - user: user1, - parentId: 'msg-1', - ); - final stateThreadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 2 in state', - user: user2, - parentId: 'msg-1', - ); - - // Load the state with only 2 messages and 1 thread with 2 replies. - // Note: In reality, storage may contain many more user1 messages - // (e.g., older messages not loaded into state yet), but the delete - // operation should remove ALL of them from storage. - channel.state?.addNewMessage(stateMessage1); - channel.state?.addNewMessage(stateMessage2); - channel.state?.addNewMessage(stateThreadMessage1); - channel.state?.addNewMessage(stateThreadMessage2); - - // Verify initial state has only 2 messages and 1 thread with 2 replies - expect(channel.state?.messages.length, equals(2)); - expect(channel.state?.threads['msg-1']?.length, equals(2)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify user1's messages are removed from state - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.threads['msg-1']?.length, equals(1)); - - expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - expect( - channel.state?.threads['msg-1']?.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - // Verify persistence delete was called - this handles ALL messages - // in storage (both those in state AND those only in storage) - verify( - () => persistenceClient.deleteMessagesFromUser( - cid: channel.cid, - userId: user1.id, - hardDelete: true, - deletedAt: any(named: 'deletedAt'), - ), - ).called(1); - - // Verify in-state messages were also removed from state's persistence - final capturedIds = - verify( - () => persistenceClient.deleteMessageByIds(captureAny()), - ).captured.first - as List; - - expect( - capturedIds, - containsAll([ - 'msg-1', // state message - 'thread-msg-1', // state thread message - ]), - ); - }, - ); - - test( - 'should delete every authored message across threads without ' - 'cross-thread leakage (regression: _updateThreadMessages)', - () async { - // user-1 authors a top-level message AND replies in two different - // threads (owned by user-2). The user.messages.deleted flow - // collects everything from user-1 across channel + threads and - // routes it through a single _updateMessages batch — historically - // this batch was passed unfiltered to every affected thread's - // merge, so replies to thread A leaked into thread B and v.v. - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); - final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); - - final topLevelFromUser1 = Message( - id: 'top-1', - text: 'user-1 top-level message', - user: user1, - ); - final replyA = Message( - id: 'reply-A', - text: 'user-1 reply in thread A', - user: user1, - parentId: 'parent-A', - ); - final replyB = Message( - id: 'reply-B', - text: 'user-1 reply in thread B', - user: user1, - parentId: 'parent-B', - ); - - channel.state?.addNewMessage(parentA); - channel.state?.addNewMessage(parentB); - channel.state?.addNewMessage(topLevelFromUser1); - channel.state?.addNewMessage(replyA); - channel.state?.addNewMessage(replyB); - - // Initial state: each thread has exactly its own reply. - expect( - channel.state?.threads['parent-A']?.map((m) => m.id), - equals(['reply-A']), - ); - expect( - channel.state?.threads['parent-B']?.map((m) => m.id), - equals(['reply-B']), - ); - - // Trigger the multi-thread batch via user.messages.deleted. - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - client.addEvent(userMessagesDeletedEvent); - await Future.delayed(Duration.zero); - - // 1) Thread membership is preserved — no cross-thread leakage. - // Without the fix, replyB would leak into thread A and v.v. - expect( - channel.state?.threads['parent-A']?.map((m) => m.id), - equals(['reply-A']), - reason: 'thread A must not contain replies from thread B', - ); - expect( - channel.state?.threads['parent-B']?.map((m) => m.id), - equals(['reply-B']), - reason: 'thread B must not contain replies from thread A', - ); - - // 2) Every message authored by user-1 is soft-deleted — top-level - // AND in both threads. The fix must not narrow this scope. - expect( - channel.state?.messages.firstWhere((m) => m.id == 'top-1').type, - equals(MessageType.deleted), - reason: 'top-level user-1 message must be deleted', - ); - expect( - channel.state?.threads['parent-A']?.first.type, - equals(MessageType.deleted), - reason: 'thread A reply from user-1 must be deleted', - ); - expect( - channel.state?.threads['parent-B']?.first.type, - equals(MessageType.deleted), - reason: 'thread B reply from user-1 must be deleted', - ); - - // 3) Other users' messages are unaffected. - expect( - channel.state?.messages.firstWhere((m) => m.id == 'parent-A').type, - isNot(MessageType.deleted), - ); - expect( - channel.state?.messages.firstWhere((m) => m.id == 'parent-B').type, - isNot(MessageType.deleted), - ); - }, - ); - }); - }); - - group('Channel State Validation and Cooldown', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); - - group('Non-initialized channel state validation', () { - test( - 'should throw StateError when accessing cooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing cooldownStream on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - }); - - group('Initialized channel cooldown functionality', () { - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - test( - 'should return default cooldown value of 0 for initialized channel', - () => expect(channel.cooldown, equals(0)), - ); - - test('should return custom cooldown value when set in channel model', () { - final channelWithCooldown = ChannelModel( - id: channelId, - type: channelType, - cooldown: 30, - ); - - final stateWithCooldown = ChannelState(channel: channelWithCooldown); - final testChannel = Channel.fromState(client, stateWithCooldown); - addTearDown(testChannel.dispose); - - expect(testChannel.cooldown, equals(30)); - }); - - test('should return 0 remaining cooldown when no cooldown is set', () { - expect(channel.getRemainingCooldown(), equals(0)); - }); - - test('should return cooldown stream with default value', () { - expectLater(channel.cooldownStream.take(1), emits(0)); - }); - }); - - group('Thread reply cooldown', () { - const currentUserId = 'test-user-id'; // matches FakeClientState default - const cooldownDuration = 30; // seconds - - Channel _buildChannelWithCooldown() { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - cooldown: cooldownDuration, - ownCapabilities: [ChannelCapability.slowMode], - ); - final state = ChannelState(channel: channelModel); - final ch = Channel.fromState(client, state); - // isUpToDate is seeded true by default - return ch; - } - - test( - 'should return positive cooldown after current user sends a thread reply', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // Simulate a thread reply by the current user sent just now. - final threadReply = Message( - id: 'thread-reply-1', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ); - ch.state!.updateThreadInfo('parent-msg-1', [threadReply]); - - expect(ch.getRemainingCooldown(), greaterThan(0)); - }, - ); - - test( - 'should return 0 cooldown when thread reply was sent outside the cooldown window', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // Reply sent cooldownDuration+5 seconds ago — outside the window. - final oldReply = Message( - id: 'thread-reply-old', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp().subtract( - const Duration(seconds: cooldownDuration + 5), - ), - user: User(id: currentUserId), - ); - ch.state!.updateThreadInfo('parent-msg-1', [oldReply]); - - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'should not trigger cooldown for a thread reply from another user', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final otherUserReply = Message( - id: 'thread-reply-other', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp(), - user: User(id: 'other-user-id'), - ); - ch.state!.updateThreadInfo('parent-msg-1', [otherUserReply]); - - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'should clear cooldown when the most-recent own message is hard-deleted', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final ownMessage = Message( - id: 'msg-1', - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ); - ch.state!.updateMessage(ownMessage); - expect(ch.getRemainingCooldown(), greaterThan(0)); - - ch.state!.deleteMessage(ownMessage, hardDelete: true); - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'currentUserLastMessageAtStream emits a new timestamp when own message is added', - () async { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final emissions = []; - final sub = ch.currentUserLastMessageAtStream.listen(emissions.add); - addTearDown(sub.cancel); - - // Let the seed emission settle. - await Future.delayed(Duration.zero); - final seededLast = emissions.last; - - ch.state!.updateMessage( - Message( - id: 'msg-1', - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ), - ); - await Future.delayed(Duration.zero); - - expect(emissions.last, isNotNull); - expect(emissions.last, isNot(equals(seededLast))); - }, - ); - - test( - 'getRemainingCooldown uses the explicit [lastMessageAt] override', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // No messages in state, so the default path returns 0. - expect(ch.getRemainingCooldown(), equals(0)); - - // Override pointing inside the cooldown window → positive remaining. - final recent = DateTime.timestamp().subtract(const Duration(seconds: 5)); - expect(ch.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); - - // Override pointing outside the window → 0. - final old = DateTime.timestamp().subtract( - const Duration(seconds: cooldownDuration + 5), - ); - expect(ch.getRemainingCooldown(lastMessageAt: old), equals(0)); - }, - ); - - test( - 'currentUserLastMessageAt picks the latest across channel messages and threads', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final older = DateTime.timestamp().subtract(const Duration(seconds: 20)); - final newer = DateTime.timestamp().subtract(const Duration(seconds: 5)); - - // Older message in the main channel. - ch.state!.updateMessage( - Message( - id: 'msg-1', - createdAt: older, - user: User(id: currentUserId), - ), - ); - // Newer reply in a thread. - ch.state!.updateThreadInfo('parent-msg-1', [ - Message( - id: 'thread-reply-1', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: newer, - user: User(id: currentUserId), - ), - ]); - - // Should pick the newer thread reply, not the older channel message. - final result = ch.currentUserLastMessageAt; - expect(result, isNotNull); - expect(result!.isAtSameMomentAs(newer), isTrue); - }, - ); - }); - - group('Disposed channel state validation', () { - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - test( - 'should throw StateError when accessing cooldown after disposal', - () { - // First verify it works when initialized - expect(channel.cooldown, equals(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldown should throw - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing getRemainingCooldown after disposal', - () { - // First verify it works when initialized - expect(channel.getRemainingCooldown(), equals(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing getRemainingCooldown should throw - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing cooldownStream after disposal', - () { - // First verify it works when initialized - expectLater(channel.cooldownStream.take(1), emits(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldownStream should throw - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - - test( - 'should handle race condition scenario - initialization then quick disposal', - () { - // This test simulates the race condition that was causing the production crash - final channelState = _generateChannelState(channelId, channelType); - final raceChannel = Channel.fromState(client, channelState); - - // Verify it works initially - expect(raceChannel.cooldown, equals(0)); - - // Simulate quick disposal (like what happens with rapid navigation) - raceChannel.dispose(); - - // This should throw StateError instead of crashing with null check operator - expect(() => raceChannel.cooldown, throwsA(isA())); - - expect(raceChannel.getRemainingCooldown, throwsA(isA())); - }, - ); - }); - - group('Channel message count events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( - 'should update channel messageCount when event contains channelMessageCount', - () async { - // Verify initial state - no messageCount - expect(channel.messageCount, isNull); - - // Create event with channelMessageCount - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 42, - ); - - // Dispatch event - client.addEvent(messageCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel messageCount was updated - expect(channel.messageCount, equals(42)); - }, - ); - - test( - 'should update channel messageCount from message.new and message.deleted events', - () async { - // Test with message.new event - count increases - final messageNewEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - client.addEvent(messageNewEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); - - // Test with another message.new event - count increases - final messageNewEvent2 = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-2', - text: 'Second message', - user: User(id: 'user-2'), - ), - channelMessageCount: 2, - ); - - client.addEvent(messageNewEvent2); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(2)); - - // Test with message.deleted event - count decreases - final messageDeletedEvent = Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - client.addEvent(messageDeletedEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); - }, - ); - - test( - 'should preserve other channel properties when updating messageCount', - () async { - // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - memberCount: 5, - frozen: true, - ); - - if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.channelState.copyWith(channel: initialChannel), - ); - } - - // Verify initial state - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); - expect(channel.messageCount, isNull); - - // Update messageCount via event - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 100, - ); - - client.addEvent(messageCountEvent); - await Future.delayed(Duration.zero); - - // Verify messageCount was updated while preserving other properties - expect(channel.messageCount, equals(100)); - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); - }, - ); - - test( - 'should provide messageCountStream for reactive updates', - () async { - expectLater( - channel.messageCountStream.distinct(), - emitsInOrder([null, 1, 5, 10]), - ); - - // Update messageCount multiple times - final counts = [1, 5, 10]; - for (final count in counts) { - final event = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'msg-$count', - text: 'Message $count', - user: User(id: 'user-1'), - ), - channelMessageCount: count, - ); - - client.addEvent(event); - await Future.delayed(Duration.zero); - } - }, - ); - }); - }); - - group('Channel filterTags', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test('should return filterTags from channel state', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); - - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); - - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - }); - - test('should update filterTags when channel state is updated', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); - - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); - - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - - final updatedChannel = channelModel.copyWith( - filterTags: ['tag3', 'tag4', 'tag5'], - ); - - testChannel.state?.updateChannelState( - testChannel.state!.channelState.copyWith(channel: updatedChannel), - ); - - expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); - }); - }); - - group('Typing Indicator', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); - - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test( - ".keystore should return if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(channel.keyStroke(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), - ), - ); - }, - ); - - test( - '.keystore should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(channel.keyStroke(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), - ), - ); - }, - ); - - test( - ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final startTypingEvent = Event(type: EventType.typingStart); - final stopTypingEvent = Event(type: EventType.typingStop); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(startTypingEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopTypingEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.keyStroke(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(startTypingEvent)), - ), - ).called(1); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopTypingEvent)), - ), - ).called(1); - }, - ); - - test( - ".startTyping should return if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(channel.startTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ); - }, - ); - - test( - '.startTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(channel.startTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ); - }, - ); - - test(".startTyping should send 'typingStart' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.startTyping(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ).called(1); - }); - - test(".stopTyping should return if we don't have the capability", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - await expectLater(channel.stopTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }); - - test( - '.stopTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - await expectLater(channel.stopTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }, - ); - - test(".stopTyping should send 'typingStop' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.stopTyping(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).called(1); - }); - }); - - group('Read Receipts', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); + group('Channel filterTags', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; setUpAll(() { // detached loggers @@ -9843,1002 +5476,670 @@ void main() { when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); }); - test( - ".markRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - completes, - ); - - verify( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).called(1); - }, - ); - - test( - ".markUnread should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markUnread('message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markUnread('message-id-123'), - completes, - ); + test('should return filterTags from channel state', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], + ); - verify( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).called(1); - }, - ); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - test( - ".markUnreadByTimestamp should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); + }); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + test('should update filterTags when channel state is updated', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], + ); - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - await expectLater( - channel.markUnreadByTimestamp(timestamp), - throwsA(isA()), - ); - }, - ); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - test( - '.markUnreadByTimestamp should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); + final updatedChannel = channelModel.copyWith( + filterTags: ['tag3', 'tag4', 'tag5'], + ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + testChannel.state?.updateChannelState( + testChannel.state!.channelState.copyWith(channel: updatedChannel), + ); - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); + }); + }); - when( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); + group('Typing Indicator', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); - await expectLater( - channel.markUnreadByTimestamp(timestamp), - completes, - ); + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); - verify( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).called(1); - }, - ); + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); - test( - ".markThreadRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - await expectLater( - channel.markThreadRead('thread-id-123'), - throwsA(isA()), - ); - }, - ); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); test( - '.markThreadRead should succeed if we have the capability', + ".keystore should return if we don't have the capability", () async { final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [ChannelCapability.readEvents], + ownCapabilities: [], // no typingEvents capability ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - when( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); + final typingEvent = Event(type: EventType.typingStart); - await expectLater( - channel.markThreadRead('thread-id-123'), - completes, - ); + await expectLater(channel.keyStroke(), completes); - verify( - () => client.markThreadRead( + verifyNever( + () => client.sendEvent( channelId, channelType, - 'thread-id-123', + any(that: isSameEventAs(typingEvent)), ), - ).called(1); + ); }, ); test( - ".markThreadUnread should throw if we don't have the capability", + '.keystore should return when user privacy settings is disabled', () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), + ); + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [], // no readEvents capability + ownCapabilities: [ChannelCapability.typingEvents], ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - await expectLater( - channel.markThreadUnread('thread-id-123'), - throwsA(isA()), + final typingEvent = Event(type: EventType.typingStart); + + await expectLater(channel.keyStroke(), completes); + + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + ), ); }, ); test( - '.markThreadUnread should succeed if we have the capability', + ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", () async { final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [ChannelCapability.readEvents], + ownCapabilities: [ChannelCapability.typingEvents], ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); + final startTypingEvent = Event(type: EventType.typingStart); + final stopTypingEvent = Event(type: EventType.typingStop); + when( - () => client.markThreadUnread( + () => client.sendEvent( channelId, channelType, - 'thread-id-123', + any(that: isSameEventAs(startTypingEvent)), ), ).thenAnswer((_) async => EmptyResponse()); - await expectLater( - channel.markThreadUnread('thread-id-123'), - completes, - ); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater(channel.keyStroke(), completes); verify( - () => client.markThreadUnread( + () => client.sendEvent( channelId, channelType, - 'thread-id-123', + any(that: isSameEventAs(startTypingEvent)), ), ).called(1); - }, - ); - }); - - 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 {}); - when( - () => client.channelDeliveryReporter.reconcileDelivery(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)); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + ).called(1); }, ); test( - 'does not increment unreadCount when local unread count tracking is ' - 'disabled', + ".startTyping should return if we don't have the capability", () 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), + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no typingEvents capability ); - 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)); - }); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test( - 'markRead resets unreadCount locally without making a network request', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 3; - expect(channel.state?.unreadCount, equals(3)); + final typingStartEvent = Event(type: EventType.typingStart); - await expectLater(channel.markRead(), completes); + await expectLater(channel.startTyping(), completes); - expect(channel.state?.unreadCount, equals(0)); verifyNever( - () => client.markChannelRead( - any(), - any(), - messageId: any(named: 'messageId'), + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), ), ); }, ); test( - 'markUnreadByTimestamp recomputes unreadCount locally without making a ' - 'network request', + '.startTyping should return when user privacy settings is disabled', () 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 currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), ), - ]; - 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, - ); + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); - // Only m2 and m3 were created after the given timestamp. - expect(channel.state?.unreadCount, equals(2)); - verifyNever( - () => client.markChannelUnreadByTimestamp(any(), any(), any()), + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], ); - }, - ); - - 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()), - ); - }, - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test( - 'markRead reconciles pending delivery receipts', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 2; + final typingStartEvent = Event(type: EventType.typingStart); - await expectLater(channel.markRead(), completes); + await expectLater(channel.startTyping(), completes); - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ); }, ); - group('local read boundary anchors', () { - final start = DateTime(2024, 1, 1); - final messages = [ - Message( - id: 'm1', - text: '1', - user: User(id: 'other-user'), - createdAt: start, - ), - Message( - id: 'm2', - text: '2', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 1)), - ), - Message( - id: 'm3', - text: '3', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 2)), - ), - ]; - - test( - 'markUnread is inclusive of the anchor and points lastReadMessageId at ' - 'the previous message', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - - await expectLater(channel.markUnread('m2'), completes); - - // m2 (the anchor) and m3 are unread; m1 stays read. - expect(channel.state?.unreadCount, equals(2)); - expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m1')); - verifyNever(() => client.markChannelUnread(any(), any(), any())); - }, + test(".startTyping should send 'typingStart' successfully", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], ); - test( - 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' - 'known message', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - await expectLater(channel.markUnread('m1'), completes); + final typingStartEvent = Event(type: EventType.typingStart); - expect(channel.state?.unreadCount, equals(3)); - expect(channel.state?.currentUserRead?.lastReadMessageId, isNull); - }, - ); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); - test( - 'markUnreadByTimestamp is exclusive of the boundary and points ' - 'lastReadMessageId at the newest message at or before it', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); + await expectLater(channel.startTyping(), completes); - // Exactly m2's createdAt: m2 stays read, only m3 becomes unread. - await expectLater(channel.markUnreadByTimestamp(messages[1].createdAt), completes); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ).called(1); + }); - expect(channel.state?.unreadCount, equals(1)); - expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m2')); - verifyNever(() => client.markChannelUnreadByTimestamp(any(), any(), any())); - }, + test(".stopTyping should return if we don't have the capability", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no typingEvents capability ); - test( - 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' - 'differ by the anchor message', - () async { - final byId = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - final byTimestamp = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - await byId.markUnread('m2'); - await byTimestamp.markUnreadByTimestamp(messages[1].createdAt); + final typingStopEvent = Event(type: EventType.typingStop); - // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. - expect(byId.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.unreadCount, equals(1)); + await expectLater(channel.stopTyping(), completes); - // ...and they agree once the timestamp is nudged below the anchor. - await byTimestamp.markUnreadByTimestamp( - messages[1].createdAt.subtract(const Duration(microseconds: 1)), - ); - expect(byTimestamp.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); - }, + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), ); }); test( - 'server payloads do not clobber the locally-tracked read state', + '.stopTyping should return when user privacy settings is disabled', () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 5; - - final serverRead = Read( - user: currentUser, - lastRead: DateTime.now(), - unreadMessages: 0, + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), ); - channel.state!.updateChannelStateFromServer( - channel.state!.channelState.copyWith(read: [serverRead]), + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], ); - expect(channel.state?.unreadCount, equals(5)); - }, - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test( - 'local (non-remote) state updates are not affected by the server-merge ' - 'guard', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 5; + final typingStopEvent = Event(type: EventType.typingStop); - // A plain local mutation (via updateChannelState, not - // updateChannelStateFromServer) should still be able to change the - // locally-tracked read state. - await expectLater(channel.markRead(), completes); + await expectLater(channel.stopTyping(), completes); - expect(channel.state?.unreadCount, equals(0)); + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ); }, ); + + test(".stopTyping should send 'typingStop' successfully", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final typingStopEvent = Event(type: EventType.typingStop); + + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater(channel.stopTyping(), completes); + + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ).called(1); + }); }); - group('updateChannelState identity guard', () { + group('Read Receipts', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; late final client = MockStreamChatClient(); setUpAll(() { + // detached loggers when(() => client.detachedLogger(any())).thenAnswer((invocation) { final name = invocation.positionalArguments.first; return _createLogger(name); }); - when(() => client.retryPolicy).thenReturn( - RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ), + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); - when(() => client.state).thenReturn(FakeClientState()); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); }); - Channel _seededChannel() { - final base = _generateChannelState(channelId, channelType); - final now = DateTime.now(); - final seeded = base.copyWith( - messages: [ - Message(id: 'm1', text: '1', createdAt: now), - Message(id: 'm2', text: '2', createdAt: now.add(const Duration(seconds: 1))), - Message(id: 'm3', text: '3', createdAt: now.add(const Duration(seconds: 2))), - ], - ); - return Channel.fromState(client, seeded); - } - test( - 'preserves messages reference when updatedState.messages is null', - () { - final channel = _seededChannel(); + ".markRead should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); + + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final before = channel.state!.messages; - channel.state!.updateChannelState( - ChannelState(channel: channel.state!.channelState.channel), + await expectLater( + channel.markRead(messageId: 'message-id-123'), + throwsA(isA()), ); - final after = channel.state!.messages; - - expect(identical(before, after), isTrue); }, ); test( - 'preserves messages reference when updatedState.messages is identical', - () { - final channel = _seededChannel(); + '.markRead should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], + ); + + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final before = channel.state!.messages; - // copyWith without messages keeps the same `messages` reference, so - // updateChannelState should hit the identity-guard fast path. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( - read: [ - Read( - user: User(id: 'me'), - lastRead: DateTime.now(), - unreadMessages: 1, - ), - ], + when( + () => client.markChannelRead( + channelId, + channelType, + messageId: 'message-id-123', ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater( + channel.markRead(messageId: 'message-id-123'), + completes, ); - final after = channel.state!.messages; - expect(identical(before, after), isTrue); + verify( + () => client.markChannelRead( + channelId, + channelType, + messageId: 'message-id-123', + ), + ).called(1); }, ); test( - 'still merges messages when updatedState.messages is a different list', - () { - final channel = _seededChannel(); + ".markUnread should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); + + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final newMessage = Message( - id: 'm4', - text: '4', - createdAt: DateTime.now().add(const Duration(seconds: 10)), + await expectLater( + channel.markUnread('message-id-123'), + throwsA(isA()), ); - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: [newMessage], - ), + }, + ); + + test( + '.markUnread should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], ); - expect( - channel.state!.messages.map((m) => m.id), - ['m1', 'm2', 'm3', 'm4'], + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + when( + () => client.markChannelUnread( + channelId, + channelType, + 'message-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater( + channel.markUnread('message-id-123'), + completes, ); + + verify( + () => client.markChannelUnread( + channelId, + channelType, + 'message-id-123', + ), + ).called(1); }, ); - test('cold-path merge interleaves new messages in sorted order', () { - final channel = _seededChannel(); - addTearDown(channel.dispose); + test( + ".markUnreadByTimestamp should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); - final base = channel.state!.messages.first.createdAt; - // Incoming list is sorted ascending by createdAt and slots between - // the existing m1, m2, m3. - final incoming = [ - Message( - id: 'm1.5', - text: 'between m1 and m2', - createdAt: base.add(const Duration(milliseconds: 500)), - ), - Message( - id: 'm2.5', - text: 'between m2 and m3', - createdAt: base.add(const Duration(milliseconds: 1500)), - ), - ]; - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: incoming, - ), - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - expect( - channel.state!.messages.map((m) => m.id), - ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], - ); - }); + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - test('cold-path merge runs syncWith on overlapping ids', () { - final channel = _seededChannel(); - addTearDown(channel.dispose); + await expectLater( + channel.markUnreadByTimestamp(timestamp), + throwsA(isA()), + ); + }, + ); - final localStamp = DateTime.now(); - // Seed m2 with a localCreatedAt that the incoming version doesn't - // carry, so we can verify syncWith fired during the merge. - channel.state!.updateMessage( - Message( - id: 'm2', - text: '2', - createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, - ).copyWith(localCreatedAt: localStamp), - ); + test( + '.markUnreadByTimestamp should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], + ); - final incoming = [ - Message( - id: 'm2', - text: '2 (server)', - createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, - ), - ]; - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: incoming, - ), - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - final m2 = channel.state!.messages.firstWhere((m) => m.id == 'm2'); - expect(m2.text, '2 (server)'); - // Local-only field carried over by syncWith during the merge. - expect(m2.localCreatedAt, localStamp); - }); - }); + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - group('updateMessage quoted-rewrite', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); + when( + () => client.markChannelUnreadByTimestamp( + channelId, + channelType, + timestamp, + ), + ).thenAnswer((_) async => EmptyResponse()); - 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()); - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); + await expectLater( + channel.markUnreadByTimestamp(timestamp), + completes, + ); - Channel _seededChannel({required List messages}) { - final base = _generateChannelState(channelId, channelType); - return Channel.fromState(client, base.copyWith(messages: messages)); - } + verify( + () => client.markChannelUnreadByTimestamp( + channelId, + channelType, + timestamp, + ), + ).called(1); + }, + ); test( - 'rewrites quotedMessage on every quoter when target is deleted', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'hi', createdAt: now); - final quoter1 = Message( - id: 'q1', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), + ".markThreadRead should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability ); - final unrelated = Message( - id: 'u1', - text: 'other', - createdAt: now.add(const Duration(seconds: 2)), + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + await expectLater( + channel.markThreadRead('thread-id-123'), + throwsA(isA()), ); - final quoter2 = Message( - id: 'q2', - text: 'reply2', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 3)), + }, + ); + + test( + '.markThreadRead should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], ); - final channel = _seededChannel(messages: [target, quoter1, unrelated, quoter2]); + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final unrelatedBefore = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + when( + () => client.markThreadRead( + channelId, + channelType, + 'thread-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), + await expectLater( + channel.markThreadRead('thread-id-123'), + completes, ); - channel.state!.updateMessage(deleted); - - final after = channel.state!.messages; - final q1After = after.firstWhere((m) => m.id == 'q1'); - final q2After = after.firstWhere((m) => m.id == 'q2'); - final uAfter = after.firstWhere((m) => m.id == 'u1'); - - expect(q1After.quotedMessage?.deletedAt, isNotNull); - expect(q1After.quotedMessage?.type, MessageType.deleted); - expect(q2After.quotedMessage?.deletedAt, isNotNull); - expect(q2After.quotedMessage?.type, MessageType.deleted); - // Unrelated messages must not be rebuilt by the rewrite. - expect(identical(uAfter, unrelatedBefore), isTrue); + + verify( + () => client.markThreadRead( + channelId, + channelType, + 'thread-id-123', + ), + ).called(1); }, ); test( - 'preserves messages reference when no message quotes the deleted one', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'hi', createdAt: now); - final unrelated = Message( - id: 'u1', - text: 'other', - createdAt: now.add(const Duration(seconds: 1)), + ".markThreadUnread should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability ); - final channel = _seededChannel(messages: [target, unrelated]); + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), + await expectLater( + channel.markThreadUnread('thread-id-123'), + throwsA(isA()), ); - channel.state!.updateMessage(deleted); - - // No message quotes `target`, so `updateIf` short-circuits and the - // remaining messages keep their identities (only `target` itself was - // replaced by `sortedUpsert`). - final unrelatedAfter = channel.state!.messages.firstWhere((m) => m.id == 'u1'); - expect(identical(unrelatedAfter, unrelated), isTrue); }, ); test( - 'does not rewrite quotes when an existing quoted target is updated ' - 'without being deleted', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'original', createdAt: now); - final quoter = Message( - id: 'q1', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), + '.markThreadUnread should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], ); - final channel = _seededChannel(messages: [target, quoter]); + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final quoterBefore = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + when( + () => client.markThreadUnread( + channelId, + channelType, + 'thread-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); - // Plain text update — not a deletion. - channel.state!.updateMessage(target.copyWith(text: 'edited')); + await expectLater( + channel.markThreadUnread('thread-id-123'), + completes, + ); - final quoterAfter = channel.state!.messages.firstWhere((m) => m.id == 'q1'); - // `updateIf` is gated on `message.isDeleted`, so the quoter must keep - // its identity (no allocation, no quoted-message overwrite). - expect(identical(quoterAfter, quoterBefore), isTrue); + verify( + () => client.markThreadUnread( + channelId, + channelType, + 'thread-id-123', + ), + ).called(1); }, ); }); @@ -11168,386 +6469,4 @@ void main() { }); }); }); - - group('Message enrichment preservation on merge', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUpAll(() { - registerFallbackValue(FakeMessage()); - registerFallbackValue([]); - - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - }); - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - clearInteractions(client); - }); - - test( - 'preserves the `poll` on a quotedMessage when the server omits it during ' - 're-sync (regression: poll quote disappears after foregrounding)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-1', - name: 'Pizza or pasta?', - options: const [ - PollOption(id: 'opt-1', text: 'Pizza'), - PollOption(id: 'opt-2', text: 'Pasta'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-1', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-1', - text: 'Voting now', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - // Seed channel state with the fully-enriched messages (mirrors what - // the local DB load produces). - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate a re-sync from the API: the server echoes the reply with - // a `quoted_message` that has only `poll_id` (no `poll` object). - // Constructed directly (not via copyWith) because copyWith cannot - // clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.id, pollMessage.id); - expect(mergedReply.quotedMessage!.poll, isNotNull); - expect(mergedReply.quotedMessage!.poll!.id, poll.id); - expect(mergedReply.quotedMessage!.poll!.name, poll.name); - }, - ); - - test( - 'preserves a nested quotedMessage (poll) two levels deep when the ' - 'server omits it during re-sync (regression: quote-of-quote of a poll ' - 'disappears completely after foregrounding)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-2', - name: 'Coffee or tea?', - options: const [ - PollOption(id: 'opt-a', text: 'Coffee'), - PollOption(id: 'opt-b', text: 'Tea'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-2', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-A', - text: 'My pick', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'user-a'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - final replyToReply = Message( - id: 'reply-B', - text: 'Same here', - quotedMessageId: replyToPoll.id, - quotedMessage: replyToPoll, - user: User(id: 'user-b'), - createdAt: DateTime.utc(2026, 4, 29, 12), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll, replyToReply], - ), - ); - - // Simulate the server response where: - // - replyA's nested quoted poll is missing the `poll` object. - // - replyB's nested quoted replyA is missing its own `quoted_message` - // (the server typically does not nest two levels deep). - // Stripped poll snapshot is constructed directly because copyWith - // cannot clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); - - final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], - ), - ); - - final mergedReplyA = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - final mergedReplyB = channel.state?.messages.firstWhere((it) => it.id == replyToReply.id); - - // First-level quote (reply A's quote of the poll) must keep the poll. - expect(mergedReplyA?.quotedMessage?.poll, isNotNull); - expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); - - // Second-level quote (reply B's quote of reply A) must keep reply A's - // own nested quotedMessage so the poll preview still resolves. - expect(mergedReplyB?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); - }, - ); - - test( - 'still preserves quotedMessage when the updated payload has no ' - 'quoted_message at all (existing behavior should not regress)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-3', - name: 'Beach or mountains?', - options: const [ - PollOption(id: 'opt-x', text: 'Beach'), - PollOption(id: 'opt-y', text: 'Mountains'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-3', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-3', - text: 'Definitely beach', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate an update event that touches the reply but doesn't echo - // the nested quoted_message at all (only quotedMessageId is set). - final reSyncedReply = Message( - id: replyToPoll.id, - text: 'Definitely beach (edited)', - quotedMessageId: pollMessage.id, - user: replyToPoll.user, - createdAt: replyToPoll.createdAt, - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.text, 'Definitely beach (edited)'); - expect(mergedReply.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.poll?.id, poll.id); - }, - ); - - test( - 'preserves the top-level `poll` when the server emits a `message.updated`' - ' that omits the `poll` object (regression: poll disappears from the ' - 'parent message after a thread reply is added)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-thread', - name: 'What is for lunch?', - options: const [ - PollOption(id: 'opt-1', text: 'Burgers'), - PollOption(id: 'opt-2', text: 'Salads'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'parent-poll-msg', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - replyCount: 0, - ); - - // Seed channel state with the fully-enriched parent poll message. - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - // Simulate the `message.updated` event the backend fires for the - // parent after a thread reply is added: bookkeeping fields are bumped - // (`reply_count`, `updated_at`) but the `poll` object is omitted from - // the payload — only `pollId` is set. Constructed directly because - // copyWith cannot clear `poll` — see Message.copyWith. - final strippedParentUpdate = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - replyCount: 1, - updatedAt: DateTime.utc(2026, 4, 29, 11), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: strippedParentUpdate, - ), - ); - - // Wait for the event to be processed. - await Future.delayed(Duration.zero); - - final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); - - // Parent poll message must remain in the channel state after a thread reply. - expect(merged, isNotNull); - // Bookkeeping fields from the event should still apply. - expect(merged!.replyCount, 1); - // Locally-known poll must be preserved when the server omits it from a - // `message.updated` payload (e.g. when a thread reply bumps reply_count). - expect(merged.poll, isNotNull); - expect(merged.poll!.id, poll.id); - expect(merged.poll!.name, poll.name); - expect(merged.pollId, poll.id); - }, - ); - - test( - 'still uses the updated `poll` when the server includes one in ' - '`message.updated` (poll edits should not be reverted to the locally ' - 'cached version)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-edit', - name: 'Initial name', - options: const [ - PollOption(id: 'opt-1', text: 'Original A'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'edit-parent', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - final updatedPoll = poll.copyWith(name: 'Edited name'); - final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: updatedParent, - ), - ); - - await Future.delayed(Duration.zero); - - final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); - - // Server-echoed poll must override the locally cached one — poll edits - // should not be reverted by the local-fallback merge. - expect(merged?.poll, isNotNull); - expect(merged?.poll?.name, 'Edited name'); - }, - ); - }); } From 6b14c8d826384d37735a5323a5a353843b24f85e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 27 Aug 2026 09:24:25 +0200 Subject: [PATCH 3/4] docs(llc): fix the dangling message doc references in ChannelReadHelper --- packages/stream_chat/lib/src/client/channel_read_helper.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel_read_helper.dart b/packages/stream_chat/lib/src/client/channel_read_helper.dart index a7366cd409..f6a784f030 100644 --- a/packages/stream_chat/lib/src/client/channel_read_helper.dart +++ b/packages/stream_chat/lib/src/client/channel_read_helper.dart @@ -10,7 +10,7 @@ extension ChannelReadHelper on ChannelClientState { return readStream.map((read) => read.userReadOf(userId: userId)); } - /// Returns the list of [Read]s that have marked the given [msg] as read. + /// Returns the list of [Read]s that have marked the given [message] as read. /// /// The [Read] is considered to have read the message if: /// - The read user is not the sender of the message. @@ -19,7 +19,7 @@ extension ChannelReadHelper on ChannelClientState { return read.readsOf(message: message); } - /// Stream of list of [Read]s that have marked the given [msg] as read. + /// Stream of list of [Read]s that have marked the given [message] as read. /// /// The [Read] is considered to have read the message if: /// - The read user is not the sender of the message. From 018d8ee78e8215d64c1b192150ecd970fce08d8b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 27 Aug 2026 09:24:25 +0200 Subject: [PATCH 4/4] test(llc): dispose the channels created by the local unread count tests --- .../test/src/client/channel_capability_check_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat/test/src/client/channel_capability_check_test.dart b/packages/stream_chat/test/src/client/channel_capability_check_test.dart index 5dba632192..42ccd60157 100644 --- a/packages/stream_chat/test/src/client/channel_capability_check_test.dart +++ b/packages/stream_chat/test/src/client/channel_capability_check_test.dart @@ -343,7 +343,10 @@ void main() { ], ); - return Channel.fromState(client, channelState); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + return channel; } test('is false when disabled and read receipts are unavailable', () {