From 9610ce2f88f8fe8c2290a1d0fbf7c0bcdcc4031b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 3 Sep 2026 14:23:34 +0200 Subject: [PATCH 1/7] fix(llc): keep Channel.memberCount fresh from channel events (#2933) `Channel.memberCount` / `memberCountStream` only ever reflected the `member_count` returned by `query` / `watch` and `channel.updated`. The `member.added` / `member.removed` handlers update the loaded member list but leave the count untouched, so it went stale for the rest of the session as soon as anyone joined or left. The backend already ships the authoritative count as `channel_member_count` event metadata on channel events, next to the `channel_message_count` we already consume. Parse it into `Event.channelMemberCount` and apply it to the channel state, folding both counters into a single listener so one event produces one state update. Co-authored-by: Claude Opus 5 --- packages/stream_chat/CHANGELOG.md | 5 + .../stream_chat/lib/src/client/channel.dart | 12 +- .../lib/src/core/models/event.dart | 11 + .../lib/src/core/models/event.g.dart | 2 + .../test/src/client/channel_test.dart | 203 ++++++++++++++++++ 5 files changed, 230 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 0205063c4f..596cd9f0a3 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming +✅ Added + +- Added `Event.channelMemberCount`, exposing the server-provided `channel_member_count` field on channel events (e.g. `member.added`, `member.removed`, `member.updated`). + 🔄 Changed - `Channel.translateMessage` now merges the translated message into the channel state, so the translation reaches anything watching the channel without the caller applying the response itself. @@ -7,6 +11,7 @@ 🐞 Fixed +- Fixed `Channel.memberCount` / `memberCountStream` staying stale for the rest of the session after members joined or left; channel events now apply the server-provided member count, the same way `messageCount` already did. - Fixed reconnect state recovery surfacing an uncatchable error when the connection dropped again mid-recovery; it is now logged, and `connection.recovered` still fires. [#2910](https://github.com/GetStream/stream-chat-flutter/issues/2910) - Fixed every failed websocket connect leaving an unhandled error in the root zone, which crash reporters listening on `PlatformDispatcher.onError` report as a fatal crash. [#2921](https://github.com/GetStream/stream-chat-flutter/issues/2921) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 6986d0a635..e798f30ef6 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -2526,7 +2526,7 @@ class ChannelClientState { // region CHANNEL EVENTS _listenChannelTruncated(); _listenChannelUpdated(); - _listenChannelMessageCount(); + _listenChannelCounts(); // endregion // region MEMBER EVENTS @@ -2680,16 +2680,22 @@ class ChannelClientState { ); } - void _listenChannelMessageCount() { + // Most channel events carry the channel's member and message counts as + // event metadata, reflecting the authoritative values after the change. + // Applying them keeps the counts fresh for the whole session instead of + // only right after a `query` / `watch`. + void _listenChannelCounts() { _subscriptions.add( _channel.on().listen( (Event e) { + final memberCount = e.channelMemberCount; final messageCount = e.channelMessageCount; - if (messageCount == null) return; + if (memberCount == null && messageCount == null) return; updateChannelState( channelState.copyWith( channel: channelState.channel?.copyWith( + memberCount: memberCount, messageCount: messageCount, ), ), diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index fe8d43d120..f00ba4ce9f 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -45,6 +45,7 @@ class Event { this.reminder, this.pushPreference, this.channelPushPreference, + this.channelMemberCount, this.channelMessageCount, this.watcherCount, this.lastDeliveredAt, @@ -172,6 +173,13 @@ class Event { /// Push notification preferences for the current user for this channel. final ChannelPushPreference? channelPushPreference; + /// The total number of members in the channel. + /// + /// Sent with channel events such as `member.added`, `member.removed` and + /// `member.updated`, reflecting the authoritative member count after the + /// change. + final int? channelMemberCount; + /// The total number of messages in the channel. final int? channelMessageCount; @@ -229,6 +237,7 @@ class Event { 'reminder', 'push_preference', 'channel_push_preference', + 'channel_member_count', 'channel_message_count', 'watcher_count', 'last_delivered_at', @@ -277,6 +286,7 @@ class Event { MessageReminder? reminder, PushPreference? pushPreference, ChannelPushPreference? channelPushPreference, + int? channelMemberCount, int? channelMessageCount, int? watcherCount, DateTime? lastDeliveredAt, @@ -318,6 +328,7 @@ class Event { reminder: reminder ?? this.reminder, pushPreference: pushPreference ?? this.pushPreference, channelPushPreference: channelPushPreference ?? this.channelPushPreference, + channelMemberCount: channelMemberCount ?? this.channelMemberCount, channelMessageCount: channelMessageCount ?? this.channelMessageCount, watcherCount: watcherCount ?? this.watcherCount, lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart index 2b9afb1691..fea0eb682b 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -56,6 +56,7 @@ Event _$EventFromJson(Map json) => Event( : ChannelPushPreference.fromJson( json['channel_push_preference'] as Map, ), + channelMemberCount: (json['channel_member_count'] as num?)?.toInt(), channelMessageCount: (json['channel_message_count'] as num?)?.toInt(), watcherCount: (json['watcher_count'] as num?)?.toInt(), lastDeliveredAt: json['last_delivered_at'] == null ? null : DateTime.parse(json['last_delivered_at'] as String), @@ -101,6 +102,7 @@ Map _$EventToJson(Event instance) => { 'reminder': ?instance.reminder?.toJson(), 'push_preference': ?instance.pushPreference?.toJson(), 'channel_push_preference': ?instance.channelPushPreference?.toJson(), + 'channel_member_count': ?instance.channelMemberCount, 'channel_message_count': ?instance.channelMessageCount, 'watcher_count': ?instance.watcherCount, 'last_delivered_at': ?instance.lastDeliveredAt?.toIso8601String(), diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 1b9dec3093..1d747ac0f7 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -10016,6 +10016,209 @@ void main() { }, ); }); + + group('Channel member 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 memberCount when event contains channelMemberCount', + () async { + // Verify initial state - default memberCount + expect(channel.memberCount, equals(0)); + + // Create event with channelMemberCount + final memberCountEvent = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 42, + ); + + // Dispatch event + client.addEvent(memberCountEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel memberCount was updated + expect(channel.memberCount, equals(42)); + }, + ); + + test( + 'should update channel memberCount from member.added and member.removed events', + () async { + // Test with member.added event - count increases + final memberAddedEvent = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 1, + ); + + client.addEvent(memberAddedEvent); + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(1)); + expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-1'])); + + // Test with another member.added event - count increases + final memberAddedEvent2 = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-2', + user: User(id: 'user-2'), + ), + channelMemberCount: 2, + ); + + client.addEvent(memberAddedEvent2); + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(2)); + expect( + channel.state?.channelState.members?.map((it) => it.userId), + equals(['user-1', 'user-2']), + ); + + // Test with member.removed event - count decreases + final memberRemovedEvent = Event( + cid: channel.cid, + type: EventType.memberRemoved, + user: User(id: 'user-1'), + channelMemberCount: 1, + ); + + client.addEvent(memberRemovedEvent); + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(1)); + expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-2'])); + }, + ); + + test( + 'should preserve other channel properties when updating memberCount', + () async { + // Set initial channel state with some properties + final initialChannel = channel.state?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + messageCount: 7, + frozen: true, + ); + + if (initialChannel != null) { + channel.state?.updateChannelState( + channel.state!.channelState.copyWith(channel: initialChannel), + ); + } + + // Verify initial state + expect(channel.name, 'Test Channel'); + expect(channel.messageCount, equals(7)); + expect(channel.frozen, equals(true)); + expect(channel.memberCount, equals(0)); + + // Update memberCount via event + final memberCountEvent = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 100, + ); + + client.addEvent(memberCountEvent); + await Future.delayed(Duration.zero); + + // Verify memberCount was updated while preserving other properties + expect(channel.memberCount, equals(100)); + expect(channel.name, 'Test Channel'); + expect(channel.messageCount, equals(7)); + expect(channel.frozen, equals(true)); + }, + ); + + test( + 'should not update memberCount when the event omits channelMemberCount', + () async { + // Seed a known member count. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 5, + ), + ); + + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(5)); + + // An event without the field should leave the count untouched. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-2', + user: User(id: 'user-2'), + ), + ), + ); + + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(5)); + }, + ); + + test( + 'should provide memberCountStream for reactive updates', + () async { + expectLater( + channel.memberCountStream.distinct(), + emitsInOrder([0, 1, 5, 10]), + ); + + // Update memberCount multiple times + final counts = [1, 5, 10]; + for (final count in counts) { + final event = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-$count', + user: User(id: 'user-$count'), + ), + channelMemberCount: count, + ); + + client.addEvent(event); + await Future.delayed(Duration.zero); + } + }, + ); + }); }); group('Channel filterTags', () { From 5dce4c9015c77457e573d888968a13e6e7bbf6ea Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 3 Sep 2026 15:33:29 +0200 Subject: [PATCH 2/7] perf(llc): make primitive value streams distinct (#2935) Co-authored-by: Claude Opus 5 (1M context) --- packages/stream_chat/CHANGELOG.md | 1 + .../stream_chat/lib/src/client/channel.dart | 32 +++++++-------- .../stream_chat/lib/src/client/client.dart | 6 +-- .../test/src/client/channel_test.dart | 40 +++++++++++-------- .../stream_message_composer.dart | 2 +- 5 files changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 596cd9f0a3..1d90f0ccfd 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -8,6 +8,7 @@ - `Channel.translateMessage` now merges the translated message into the channel state, so the translation reaches anything watching the channel without the caller applying the response itself. - Raised minimum Dart SDK to `^3.12.0`. +- `Channel` and `ClientState` streams that expose a single primitive value are now distinct, so they only emit when the value actually changes. Affects `Channel.memberCountStream`, `messageCountStream`, `watcherCountStream`, `cooldownStream`, `nameStream`, `imageStream`, `frozenStream`, `disabledStream`, `hiddenStream`, `isPinnedStream`, `isArchivedStream`, `createdAtStream`, `updatedAtStream`, `deletedAtStream`, `truncatedAtStream`, `lastMessageAtStream`, and `ClientState.totalUnreadCountStream`, `unreadChannelsStream`, `unreadThreadsStream`. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index e798f30ef6..bf369ae62d 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -241,7 +241,7 @@ class Channel { /// Channel frozen status as a stream. Stream get frozenStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.frozen == true); + return state!.channelStateStream.map((cs) => cs.channel?.frozen == true).distinct(); } /// Channel disabled status. @@ -253,7 +253,7 @@ class Channel { /// Channel disabled status as a stream. Stream get disabledStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.disabled == true); + return state!.channelStateStream.map((cs) => cs.channel?.disabled == true).distinct(); } /// Channel hidden status. @@ -265,7 +265,7 @@ class Channel { /// Channel hidden status as a stream. Stream get hiddenStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.hidden == true); + return state!.channelStateStream.map((cs) => cs.channel?.hidden == true).distinct(); } /// Channel pinned status. @@ -278,7 +278,7 @@ class Channel { /// Channel pinned status as a stream. /// Status is specific to the current user. Stream get isPinnedStream { - return membershipStream.map((m) => m?.pinnedAt != null); + return membershipStream.map((m) => m?.pinnedAt != null).distinct(); } /// Channel archived status. @@ -291,7 +291,7 @@ class Channel { /// Channel archived status as a stream. /// Status is specific to the current user. Stream get isArchivedStream { - return membershipStream.map((m) => m?.archivedAt != null); + return membershipStream.map((m) => m?.archivedAt != null).distinct(); } /// The last date at which the channel got truncated. @@ -303,7 +303,7 @@ class Channel { /// The last date at which the channel got truncated as a stream. Stream get truncatedAtStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.truncatedAt); + return state!.channelStateStream.map((cs) => cs.channel?.truncatedAt).distinct(); } /// Cooldown count @@ -315,7 +315,7 @@ class Channel { /// Cooldown count as a stream Stream get cooldownStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.cooldown ?? 0); + return state!.channelStateStream.map((cs) => cs.channel?.cooldown ?? 0).distinct(); } /// Remaining cooldown duration in seconds for the channel. @@ -350,7 +350,7 @@ class Channel { /// Channel creation date as a stream. Stream get createdAtStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.createdAt); + return state!.channelStateStream.map((cs) => cs.channel?.createdAt).distinct(); } /// Channel last message date. @@ -362,7 +362,7 @@ class Channel { /// Channel last message date as a stream. Stream get lastMessageAtStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt); + return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt).distinct(); } DateTime? _currentUserLastMessageAt({ @@ -442,7 +442,7 @@ class Channel { /// Channel updated date as a stream. Stream get updatedAtStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.updatedAt); + return state!.channelStateStream.map((cs) => cs.channel?.updatedAt).distinct(); } /// Channel deletion date. @@ -454,7 +454,7 @@ class Channel { /// Channel deletion date as a stream. Stream get deletedAtStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.deletedAt); + return state!.channelStateStream.map((cs) => cs.channel?.deletedAt).distinct(); } /// Channel member count. @@ -466,7 +466,7 @@ class Channel { /// Channel member count as a stream. Stream get memberCountStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.memberCount); + return state!.channelStateStream.map((cs) => cs.channel?.memberCount).distinct(); } /// Channel message count. @@ -484,7 +484,7 @@ class Channel { /// enabled for your app. Stream get messageCountStream { _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.messageCount); + return state!.channelStateStream.map((cs) => cs.channel?.messageCount).distinct(); } /// List of filter tags applied to this channel. @@ -548,7 +548,7 @@ class Channel { /// {@macro name} Stream get nameStream { _checkInitialized(); - return extraDataStream.map((it) => it['name'] as String?); + return extraDataStream.map((it) => it['name'] as String?).distinct(); } /// Shortcut to get channel image. @@ -563,7 +563,7 @@ class Channel { /// {@macro image} Stream get imageStream { _checkInitialized(); - return extraDataStream.map((it) => it['image'] as String?); + return extraDataStream.map((it) => it['image'] as String?).distinct(); } /// The main Stream chat client. @@ -3592,7 +3592,7 @@ class ChannelClientState { int? get watcherCount => _channelState.watcherCount; /// Channel watcher count as a stream. - Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount); + Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount).distinct(); /// Channel watchers list. List get watchers => (_channelState.watchers ?? []).map((e) => _client.state.users[e.id] ?? e).toList(); diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index d174612a16..123685cbb4 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2928,19 +2928,19 @@ class ClientState { int get unreadChannels => _unreadChannelsController.value; /// The current unread channels count as a stream - Stream get unreadChannelsStream => _unreadChannelsController.stream; + Stream get unreadChannelsStream => _unreadChannelsController.stream.distinct(); /// The current unread thread count. int get unreadThreads => _unreadThreadsController.value; /// The current unread threads count as a stream. - Stream get unreadThreadsStream => _unreadThreadsController.stream; + Stream get unreadThreadsStream => _unreadThreadsController.stream.distinct(); /// The current total unread messages count int get totalUnreadCount => _totalUnreadCountController.value; /// The current total unread messages count as a stream - Stream get totalUnreadCountStream => _totalUnreadCountController.stream; + Stream get totalUnreadCountStream => _totalUnreadCountController.stream.distinct(); /// The current list of channels in memory as a stream Stream> get channelsStream => _channelsController.stream; diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 1d747ac0f7..0a71f7cdbc 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -9991,19 +9991,19 @@ void main() { test( 'should provide messageCountStream for reactive updates', () async { - expectLater( - channel.messageCountStream.distinct(), - emitsInOrder([null, 1, 5, 10]), - ); + final emitted = []; + final subscription = channel.messageCountStream.listen(emitted.add); + addTearDown(subscription.cancel); + await Future.delayed(Duration.zero); - // Update messageCount multiple times - final counts = [1, 5, 10]; - for (final count in counts) { + // Update messageCount multiple times, repeating one of the counts. + final counts = [1, 5, 5, 10]; + for (final (index, count) in counts.indexed) { final event = Event( cid: channel.cid, type: EventType.messageNew, message: Message( - id: 'msg-$count', + id: 'msg-$index', text: 'Message $count', user: User(id: 'user-1'), ), @@ -10013,6 +10013,9 @@ void main() { client.addEvent(event); await Future.delayed(Duration.zero); } + + // The repeated count should not be emitted twice. + expect(emitted, equals([null, 1, 5, 10])); }, ); }); @@ -10195,20 +10198,20 @@ void main() { test( 'should provide memberCountStream for reactive updates', () async { - expectLater( - channel.memberCountStream.distinct(), - emitsInOrder([0, 1, 5, 10]), - ); + final emitted = []; + final subscription = channel.memberCountStream.listen(emitted.add); + addTearDown(subscription.cancel); + await Future.delayed(Duration.zero); - // Update memberCount multiple times - final counts = [1, 5, 10]; - for (final count in counts) { + // Update memberCount multiple times, repeating one of the counts. + final counts = [1, 5, 5, 10]; + for (final (index, count) in counts.indexed) { final event = Event( cid: channel.cid, type: EventType.memberAdded, member: Member( - userId: 'user-$count', - user: User(id: 'user-$count'), + userId: 'user-$index', + user: User(id: 'user-$index'), ), channelMemberCount: count, ); @@ -10216,6 +10219,9 @@ void main() { client.addEvent(event); await Future.delayed(Duration.zero); } + + // The repeated count should not be emitted twice. + expect(emitted, equals([0, 1, 5, 10])); }, ); }); diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 041c52577f..83eb01f77b 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -687,7 +687,7 @@ class DefaultStreamMessageComposerState extends State channel.state?.draftStream, }; - _draftStreamSubscription = draftStream?.distinct().listen(_onDraftUpdate); + _draftStreamSubscription = draftStream?.listen(_onDraftUpdate); } // Keeps the composer in sync with remote message changes. From 1fd16718be4e63c36b475601237507aec3d76b74 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 3 Sep 2026 16:37:56 +0200 Subject: [PATCH 3/7] chore(repo): release v10.4.0 (#2938) Co-authored-by: Claude Opus 5 (1M context) --- docs/docs_screenshots/pubspec.yaml | 8 ++------ .../test/channel/channel_preview_test.dart | 4 ++-- melos.yaml | 16 ++++++---------- packages/stream_chat/CHANGELOG.md | 2 +- packages/stream_chat/example/pubspec.yaml | 2 +- packages/stream_chat/lib/version.dart | 2 +- packages/stream_chat/pubspec.yaml | 2 +- packages/stream_chat_flutter/CHANGELOG.md | 10 +++++----- .../stream_chat_flutter/example/pubspec.yaml | 6 +++--- packages/stream_chat_flutter/pubspec.yaml | 18 +++--------------- packages/stream_chat_flutter_core/CHANGELOG.md | 2 +- .../example/pubspec.yaml | 2 +- packages/stream_chat_flutter_core/pubspec.yaml | 4 ++-- .../stream_chat_localizations/CHANGELOG.md | 2 +- .../example/pubspec.yaml | 4 ++-- .../stream_chat_localizations/pubspec.yaml | 4 ++-- packages/stream_chat_persistence/CHANGELOG.md | 2 +- .../example/pubspec.yaml | 4 ++-- packages/stream_chat_persistence/pubspec.yaml | 4 ++-- sample_app/lib/widgets/channel_list.dart | 2 +- sample_app/pubspec.yaml | 8 ++++---- 21 files changed, 44 insertions(+), 64 deletions(-) diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 1766a3b40b..32e872807b 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -18,12 +18,8 @@ dependencies: flutter: sdk: flutter record: ^6.2.0 - stream_chat_flutter: ^10.3.0 - stream_core_flutter: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: 4b7ed86a52c3109fad0a6f5118120082e378c610 - path: packages/stream_core_flutter + stream_chat_flutter: ^10.4.0 + stream_core_flutter: ^0.5.1 dev_dependencies: alchemist: ^0.14.0 diff --git a/docs/docs_screenshots/test/channel/channel_preview_test.dart b/docs/docs_screenshots/test/channel/channel_preview_test.dart index c663120f7c..abb4863198 100644 --- a/docs/docs_screenshots/test/channel/channel_preview_test.dart +++ b/docs/docs_screenshots/test/channel/channel_preview_test.dart @@ -225,7 +225,7 @@ void main() { foregroundColor: colorScheme.textPrimary, backgroundColor: colorScheme.backgroundSurface, onPressed: (_) {}, - child: Icon(icons.more, size: 20), + child: Icon(icons.moreHorizontal, size: 20), ), CustomSlidableAction( foregroundColor: colorScheme.textOnAccent, @@ -350,7 +350,7 @@ void main() { foregroundColor: colorScheme.textPrimary, backgroundColor: colorScheme.backgroundSurface, onPressed: (_) {}, - child: Icon(icons.more, size: 20), + child: Icon(icons.moreHorizontal, size: 20), ), CustomSlidableAction( foregroundColor: colorScheme.textOnAccent, diff --git a/melos.yaml b/melos.yaml index 4172629cd9..2a130bf5a9 100644 --- a/melos.yaml +++ b/melos.yaml @@ -97,18 +97,14 @@ command: share_plus: ">=12.0.2 <14.0.0" shimmer: ^3.0.0 sqlite3_flutter_libs: ^0.5.26 - stream_chat: ^10.3.0 - stream_chat_flutter: ^10.3.0 - stream_chat_flutter_core: ^10.3.0 - stream_chat_localizations: ^10.3.0 - stream_chat_persistence: ^10.3.0 + stream_chat: ^10.4.0 + stream_chat_flutter: ^10.4.0 + stream_chat_flutter_core: ^10.4.0 + stream_chat_localizations: ^10.4.0 + stream_chat_persistence: ^10.4.0 streaming_shared_preferences: ^2.0.0 svg_icon_widget: ^0.0.1 - stream_core_flutter: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: 4b7ed86a52c3109fad0a6f5118120082e378c610 - path: packages/stream_core_flutter + stream_core_flutter: ^0.5.1 stream_thumbnail: ^0.1.0 synchronized: ^3.4.0 thumblr: ^0.0.4 diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 1d90f0ccfd..5c2d60971d 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,4 +1,4 @@ -## Upcoming +## 10.4.0 ✅ Added diff --git a/packages/stream_chat/example/pubspec.yaml b/packages/stream_chat/example/pubspec.yaml index 193816c51e..4ef732589e 100644 --- a/packages/stream_chat/example/pubspec.yaml +++ b/packages/stream_chat/example/pubspec.yaml @@ -24,7 +24,7 @@ dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat: ^10.3.0 + stream_chat: ^10.4.0 dependency_overrides: stream_chat: diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index a99a38dfa5..de2f022d6e 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -9,4 +9,4 @@ /// Current package version /// Used in [SystemEnvironmentManager] to build the `x-stream-client` header -const PACKAGE_VERSION = '10.3.0'; +const PACKAGE_VERSION = '10.4.0'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index fa3a266a99..334c69fde7 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 10.3.0 +version: 10.4.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 7a1c74ed54..166768a44a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,8 +1,11 @@ -## Upcoming +## 10.4.0 ✅ Added - Added `onReactionLongPress` to `StreamMessageItem` and `StreamMessageListView`, reporting the long-pressed message's `BuildContext` and a `ReactionLongPressDetails` with the `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). +- Exported this package's `StreamMessageContent`, which was previously unreachable from `package:stream_chat_flutter/stream_chat_flutter.dart`. +- Added `StreamChatConfigurationData.messageTranslation`, a `StreamMessageTranslationConfiguration` with `enabled` (default `true`) to display translations from `Message.i18n` and `annotationEnabled` (default `false`) to opt into a "Translated"/"Original" annotation with a toggle link. +- Added `StreamMessageTranslationStore`, tracking which messages show their original text instead of their translation. `StreamChat` owns one and provides it through a `StreamMessageTranslations` scope, so every message list agrees on which text a message shows. Also drivable per widget via `showTranslatedText` and `onToggleTranslatedText`. ⚠️ Changed @@ -15,6 +18,7 @@ 🐞 Fixed +- Fixed message text and previews translating to English for users with no `User.language` set; they now show the original text. - Fixed a crash on web when the message list rebuilt while messages were selectable, for example after opening the attachment picker. - Fixed the browser's native context menu reappearing over the message context menu on web after scrolling messages out of view or deleting one. - Fixed the SDK re-enabling the browser's native context menu on web in apps that had disabled it themselves. @@ -46,9 +50,6 @@ - Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). - Exported `StreamEphemeralMessage`, the row `StreamMessageListView` builds for ephemeral messages, matching its already-exported `StreamSystemMessage` and `StreamModeratedMessage` siblings. - Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count. -- Exported this package's `StreamMessageContent`, which was previously unreachable from `package:stream_chat_flutter/stream_chat_flutter.dart`. -- Added `StreamChatConfigurationData.messageTranslation`, a `StreamMessageTranslationConfiguration` with `enabled` (default `true`) to display translations from `Message.i18n` and `annotationEnabled` (default `false`) to opt into a "Translated"/"Original" annotation with a toggle link. -- Added `StreamMessageTranslationStore`, tracking which messages show their original text instead of their translation. `StreamChat` owns one and provides it through a `StreamMessageTranslations` scope, so every message list agrees on which text a message shows. Also drivable per widget via `showTranslatedText` and `onToggleTranslatedText`. ⚠️ Deprecated @@ -59,7 +60,6 @@ 🐞 Fixed -- Fixed message text and previews translating to English for users with no `User.language` set; they now show the original text. - Fixed a failed send surfacing as an unhandled async error when `StreamMessageComposer` has no `onError`; it is now reported through `FlutterError.reportError`. - Fixed the default `StreamChannel` loading and error states not being themed or localized; `StreamChat` now installs themed, connection-aware defaults, overridable per `StreamChannel` or via `DefaultStreamChannelBuilders`. - Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, reaction, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text. diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index cab1e79ac8..702d735ff7 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -25,9 +25,9 @@ dependencies: flutter: sdk: flutter responsive_builder: ^0.7.0 - stream_chat_flutter: ^10.3.0 - stream_chat_localizations: ^10.3.0 - stream_chat_persistence: ^10.3.0 + stream_chat_flutter: ^10.4.0 + stream_chat_localizations: ^10.4.0 + stream_chat_persistence: ^10.4.0 dependency_overrides: stream_chat: diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index bba0f2a0f3..e259c5ce32 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 10.3.0 +version: 10.4.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -54,20 +54,8 @@ dependencies: rxdart: ^0.28.0 share_plus: ">=12.0.2 <14.0.0" shimmer: ^3.0.0 - stream_chat_flutter_core: ^10.3.0 - stream_core_flutter: - # The ignore below silences `invalid_dependency` because we occasionally - # pin stream_core_flutter to a git ref to iterate on it alongside this - # SDK between its releases. - # - # **Note:** Before publishing stream_chat_flutter, this MUST be swapped - # back to a pub version constraint — git deps are not allowed on pub.dev - # and will block the release. - # ignore: invalid_dependency - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: 4b7ed86a52c3109fad0a6f5118120082e378c610 - path: packages/stream_core_flutter + stream_chat_flutter_core: ^10.4.0 + stream_core_flutter: ^0.5.1 stream_thumbnail: ^0.1.0 svg_icon_widget: ^0.0.1 synchronized: ^3.4.0 diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 5b4b112243..baac1765f4 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,4 +1,4 @@ -## Upcoming +## 10.4.0 🔄 Changed diff --git a/packages/stream_chat_flutter_core/example/pubspec.yaml b/packages/stream_chat_flutter_core/example/pubspec.yaml index ce0041df2a..46f4631bda 100644 --- a/packages/stream_chat_flutter_core/example/pubspec.yaml +++ b/packages/stream_chat_flutter_core/example/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat_flutter_core: ^10.3.0 + stream_chat_flutter_core: ^10.4.0 dependency_overrides: stream_chat: diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 7906022d7a..f4b1ef3dea 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 10.3.0 +version: 10.4.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -32,7 +32,7 @@ dependencies: package_info_plus: ">=9.0.1 <11.0.0" rate_limiter: ^1.1.0 rxdart: ^0.28.0 - stream_chat: ^10.3.0 + stream_chat: ^10.4.0 dev_dependencies: build_runner: ^2.15.0 diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 32cac6298b..0835dd4764 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,4 +1,4 @@ -## Upcoming +## 10.4.0 ✅ Added diff --git a/packages/stream_chat_localizations/example/pubspec.yaml b/packages/stream_chat_localizations/example/pubspec.yaml index c15e6065a7..73d439d125 100644 --- a/packages/stream_chat_localizations/example/pubspec.yaml +++ b/packages/stream_chat_localizations/example/pubspec.yaml @@ -24,8 +24,8 @@ dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat_flutter: ^10.3.0 - stream_chat_localizations: ^10.3.0 + stream_chat_flutter: ^10.4.0 + stream_chat_localizations: ^10.4.0 dependency_overrides: stream_chat: diff --git a/packages/stream_chat_localizations/pubspec.yaml b/packages/stream_chat_localizations/pubspec.yaml index a184f54636..994f695bdc 100644 --- a/packages/stream_chat_localizations/pubspec.yaml +++ b/packages/stream_chat_localizations/pubspec.yaml @@ -1,6 +1,6 @@ name: stream_chat_localizations description: The Official localizations for Stream Chat Flutter, a service for building chat applications -version: 10.3.0 +version: 10.4.0 homepage: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -27,7 +27,7 @@ dependencies: flutter_localizations: sdk: flutter intl: ^0.20.2 - stream_chat_flutter: ^10.3.0 + stream_chat_flutter: ^10.4.0 dev_dependencies: clock: ^1.1.2 diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index a7be41f459..715d7fab5c 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,4 +1,4 @@ -## Upcoming +## 10.4.0 🔄 Changed diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml index ec1ee624c5..a6573ca2c6 100644 --- a/packages/stream_chat_persistence/example/pubspec.yaml +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -23,8 +23,8 @@ dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat: ^10.3.0 - stream_chat_persistence: ^10.3.0 + stream_chat: ^10.4.0 + stream_chat_persistence: ^10.4.0 dependency_overrides: stream_chat: diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index cb465112b4..b3f6dd69c0 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 10.3.0 +version: 10.4.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -30,7 +30,7 @@ dependencies: path: ^1.9.1 path_provider: ^2.1.5 sqlite3_flutter_libs: ^0.5.26 - stream_chat: ^10.3.0 + stream_chat: ^10.4.0 dev_dependencies: build_runner: ^2.15.0 diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index ffb70ced2c..f557eac3e5 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -166,7 +166,7 @@ class _ChannelListDefault extends StatelessWidget { foregroundColor: colorScheme.textPrimary, backgroundColor: colorScheme.backgroundSurface, onPressed: (_) => _openChannelDetailSheet(context, channel), - child: Icon(icons.more, size: 20), + child: Icon(icons.moreHorizontal, size: 20), ), BetterStreamBuilder( stream: channel.isMutedStream, diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 696ef9ab16..778595a67f 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -1,7 +1,7 @@ name: sample_app description: A new Flutter project. publish_to: "none" -version: 10.3.0 +version: 10.4.0 # Note: The environment configuration and dependency versions are managed by Melos. # @@ -46,9 +46,9 @@ dependencies: media_kit_video: ^2.0.1 path_provider_android: ">=2.2.5 <2.3.0" rxdart: ^0.28.0 - stream_chat_flutter: ^10.3.0 - stream_chat_localizations: ^10.3.0 - stream_chat_persistence: ^10.3.0 + stream_chat_flutter: ^10.4.0 + stream_chat_localizations: ^10.4.0 + stream_chat_persistence: ^10.4.0 streaming_shared_preferences: ^2.0.0 uuid: ^4.5.3 video_player: ^2.11.1 From c665778909ce5853219895a2322fda57524788fb Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 4 Sep 2026 13:50:09 +0200 Subject: [PATCH 4/7] fix(ui,llc): wasm support (#2940) * minor fixes for wasm * update style guide --- STYLE_GUIDE.md | 30 +++++++++++++++++++ analysis_options.yaml | 1 + packages/stream_chat/CHANGELOG.md | 6 ++++ .../platform_detector/platform_detector.dart | 2 +- packages/stream_chat_flutter/CHANGELOG.md | 6 ++++ .../handler/stream_attachment_handler.dart | 2 +- 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 4650db3680..a23f16c40a 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -69,6 +69,9 @@ document; the section link is provided. positional `bool` at a call site tells the reader nothing. - File names are `snake_case.dart` (`file_names`). Imports follow the standard order: `dart:` → `package:` → relative — one blank line between groups (`directives_ordering`). +- Web conditional imports select the web file with `dart.library.js_interop`, never + `dart.library.html`, which does not exist under `dart2wasm`. + → [Web conditional imports use `js_interop`, not `html`](#web-conditional-imports-use-js_interop-not-html) - Public API members require dartdoc (`///`). Private members (`_`-prefixed) use `//` block comments, not `///`. → [Private members use `//`](#private-members-use--not-) - Default to zero inline `//` comments in implementation. Prefer well-named locals and @@ -926,6 +929,33 @@ Consuming streams in widgets: only rebuilds when the value changes. - Always cancel `StreamSubscription`s in `dispose()`. +### Web conditional imports use `js_interop`, not `html` + +Conditional imports that swap in a web implementation key on `dart.library.js_interop`: + +```dart +// GOOD +import 'foo_stub.dart' + if (dart.library.js_interop) 'foo_web.dart' + if (dart.library.io) 'foo_io.dart'; + +// BAD +import 'foo_stub.dart' + if (dart.library.html) 'foo_web.dart' + if (dart.library.io) 'foo_io.dart'; +``` + +`dart:html` does not exist under `dart2wasm`, and neither does `dart:io`. On a +WebAssembly build the bad form therefore matches no condition and silently resolves to +the stub. It still compiles cleanly; the failure appears only at runtime, when the stub +throws `UnimplementedError`. `dart2js` supports both constants, so `js_interop` covers +JS and Wasm alike. + +Nothing in the analyzer catches this. `avoid_web_libraries_in_flutter` inspects import +URIs, and a condition is not a URI — so the bad form produces no warning. + +The same applies to `export` directives. + ## Testing diff --git a/analysis_options.yaml b/analysis_options.yaml index 237db5221b..9e1bb75d0d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -17,6 +17,7 @@ linter: - avoid_relative_lib_imports - avoid_slow_async_io - avoid_types_as_parameter_names + - avoid_web_libraries_in_flutter - cancel_subscriptions - close_sinks - control_flow_in_finally diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 5c2d60971d..fe911ca2db 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +🐞 Fixed + +- Fixed `CurrentPlatform` throwing `UnimplementedError` on WebAssembly builds. + ## 10.4.0 ✅ Added diff --git a/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart index 8b385181e4..74e1f6c788 100644 --- a/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart @@ -1,6 +1,6 @@ import 'package:meta/meta.dart' show visibleForTesting; import 'platform_detector_stub.dart' - if (dart.library.html) 'platform_detector_web.dart' + if (dart.library.js_interop) 'platform_detector_web.dart' if (dart.library.io) 'platform_detector_io.dart'; /// Possible platforms diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 166768a44a..da24a1dbea 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +🐞 Fixed + +- Fixed `StreamAttachmentHandler` throwing `UnimplementedError` on WebAssembly builds. + ## 10.4.0 ✅ Added diff --git a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler.dart b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler.dart index 36d45725f5..22288d489d 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler.dart @@ -1,4 +1,4 @@ export 'stream_attachment_handler_base.dart' - if (dart.library.html) 'stream_attachment_handler_html.dart' + if (dart.library.js_interop) 'stream_attachment_handler_html.dart' if (dart.library.io) 'stream_attachment_handler_io.dart' show StreamAttachmentHandler; From 7b727f891501ecbc731c924c9b438a4962a11f83 Mon Sep 17 00:00:00 2001 From: Petar Velikov Date: Mon, 7 Sep 2026 13:51:34 +0200 Subject: [PATCH 5/7] refactor(llc): split `channel.dart` into focused files (#2930) * refactor(llc): move the channel extensions into their own files * refactor(llc): move ChannelClientState into its own file * docs(llc): fix the dangling message doc references in ChannelReadHelper * test(llc): dispose the channels created by the local unread count tests * refactor(llc): move the channel files into their own directory Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../stream_chat/lib/src/client/channel.dart | 4896 ----------- .../lib/src/client/channel/channel.dart | 2468 ++++++ .../channel/channel_capability_check.dart | 235 + .../client/channel/channel_client_state.dart | 2155 +++++ .../client/channel/channel_read_helper.dart | 52 + .../src/client/channel_delivery_reporter.dart | 2 +- .../stream_chat/lib/src/client/client.dart | 2 +- .../lib/src/client/query_channels_result.dart | 2 +- .../lib/src/core/util/message_rules.dart | 3 +- packages/stream_chat/lib/stream_chat.dart | 5 +- .../channel_capability_check_test.dart | 410 + .../channel/channel_client_state_test.dart | 5124 +++++++++++ .../channel/channel_read_helper_test.dart | 319 + .../client/{ => channel}/channel_test.dart | 7645 +++-------------- packages/stream_chat/test/src/matchers.dart | 2 +- packages/stream_chat/test/src/mocks.dart | 2 +- 16 files changed, 11762 insertions(+), 11560 deletions(-) delete mode 100644 packages/stream_chat/lib/src/client/channel.dart create mode 100644 packages/stream_chat/lib/src/client/channel/channel.dart create mode 100644 packages/stream_chat/lib/src/client/channel/channel_capability_check.dart create mode 100644 packages/stream_chat/lib/src/client/channel/channel_client_state.dart create mode 100644 packages/stream_chat/lib/src/client/channel/channel_read_helper.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_client_state_test.dart create mode 100644 packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart rename packages/stream_chat/test/src/client/{ => channel}/channel_test.dart (51%) diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart deleted file mode 100644 index bf369ae62d..0000000000 --- a/packages/stream_chat/lib/src/client/channel.dart +++ /dev/null @@ -1,4896 +0,0 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'dart:async'; -import 'dart:math' as math; - -import 'package:collection/collection.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:synchronized/synchronized.dart'; - -import '../../stream_chat.dart'; -import '../core/util/utils.dart'; -import 'retry_queue.dart'; - -/// The maximum time the incoming [Event.typingStart] event is valid before a -/// [Event.typingStop] event is emitted automatically. -const incomingTypingStartEventTimeout = 7; - -/// Class that manages a specific channel. -/// -/// #### Channel name -/// -/// {@template name} -/// If an optional [name] argument is provided in the constructor then it -/// will be set on [extraData] with a key of 'name'. -/// -/// ```dart -/// final channel = Channel(client, type, id, name: 'Channel name'); -/// print(channel.name == channel.extraData['name']); // true -/// ``` -/// -/// Before the channel is initialized the name can be set directly: -/// ```dart -/// channel.name = 'New channel name'; -/// ``` -/// -/// To update the name after the channel has been initialized, call: -/// ```dart -/// channel.updateName('Updated channel name'); -/// ``` -/// -/// This will do a partial update to update the name. -/// {@endtemplate} -/// -/// #### Channel image -/// -/// {@template image} -/// If an optional [image] argument is provided in the constructor then it -/// will be set on [extraData] with a key of 'image'. -/// -/// ```dart -/// final channel = Channel(client, type, id, image: 'https://getstream.io/image.png'); -/// print(channel.image == channel.extraData['image']); // true -/// ``` -/// -/// Before the channel is initialized the image can be set directly: -/// ```dart -/// channel.image = 'https://getstream.io/new-image'; -/// ``` -/// -/// To update the image after the channel has been initialized, call: -/// ```dart -/// channel.updateImage('https://getstream.io/new-image'); -/// ``` -/// -/// This will do a partial update to update the image. -/// {@endtemplate} -class Channel { - /// Class that manages a specific channel. - /// - /// Optional [extraData] and [image] properties can be provided. The [image] - /// is exposed to easily set a key of 'image' on [extraData]. - Channel( - this._client, - this._type, - this._id, { - String? name, - String? image, - Map? extraData, - }) : _cid = _id != null ? '$_type:$_id' : null, - _extraData = { - ...?extraData, - if (name != null) 'name': name, - if (image != null) 'image': image, - } { - _client.logger.info('New Channel instance created, not yet initialized'); - } - - /// Create a channel client instance from a [ChannelState] object. - Channel.fromState(this._client, ChannelState channelState) - : assert( - channelState.channel != null, - 'No channel found inside channel state', - ), - _id = channelState.channel!.id, - _type = channelState.channel!.type, - _cid = channelState.channel!.cid, - _extraData = channelState.channel!.extraData { - _initState(channelState); // Initialize the state immediately. - } - - /// This client state - ChannelClientState? state; - - /// The channel type - final String _type; - - String? _id; - String? _cid; - final Map _extraData; - - /// Shortcut to set channel name. - /// - /// {@macro name} - set name(String? name) { - if (_isInitialized) { - throw StateError( - 'Once the channel is initialized you should use `channel.updateName` ' - 'to update the channel name', - ); - } - _extraData.addAll({'name': name}); - } - - /// Shortcut to set channel image. - /// - /// {@macro image} - set image(String? image) { - if (_isInitialized) { - throw StateError( - 'Once the channel is initialized you should use `channel.updateImage` ' - 'to update the channel image', - ); - } - _extraData.addAll({'image': image}); - } - - set extraData(Map extraData) { - if (_isInitialized) { - throw StateError( - 'Once the channel is initialized you should use `channel.update` ' - 'to update channel data', - ); - } - _extraData.addAll(extraData); - } - - /// Whether this channel is identified by its member set rather than an - /// explicit id. - /// - /// Stream auto-generates ids of the form `!members-` for channels - /// created with members but no id, so the same set of users always - /// references the same channel. - /// - /// Distinct channels can lose members but can't gain them after creation. - /// - /// See [isOneToOne] for the typical 1-to-1 predicate built on this. - bool get isDistinct => id?.startsWith('!members') == true; - - /// Whether this is a group channel. - /// - /// True when the channel has more than two members, or isn't [isDistinct]. - /// Custom-id channels are treated as groups regardless of current member - /// count because they aren't bounded — they can grow back into - /// multi-person conversations. - /// - /// Near-inverse of [isOneToOne]. - bool get isGroup => (memberCount ?? 0) > 2 || !isDistinct; - - /// Whether this is a 1-to-1 conversation. - /// - /// True when the channel is [isDistinct] and has exactly two members. - /// Distinct channels can't gain members, so a 2-member distinct channel - /// is permanently bounded to two participants — including channels that - /// shrunk down from a larger group DM. - /// - /// This is a structural predicate without a current-user check. Combine - /// with capability / permission checks at the call site if you need - /// perspective gating. - /// - /// Near-inverse of [isGroup]. - bool get isOneToOne => isDistinct && memberCount == 2; - - /// Returns true if the channel is muted. - bool get isMuted { - final channelMutes = _client.state.currentUser?.channelMutes; - if (channelMutes == null) return false; - - return channelMutes.any((it) => it.channel.cid == cid); - } - - /// Returns true if the channel is muted, as a stream. - Stream get isMutedStream => _client.state.currentUserStream.map((user) { - final channelMutes = user?.channelMutes; - if (channelMutes == null) return false; - - return channelMutes.any((it) => it.channel.cid == cid); - }).distinct(); - - /// Channel configuration. - ChannelConfig? get config { - _checkInitialized(); - return state!._channelState.channel?.config; - } - - /// Channel configuration as a stream. - Stream get configStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.config); - } - - /// Relationship of the current user to this channel. - Member? get membership { - _checkInitialized(); - return state!._channelState.membership; - } - - /// Relationship of the current user to this channel as a stream. - Stream get membershipStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.membership); - } - - /// Channel user creator. - User? get createdBy { - _checkInitialized(); - return state!._channelState.channel?.createdBy; - } - - /// Channel user creator as a stream. - Stream get createdByStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.createdBy); - } - - /// Channel frozen status. - bool get frozen { - _checkInitialized(); - return state!._channelState.channel?.frozen == true; - } - - /// Channel frozen status as a stream. - Stream get frozenStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.frozen == true).distinct(); - } - - /// Channel disabled status. - bool get disabled { - _checkInitialized(); - return state!._channelState.channel?.disabled == true; - } - - /// Channel disabled status as a stream. - Stream get disabledStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.disabled == true).distinct(); - } - - /// Channel hidden status. - bool get hidden { - _checkInitialized(); - return state!._channelState.channel?.hidden == true; - } - - /// Channel hidden status as a stream. - Stream get hiddenStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.hidden == true).distinct(); - } - - /// Channel pinned status. - /// Status is specific to the current user. - bool get isPinned { - _checkInitialized(); - return membership?.pinnedAt != null; - } - - /// Channel pinned status as a stream. - /// Status is specific to the current user. - Stream get isPinnedStream { - return membershipStream.map((m) => m?.pinnedAt != null).distinct(); - } - - /// Channel archived status. - /// Status is specific to the current user. - bool get isArchived { - _checkInitialized(); - return membership?.archivedAt != null; - } - - /// Channel archived status as a stream. - /// Status is specific to the current user. - Stream get isArchivedStream { - return membershipStream.map((m) => m?.archivedAt != null).distinct(); - } - - /// The last date at which the channel got truncated. - DateTime? get truncatedAt { - _checkInitialized(); - return state!._channelState.channel?.truncatedAt; - } - - /// The last date at which the channel got truncated as a stream. - Stream get truncatedAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.truncatedAt).distinct(); - } - - /// Cooldown count - int get cooldown { - _checkInitialized(); - return state!._channelState.channel?.cooldown ?? 0; - } - - /// Cooldown count as a stream - Stream get cooldownStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.cooldown ?? 0).distinct(); - } - - /// Remaining cooldown duration in seconds for the channel. - /// - /// Returns 0 if there is no cooldown active. - /// - /// Optionally, provide [lastMessageAt] to calculate the remaining cooldown based on a specific message timestamp - /// instead of the last message sent by the current user in this channel. - int getRemainingCooldown({DateTime? lastMessageAt}) { - _checkInitialized(); - - final cooldownDuration = cooldown; - if (cooldownDuration <= 0) return 0; - - final userLastMessageAt = lastMessageAt ?? currentUserLastMessageAt; - if (userLastMessageAt == null) return 0; - - if (canSkipSlowMode) return 0; - - final currentTime = DateTime.timestamp(); - final elapsedTime = currentTime.difference(userLastMessageAt).inSeconds; - - return math.max(0, cooldownDuration - elapsedTime); - } - - /// Channel creation date. - DateTime? get createdAt { - _checkInitialized(); - return state!._channelState.channel?.createdAt; - } - - /// Channel creation date as a stream. - Stream get createdAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.createdAt).distinct(); - } - - /// Channel last message date. - DateTime? get lastMessageAt { - _checkInitialized(); - return state!._channelState.channel?.lastMessageAt; - } - - /// Channel last message date as a stream. - Stream get lastMessageAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt).distinct(); - } - - DateTime? _currentUserLastMessageAt({ - required List? messages, - required Map> threads, - }) { - final currentUserId = client.state.currentUser?.id; - if (currentUserId == null) return null; - - bool ours(Message m) => !m.isEphemeral && m.user?.id == currentUserId; - - DateTime? max; - - if (messages != null) { - final idx = messages.lastIndexWhere(ours); - if (idx != -1) max = messages[idx].createdAt; - } - - for (final replies in threads.values) { - final idx = replies.lastIndexWhere(ours); - if (idx == -1) continue; - final createdAt = replies[idx].createdAt; - if (max == null || createdAt.isAfter(max)) max = createdAt; - } - - return max; - } - - /// The date of the last message sent by the current user. - /// - /// Returns null if the channel is not up to date or - /// if the current user has not sent any messages in this channel. - /// - /// Note: This includes both regular messages and thread messages. - DateTime? get currentUserLastMessageAt { - _checkInitialized(); - - // If the channel is not up to date, we can't rely on the last message - // from the current user. - if (!state!.isUpToDate) return null; - - final threads = state!.threads; - final messages = state!.channelState.messages; - - return _currentUserLastMessageAt(messages: messages, threads: threads); - } - - /// The date of the last message sent by the current user as a stream. - /// - /// Returns null if the channel is not up to date or - /// if the current user has not sent any messages in this channel. - /// - /// Note: This includes both regular messages and thread messages. - Stream get currentUserLastMessageAtStream { - _checkInitialized(); - - return CombineLatestStream.combine3( - state!.isUpToDateStream, - state!.channelStateStream.map((s) => s.messages).distinct(identical), - state!.threadsStream, - (isUpToDate, messages, threads) { - // If the channel is not up to date, we can't rely on the last message - // from the current user. - if (!isUpToDate) return null; - - return _currentUserLastMessageAt(messages: messages, threads: threads); - }, - ); - } - - /// Channel updated date. - DateTime? get updatedAt { - _checkInitialized(); - return state!._channelState.channel?.updatedAt; - } - - /// Channel updated date as a stream. - Stream get updatedAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.updatedAt).distinct(); - } - - /// Channel deletion date. - DateTime? get deletedAt { - _checkInitialized(); - return state!._channelState.channel?.deletedAt; - } - - /// Channel deletion date as a stream. - Stream get deletedAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.deletedAt).distinct(); - } - - /// Channel member count. - int? get memberCount { - _checkInitialized(); - return state!._channelState.channel?.memberCount; - } - - /// Channel member count as a stream. - Stream get memberCountStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.memberCount).distinct(); - } - - /// Channel message count. - /// - /// Note: This field is only populated if the `count_messages` option is - /// enabled for your app. - int? get messageCount { - _checkInitialized(); - return state!._channelState.channel?.messageCount; - } - - /// Channel message count as a stream. - /// - /// Note: This field is only populated if the `count_messages` option is - /// enabled for your app. - Stream get messageCountStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.messageCount).distinct(); - } - - /// List of filter tags applied to this channel. - /// - /// Generally used for filtering channels while querying. - List? get filterTags { - _checkInitialized(); - return state!._channelState.channel?.filterTags; - } - - /// Channel id. - String? get id => state?._channelState.channel?.id ?? _id; - - /// Channel type. - String get type => state?._channelState.channel?.type ?? _type; - - /// Channel cid. - String? get cid => state?._channelState.channel?.cid ?? _cid; - - /// Channel team. - String? get team { - _checkInitialized(); - return state!._channelState.channel?.team; - } - - /// Channel extra data. - Map get extraData { - var data = state?._channelState.channel?.extraData; - if (data == null || data.isEmpty) { - data = _extraData; - } - return data; - } - - /// List of user permissions on this channel - List get ownCapabilities => state?._channelState.channel?.ownCapabilities ?? []; - - /// List of user permissions on this channel - Stream> get ownCapabilitiesStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.ownCapabilities ?? []).distinct(); - } - - /// Channel extra data as a stream. - Stream> get extraDataStream { - _checkInitialized(); - return state!.channelStateStream.map( - (cs) => cs.channel?.extraData ?? _extraData, - ); - } - - /// Shortcut to get channel name. - /// - /// {@macro name} - String? get name => extraData['name'] as String?; - - /// Channel [name] as a stream. - /// - /// The channel needs to be initialized. - /// - /// {@macro name} - Stream get nameStream { - _checkInitialized(); - return extraDataStream.map((it) => it['name'] as String?).distinct(); - } - - /// Shortcut to get channel image. - /// - /// {@macro image} - String? get image => extraData['image'] as String?; - - /// Channel [image] as a stream. - /// - /// The channel needs to be initialized. - /// - /// {@macro image} - Stream get imageStream { - _checkInitialized(); - return extraDataStream.map((it) => it['image'] as String?).distinct(); - } - - /// The main Stream chat client. - StreamChatClient get client => _client; - final StreamChatClient _client; - - Completer _initializedCompleter = Completer(); - - /// True if this is initialized. - /// - /// Call [watch] to initialize the client or instantiate it using - /// [Channel.fromState]. - Future get initialized => _initializedCompleter.future; - - // Whether the channel is successfully initialized and not disposed. - bool get _isInitialized => _initializedCompleter.isCompleted && state != null; - - final _cancelableAttachmentUploadRequest = {}; - final _messageAttachmentsUploadCompleter = >{}; - - /// Cancels [attachmentId] upload request. Throws exception if the request - /// hasn't even started yet, Already completed or Already cancelled. - /// - /// Optionally, provide a [reason] for the cancellation. - void cancelAttachmentUpload( - String attachmentId, { - String? reason, - }) { - final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; - if (cancelToken == null) { - throw const StreamChatError( - "Upload request for this Attachment hasn't started yet or maybe " - 'Already completed', - ); - } - if (cancelToken.isCancelled) { - throw const StreamChatError('Upload request already cancelled'); - } - cancelToken.cancel(reason); - } - - /// Retries the failed [attachmentId] upload request. - Future retryAttachmentUpload(String messageId, String attachmentId) => - _uploadAttachments(messageId, [attachmentId]); - - Future _uploadAttachments( - String messageId, - Iterable attachmentIds, - ) { - var message = [ - ...state!.messages, - ...state!.threads.values.expand((messages) => messages), - ].firstWhereOrNull((it) => it.id == messageId); - - if (message == null) { - throw const StreamChatError('Error, Message not found'); - } - - final attachments = message.attachments.where((it) { - if (it.uploadState.isSuccess) return false; - return attachmentIds.contains(it.id); - }); - - if (attachments.isEmpty) { - client.logger.info('No attachments available to upload'); - if (message.attachments.every((it) => it.uploadState.isSuccess)) { - _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); - } - return Future.value(); - } - - client.logger.info('Found ${attachments.length} attachments'); - - void updateAttachment(Attachment attachment, {bool remove = false}) { - final index = message!.attachments.indexWhere( - (it) => it.id == attachment.id, - ); - if (index != -1) { - // update or remove attachment from message. - final List newAttachments; - if (remove) { - newAttachments = [...message!.attachments]..removeAt(index); - } else { - newAttachments = [...message!.attachments]..[index] = attachment; - } - - final updatedMessage = message!.copyWith(attachments: newAttachments); - state?.updateMessage(updatedMessage); - // updating original message for next iteration - message = message!.merge(updatedMessage); - } - } - - return Future.wait( - attachments.map((it) { - client.logger.info('Uploading ${it.id} attachment...'); - - final throttledUpdateAttachment = updateAttachment.throttled( - const Duration(milliseconds: 500), - ); - - void onSendProgress(int sent, int total) { - throttledUpdateAttachment([ - it.copyWith( - uploadState: UploadState.inProgress(uploaded: sent, total: total), - ), - ]); - } - - final isImage = it.type == AttachmentType.image; - final cancelToken = CancelToken(); - Future future; - if (isImage) { - future = sendImage( - it.file!, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: it.extraData, - ); - } else { - future = sendFile( - it.file!, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: it.extraData, - ); - } - _cancelableAttachmentUploadRequest[it.id] = cancelToken; - return future - .then((response) { - client.logger.info('Attachment ${it.id} uploaded successfully...'); - - // If the response is SendFileResponse, then we might also be getting - // thumbUrl in case of video. So we need to update the attachment with - // both the assetUrl and thumbUrl. - if (response is SendFileResponse) { - updateAttachment( - it.copyWith( - assetUrl: response.file, - thumbUrl: response.thumbUrl, - uploadState: const UploadState.success(), - ), - ); - } else { - updateAttachment( - it.copyWith( - imageUrl: response.file, - uploadState: const UploadState.success(), - ), - ); - } - }) - .catchError((e, stk) { - if (e is StreamChatNetworkError && e.type == .cancel) { - client.logger.info('Attachment ${it.id} upload cancelled'); - - // remove attachment from message if cancelled. - updateAttachment(it, remove: true); - return; - } - - client.logger.severe('error uploading the attachment', e, stk); - updateAttachment( - it.copyWith(uploadState: UploadState.failed(error: e.toString())), - ); - }) - .whenComplete(() { - throttledUpdateAttachment.cancel(); - _cancelableAttachmentUploadRequest.remove(it.id); - }); - }), - ).whenComplete(() { - final completer = _messageAttachmentsUploadCompleter.remove(messageId); - if (completer == null || completer.isCompleted) return; - - // Always complete with the latest message view so callers can decide - // success vs. partial failure by inspecting per-attachment upload - // states. Cancellation is still surfaced via `completeError` from the - // sendMessage/updateMessage/deleteMessage entry points. - completer.complete(message); - }); - } - - final _sendMessageLock = Lock(); - - /// Send a [message] to this channel. - /// - /// If [skipPush] is true the message will not send a push notification. - /// - /// Waits for a [_messageAttachmentsUploadCompleter] to complete - /// before actually sending the message. - Future sendMessage( - Message message, { - bool skipPush = false, - bool skipEnrichUrl = false, - }) async { - _checkInitialized(); - - // Clean up stale error messages before sending a new message. - state?.cleanUpStaleErrorMessages(); - - // Cancelling previous completer in case it's called again in the process - // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); - - final quotedMessage = state!.messages.firstWhereOrNull( - (m) => m.id == message.quotedMessageId, - ); - // ignore: parameter_assignments - message = message.copyWith( - localCreatedAt: DateTime.now(), - user: _client.state.currentUser, - quotedMessage: quotedMessage, - state: MessageState.sending, - attachments: message.attachments.map( - (it) { - if (it.uploadState.isSuccess) return it; - return it.copyWith(uploadState: const UploadState.preparing()); - }, - ).toList(), - ); - - state?.updateMessage(message); - - try { - if (message.attachments.any((it) => !it.uploadState.isSuccess)) { - final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - - _uploadAttachments( - message.id, - message.attachments.map((it) => it.id), - ); - - // ignore: parameter_assignments - message = await attachmentsUploadCompleter.future; - - // Fail the whole message if any attachment failed to upload - if (message.attachments.any((it) => it.uploadState.isFailed)) { - throw const StreamChatError('Failed to upload one or more attachments'); - } - } - - // Validate the final message before sending it to the server. - if (MessageRules.canUpload(message) != true) { - client.logger.warning('Message is not valid for sending, removing it'); - - // Remove the message from state as it is invalid. - state!.deleteMessage(message, hardDelete: true); - throw const StreamChatError('Message is not valid for sending'); - } - - // Wait for the previous sendMessage call to finish. Otherwise, the order - // of messages will not be maintained. - final response = await _sendMessageLock.synchronized( - () => _client.sendMessage( - message, - id!, - type, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - final sentMessage = message - .updateWith(response.message) - .copyWith( - // Update the message state to sent. - state: MessageState.sent, - ); - - state?.updateMessage(sentMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.sendingFailed( - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - state?.updateMessage(failedMessage); - // If the error is retriable, add it to the retry queue. - if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); - } - - rethrow; - } - } - - final _updateMessageLock = Lock(); - - /// Updates the [message] in this channel. - /// - /// Waits for a [_messageAttachmentsUploadCompleter] to complete - /// before actually updating the message. - Future updateMessage( - Message message, { - bool skipPush = false, - bool skipEnrichUrl = false, - }) async { - _checkInitialized(); - - // Cancelling previous completer in case it's called again in the process - // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); - - // ignore: parameter_assignments - message = message.copyWith( - state: MessageState.updating, - localUpdatedAt: DateTime.now(), - attachments: message.attachments.map( - (it) { - if (it.uploadState.isSuccess) return it; - return it.copyWith(uploadState: const UploadState.preparing()); - }, - ).toList(), - ); - - state?.updateMessage(message); - - try { - if (message.attachments.any((it) => !it.uploadState.isSuccess)) { - final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - - _uploadAttachments( - message.id, - message.attachments.map((it) => it.id), - ); - - // ignore: parameter_assignments - message = await attachmentsUploadCompleter.future; - - // Fail the whole message if any attachment failed to upload - if (message.attachments.any((it) => it.uploadState.isFailed)) { - throw const StreamChatError('Failed to upload one or more attachments'); - } - } - - // Wait for the previous update call to finish. Otherwise, the order of - // messages will not be maintained. - final response = await _updateMessageLock.synchronized( - () => _client.updateMessage( - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - final updateMessage = message - .updateWith(response.message) - .copyWith( - // Update the message state to updated. - state: MessageState.updated, - ); - - state?.updateMessage(updateMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.updatingFailed( - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - state?.updateMessage(failedMessage); - // If the error is retriable, add it to the retry queue. - if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); - } - - rethrow; - } - } - - /// Partially updates the [message] in this channel. - /// - /// Use [set] to define values to be set. - /// - /// Use [unset] to define values to be unset. - Future partialUpdateMessage( - Message message, { - Map? set, - List? unset, - bool skipEnrichUrl = false, - }) async { - _checkInitialized(); - - // Cancelling previous completer in case it's called again in the process - // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); - - // ignore: parameter_assignments - message = message.copyWith( - state: MessageState.updating, - localUpdatedAt: DateTime.now(), - ); - - state?.updateMessage(message); - - try { - // Wait for the previous update call to finish. Otherwise, the order of - // messages will not be maintained. - final response = await _updateMessageLock.synchronized( - () => _client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - final updatedMessage = message - .updateWith(response.message) - .copyWith( - // Update the message state to updated. - state: MessageState.updated, - ); - - state?.updateMessage(updatedMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - state?.updateMessage(failedMessage); - // If the error is retriable, add it to the retry queue. - if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); - } - - rethrow; - } - } - - final _deleteMessageLock = Lock(); - - /// Deletes the [message] for everyone. - /// - /// If [hard] is true, the message is permanently deleted from the server - /// and cannot be recovered. In this case, any attachments associated with the - /// message are also deleted from the server. - Future deleteMessage(Message message, {bool hard = false}) { - final deletionScope = MessageDeleteScope.deleteForAll(hard: hard); - - return _deleteMessage(message, scope: deletionScope); - } - - /// Deletes the [message] only for the current user. - /// - /// Note: This does not delete the message for other channel members and - /// they can still see the message. - Future deleteMessageForMe(Message message) { - const deletionScope = MessageDeleteScope.deleteForMe(); - - return _deleteMessage(message, scope: deletionScope); - } - - // Deletes the [message] from the channel. - // - // The [scope] defines whether to delete the message for everyone or just - // for the current user. - // - // If the message is a local message (not yet sent to the server) or a bounced - // error message, it is deleted locally without making an API call. - // - // If the message is deleted for everyone and [scope.hard] is true, the - // message is permanently deleted from the server and cannot be recovered. - // In this case, any attachments associated with the message are also deleted - // from the server. - Future _deleteMessage( - Message message, { - required MessageDeleteScope scope, - }) async { - _checkInitialized(); - - // Directly deleting the local messages and bounced error messages as they - // are not available on the server. - if (message.remoteCreatedAt == null || message.isBouncedWithError) { - _deleteLocalMessage(message); - // Returning empty response to mark the api call as success. - return EmptyResponse(); - } - - // ignore: parameter_assignments - message = message.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.now(), - deletedForMe: scope is DeleteForMe, - state: MessageState.deleting(scope: scope), - ); - - state?.deleteMessage(message, hardDelete: scope.hard); - - try { - // Wait for the previous delete call to finish. Otherwise, the order of - // messages will not be maintained. - final response = await _deleteMessageLock.synchronized( - () => switch (scope) { - DeleteForMe() => _client.deleteMessageForMe(message.id), - DeleteForAll() => _client.deleteMessage(message.id, hard: scope.hard), - }, - ); - - final deletedMessage = message.copyWith( - deletedForMe: scope is DeleteForMe, - state: MessageState.deleted(scope: scope), - ); - - state?.deleteMessage(deletedMessage, hardDelete: scope.hard); - // If hard delete, also delete the attachments from the server. - if (scope.hard) _deleteMessageAttachments(deletedMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.deletingFailed(scope: scope), - ); - - 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]); - } - - rethrow; - } - } - - // Deletes a local [message] that is not yet sent to the server. - // - // This is typically called when a user wants to delete a message that they - // have composed but not yet sent, or if a message failed to send and the user - // wants to remove it from their local view. - void _deleteLocalMessage(Message message) { - state?.deleteMessage( - hardDelete: true, // Local messages are always hard deleted. - message.copyWith( - type: MessageType.deleted, - localDeletedAt: DateTime.now(), - state: MessageState.hardDeleted, - ), - ); - - // Removing the attachments upload completer to stop the `sendMessage` - // waiting for attachments to complete. - final completer = _messageAttachmentsUploadCompleter.remove(message.id); - completer?.completeError(const StreamChatError('Message deleted')); - } - - // Deletes all the attachments associated with the given [message] - // from the server. This is typically called when a message is hard deleted. - Future _deleteMessageAttachments(Message message) async { - final attachments = message.attachments; - final deleteFutures = attachments.map((it) async { - if (it.imageUrl case final url?) return deleteImage(url); - if (it.assetUrl case final url?) return deleteFile(url); - }); - - try { - await Future.wait(deleteFutures); - } catch (e, stk) { - _client.logger.warning('Error deleting message attachments', e, stk); - } - } - - /// Retries operations on a message based on its failed state. - /// - /// This method examines the message's state and performs the appropriate - /// retry action: - /// - For [MessageState.sendingFailed], it attempts to send the message. - /// - For [MessageState.updatingFailed], it attempts to update the message. - /// - For [MessageState.partialUpdatingFailed], it attempts to partially - /// update the message with the same 'set' and 'unset' parameters that were - /// used in the original request. - /// - For [MessageState.deletingFailed], it attempts to delete the message - /// again, using the same scope (for me or for all) as the original request. - /// - For messages with [isBouncedWithError], it attempts to send the message. - /// - /// Throws a [StateError] if the message is not in a failed state or - /// bounced with an error. - Future retryMessage(Message message) async { - assert( - message.state.isFailed || message.isBouncedWithError, - 'Only failed or bounced messages can be retried', - ); - - return message.state.maybeWhen( - failed: (state, _) => state.when( - sendingFailed: (skipPush, skipEnrichUrl) => sendMessage( - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - updatingFailed: (skipPush, skipEnrichUrl) => updateMessage( - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - partialUpdatingFailed: (set, unset, skipEnrichUrl) { - return partialUpdateMessage( - message, - set: set, - unset: unset, - skipEnrichUrl: skipEnrichUrl, - ); - }, - deletingFailed: (scope) => switch (scope) { - DeleteForMe() => deleteMessageForMe(message), - DeleteForAll(hard: final hard) => deleteMessage(message, hard: hard), - }, - ), - orElse: () { - // Check if the message is bounced with error. - if (message.isBouncedWithError) return sendMessage(message); - - throw StateError( - 'Only failed or bounced messages can be retried', - ); - }, - ); - } - - /// Pins provided message - Future pinMessage( - Message message, { - Object? /*num|DateTime*/ timeoutOrExpirationDate, - }) { - assert(() { - if (timeoutOrExpirationDate is! DateTime && timeoutOrExpirationDate != null && timeoutOrExpirationDate is! num) { - throw ArgumentError('Invalid timeout or Expiration date'); - } - return true; - }(), 'Check for invalid timeout or expiration date'); - - DateTime? pinExpires; - if (timeoutOrExpirationDate is DateTime) { - pinExpires = timeoutOrExpirationDate; - } else if (timeoutOrExpirationDate is num) { - pinExpires = DateTime.now().add( - Duration(seconds: timeoutOrExpirationDate.toInt()), - ); - } - return partialUpdateMessage( - message, - set: { - 'pinned': true, - 'pin_expires': pinExpires?.toUtc().toIso8601String(), - }, - ); - } - - /// Unpins provided message. - Future unpinMessage(Message message) => partialUpdateMessage( - message, - set: { - 'pinned': false, - }, - ); - - /// Creates or updates a new [draft] for this channel. - Future createDraft( - DraftMessage draft, - ) { - _checkInitialized(); - return _client.createDraft(draft, id!, type); - } - - /// Retrieves the draft for this channel. - /// - /// Optionally, provide a [parentId] to get the draft for a specific thread. - Future getDraft({ - String? parentId, - }) { - _checkInitialized(); - return _client.getDraft(id!, type, parentId: parentId); - } - - /// Deletes the draft for this channel. - /// - /// Optionally, provide a [parentId] to delete the draft for a specific - /// thread. - Future deleteDraft({ - String? parentId, - }) { - _checkInitialized(); - return _client.deleteDraft(id!, type, parentId: parentId); - } - - /// Sends a static location to this channel. - /// - /// Optionally, provide a [messageText] and [extraData] to send along with - /// the location. - Future sendStaticLocation({ - String? id, - String? messageText, - String? createdByDeviceId, - required LocationCoordinates location, - Map extraData = const {}, - }) { - final message = Message( - id: id, - text: messageText, - extraData: extraData, - ); - - final currentUserId = _client.state.currentUser?.id; - final locationMessage = message.copyWith( - sharedLocation: Location( - channelCid: cid, - userId: currentUserId, - messageId: message.id, - latitude: location.latitude, - longitude: location.longitude, - createdByDeviceId: createdByDeviceId, - ), - ); - - return sendMessage(locationMessage); - } - - /// Sends a live location sharing message to this channel. - /// - /// Optionally, provide a [messageText] and [extraData] to send along with - /// the location. - Future startLiveLocationSharing({ - String? id, - String? messageText, - String? createdByDeviceId, - required DateTime endSharingAt, - required LocationCoordinates location, - Map extraData = const {}, - }) { - final message = Message( - id: id, - text: messageText, - extraData: extraData, - ); - - final currentUserId = _client.state.currentUser?.id; - final locationMessage = message.copyWith( - sharedLocation: Location( - channelCid: cid, - userId: currentUserId, - messageId: message.id, - endAt: endSharingAt, - latitude: location.latitude, - longitude: location.longitude, - createdByDeviceId: createdByDeviceId, - ), - ); - - return sendMessage(locationMessage); - } - - /// Send a file to this channel. - Future sendFile( - AttachmentFile file, { - ProgressCallback? onSendProgress, - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.sendFile( - file, - id!, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Send an image to this channel. - Future sendImage( - AttachmentFile file, { - ProgressCallback? onSendProgress, - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.sendImage( - file, - id!, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Search for a message with the given options. - Future search({ - String? query, - Filter? messageFilters, - List? sort, - PaginationParams? paginationParams, - }) { - _checkInitialized(); - return _client.search( - Filter.in_('cid', [cid!]), - sort: sort, - query: query, - paginationParams: paginationParams, - messageFilters: messageFilters, - ); - } - - /// Delete a file from this channel. - Future deleteFile( - String url, { - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.deleteFile( - url, - id!, - type, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Delete an image from this channel. - Future deleteImage( - String url, { - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.deleteImage( - url, - id!, - type, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Send an event on this channel. - Future sendEvent(Event event) { - _checkInitialized(); - return _client.sendEvent(id!, type, event); - } - - final _pollLock = Lock(); - - /// Send a message with a poll to this channel. - /// - /// Optionally provide a [messageText] to send a message along with the poll. - Future sendPoll( - Poll poll, { - String? messageText, - }) async { - _checkInitialized(); - final res = await _pollLock.synchronized(() => _client.createPoll(poll)); - return sendMessage( - Message( - text: messageText, - poll: res.poll, - pollId: res.poll.id, - ), - ); - } - - /// Updates the [poll] in this channel. - Future updatePoll(Poll poll) { - _checkInitialized(); - return _pollLock.synchronized(() => _client.updatePoll(poll)); - } - - /// Deletes the given [poll] from this channel. - Future deletePoll(Poll poll) { - _checkInitialized(); - return _pollLock.synchronized(() => _client.deletePoll(poll.id)); - } - - /// Close the given [poll]. - Future closePoll(Poll poll) { - _checkInitialized(); - return _pollLock.synchronized(() => _client.closePoll(poll.id)); - } - - /// Create a new poll option for the given [poll]. - Future createPollOption( - Poll poll, - PollOption option, - ) { - _checkInitialized(); - return _pollLock.synchronized( - () => _client.createPollOption(poll.id, option), - ); - } - - final _pollVoteLock = Lock(); - - /// Cast a vote on the given [poll] with the given [option]. - Future castPollVote( - Message message, - Poll poll, - PollOption option, - ) async { - _checkInitialized(); - - final optionId = option.id; - if (optionId == null) { - throw ArgumentError('Option id cannot be null'); - } - - return _pollVoteLock.synchronized( - () => _client.castPollVote( - message.id, - poll.id, - optionId: optionId, - ), - ); - } - - /// Add a new answer to the given [poll]. - Future addPollAnswer( - Message message, - Poll poll, { - required String answerText, - }) { - _checkInitialized(); - return _pollVoteLock.synchronized( - () => _client.addPollAnswer( - message.id, - poll.id, - answerText: answerText, - ), - ); - } - - /// Remove a vote on the given [poll] with the given [vote]. - Future removePollVote( - Message message, - Poll poll, - PollVote vote, - ) { - _checkInitialized(); - - final voteId = vote.id; - if (voteId == null) { - throw ArgumentError('Vote id cannot be null'); - } - - return _pollVoteLock.synchronized( - () => _client.removePollVote( - message.id, - poll.id, - voteId, - ), - ); - } - - /// Query the poll votes for the given [pollId] with the given [filter] and - /// [sort] options. - Future queryPollVotes( - String pollId, { - Filter? filter, - SortOrder? sort, - PaginationParams pagination = const PaginationParams(), - }) { - _checkInitialized(); - return _client.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - } - - /// Create a reminder for the given [messageId]. - /// - /// Optionally, provide a [remindAt] date to set when the reminder should - /// be triggered. If not provided, the reminder will be created as a - /// bookmark type instead. - Future createReminder( - String messageId, { - DateTime? remindAt, - }) { - _checkInitialized(); - return _client.createReminder( - messageId, - remindAt: remindAt, - ); - } - - /// Update an existing reminder with the given [reminderId]. - /// - /// Optionally, provide a [remindAt] date to set when the reminder should - /// be triggered. If not provided, the reminder will be updated as a - /// bookmark type instead. - Future updateReminder( - String messageId, { - DateTime? remindAt, - }) { - _checkInitialized(); - return _client.updateReminder( - messageId, - remindAt: remindAt, - ); - } - - /// Remove the reminder for the given [messageId]. - Future deleteReminder(String messageId) { - _checkInitialized(); - return _client.deleteReminder(messageId); - } - - /// Send a reaction to this channel. - /// - /// Set [enforceUnique] to true to remove the existing user reaction. - Future sendReaction( - Message message, - Reaction reaction, { - bool skipPush = false, - bool enforceUnique = false, - }) async { - _checkInitialized(); - - final messageId = message.id; - // ignore: parameter_assignments - reaction = reaction.copyWith( - messageId: messageId, - user: _client.state.currentUser, - ); - - final updatedMessage = message.addMyReaction( - reaction, - enforceUnique: enforceUnique, - ); - - state?.updateMessage(updatedMessage); - - try { - final reactionResp = await _client.sendReaction( - messageId, - reaction, - skipPush: skipPush, - enforceUnique: enforceUnique, - ); - return reactionResp; - } catch (_) { - // Reset the message if the update fails. Use replace (not merge) - // so the rollback wins over the optimistic local state — otherwise - // `Message.updateWith`'s enrichment preservation would keep the - // optimistic `ownReactions` for messages that previously had none. - state?.replaceMessage(message); - rethrow; - } - } - - /// Delete a reaction from this channel. - Future deleteReaction( - Message message, - Reaction reaction, - ) async { - _checkInitialized(); - - final updatedMessage = message.deleteMyReaction( - reactionType: reaction.type, - ); - - state?.updateMessage(updatedMessage); - - try { - final deleteResponse = await _client.deleteReaction( - message.id, - reaction.type, - ); - return deleteResponse; - } catch (_) { - // Reset the message if the update fails. Use replace (not merge) - // for symmetry with `sendReaction` — see that method for context. - state?.replaceMessage(message); - rethrow; - } - } - - /// Sends an event to stop AI response generation, leaving the message in - /// its current state. - Future stopAIResponse() async { - return sendEvent( - Event( - type: EventType.aiIndicatorStop, - ), - ); - } - - /// Update the channel's [name]. - /// - /// This is the same as calling [updatePartial] and providing a map with a - /// 'name' key: - /// - /// ```dart - /// channel.updatePartial( - /// set: {'name': 'Updated channel name'} - /// ); - /// ``` - /// - /// Instead do: - /// ```dart - /// channel.updateName('Updated channel name'); - /// ``` - Future updateName(String name) => updatePartial(set: {'name': name}); - - /// Update the channel's [image]. - /// - /// This is the same as calling [updatePartial] and providing a map with an - /// 'image' key: - /// - /// ```dart - /// channel.updatePartial( - /// set: {'image': 'https://getstream.io/new-image'} - /// ); - /// ``` - /// - /// Instead do: - /// ```dart - /// channel.updateImage('https://getstream.io/new-image'); - /// ``` - Future updateImage(String image) => updatePartial(set: {'image': image}); - - /// Update the channel custom data. This replaces all of the channel data - /// with the given [channelData]. - /// - /// If you instead want to do a partial update, use [updatePartial]. - /// - /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart - /// for more information. - Future update( - Map channelData, { - Message? updateMessage, - }) async { - _checkInitialized(); - return _client.updateChannel( - id!, - type, - channelData, - message: updateMessage, - ); - } - - /// A partial update can be used to set and unset specific custom data fields - /// when it is necessary to retain additional custom data fields on the - /// object. - /// - /// - [set] will add, or update existing attributes. - /// - [unset] will remove the attributes with the provided list of - /// values (keys). - /// - /// If you want to do a full update/replacement, use [update] instead. - /// - /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart - /// for more information. - Future updatePartial({ - Map? set, - List? unset, - }) async { - _checkInitialized(); - return _client.updateChannelPartial(id!, type, set: set, unset: unset); - } - - /// Enable slow mode - Future enableSlowMode({ - required int cooldownInterval, - }) async { - _checkInitialized(); - return _client.enableSlowdown(id!, type, cooldownInterval); - } - - /// Disable slow mode - Future disableSlowMode() async { - _checkInitialized(); - return _client.disableSlowdown(id!, type); - } - - /// Delete this channel. Messages are permanently removed. - Future delete() async { - _checkInitialized(); - return _client.deleteChannel(id!, type); - } - - /// Removes all messages from the channel up to [truncatedAt] or now if - /// [truncatedAt] is not provided. - /// If [skipPush] is true, no push notification will be sent. - /// [Message] is the system message that will be sent to the channel. - Future truncate({ - Message? message, - bool? skipPush, - DateTime? truncatedAt, - }) async { - _checkInitialized(); - return _client.truncateChannel( - id!, - type, - message: message, - skipPush: skipPush, - truncatedAt: truncatedAt, - ); - } - - /// Accept invitation to the channel. - Future acceptInvite([Message? message]) async { - _checkInitialized(); - return _client.acceptChannelInvite(id!, type, message: message); - } - - /// Reject invitation to the channel. - Future rejectInvite([Message? message]) async { - _checkInitialized(); - return _client.rejectChannelInvite(id!, type, message: message); - } - - /// Add members to the channel. - Future addMembers( - List memberIds, { - Message? message, - bool hideHistory = false, - DateTime? hideHistoryBefore, - }) async { - _checkInitialized(); - return _client.addChannelMembers( - id!, - type, - memberIds, - message: message, - hideHistory: hideHistory, - hideHistoryBefore: hideHistoryBefore, - ); - } - - /// Invite members to the channel. - Future inviteMembers( - List memberIds, { - Message? message, - }) async { - _checkInitialized(); - return _client.inviteChannelMembers(id!, type, memberIds, message: message); - } - - /// Remove members from the channel. - Future removeMembers( - List memberIds, { - Message? message, - }) async { - _checkInitialized(); - return _client.removeChannelMembers(id!, type, memberIds, message: message); - } - - /// Send action for a specific message of this channel. - Future sendAction( - Message message, - Map formData, - ) async { - _checkInitialized(); - final messageId = message.id; - final res = await _client.sendAction(id!, type, messageId, formData); - - // update the passed message with response message - if (res.message != null) { - state!.updateMessage(res.message!); - } else { - // remove the passed message if response does - // not contain message - state!.removeMessage(message); - } - return res; - } - - /// Mark all messages as read. - /// - /// Optionally provide a [messageId] if you want to mark channel as - /// read from a particular message onwards. - /// - /// If [usesLocalUnreadCount] is `true` for this channel, this updates the - /// unread count locally, on-device, without making a network request. In - /// that case [messageId] is recorded as the read boundary but does **not** - /// narrow the count: the channel is always treated as fully read and the - /// count drops to zero. See [ChannelClientState.markReadLocally]. - Future markRead({String? messageId}) async { - _checkInitialized(); - - if (usesLocalUnreadCount) { - state!.markReadLocally(messageId: messageId); - return EmptyResponse(); - } - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark as read: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markChannelRead(id!, type, messageId: messageId); - } - - /// Marks the channel as unread by a given [messageId]. - /// - /// All messages from the provided message onwards will be marked as unread, - /// **including** the message itself. Contrast with - /// [markUnreadByTimestamp], which is exclusive: a message created at - /// exactly the given timestamp stays read. - /// - /// If [usesLocalUnreadCount] is `true` for this channel, this updates the - /// unread count locally, on-device, without making a network request. The - /// message must be part of the locally-known messages ([Channel.messages]) - /// for the count to be recomputed. - Future markUnread(String messageId) async { - _checkInitialized(); - - if (usesLocalUnreadCount) { - // [ChannelClientState.messages] is sorted ascending by `createdAt`, so - // the entry before the anchor is the newest message that stays read. - final messages = state!.messages; - final anchorIndex = messages.indexWhere((it) => it.id == messageId); - if (anchorIndex < 0) { - throw StreamChatError( - 'Cannot mark as unread: Message "$messageId" was not found in the ' - 'locally-known messages for this channel.', - ); - } - - final anchor = messages[anchorIndex]; - final previous = anchorIndex > 0 ? messages[anchorIndex - 1] : null; - - // Subtract a microsecond so the anchor message itself is treated as - // "after" the new read boundary, matching the "from the provided - // message onwards" semantics described above. Preferred over using - // `previous.createdAt` as the boundary, which would leak the anchor - // back into the read set if the two share an identical `createdAt`. - final lastRead = anchor.createdAt.subtract(const Duration(microseconds: 1)); - state!.markUnreadLocally(lastRead: lastRead, lastReadMessageId: previous?.id); - return EmptyResponse(); - } - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark as unread: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markChannelUnread(id!, type, messageId); - } - - /// Marks the channel as unread by a given [timestamp]. - /// - /// All messages after the provided timestamp will be marked as unread. This - /// boundary is **exclusive**: a message created at exactly [timestamp] stays - /// read. Contrast with [markUnread], which is inclusive of the message it is - /// given — `markUnread(m.id)` is equivalent to - /// `markUnreadByTimestamp(m.createdAt - 1µs)`, not to - /// `markUnreadByTimestamp(m.createdAt)`. - /// - /// If [usesLocalUnreadCount] is `true` for this channel, this updates the - /// unread count locally, on-device, without making a network request. - Future markUnreadByTimestamp(DateTime timestamp) async { - _checkInitialized(); - - if (usesLocalUnreadCount) { - // The newest locally-known message at or before the boundary is the last - // one that stays read. - final lastReadMessage = state!.messages.lastWhereOrNull( - (it) => !it.createdAt.isAfter(timestamp), - ); - - state!.markUnreadLocally( - lastRead: timestamp, - lastReadMessageId: lastReadMessage?.id, - ); - return EmptyResponse(); - } - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark as unread: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markChannelUnreadByTimestamp(id!, type, timestamp); - } - - /// Mark the thread with [threadId] in the channel as read. - Future markThreadRead(String threadId) async { - _checkInitialized(); - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark thread as read: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markThreadRead(id!, type, threadId); - } - - /// Mark the thread with [threadId] in the channel as unread. - Future markThreadUnread(String threadId) async { - _checkInitialized(); - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark thread as unread: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markThreadUnread(id!, type, threadId); - } - - void _initState(ChannelState channelState) { - state = ChannelClientState(this, channelState); - _initializedCompleter.safeComplete(true); - - if (cid case final cid?) client.state.addChannels({cid: this}); - _client.logger.info('Channel ${channelState.channel?.cid} initialized'); - } - - /// Loads the initial channel state and watches for changes. - Future watch({ - bool presence = false, - PaginationParams? messagesPagination, - PaginationParams? membersPagination, - PaginationParams? watchersPagination, - }) { - return query( - watch: true, - presence: presence, - messagesPagination: messagesPagination, - membersPagination: membersPagination, - watchersPagination: watchersPagination, - ); - } - - /// Stop watching the channel. - Future stopWatching() async { - _checkInitialized(); - return _client.stopChannelWatching(id!, type); - } - - /// List the message replies for a parent message. - /// - /// Set [preferOffline] to true to avoid the api call if the data is already - /// in the offline storage. - Future getReplies( - String parentId, { - PaginationParams? options, - bool preferOffline = false, - }) async { - QueryRepliesResponse? response; - - // If we prefer offline, we first try to get the replies from the - // offline storage. - if (preferOffline) { - if (_client.chatPersistenceClient case final persistenceClient?) { - final cachedReplies = await persistenceClient.getReplies( - parentId, - options: options, - ); - - // If the cached replies are not empty, we can use them. - if (cachedReplies.isNotEmpty) { - response = QueryRepliesResponse()..messages = cachedReplies; - } - } - } - - // If we still don't have the replies, we try to get them from the API. - response ??= await _client.getReplies(parentId, options: options); - - // Before updating the state, we check if we are querying around a - // reply, If we are, we have to clear the state to avoid potential - // gaps in the message sequence. - final isQueryingAround = switch (options) { - PaginationParams(idAround: _?) => true, - PaginationParams(createdAtAround: _?) => true, - _ => false, - }; - - if (isQueryingAround) state?.clearThread(parentId); - state?.updateThreadInfo(parentId, response.messages); - - return response; - } - - /// List the reactions for a message in the channel. - Future getReactions( - String messageId, { - PaginationParams? pagination, - }) => _client.getReactions( - messageId, - pagination: pagination, - ); - - /// Retrieves a list of messages by given [messageIDs]. - Future getMessagesById( - List messageIDs, - ) async { - _checkInitialized(); - return _client.getMessagesById(id!, type, messageIDs); - } - - /// Translate a message by given [messageId] and [language]. - /// - /// The translated message is merged into the channel state, so its - /// translations are available to everything listening to it without the - /// caller having to apply the response itself. - Future translateMessage( - String messageId, - String language, - ) async { - final response = await _client.translateMessage(messageId, language); - state?.updateMessage(response.message); - return response; - } - - /// Creates a new channel. - Future create() => query(state: false); - - /// Query the API, get messages, members or other channel fields. - /// - /// Set [preferOffline] to true to avoid the API call if the data is already - /// in the offline storage. - Future query({ - bool state = true, - bool watch = false, - bool presence = false, - PaginationParams? messagesPagination, - PaginationParams? membersPagination, - PaginationParams? watchersPagination, - bool preferOffline = false, - }) async { - // A prior failed init left the completer errored; reset it so this attempt - // owns the `initialized` result. Must stay before the first `await` so a - // caller reading `initialized` right after `query()`/`watch()` begins sees - // the fresh completer. - if (_initializedCompleter.isCompleted && this.state == null) { - _initializedCompleter = Completer(); - } - - ChannelState? channelState; - - try { - // If we prefer offline, we first try to get the channel state from the - // offline storage. - if (preferOffline && !watch && cid != null) { - final persistenceClient = _client.chatPersistenceClient; - if (persistenceClient != null) { - final cachedState = await persistenceClient.getChannelStateByCid( - cid!, - messagePagination: messagesPagination, - ); - - // If the cached state contains messages, we can use it. - if (cachedState.messages?.isNotEmpty == true) { - channelState = cachedState; - } - } - } - - // If we still don't have the channelState, we try to get it from the API. - channelState ??= await _client.queryChannel( - type, - channelId: id, - channelData: _extraData, - state: state, - watch: watch, - presence: presence, - messagesPagination: messagesPagination, - membersPagination: membersPagination, - watchersPagination: watchersPagination, - ); - - if (_id == null) { - _id = channelState.channel!.id; - _cid = channelState.channel!.cid; - } - - // Initialize the channel state if it's not initialized yet. - if (this.state == null) { - _initState(channelState); - } else { - // Otherwise, we update the existing state with the new channel state. - // - // But, before updating the state, we check if we are querying around a - // message, If we are, we have to truncate the state to avoid potential - // gaps in the message sequence. - final isQueryingAround = switch (messagesPagination) { - PaginationParams(idAround: _?) => true, - PaginationParams(createdAtAround: _?) => true, - _ => false, - }; - - if (isQueryingAround) this.state?.truncate(); - this.state?.updateChannelStateFromServer(channelState); - } - - // Submit for delivery reporting only when fetching the latest messages. - // This happens when no pagination params are provided (initial query). - if (messagesPagination == null) { - _client.channelDeliveryReporter.submitForDelivery([this]); - } - - return channelState; - } catch (e, stk) { - // If we failed to get the channel state from the API and we were not - // supposed to watch the channel, we will try to get the channel state - // from the offline storage. - if (watch == false) { - if (_client.persistenceEnabled) { - return _client.chatPersistenceClient!.getChannelStateByCid( - cid!, - messagePagination: messagesPagination, - ); - } - } - - // Otherwise, we will just rethrow the error. - _initializedCompleter.safeCompleteError(e, stk); - - rethrow; - } - } - - /// Query channel members. - Future queryMembers({ - Filter? filter, - SortOrder? sort, - PaginationParams? pagination, - }) => _client.queryMembers( - type, - channelId: id, - filter: filter, - members: state?.members, - sort: sort, - pagination: pagination, - ); - - /// Query channel banned users. - Future queryBannedUsers({ - Filter? filter, - SortOrder? sort, - PaginationParams? pagination, - }) { - _checkInitialized(); - filter ??= Filter.equal('channel_cid', cid!); - return _client.queryBannedUsers( - filter: filter, - sort: sort, - pagination: pagination, - ); - } - - // Timer to keep track of mute expiration. This is used to update the channel - // state when the mute expires. - Timer? _muteExpirationTimer; - - /// Mutes the channel. - Future mute({Duration? expiration}) { - _checkInitialized(); - - // If there is a expiration set, we will set a timer to automatically unmute - // the channel when the mute expires. - if (expiration != null) { - _muteExpirationTimer?.cancel(); - _muteExpirationTimer = Timer(expiration, unmute); - } - - return _client.muteChannel(cid!, expiration: expiration); - } - - /// Unmute the channel. - Future unmute() { - _checkInitialized(); - - // Cancel the mute expiration timer if it is set. - _muteExpirationTimer?.cancel(); - _muteExpirationTimer = null; - - return _client.unmuteChannel(cid!); - } - - /// Bans the member with given [userID] from the channel. - Future banMember( - String userID, - Map options, - ) async { - _checkInitialized(); - final opts = Map.from(options) - ..addAll({ - 'type': type, - 'id': id, - }); - return _client.banUser(userID, opts); - } - - /// Remove the ban for the member with given [userID] in the channel. - Future unbanMember(String userID) async { - _checkInitialized(); - return _client.unbanUser(userID, { - 'type': type, - 'id': id, - }); - } - - /// Shadow bans the user with the given [userID] from the channel. - Future shadowBan( - String userID, - Map options, - ) async { - _checkInitialized(); - final opts = Map.from(options) - ..addAll({ - 'type': type, - 'id': id, - }); - return _client.shadowBan(userID, opts); - } - - /// Remove the shadow ban for the user with the given [userID] in the channel. - Future removeShadowBan(String userID) async { - _checkInitialized(); - return _client.removeShadowBan(userID, { - 'type': type, - 'id': id, - }); - } - - /// Hides the channel from [StreamChatClient.queryChannels] for the user - /// until a message is added. - /// - /// If [clearHistory] is set to true - all messages - /// will be removed for the user. - Future hide({bool clearHistory = false}) async { - _checkInitialized(); - return _client.hideChannel( - id!, - type, - clearHistory: clearHistory, - ); - } - - /// Removes the hidden status for the channel. - Future show() async { - _checkInitialized(); - return _client.showChannel(id!, type); - } - - /// Pins the channel for the current user. - Future pin() async { - _checkInitialized(); - - final response = await _client.pinChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Unpins the channel. - Future unpin() async { - _checkInitialized(); - - final response = await _client.unpinChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Archives the channel. - Future archive() async { - _checkInitialized(); - - final response = await _client.archiveChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Unarchives the channel for the current user. - Future unarchive() async { - _checkInitialized(); - - final response = await _client.unarchiveChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Stream of [Event] coming from websocket connection specific for the - /// channel. Pass an eventType as parameter in order to filter just a type - /// of event. - Stream on([ - String? eventType, - String? eventType2, - String? eventType3, - String? eventType4, - ]) => _client - .on( - eventType, - eventType2, - eventType3, - eventType4, - ) - .where((e) => e.cid == cid); - - late final _keyStrokeHandler = KeyStrokeHandler( - onStartTyping: startTyping, - onStopTyping: stopTyping, - ); - - // Whether sending typing events is allowed in the channel and by the user - // privacy settings. - bool get _canSendTypingEvents { - final currentUser = client.state.currentUser; - if (currentUser == null) return false; - - return canUseTypingEvents && currentUser.isTypingIndicatorsEnabled; - } - - /// Sends the [Event.typingStart] event and schedules a timer to invoke the - /// [Event.typingStop] event. - /// - /// This is meant to be called every time the user presses a key. - Future keyStroke([String? parentId]) async { - if (!_canSendTypingEvents) return; - - client.logger.info('KeyStroke received'); - return _keyStrokeHandler(parentId); - } - - /// Sends the [EventType.typingStart] event. - Future startTyping([String? parentId]) async { - if (!_canSendTypingEvents) return; - - client.logger.info('start typing'); - await sendEvent( - Event( - type: EventType.typingStart, - parentId: parentId, - ), - ); - } - - /// Sends the [EventType.typingStop] event. - Future stopTyping([String? parentId]) async { - if (!_canSendTypingEvents) return; - - client.logger.info('stop typing'); - await sendEvent( - Event( - type: EventType.typingStop, - parentId: parentId, - ), - ); - } - - /// Call this method to dispose the channel client. - void dispose() { - client.state.removeChannel('$cid'); - state?.dispose(); - state = null; - _muteExpirationTimer?.cancel(); - _keyStrokeHandler.cancel(); - } - - void _checkInitialized() { - if (_isInitialized) return; - - throw StateError( - "Channel $cid hasn't been initialized yet or has been disposed. " - 'Make sure to call .watch() or instantiate the client using ' - '[Channel.fromState]', - ); - } -} - -/// 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(); - _listenChannelCounts(); - // 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, - ), - ); - }), - ); - } - - // Most channel events carry the channel's member and message counts as - // event metadata, reflecting the authoritative values after the change. - // Applying them keeps the counts fresh for the whole session instead of - // only right after a `query` / `watch`. - void _listenChannelCounts() { - _subscriptions.add( - _channel.on().listen( - (Event e) { - final memberCount = e.channelMemberCount; - final messageCount = e.channelMessageCount; - if (memberCount == null && messageCount == null) return; - - updateChannelState( - channelState.copyWith( - channel: channelState.channel?.copyWith( - memberCount: memberCount, - 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).distinct(); - - /// 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()); -} - -/// 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/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart new file mode 100644 index 0000000000..8a0b26cc80 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -0,0 +1,2468 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:synchronized/synchronized.dart'; + +import '../../../stream_chat.dart'; + +/// The maximum time the incoming [Event.typingStart] event is valid before a +/// [Event.typingStop] event is emitted automatically. +const incomingTypingStartEventTimeout = 7; + +/// Class that manages a specific channel. +/// +/// #### Channel name +/// +/// {@template name} +/// If an optional [name] argument is provided in the constructor then it +/// will be set on [extraData] with a key of 'name'. +/// +/// ```dart +/// final channel = Channel(client, type, id, name: 'Channel name'); +/// print(channel.name == channel.extraData['name']); // true +/// ``` +/// +/// Before the channel is initialized the name can be set directly: +/// ```dart +/// channel.name = 'New channel name'; +/// ``` +/// +/// To update the name after the channel has been initialized, call: +/// ```dart +/// channel.updateName('Updated channel name'); +/// ``` +/// +/// This will do a partial update to update the name. +/// {@endtemplate} +/// +/// #### Channel image +/// +/// {@template image} +/// If an optional [image] argument is provided in the constructor then it +/// will be set on [extraData] with a key of 'image'. +/// +/// ```dart +/// final channel = Channel(client, type, id, image: 'https://getstream.io/image.png'); +/// print(channel.image == channel.extraData['image']); // true +/// ``` +/// +/// Before the channel is initialized the image can be set directly: +/// ```dart +/// channel.image = 'https://getstream.io/new-image'; +/// ``` +/// +/// To update the image after the channel has been initialized, call: +/// ```dart +/// channel.updateImage('https://getstream.io/new-image'); +/// ``` +/// +/// This will do a partial update to update the image. +/// {@endtemplate} +class Channel { + /// Class that manages a specific channel. + /// + /// Optional [extraData] and [image] properties can be provided. The [image] + /// is exposed to easily set a key of 'image' on [extraData]. + Channel( + this._client, + this._type, + this._id, { + String? name, + String? image, + Map? extraData, + }) : _cid = _id != null ? '$_type:$_id' : null, + _extraData = { + ...?extraData, + if (name != null) 'name': name, + if (image != null) 'image': image, + } { + _client.logger.info('New Channel instance created, not yet initialized'); + } + + /// Create a channel client instance from a [ChannelState] object. + Channel.fromState(this._client, ChannelState channelState) + : assert( + channelState.channel != null, + 'No channel found inside channel state', + ), + _id = channelState.channel!.id, + _type = channelState.channel!.type, + _cid = channelState.channel!.cid, + _extraData = channelState.channel!.extraData { + _initState(channelState); // Initialize the state immediately. + } + + /// This client state + ChannelClientState? state; + + /// The channel type + final String _type; + + String? _id; + String? _cid; + final Map _extraData; + + /// Shortcut to set channel name. + /// + /// {@macro name} + set name(String? name) { + if (_isInitialized) { + throw StateError( + 'Once the channel is initialized you should use `channel.updateName` ' + 'to update the channel name', + ); + } + _extraData.addAll({'name': name}); + } + + /// Shortcut to set channel image. + /// + /// {@macro image} + set image(String? image) { + if (_isInitialized) { + throw StateError( + 'Once the channel is initialized you should use `channel.updateImage` ' + 'to update the channel image', + ); + } + _extraData.addAll({'image': image}); + } + + set extraData(Map extraData) { + if (_isInitialized) { + throw StateError( + 'Once the channel is initialized you should use `channel.update` ' + 'to update channel data', + ); + } + _extraData.addAll(extraData); + } + + /// Whether this channel is identified by its member set rather than an + /// explicit id. + /// + /// Stream auto-generates ids of the form `!members-` for channels + /// created with members but no id, so the same set of users always + /// references the same channel. + /// + /// Distinct channels can lose members but can't gain them after creation. + /// + /// See [isOneToOne] for the typical 1-to-1 predicate built on this. + bool get isDistinct => id?.startsWith('!members') == true; + + /// Whether this is a group channel. + /// + /// True when the channel has more than two members, or isn't [isDistinct]. + /// Custom-id channels are treated as groups regardless of current member + /// count because they aren't bounded — they can grow back into + /// multi-person conversations. + /// + /// Near-inverse of [isOneToOne]. + bool get isGroup => (memberCount ?? 0) > 2 || !isDistinct; + + /// Whether this is a 1-to-1 conversation. + /// + /// True when the channel is [isDistinct] and has exactly two members. + /// Distinct channels can't gain members, so a 2-member distinct channel + /// is permanently bounded to two participants — including channels that + /// shrunk down from a larger group DM. + /// + /// This is a structural predicate without a current-user check. Combine + /// with capability / permission checks at the call site if you need + /// perspective gating. + /// + /// Near-inverse of [isGroup]. + bool get isOneToOne => isDistinct && memberCount == 2; + + /// Returns true if the channel is muted. + bool get isMuted { + final channelMutes = _client.state.currentUser?.channelMutes; + if (channelMutes == null) return false; + + return channelMutes.any((it) => it.channel.cid == cid); + } + + /// Returns true if the channel is muted, as a stream. + Stream get isMutedStream => _client.state.currentUserStream.map((user) { + final channelMutes = user?.channelMutes; + if (channelMutes == null) return false; + + return channelMutes.any((it) => it.channel.cid == cid); + }).distinct(); + + /// Channel configuration. + ChannelConfig? get config { + _checkInitialized(); + return state!.channelState.channel?.config; + } + + /// Channel configuration as a stream. + Stream get configStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.config); + } + + /// Relationship of the current user to this channel. + Member? get membership { + _checkInitialized(); + return state!.channelState.membership; + } + + /// Relationship of the current user to this channel as a stream. + Stream get membershipStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.membership); + } + + /// Channel user creator. + User? get createdBy { + _checkInitialized(); + return state!.channelState.channel?.createdBy; + } + + /// Channel user creator as a stream. + Stream get createdByStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.createdBy); + } + + /// Channel frozen status. + bool get frozen { + _checkInitialized(); + return state!.channelState.channel?.frozen == true; + } + + /// Channel frozen status as a stream. + Stream get frozenStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.frozen == true).distinct(); + } + + /// Channel disabled status. + bool get disabled { + _checkInitialized(); + return state!.channelState.channel?.disabled == true; + } + + /// Channel disabled status as a stream. + Stream get disabledStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.disabled == true).distinct(); + } + + /// Channel hidden status. + bool get hidden { + _checkInitialized(); + return state!.channelState.channel?.hidden == true; + } + + /// Channel hidden status as a stream. + Stream get hiddenStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.hidden == true).distinct(); + } + + /// Channel pinned status. + /// Status is specific to the current user. + bool get isPinned { + _checkInitialized(); + return membership?.pinnedAt != null; + } + + /// Channel pinned status as a stream. + /// Status is specific to the current user. + Stream get isPinnedStream { + return membershipStream.map((m) => m?.pinnedAt != null).distinct(); + } + + /// Channel archived status. + /// Status is specific to the current user. + bool get isArchived { + _checkInitialized(); + return membership?.archivedAt != null; + } + + /// Channel archived status as a stream. + /// Status is specific to the current user. + Stream get isArchivedStream { + return membershipStream.map((m) => m?.archivedAt != null).distinct(); + } + + /// The last date at which the channel got truncated. + DateTime? get truncatedAt { + _checkInitialized(); + return state!.channelState.channel?.truncatedAt; + } + + /// The last date at which the channel got truncated as a stream. + Stream get truncatedAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.truncatedAt).distinct(); + } + + /// Cooldown count + int get cooldown { + _checkInitialized(); + return state!.channelState.channel?.cooldown ?? 0; + } + + /// Cooldown count as a stream + Stream get cooldownStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.cooldown ?? 0).distinct(); + } + + /// Remaining cooldown duration in seconds for the channel. + /// + /// Returns 0 if there is no cooldown active. + /// + /// Optionally, provide [lastMessageAt] to calculate the remaining cooldown based on a specific message timestamp + /// instead of the last message sent by the current user in this channel. + int getRemainingCooldown({DateTime? lastMessageAt}) { + _checkInitialized(); + + final cooldownDuration = cooldown; + if (cooldownDuration <= 0) return 0; + + final userLastMessageAt = lastMessageAt ?? currentUserLastMessageAt; + if (userLastMessageAt == null) return 0; + + if (canSkipSlowMode) return 0; + + final currentTime = DateTime.timestamp(); + final elapsedTime = currentTime.difference(userLastMessageAt).inSeconds; + + return math.max(0, cooldownDuration - elapsedTime); + } + + /// Channel creation date. + DateTime? get createdAt { + _checkInitialized(); + return state!.channelState.channel?.createdAt; + } + + /// Channel creation date as a stream. + Stream get createdAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.createdAt).distinct(); + } + + /// Channel last message date. + DateTime? get lastMessageAt { + _checkInitialized(); + return state!.channelState.channel?.lastMessageAt; + } + + /// Channel last message date as a stream. + Stream get lastMessageAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt).distinct(); + } + + DateTime? _currentUserLastMessageAt({ + required List? messages, + required Map> threads, + }) { + final currentUserId = client.state.currentUser?.id; + if (currentUserId == null) return null; + + bool ours(Message m) => !m.isEphemeral && m.user?.id == currentUserId; + + DateTime? max; + + if (messages != null) { + final idx = messages.lastIndexWhere(ours); + if (idx != -1) max = messages[idx].createdAt; + } + + for (final replies in threads.values) { + final idx = replies.lastIndexWhere(ours); + if (idx == -1) continue; + final createdAt = replies[idx].createdAt; + if (max == null || createdAt.isAfter(max)) max = createdAt; + } + + return max; + } + + /// The date of the last message sent by the current user. + /// + /// Returns null if the channel is not up to date or + /// if the current user has not sent any messages in this channel. + /// + /// Note: This includes both regular messages and thread messages. + DateTime? get currentUserLastMessageAt { + _checkInitialized(); + + // If the channel is not up to date, we can't rely on the last message + // from the current user. + if (!state!.isUpToDate) return null; + + final threads = state!.threads; + final messages = state!.channelState.messages; + + return _currentUserLastMessageAt(messages: messages, threads: threads); + } + + /// The date of the last message sent by the current user as a stream. + /// + /// Returns null if the channel is not up to date or + /// if the current user has not sent any messages in this channel. + /// + /// Note: This includes both regular messages and thread messages. + Stream get currentUserLastMessageAtStream { + _checkInitialized(); + + return CombineLatestStream.combine3( + state!.isUpToDateStream, + state!.channelStateStream.map((s) => s.messages).distinct(identical), + state!.threadsStream, + (isUpToDate, messages, threads) { + // If the channel is not up to date, we can't rely on the last message + // from the current user. + if (!isUpToDate) return null; + + return _currentUserLastMessageAt(messages: messages, threads: threads); + }, + ); + } + + /// Channel updated date. + DateTime? get updatedAt { + _checkInitialized(); + return state!.channelState.channel?.updatedAt; + } + + /// Channel updated date as a stream. + Stream get updatedAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.updatedAt).distinct(); + } + + /// Channel deletion date. + DateTime? get deletedAt { + _checkInitialized(); + return state!.channelState.channel?.deletedAt; + } + + /// Channel deletion date as a stream. + Stream get deletedAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.deletedAt).distinct(); + } + + /// Channel member count. + int? get memberCount { + _checkInitialized(); + return state!.channelState.channel?.memberCount; + } + + /// Channel member count as a stream. + Stream get memberCountStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.memberCount).distinct(); + } + + /// Channel message count. + /// + /// Note: This field is only populated if the `count_messages` option is + /// enabled for your app. + int? get messageCount { + _checkInitialized(); + return state!.channelState.channel?.messageCount; + } + + /// Channel message count as a stream. + /// + /// Note: This field is only populated if the `count_messages` option is + /// enabled for your app. + Stream get messageCountStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.messageCount).distinct(); + } + + /// List of filter tags applied to this channel. + /// + /// Generally used for filtering channels while querying. + List? get filterTags { + _checkInitialized(); + return state!.channelState.channel?.filterTags; + } + + /// Channel id. + String? get id => state?.channelState.channel?.id ?? _id; + + /// Channel type. + String get type => state?.channelState.channel?.type ?? _type; + + /// Channel cid. + String? get cid => state?.channelState.channel?.cid ?? _cid; + + /// Channel team. + String? get team { + _checkInitialized(); + return state!.channelState.channel?.team; + } + + /// Channel extra data. + Map get extraData { + var data = state?.channelState.channel?.extraData; + if (data == null || data.isEmpty) { + data = _extraData; + } + return data; + } + + /// List of user permissions on this channel + List get ownCapabilities => state?.channelState.channel?.ownCapabilities ?? []; + + /// List of user permissions on this channel + Stream> get ownCapabilitiesStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.ownCapabilities ?? []).distinct(); + } + + /// Channel extra data as a stream. + Stream> get extraDataStream { + _checkInitialized(); + return state!.channelStateStream.map( + (cs) => cs.channel?.extraData ?? _extraData, + ); + } + + /// Shortcut to get channel name. + /// + /// {@macro name} + String? get name => extraData['name'] as String?; + + /// Channel [name] as a stream. + /// + /// The channel needs to be initialized. + /// + /// {@macro name} + Stream get nameStream { + _checkInitialized(); + return extraDataStream.map((it) => it['name'] as String?).distinct(); + } + + /// Shortcut to get channel image. + /// + /// {@macro image} + String? get image => extraData['image'] as String?; + + /// Channel [image] as a stream. + /// + /// The channel needs to be initialized. + /// + /// {@macro image} + Stream get imageStream { + _checkInitialized(); + return extraDataStream.map((it) => it['image'] as String?).distinct(); + } + + /// The main Stream chat client. + StreamChatClient get client => _client; + final StreamChatClient _client; + + Completer _initializedCompleter = Completer(); + + /// True if this is initialized. + /// + /// Call [watch] to initialize the client or instantiate it using + /// [Channel.fromState]. + Future get initialized => _initializedCompleter.future; + + // Whether the channel is successfully initialized and not disposed. + bool get _isInitialized => _initializedCompleter.isCompleted && state != null; + + final _cancelableAttachmentUploadRequest = {}; + final _messageAttachmentsUploadCompleter = >{}; + + /// Cancels [attachmentId] upload request. Throws exception if the request + /// hasn't even started yet, Already completed or Already cancelled. + /// + /// Optionally, provide a [reason] for the cancellation. + void cancelAttachmentUpload( + String attachmentId, { + String? reason, + }) { + final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; + if (cancelToken == null) { + throw const StreamChatError( + "Upload request for this Attachment hasn't started yet or maybe " + 'Already completed', + ); + } + if (cancelToken.isCancelled) { + throw const StreamChatError('Upload request already cancelled'); + } + cancelToken.cancel(reason); + } + + /// Retries the failed [attachmentId] upload request. + Future retryAttachmentUpload(String messageId, String attachmentId) => + _uploadAttachments(messageId, [attachmentId]); + + Future _uploadAttachments( + String messageId, + Iterable attachmentIds, + ) { + var message = [ + ...state!.messages, + ...state!.threads.values.expand((messages) => messages), + ].firstWhereOrNull((it) => it.id == messageId); + + if (message == null) { + throw const StreamChatError('Error, Message not found'); + } + + final attachments = message.attachments.where((it) { + if (it.uploadState.isSuccess) return false; + return attachmentIds.contains(it.id); + }); + + if (attachments.isEmpty) { + client.logger.info('No attachments available to upload'); + if (message.attachments.every((it) => it.uploadState.isSuccess)) { + _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); + } + return Future.value(); + } + + client.logger.info('Found ${attachments.length} attachments'); + + void updateAttachment(Attachment attachment, {bool remove = false}) { + final index = message!.attachments.indexWhere( + (it) => it.id == attachment.id, + ); + if (index != -1) { + // update or remove attachment from message. + final List newAttachments; + if (remove) { + newAttachments = [...message!.attachments]..removeAt(index); + } else { + newAttachments = [...message!.attachments]..[index] = attachment; + } + + final updatedMessage = message!.copyWith(attachments: newAttachments); + state?.updateMessage(updatedMessage); + // updating original message for next iteration + message = message!.merge(updatedMessage); + } + } + + return Future.wait( + attachments.map((it) { + client.logger.info('Uploading ${it.id} attachment...'); + + final throttledUpdateAttachment = updateAttachment.throttled( + const Duration(milliseconds: 500), + ); + + void onSendProgress(int sent, int total) { + throttledUpdateAttachment([ + it.copyWith( + uploadState: UploadState.inProgress(uploaded: sent, total: total), + ), + ]); + } + + final isImage = it.type == AttachmentType.image; + final cancelToken = CancelToken(); + Future future; + if (isImage) { + future = sendImage( + it.file!, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: it.extraData, + ); + } else { + future = sendFile( + it.file!, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: it.extraData, + ); + } + _cancelableAttachmentUploadRequest[it.id] = cancelToken; + return future + .then((response) { + client.logger.info('Attachment ${it.id} uploaded successfully...'); + + // If the response is SendFileResponse, then we might also be getting + // thumbUrl in case of video. So we need to update the attachment with + // both the assetUrl and thumbUrl. + if (response is SendFileResponse) { + updateAttachment( + it.copyWith( + assetUrl: response.file, + thumbUrl: response.thumbUrl, + uploadState: const UploadState.success(), + ), + ); + } else { + updateAttachment( + it.copyWith( + imageUrl: response.file, + uploadState: const UploadState.success(), + ), + ); + } + }) + .catchError((e, stk) { + if (e is StreamChatNetworkError && e.type == .cancel) { + client.logger.info('Attachment ${it.id} upload cancelled'); + + // remove attachment from message if cancelled. + updateAttachment(it, remove: true); + return; + } + + client.logger.severe('error uploading the attachment', e, stk); + updateAttachment( + it.copyWith(uploadState: UploadState.failed(error: e.toString())), + ); + }) + .whenComplete(() { + throttledUpdateAttachment.cancel(); + _cancelableAttachmentUploadRequest.remove(it.id); + }); + }), + ).whenComplete(() { + final completer = _messageAttachmentsUploadCompleter.remove(messageId); + if (completer == null || completer.isCompleted) return; + + // Always complete with the latest message view so callers can decide + // success vs. partial failure by inspecting per-attachment upload + // states. Cancellation is still surfaced via `completeError` from the + // sendMessage/updateMessage/deleteMessage entry points. + completer.complete(message); + }); + } + + final _sendMessageLock = Lock(); + + /// Send a [message] to this channel. + /// + /// If [skipPush] is true the message will not send a push notification. + /// + /// Waits for a [_messageAttachmentsUploadCompleter] to complete + /// before actually sending the message. + Future sendMessage( + Message message, { + bool skipPush = false, + bool skipEnrichUrl = false, + }) async { + _checkInitialized(); + + // Clean up stale error messages before sending a new message. + state?.cleanUpStaleErrorMessages(); + + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); + + final quotedMessage = state!.messages.firstWhereOrNull( + (m) => m.id == message.quotedMessageId, + ); + // ignore: parameter_assignments + message = message.copyWith( + localCreatedAt: DateTime.now(), + user: _client.state.currentUser, + quotedMessage: quotedMessage, + state: MessageState.sending, + attachments: message.attachments.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith(uploadState: const UploadState.preparing()); + }, + ).toList(), + ); + + state?.updateMessage(message); + + try { + if (message.attachments.any((it) => !it.uploadState.isSuccess)) { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; + + _uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + ); + + // ignore: parameter_assignments + message = await attachmentsUploadCompleter.future; + + // Fail the whole message if any attachment failed to upload + if (message.attachments.any((it) => it.uploadState.isFailed)) { + throw const StreamChatError('Failed to upload one or more attachments'); + } + } + + // Validate the final message before sending it to the server. + if (MessageRules.canUpload(message) != true) { + client.logger.warning('Message is not valid for sending, removing it'); + + // Remove the message from state as it is invalid. + state!.deleteMessage(message, hardDelete: true); + throw const StreamChatError('Message is not valid for sending'); + } + + // Wait for the previous sendMessage call to finish. Otherwise, the order + // of messages will not be maintained. + final response = await _sendMessageLock.synchronized( + () => _client.sendMessage( + message, + id!, + type, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + final sentMessage = message + .updateWith(response.message) + .copyWith( + // Update the message state to sent. + state: MessageState.sent, + ); + + state?.updateMessage(sentMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.sendingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + final _updateMessageLock = Lock(); + + /// Updates the [message] in this channel. + /// + /// Waits for a [_messageAttachmentsUploadCompleter] to complete + /// before actually updating the message. + Future updateMessage( + Message message, { + bool skipPush = false, + bool skipEnrichUrl = false, + }) async { + _checkInitialized(); + + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); + + // ignore: parameter_assignments + message = message.copyWith( + state: MessageState.updating, + localUpdatedAt: DateTime.now(), + attachments: message.attachments.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith(uploadState: const UploadState.preparing()); + }, + ).toList(), + ); + + state?.updateMessage(message); + + try { + if (message.attachments.any((it) => !it.uploadState.isSuccess)) { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; + + _uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + ); + + // ignore: parameter_assignments + message = await attachmentsUploadCompleter.future; + + // Fail the whole message if any attachment failed to upload + if (message.attachments.any((it) => it.uploadState.isFailed)) { + throw const StreamChatError('Failed to upload one or more attachments'); + } + } + + // Wait for the previous update call to finish. Otherwise, the order of + // messages will not be maintained. + final response = await _updateMessageLock.synchronized( + () => _client.updateMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + final updateMessage = message + .updateWith(response.message) + .copyWith( + // Update the message state to updated. + state: MessageState.updated, + ); + + state?.updateMessage(updateMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.updatingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + /// Partially updates the [message] in this channel. + /// + /// Use [set] to define values to be set. + /// + /// Use [unset] to define values to be unset. + Future partialUpdateMessage( + Message message, { + Map? set, + List? unset, + bool skipEnrichUrl = false, + }) async { + _checkInitialized(); + + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); + + // ignore: parameter_assignments + message = message.copyWith( + state: MessageState.updating, + localUpdatedAt: DateTime.now(), + ); + + state?.updateMessage(message); + + try { + // Wait for the previous update call to finish. Otherwise, the order of + // messages will not be maintained. + final response = await _updateMessageLock.synchronized( + () => _client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + final updatedMessage = message + .updateWith(response.message) + .copyWith( + // Update the message state to updated. + state: MessageState.updated, + ); + + state?.updateMessage(updatedMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + final _deleteMessageLock = Lock(); + + /// Deletes the [message] for everyone. + /// + /// If [hard] is true, the message is permanently deleted from the server + /// and cannot be recovered. In this case, any attachments associated with the + /// message are also deleted from the server. + Future deleteMessage(Message message, {bool hard = false}) { + final deletionScope = MessageDeleteScope.deleteForAll(hard: hard); + + return _deleteMessage(message, scope: deletionScope); + } + + /// Deletes the [message] only for the current user. + /// + /// Note: This does not delete the message for other channel members and + /// they can still see the message. + Future deleteMessageForMe(Message message) { + const deletionScope = MessageDeleteScope.deleteForMe(); + + return _deleteMessage(message, scope: deletionScope); + } + + // Deletes the [message] from the channel. + // + // The [scope] defines whether to delete the message for everyone or just + // for the current user. + // + // If the message is a local message (not yet sent to the server) or a bounced + // error message, it is deleted locally without making an API call. + // + // If the message is deleted for everyone and [scope.hard] is true, the + // message is permanently deleted from the server and cannot be recovered. + // In this case, any attachments associated with the message are also deleted + // from the server. + Future _deleteMessage( + Message message, { + required MessageDeleteScope scope, + }) async { + _checkInitialized(); + + // Directly deleting the local messages and bounced error messages as they + // are not available on the server. + if (message.remoteCreatedAt == null || message.isBouncedWithError) { + _deleteLocalMessage(message); + // Returning empty response to mark the api call as success. + return EmptyResponse(); + } + + // ignore: parameter_assignments + message = message.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.now(), + deletedForMe: scope is DeleteForMe, + state: MessageState.deleting(scope: scope), + ); + + state?.deleteMessage(message, hardDelete: scope.hard); + + try { + // Wait for the previous delete call to finish. Otherwise, the order of + // messages will not be maintained. + final response = await _deleteMessageLock.synchronized( + () => switch (scope) { + DeleteForMe() => _client.deleteMessageForMe(message.id), + DeleteForAll() => _client.deleteMessage(message.id, hard: scope.hard), + }, + ); + + final deletedMessage = message.copyWith( + deletedForMe: scope is DeleteForMe, + state: MessageState.deleted(scope: scope), + ); + + state?.deleteMessage(deletedMessage, hardDelete: scope.hard); + // If hard delete, also delete the attachments from the server. + if (scope.hard) _deleteMessageAttachments(deletedMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.deletingFailed(scope: scope), + ); + + state?.deleteMessage(failedMessage, hardDelete: scope.hard); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + // Deletes a local [message] that is not yet sent to the server. + // + // This is typically called when a user wants to delete a message that they + // have composed but not yet sent, or if a message failed to send and the user + // wants to remove it from their local view. + void _deleteLocalMessage(Message message) { + state?.deleteMessage( + hardDelete: true, // Local messages are always hard deleted. + message.copyWith( + type: MessageType.deleted, + localDeletedAt: DateTime.now(), + state: MessageState.hardDeleted, + ), + ); + + // Removing the attachments upload completer to stop the `sendMessage` + // waiting for attachments to complete. + final completer = _messageAttachmentsUploadCompleter.remove(message.id); + completer?.completeError(const StreamChatError('Message deleted')); + } + + // Deletes all the attachments associated with the given [message] + // from the server. This is typically called when a message is hard deleted. + Future _deleteMessageAttachments(Message message) async { + final attachments = message.attachments; + final deleteFutures = attachments.map((it) async { + if (it.imageUrl case final url?) return deleteImage(url); + if (it.assetUrl case final url?) return deleteFile(url); + }); + + try { + await Future.wait(deleteFutures); + } catch (e, stk) { + _client.logger.warning('Error deleting message attachments', e, stk); + } + } + + /// Retries operations on a message based on its failed state. + /// + /// This method examines the message's state and performs the appropriate + /// retry action: + /// - For [MessageState.sendingFailed], it attempts to send the message. + /// - For [MessageState.updatingFailed], it attempts to update the message. + /// - For [MessageState.partialUpdatingFailed], it attempts to partially + /// update the message with the same 'set' and 'unset' parameters that were + /// used in the original request. + /// - For [MessageState.deletingFailed], it attempts to delete the message + /// again, using the same scope (for me or for all) as the original request. + /// - For messages with [isBouncedWithError], it attempts to send the message. + /// + /// Throws a [StateError] if the message is not in a failed state or + /// bounced with an error. + Future retryMessage(Message message) async { + assert( + message.state.isFailed || message.isBouncedWithError, + 'Only failed or bounced messages can be retried', + ); + + return message.state.maybeWhen( + failed: (state, _) => state.when( + sendingFailed: (skipPush, skipEnrichUrl) => sendMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + updatingFailed: (skipPush, skipEnrichUrl) => updateMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + partialUpdatingFailed: (set, unset, skipEnrichUrl) { + return partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ); + }, + deletingFailed: (scope) => switch (scope) { + DeleteForMe() => deleteMessageForMe(message), + DeleteForAll(hard: final hard) => deleteMessage(message, hard: hard), + }, + ), + orElse: () { + // Check if the message is bounced with error. + if (message.isBouncedWithError) return sendMessage(message); + + throw StateError( + 'Only failed or bounced messages can be retried', + ); + }, + ); + } + + /// Pins provided message + Future pinMessage( + Message message, { + Object? /*num|DateTime*/ timeoutOrExpirationDate, + }) { + assert(() { + if (timeoutOrExpirationDate is! DateTime && timeoutOrExpirationDate != null && timeoutOrExpirationDate is! num) { + throw ArgumentError('Invalid timeout or Expiration date'); + } + return true; + }(), 'Check for invalid timeout or expiration date'); + + DateTime? pinExpires; + if (timeoutOrExpirationDate is DateTime) { + pinExpires = timeoutOrExpirationDate; + } else if (timeoutOrExpirationDate is num) { + pinExpires = DateTime.now().add( + Duration(seconds: timeoutOrExpirationDate.toInt()), + ); + } + return partialUpdateMessage( + message, + set: { + 'pinned': true, + 'pin_expires': pinExpires?.toUtc().toIso8601String(), + }, + ); + } + + /// Unpins provided message. + Future unpinMessage(Message message) => partialUpdateMessage( + message, + set: { + 'pinned': false, + }, + ); + + /// Creates or updates a new [draft] for this channel. + Future createDraft( + DraftMessage draft, + ) { + _checkInitialized(); + return _client.createDraft(draft, id!, type); + } + + /// Retrieves the draft for this channel. + /// + /// Optionally, provide a [parentId] to get the draft for a specific thread. + Future getDraft({ + String? parentId, + }) { + _checkInitialized(); + return _client.getDraft(id!, type, parentId: parentId); + } + + /// Deletes the draft for this channel. + /// + /// Optionally, provide a [parentId] to delete the draft for a specific + /// thread. + Future deleteDraft({ + String? parentId, + }) { + _checkInitialized(); + return _client.deleteDraft(id!, type, parentId: parentId); + } + + /// Sends a static location to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future sendStaticLocation({ + String? id, + String? messageText, + String? createdByDeviceId, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + + /// Sends a live location sharing message to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future startLiveLocationSharing({ + String? id, + String? messageText, + String? createdByDeviceId, + required DateTime endSharingAt, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + endAt: endSharingAt, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + + /// Send a file to this channel. + Future sendFile( + AttachmentFile file, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.sendFile( + file, + id!, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Send an image to this channel. + Future sendImage( + AttachmentFile file, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.sendImage( + file, + id!, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Search for a message with the given options. + Future search({ + String? query, + Filter? messageFilters, + List? sort, + PaginationParams? paginationParams, + }) { + _checkInitialized(); + return _client.search( + Filter.in_('cid', [cid!]), + sort: sort, + query: query, + paginationParams: paginationParams, + messageFilters: messageFilters, + ); + } + + /// Delete a file from this channel. + Future deleteFile( + String url, { + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.deleteFile( + url, + id!, + type, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Delete an image from this channel. + Future deleteImage( + String url, { + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.deleteImage( + url, + id!, + type, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Send an event on this channel. + Future sendEvent(Event event) { + _checkInitialized(); + return _client.sendEvent(id!, type, event); + } + + final _pollLock = Lock(); + + /// Send a message with a poll to this channel. + /// + /// Optionally provide a [messageText] to send a message along with the poll. + Future sendPoll( + Poll poll, { + String? messageText, + }) async { + _checkInitialized(); + final res = await _pollLock.synchronized(() => _client.createPoll(poll)); + return sendMessage( + Message( + text: messageText, + poll: res.poll, + pollId: res.poll.id, + ), + ); + } + + /// Updates the [poll] in this channel. + Future updatePoll(Poll poll) { + _checkInitialized(); + return _pollLock.synchronized(() => _client.updatePoll(poll)); + } + + /// Deletes the given [poll] from this channel. + Future deletePoll(Poll poll) { + _checkInitialized(); + return _pollLock.synchronized(() => _client.deletePoll(poll.id)); + } + + /// Close the given [poll]. + Future closePoll(Poll poll) { + _checkInitialized(); + return _pollLock.synchronized(() => _client.closePoll(poll.id)); + } + + /// Create a new poll option for the given [poll]. + Future createPollOption( + Poll poll, + PollOption option, + ) { + _checkInitialized(); + return _pollLock.synchronized( + () => _client.createPollOption(poll.id, option), + ); + } + + final _pollVoteLock = Lock(); + + /// Cast a vote on the given [poll] with the given [option]. + Future castPollVote( + Message message, + Poll poll, + PollOption option, + ) async { + _checkInitialized(); + + final optionId = option.id; + if (optionId == null) { + throw ArgumentError('Option id cannot be null'); + } + + return _pollVoteLock.synchronized( + () => _client.castPollVote( + message.id, + poll.id, + optionId: optionId, + ), + ); + } + + /// Add a new answer to the given [poll]. + Future addPollAnswer( + Message message, + Poll poll, { + required String answerText, + }) { + _checkInitialized(); + return _pollVoteLock.synchronized( + () => _client.addPollAnswer( + message.id, + poll.id, + answerText: answerText, + ), + ); + } + + /// Remove a vote on the given [poll] with the given [vote]. + Future removePollVote( + Message message, + Poll poll, + PollVote vote, + ) { + _checkInitialized(); + + final voteId = vote.id; + if (voteId == null) { + throw ArgumentError('Vote id cannot be null'); + } + + return _pollVoteLock.synchronized( + () => _client.removePollVote( + message.id, + poll.id, + voteId, + ), + ); + } + + /// Query the poll votes for the given [pollId] with the given [filter] and + /// [sort] options. + Future queryPollVotes( + String pollId, { + Filter? filter, + SortOrder? sort, + PaginationParams pagination = const PaginationParams(), + }) { + _checkInitialized(); + return _client.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ); + } + + /// Create a reminder for the given [messageId]. + /// + /// Optionally, provide a [remindAt] date to set when the reminder should + /// be triggered. If not provided, the reminder will be created as a + /// bookmark type instead. + Future createReminder( + String messageId, { + DateTime? remindAt, + }) { + _checkInitialized(); + return _client.createReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Update an existing reminder with the given [reminderId]. + /// + /// Optionally, provide a [remindAt] date to set when the reminder should + /// be triggered. If not provided, the reminder will be updated as a + /// bookmark type instead. + Future updateReminder( + String messageId, { + DateTime? remindAt, + }) { + _checkInitialized(); + return _client.updateReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Remove the reminder for the given [messageId]. + Future deleteReminder(String messageId) { + _checkInitialized(); + return _client.deleteReminder(messageId); + } + + /// Send a reaction to this channel. + /// + /// Set [enforceUnique] to true to remove the existing user reaction. + Future sendReaction( + Message message, + Reaction reaction, { + bool skipPush = false, + bool enforceUnique = false, + }) async { + _checkInitialized(); + + final messageId = message.id; + // ignore: parameter_assignments + reaction = reaction.copyWith( + messageId: messageId, + user: _client.state.currentUser, + ); + + final updatedMessage = message.addMyReaction( + reaction, + enforceUnique: enforceUnique, + ); + + state?.updateMessage(updatedMessage); + + try { + final reactionResp = await _client.sendReaction( + messageId, + reaction, + skipPush: skipPush, + enforceUnique: enforceUnique, + ); + return reactionResp; + } catch (_) { + // Reset the message if the update fails. Use replace (not merge) + // so the rollback wins over the optimistic local state — otherwise + // `Message.updateWith`'s enrichment preservation would keep the + // optimistic `ownReactions` for messages that previously had none. + state?.replaceMessage(message); + rethrow; + } + } + + /// Delete a reaction from this channel. + Future deleteReaction( + Message message, + Reaction reaction, + ) async { + _checkInitialized(); + + final updatedMessage = message.deleteMyReaction( + reactionType: reaction.type, + ); + + state?.updateMessage(updatedMessage); + + try { + final deleteResponse = await _client.deleteReaction( + message.id, + reaction.type, + ); + return deleteResponse; + } catch (_) { + // Reset the message if the update fails. Use replace (not merge) + // for symmetry with `sendReaction` — see that method for context. + state?.replaceMessage(message); + rethrow; + } + } + + /// Sends an event to stop AI response generation, leaving the message in + /// its current state. + Future stopAIResponse() async { + return sendEvent( + Event( + type: EventType.aiIndicatorStop, + ), + ); + } + + /// Update the channel's [name]. + /// + /// This is the same as calling [updatePartial] and providing a map with a + /// 'name' key: + /// + /// ```dart + /// channel.updatePartial( + /// set: {'name': 'Updated channel name'} + /// ); + /// ``` + /// + /// Instead do: + /// ```dart + /// channel.updateName('Updated channel name'); + /// ``` + Future updateName(String name) => updatePartial(set: {'name': name}); + + /// Update the channel's [image]. + /// + /// This is the same as calling [updatePartial] and providing a map with an + /// 'image' key: + /// + /// ```dart + /// channel.updatePartial( + /// set: {'image': 'https://getstream.io/new-image'} + /// ); + /// ``` + /// + /// Instead do: + /// ```dart + /// channel.updateImage('https://getstream.io/new-image'); + /// ``` + Future updateImage(String image) => updatePartial(set: {'image': image}); + + /// Update the channel custom data. This replaces all of the channel data + /// with the given [channelData]. + /// + /// If you instead want to do a partial update, use [updatePartial]. + /// + /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart + /// for more information. + Future update( + Map channelData, { + Message? updateMessage, + }) async { + _checkInitialized(); + return _client.updateChannel( + id!, + type, + channelData, + message: updateMessage, + ); + } + + /// A partial update can be used to set and unset specific custom data fields + /// when it is necessary to retain additional custom data fields on the + /// object. + /// + /// - [set] will add, or update existing attributes. + /// - [unset] will remove the attributes with the provided list of + /// values (keys). + /// + /// If you want to do a full update/replacement, use [update] instead. + /// + /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart + /// for more information. + Future updatePartial({ + Map? set, + List? unset, + }) async { + _checkInitialized(); + return _client.updateChannelPartial(id!, type, set: set, unset: unset); + } + + /// Enable slow mode + Future enableSlowMode({ + required int cooldownInterval, + }) async { + _checkInitialized(); + return _client.enableSlowdown(id!, type, cooldownInterval); + } + + /// Disable slow mode + Future disableSlowMode() async { + _checkInitialized(); + return _client.disableSlowdown(id!, type); + } + + /// Delete this channel. Messages are permanently removed. + Future delete() async { + _checkInitialized(); + return _client.deleteChannel(id!, type); + } + + /// Removes all messages from the channel up to [truncatedAt] or now if + /// [truncatedAt] is not provided. + /// If [skipPush] is true, no push notification will be sent. + /// [Message] is the system message that will be sent to the channel. + Future truncate({ + Message? message, + bool? skipPush, + DateTime? truncatedAt, + }) async { + _checkInitialized(); + return _client.truncateChannel( + id!, + type, + message: message, + skipPush: skipPush, + truncatedAt: truncatedAt, + ); + } + + /// Accept invitation to the channel. + Future acceptInvite([Message? message]) async { + _checkInitialized(); + return _client.acceptChannelInvite(id!, type, message: message); + } + + /// Reject invitation to the channel. + Future rejectInvite([Message? message]) async { + _checkInitialized(); + return _client.rejectChannelInvite(id!, type, message: message); + } + + /// Add members to the channel. + Future addMembers( + List memberIds, { + Message? message, + bool hideHistory = false, + DateTime? hideHistoryBefore, + }) async { + _checkInitialized(); + return _client.addChannelMembers( + id!, + type, + memberIds, + message: message, + hideHistory: hideHistory, + hideHistoryBefore: hideHistoryBefore, + ); + } + + /// Invite members to the channel. + Future inviteMembers( + List memberIds, { + Message? message, + }) async { + _checkInitialized(); + return _client.inviteChannelMembers(id!, type, memberIds, message: message); + } + + /// Remove members from the channel. + Future removeMembers( + List memberIds, { + Message? message, + }) async { + _checkInitialized(); + return _client.removeChannelMembers(id!, type, memberIds, message: message); + } + + /// Send action for a specific message of this channel. + Future sendAction( + Message message, + Map formData, + ) async { + _checkInitialized(); + final messageId = message.id; + final res = await _client.sendAction(id!, type, messageId, formData); + + // update the passed message with response message + if (res.message != null) { + state!.updateMessage(res.message!); + } else { + // remove the passed message if response does + // not contain message + state!.removeMessage(message); + } + return res; + } + + /// Mark all messages as read. + /// + /// Optionally provide a [messageId] if you want to mark channel as + /// read from a particular message onwards. + /// + /// If [usesLocalUnreadCount] is `true` for this channel, this updates the + /// unread count locally, on-device, without making a network request. In + /// that case [messageId] is recorded as the read boundary but does **not** + /// narrow the count: the channel is always treated as fully read and the + /// count drops to zero. See [ChannelClientState.markReadLocally]. + Future markRead({String? messageId}) async { + _checkInitialized(); + + if (usesLocalUnreadCount) { + state!.markReadLocally(messageId: messageId); + return EmptyResponse(); + } + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark as read: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markChannelRead(id!, type, messageId: messageId); + } + + /// Marks the channel as unread by a given [messageId]. + /// + /// All messages from the provided message onwards will be marked as unread, + /// **including** the message itself. Contrast with + /// [markUnreadByTimestamp], which is exclusive: a message created at + /// exactly the given timestamp stays read. + /// + /// If [usesLocalUnreadCount] is `true` for this channel, this updates the + /// unread count locally, on-device, without making a network request. The + /// message must be part of the locally-known messages ([Channel.messages]) + /// for the count to be recomputed. + Future markUnread(String messageId) async { + _checkInitialized(); + + if (usesLocalUnreadCount) { + // [ChannelClientState.messages] is sorted ascending by `createdAt`, so + // the entry before the anchor is the newest message that stays read. + final messages = state!.messages; + final anchorIndex = messages.indexWhere((it) => it.id == messageId); + if (anchorIndex < 0) { + throw StreamChatError( + 'Cannot mark as unread: Message "$messageId" was not found in the ' + 'locally-known messages for this channel.', + ); + } + + final anchor = messages[anchorIndex]; + final previous = anchorIndex > 0 ? messages[anchorIndex - 1] : null; + + // Subtract a microsecond so the anchor message itself is treated as + // "after" the new read boundary, matching the "from the provided + // message onwards" semantics described above. Preferred over using + // `previous.createdAt` as the boundary, which would leak the anchor + // back into the read set if the two share an identical `createdAt`. + final lastRead = anchor.createdAt.subtract(const Duration(microseconds: 1)); + state!.markUnreadLocally(lastRead: lastRead, lastReadMessageId: previous?.id); + return EmptyResponse(); + } + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark as unread: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markChannelUnread(id!, type, messageId); + } + + /// Marks the channel as unread by a given [timestamp]. + /// + /// All messages after the provided timestamp will be marked as unread. This + /// boundary is **exclusive**: a message created at exactly [timestamp] stays + /// read. Contrast with [markUnread], which is inclusive of the message it is + /// given — `markUnread(m.id)` is equivalent to + /// `markUnreadByTimestamp(m.createdAt - 1µs)`, not to + /// `markUnreadByTimestamp(m.createdAt)`. + /// + /// If [usesLocalUnreadCount] is `true` for this channel, this updates the + /// unread count locally, on-device, without making a network request. + Future markUnreadByTimestamp(DateTime timestamp) async { + _checkInitialized(); + + if (usesLocalUnreadCount) { + // The newest locally-known message at or before the boundary is the last + // one that stays read. + final lastReadMessage = state!.messages.lastWhereOrNull( + (it) => !it.createdAt.isAfter(timestamp), + ); + + state!.markUnreadLocally( + lastRead: timestamp, + lastReadMessageId: lastReadMessage?.id, + ); + return EmptyResponse(); + } + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark as unread: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markChannelUnreadByTimestamp(id!, type, timestamp); + } + + /// Mark the thread with [threadId] in the channel as read. + Future markThreadRead(String threadId) async { + _checkInitialized(); + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark thread as read: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markThreadRead(id!, type, threadId); + } + + /// Mark the thread with [threadId] in the channel as unread. + Future markThreadUnread(String threadId) async { + _checkInitialized(); + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark thread as unread: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markThreadUnread(id!, type, threadId); + } + + void _initState(ChannelState channelState) { + state = ChannelClientState(this, channelState); + _initializedCompleter.safeComplete(true); + + if (cid case final cid?) client.state.addChannels({cid: this}); + _client.logger.info('Channel ${channelState.channel?.cid} initialized'); + } + + /// Loads the initial channel state and watches for changes. + Future watch({ + bool presence = false, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) { + return query( + watch: true, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + } + + /// Stop watching the channel. + Future stopWatching() async { + _checkInitialized(); + return _client.stopChannelWatching(id!, type); + } + + /// List the message replies for a parent message. + /// + /// Set [preferOffline] to true to avoid the api call if the data is already + /// in the offline storage. + Future getReplies( + String parentId, { + PaginationParams? options, + bool preferOffline = false, + }) async { + QueryRepliesResponse? response; + + // If we prefer offline, we first try to get the replies from the + // offline storage. + if (preferOffline) { + if (_client.chatPersistenceClient case final persistenceClient?) { + final cachedReplies = await persistenceClient.getReplies( + parentId, + options: options, + ); + + // If the cached replies are not empty, we can use them. + if (cachedReplies.isNotEmpty) { + response = QueryRepliesResponse()..messages = cachedReplies; + } + } + } + + // If we still don't have the replies, we try to get them from the API. + response ??= await _client.getReplies(parentId, options: options); + + // Before updating the state, we check if we are querying around a + // reply, If we are, we have to clear the state to avoid potential + // gaps in the message sequence. + final isQueryingAround = switch (options) { + PaginationParams(idAround: _?) => true, + PaginationParams(createdAtAround: _?) => true, + _ => false, + }; + + if (isQueryingAround) state?.clearThread(parentId); + state?.updateThreadInfo(parentId, response.messages); + + return response; + } + + /// List the reactions for a message in the channel. + Future getReactions( + String messageId, { + PaginationParams? pagination, + }) => _client.getReactions( + messageId, + pagination: pagination, + ); + + /// Retrieves a list of messages by given [messageIDs]. + Future getMessagesById( + List messageIDs, + ) async { + _checkInitialized(); + return _client.getMessagesById(id!, type, messageIDs); + } + + /// Translate a message by given [messageId] and [language]. + /// + /// The translated message is merged into the channel state, so its + /// translations are available to everything listening to it without the + /// caller having to apply the response itself. + Future translateMessage( + String messageId, + String language, + ) async { + final response = await _client.translateMessage(messageId, language); + state?.updateMessage(response.message); + return response; + } + + /// Creates a new channel. + Future create() => query(state: false); + + /// Query the API, get messages, members or other channel fields. + /// + /// Set [preferOffline] to true to avoid the API call if the data is already + /// in the offline storage. + Future query({ + bool state = true, + bool watch = false, + bool presence = false, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + bool preferOffline = false, + }) async { + // A prior failed init left the completer errored; reset it so this attempt + // owns the `initialized` result. Must stay before the first `await` so a + // caller reading `initialized` right after `query()`/`watch()` begins sees + // the fresh completer. + if (_initializedCompleter.isCompleted && this.state == null) { + _initializedCompleter = Completer(); + } + + ChannelState? channelState; + + try { + // If we prefer offline, we first try to get the channel state from the + // offline storage. + if (preferOffline && !watch && cid != null) { + final persistenceClient = _client.chatPersistenceClient; + if (persistenceClient != null) { + final cachedState = await persistenceClient.getChannelStateByCid( + cid!, + messagePagination: messagesPagination, + ); + + // If the cached state contains messages, we can use it. + if (cachedState.messages?.isNotEmpty == true) { + channelState = cachedState; + } + } + } + + // If we still don't have the channelState, we try to get it from the API. + channelState ??= await _client.queryChannel( + type, + channelId: id, + channelData: _extraData, + state: state, + watch: watch, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + + if (_id == null) { + _id = channelState.channel!.id; + _cid = channelState.channel!.cid; + } + + // Initialize the channel state if it's not initialized yet. + if (this.state == null) { + _initState(channelState); + } else { + // Otherwise, we update the existing state with the new channel state. + // + // But, before updating the state, we check if we are querying around a + // message, If we are, we have to truncate the state to avoid potential + // gaps in the message sequence. + final isQueryingAround = switch (messagesPagination) { + PaginationParams(idAround: _?) => true, + PaginationParams(createdAtAround: _?) => true, + _ => false, + }; + + if (isQueryingAround) this.state?.truncate(); + this.state?.updateChannelStateFromServer(channelState); + } + + // Submit for delivery reporting only when fetching the latest messages. + // This happens when no pagination params are provided (initial query). + if (messagesPagination == null) { + _client.channelDeliveryReporter.submitForDelivery([this]); + } + + return channelState; + } catch (e, stk) { + // If we failed to get the channel state from the API and we were not + // supposed to watch the channel, we will try to get the channel state + // from the offline storage. + if (watch == false) { + if (_client.persistenceEnabled) { + return _client.chatPersistenceClient!.getChannelStateByCid( + cid!, + messagePagination: messagesPagination, + ); + } + } + + // Otherwise, we will just rethrow the error. + _initializedCompleter.safeCompleteError(e, stk); + + rethrow; + } + } + + /// Query channel members. + Future queryMembers({ + Filter? filter, + SortOrder? sort, + PaginationParams? pagination, + }) => _client.queryMembers( + type, + channelId: id, + filter: filter, + members: state?.members, + sort: sort, + pagination: pagination, + ); + + /// Query channel banned users. + Future queryBannedUsers({ + Filter? filter, + SortOrder? sort, + PaginationParams? pagination, + }) { + _checkInitialized(); + filter ??= Filter.equal('channel_cid', cid!); + return _client.queryBannedUsers( + filter: filter, + sort: sort, + pagination: pagination, + ); + } + + // Timer to keep track of mute expiration. This is used to update the channel + // state when the mute expires. + Timer? _muteExpirationTimer; + + /// Mutes the channel. + Future mute({Duration? expiration}) { + _checkInitialized(); + + // If there is a expiration set, we will set a timer to automatically unmute + // the channel when the mute expires. + if (expiration != null) { + _muteExpirationTimer?.cancel(); + _muteExpirationTimer = Timer(expiration, unmute); + } + + return _client.muteChannel(cid!, expiration: expiration); + } + + /// Unmute the channel. + Future unmute() { + _checkInitialized(); + + // Cancel the mute expiration timer if it is set. + _muteExpirationTimer?.cancel(); + _muteExpirationTimer = null; + + return _client.unmuteChannel(cid!); + } + + /// Bans the member with given [userID] from the channel. + Future banMember( + String userID, + Map options, + ) async { + _checkInitialized(); + final opts = Map.from(options) + ..addAll({ + 'type': type, + 'id': id, + }); + return _client.banUser(userID, opts); + } + + /// Remove the ban for the member with given [userID] in the channel. + Future unbanMember(String userID) async { + _checkInitialized(); + return _client.unbanUser(userID, { + 'type': type, + 'id': id, + }); + } + + /// Shadow bans the user with the given [userID] from the channel. + Future shadowBan( + String userID, + Map options, + ) async { + _checkInitialized(); + final opts = Map.from(options) + ..addAll({ + 'type': type, + 'id': id, + }); + return _client.shadowBan(userID, opts); + } + + /// Remove the shadow ban for the user with the given [userID] in the channel. + Future removeShadowBan(String userID) async { + _checkInitialized(); + return _client.removeShadowBan(userID, { + 'type': type, + 'id': id, + }); + } + + /// Hides the channel from [StreamChatClient.queryChannels] for the user + /// until a message is added. + /// + /// If [clearHistory] is set to true - all messages + /// will be removed for the user. + Future hide({bool clearHistory = false}) async { + _checkInitialized(); + return _client.hideChannel( + id!, + type, + clearHistory: clearHistory, + ); + } + + /// Removes the hidden status for the channel. + Future show() async { + _checkInitialized(); + return _client.showChannel(id!, type); + } + + /// Pins the channel for the current user. + Future pin() async { + _checkInitialized(); + + final response = await _client.pinChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Unpins the channel. + Future unpin() async { + _checkInitialized(); + + final response = await _client.unpinChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Archives the channel. + Future archive() async { + _checkInitialized(); + + final response = await _client.archiveChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Unarchives the channel for the current user. + Future unarchive() async { + _checkInitialized(); + + final response = await _client.unarchiveChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Stream of [Event] coming from websocket connection specific for the + /// channel. Pass an eventType as parameter in order to filter just a type + /// of event. + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) => _client + .on( + eventType, + eventType2, + eventType3, + eventType4, + ) + .where((e) => e.cid == cid); + + late final _keyStrokeHandler = KeyStrokeHandler( + onStartTyping: startTyping, + onStopTyping: stopTyping, + ); + + // Whether sending typing events is allowed in the channel and by the user + // privacy settings. + bool get _canSendTypingEvents { + final currentUser = client.state.currentUser; + if (currentUser == null) return false; + + return canUseTypingEvents && currentUser.isTypingIndicatorsEnabled; + } + + /// Sends the [Event.typingStart] event and schedules a timer to invoke the + /// [Event.typingStop] event. + /// + /// This is meant to be called every time the user presses a key. + Future keyStroke([String? parentId]) async { + if (!_canSendTypingEvents) return; + + client.logger.info('KeyStroke received'); + return _keyStrokeHandler(parentId); + } + + /// Sends the [EventType.typingStart] event. + Future startTyping([String? parentId]) async { + if (!_canSendTypingEvents) return; + + client.logger.info('start typing'); + await sendEvent( + Event( + type: EventType.typingStart, + parentId: parentId, + ), + ); + } + + /// Sends the [EventType.typingStop] event. + Future stopTyping([String? parentId]) async { + if (!_canSendTypingEvents) return; + + client.logger.info('stop typing'); + await sendEvent( + Event( + type: EventType.typingStop, + parentId: parentId, + ), + ); + } + + /// Call this method to dispose the channel client. + void dispose() { + client.state.removeChannel('$cid'); + state?.dispose(); + state = null; + _muteExpirationTimer?.cancel(); + _keyStrokeHandler.cancel(); + } + + void _checkInitialized() { + if (_isInitialized) return; + + throw StateError( + "Channel $cid hasn't been initialized yet or has been disposed. " + 'Make sure to call .watch() or instantiate the client using ' + '[Channel.fromState]', + ); + } +} diff --git a/packages/stream_chat/lib/src/client/channel/channel_capability_check.dart b/packages/stream_chat/lib/src/client/channel/channel_capability_check.dart new file mode 100644 index 0000000000..c79245ac4f --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel_capability_check.dart @@ -0,0 +1,235 @@ +import '../../../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/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart new file mode 100644 index 0000000000..3e1b3a3624 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart @@ -0,0 +1,2155 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../../stream_chat.dart'; +import '../../core/util/utils.dart'; +import '../retry_queue.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(); + _listenChannelCounts(); + // 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, + ), + ); + }), + ); + } + + // Most channel events carry the channel's member and message counts as + // event metadata, reflecting the authoritative values after the change. + // Applying them keeps the counts fresh for the whole session instead of + // only right after a `query` / `watch`. + void _listenChannelCounts() { + _subscriptions.add( + _channel.on().listen( + (Event e) { + final memberCount = e.channelMemberCount; + final messageCount = e.channelMessageCount; + if (memberCount == null && messageCount == null) return; + + updateChannelState( + channelState.copyWith( + channel: channelState.channel?.copyWith( + memberCount: memberCount, + 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).distinct(); + + /// 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/channel_read_helper.dart b/packages/stream_chat/lib/src/client/channel/channel_read_helper.dart new file mode 100644 index 0000000000..558404d3a3 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel_read_helper.dart @@ -0,0 +1,52 @@ +import '../../../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 [message] 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 [message] 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/lib/src/client/channel_delivery_reporter.dart b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart index e3c7d7a4e1..3c9217d9c6 100644 --- a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart +++ b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart @@ -5,7 +5,7 @@ import 'package:synchronized/synchronized.dart'; import '../core/models/message.dart'; import '../core/models/message_delivery.dart'; import '../core/util/message_rules.dart'; -import 'channel.dart'; +import 'channel/channel.dart'; /// A callback that sends delivery receipts for multiple channels. /// diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 123685cbb4..4bc98f992e 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -54,7 +54,7 @@ import '../event_type.dart'; import '../system_environment.dart'; import '../ws/connection_status.dart'; import '../ws/websocket.dart'; -import 'channel.dart'; +import 'channel/channel.dart'; import 'channel_delivery_reporter.dart'; import 'event_resolvers.dart' as event_resolvers; import 'query_channels_result.dart'; diff --git a/packages/stream_chat/lib/src/client/query_channels_result.dart b/packages/stream_chat/lib/src/client/query_channels_result.dart index 4ca3abd47f..c28e3740e0 100644 --- a/packages/stream_chat/lib/src/client/query_channels_result.dart +++ b/packages/stream_chat/lib/src/client/query_channels_result.dart @@ -1,5 +1,5 @@ import '../core/models/predefined_filter.dart'; -import 'channel.dart'; +import 'channel/channel.dart'; /// The result of a `queryChannelsWithResult` call on [StreamChatClient]. /// diff --git a/packages/stream_chat/lib/src/core/util/message_rules.dart b/packages/stream_chat/lib/src/core/util/message_rules.dart index 52c89b4517..b368f3ec7d 100644 --- a/packages/stream_chat/lib/src/core/util/message_rules.dart +++ b/packages/stream_chat/lib/src/core/util/message_rules.dart @@ -1,4 +1,5 @@ -import '../../client/channel.dart'; +import '../../client/channel/channel.dart'; +import '../../client/channel/channel_capability_check.dart'; import '../models/message.dart'; import '../models/own_user.dart'; diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index d81495aeef..9f4a057352 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -16,7 +16,10 @@ export 'package:logging/logging.dart' show Logger, Level, LogRecord; export 'package:rate_limiter/rate_limiter.dart'; export 'package:uuid/uuid.dart'; -export 'src/client/channel.dart'; +export 'src/client/channel/channel.dart'; +export 'src/client/channel/channel_capability_check.dart'; +export 'src/client/channel/channel_client_state.dart'; +export 'src/client/channel/channel_read_helper.dart'; export 'src/client/channel_delivery_reporter.dart'; export 'src/client/client.dart'; export 'src/client/key_stroke_handler.dart'; diff --git a/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart b/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart new file mode 100644 index 0000000000..cf02992e4e --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart @@ -0,0 +1,410 @@ +// 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, + ], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + return channel; + } + + 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/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart new file mode 100644 index 0000000000..c7b5114328 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/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/channel_read_helper_test.dart b/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart new file mode 100644 index 0000000000..ecda0d62d5 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/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/channel_test.dart similarity index 51% rename from packages/stream_chat/test/src/client/channel_test.dart rename to packages/stream_chat/test/src/client/channel/channel_test.dart index 0a71f7cdbc..d7f3b27a86 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -4,9 +4,9 @@ import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -import '../fakes.dart'; -import '../matchers.dart'; -import '../mocks.dart'; +import '../../fakes.dart'; +import '../../matchers.dart'; +import '../../mocks.dart'; void main() { ChannelState _generateChannelState( @@ -4947,15 +4947,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; @@ -4981,1212 +4978,487 @@ 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 { + final emitted = []; + final subscription = channel.messageCountStream.listen(emitted.add); + addTearDown(subscription.cancel); + await Future.delayed(Duration.zero); - 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, repeating one of the counts. + final counts = [1, 5, 5, 10]; + for (final (index, count) in counts.indexed) { + final event = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'msg-$index', + 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); + // The repeated count should not be emitted twice. + expect(emitted, equals([null, 1, 5, 10])); + }, + ); + }); - 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', () { + group('Channel member count events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; late Channel channel; @@ -6201,5447 +5473,888 @@ void main() { }); test( - 'should update membership when member is updated and is current user', + 'should update channel memberCount when event contains channelMemberCount', () 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, - ); + // Verify initial state - default memberCount + expect(channel.memberCount, equals(0)); - // Create member updated event - final memberUpdatedEvent = Event( + // Create event with channelMemberCount + final memberCountEvent = Event( cid: channel.cid, - type: EventType.memberUpdated, - user: currentUser, - member: updatedMember, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 42, ); // Dispatch event - client.addEvent(memberUpdatedEvent); + client.addEvent(memberCountEvent); // 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); + // Verify channel memberCount was updated + expect(channel.memberCount, equals(42)); }, ); test( - 'should update membership user when any event containing user is updated', + 'should update channel memberCount from member.added and member.removed events', () 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( + // Test with member.added event - count increases + final memberAddedEvent = Event( cid: channel.cid, - type: EventType.any, - user: updatedUser, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 1, ); - // Dispatch event - client.addEvent(anyEvent); - - // Wait for the event to be processed + client.addEvent(memberAddedEvent); await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(1)); + expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-1'])); - // 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, + // Test with another member.added event - count increases + final memberAddedEvent2 = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-2', + user: User(id: 'user-2'), ), + channelMemberCount: 2, ); - // Wait for the event to get processed + client.addEvent(memberAddedEvent2); await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 3); + expect(channel.memberCount, equals(2)); expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), + channel.state?.channelState.members?.map((it) => it.userId), + equals(['user-1', 'user-2']), ); - }, - ); - - 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'), + // Test with member.removed event - count decreases + final memberRemovedEvent = Event( + cid: channel.cid, + type: EventType.memberRemoved, + user: User(id: 'user-1'), + channelMemberCount: 1, ); - // Then stops watching (count = 1). - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStop, - user: watcher, - watcherCount: 1, - ), - ); + client.addEvent(memberRemovedEvent); await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 1); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - isNot(contains('watcher-1')), - ); + expect(channel.memberCount, equals(1)); + expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-2'])); }, ); test( - 'watching event without watcherCount preserves the existing count', + 'should preserve other channel properties when updating memberCount', () async { - // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), + // Set initial channel state with some properties + final initialChannel = channel.state?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + messageCount: 7, + frozen: true, ); - 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'), + if (initialChannel != null) { + channel.state?.updateChannelState( + channel.state!.channelState.copyWith(channel: initialChannel), + ); + } + + // Verify initial state + expect(channel.name, 'Test Channel'); + expect(channel.messageCount, equals(7)); + expect(channel.frozen, equals(true)); + expect(channel.memberCount, equals(0)); + + // Update memberCount via event + final memberCountEvent = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), ), + channelMemberCount: 100, ); + + client.addEvent(memberCountEvent); await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 5); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-2'), - ); + // Verify memberCount was updated while preserving other properties + expect(channel.memberCount, equals(100)); + expect(channel.name, 'Test Channel'); + expect(channel.messageCount, equals(7)); + expect(channel.frozen, equals(true)); }, ); test( - '${EventType.messageNew} updates watcherCount from the event', + 'should not update memberCount when the event omits channelMemberCount', () async { - expect(channel.state!.watcherCount, isNull); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: DateTime.now(), - ); - + // Seed a known member count. client.addEvent( Event( cid: channel.cid, - type: EventType.messageNew, - message: message, - watcherCount: 7, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 5, ), ); - 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); + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(5)); - // A local/optimistic message.new without watcher_count must not - // reset the count. + // An event without the field should leave the count untouched. client.addEvent( Event( cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'test-message-id-2', - user: client.state.currentUser, - createdAt: DateTime.now(), + type: EventType.memberAdded, + member: Member( + userId: 'user-2', + user: User(id: 'user-2'), ), ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 4); + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(5)); }, ); test( - '${EventType.notificationMessageNew} does not overwrite watcherCount', + 'should provide memberCountStream for reactive updates', () async { - // Seed a known watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), - ); - expect(channel.state!.watcherCount, 5); + final emitted = []; + final subscription = channel.memberCountStream.listen(emitted.add); + addTearDown(subscription.cancel); + await Future.delayed(Duration.zero); - // notification.message_new is delivered to non-watchers and reports - // watcher_count: 0; it must not clobber the real count. - client.addEvent( - Event( + // Update memberCount multiple times, repeating one of the counts. + final counts = [1, 5, 5, 10]; + for (final (index, count) in counts.indexed) { + final event = Event( cid: channel.cid, - type: EventType.notificationMessageNew, - message: Message( - id: 'notif-message-id', - user: User(id: 'other-user'), - createdAt: DateTime.now(), + type: EventType.memberAdded, + member: Member( + userId: 'user-$index', + user: User(id: 'user-$index'), ), - watcherCount: 0, - ), - ); - await Future.delayed(Duration.zero); + channelMemberCount: count, + ); - expect(channel.state!.watcherCount, 5); + client.addEvent(event); + await Future.delayed(Duration.zero); + } + + // The repeated count should not be emitted twice. + expect(emitted, equals([0, 1, 5, 10])); }, ); }); + }); - 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); + group('Channel filterTags', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - // 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); + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); }); - 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); - }, + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - 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); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); - // 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 return filterTags from channel state', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], ); - 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); - }); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - 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); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); + }); - // 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', - ); + test('should update filterTags when channel state is updated', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], + ); - // Dispatch event - client.addEvent(markUnreadEvent); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - // Wait for event to be processed - await Future.delayed(Duration.zero); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - // 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); - }, + final updatedChannel = channelModel.copyWith( + filterTags: ['tag3', 'tag4', 'tag5'], ); - 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], - ), - ); + testChannel.state?.updateChannelState( + testChannel.state!.channelState.copyWith(channel: updatedChannel), + ); - // 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'); + expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); + }); + }); - // 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', - ); + group('Typing Indicator', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); - // Dispatch event - client.addEvent(messageReadEvent); + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); - // 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'); - }, + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - 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('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'; - 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 { - final emitted = []; - final subscription = channel.messageCountStream.listen(emitted.add); - addTearDown(subscription.cancel); - await Future.delayed(Duration.zero); - - // Update messageCount multiple times, repeating one of the counts. - final counts = [1, 5, 5, 10]; - for (final (index, count) in counts.indexed) { - final event = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'msg-$index', - text: 'Message $count', - user: User(id: 'user-1'), - ), - channelMessageCount: count, - ); - - client.addEvent(event); - await Future.delayed(Duration.zero); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([null, 1, 5, 10])); - }, - ); - }); - - group('Channel member 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 memberCount when event contains channelMemberCount', - () async { - // Verify initial state - default memberCount - expect(channel.memberCount, equals(0)); - - // Create event with channelMemberCount - final memberCountEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 42, - ); - - // Dispatch event - client.addEvent(memberCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel memberCount was updated - expect(channel.memberCount, equals(42)); - }, - ); - - test( - 'should update channel memberCount from member.added and member.removed events', - () async { - // Test with member.added event - count increases - final memberAddedEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 1, - ); - - client.addEvent(memberAddedEvent); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(1)); - expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-1'])); - - // Test with another member.added event - count increases - final memberAddedEvent2 = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), - channelMemberCount: 2, - ); - - client.addEvent(memberAddedEvent2); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(2)); - expect( - channel.state?.channelState.members?.map((it) => it.userId), - equals(['user-1', 'user-2']), - ); - - // Test with member.removed event - count decreases - final memberRemovedEvent = Event( - cid: channel.cid, - type: EventType.memberRemoved, - user: User(id: 'user-1'), - channelMemberCount: 1, - ); - - client.addEvent(memberRemovedEvent); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(1)); - expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-2'])); - }, - ); - - test( - 'should preserve other channel properties when updating memberCount', - () async { - // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - messageCount: 7, - frozen: true, - ); - - if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.channelState.copyWith(channel: initialChannel), - ); - } - - // Verify initial state - expect(channel.name, 'Test Channel'); - expect(channel.messageCount, equals(7)); - expect(channel.frozen, equals(true)); - expect(channel.memberCount, equals(0)); - - // Update memberCount via event - final memberCountEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 100, - ); - - client.addEvent(memberCountEvent); - await Future.delayed(Duration.zero); - - // Verify memberCount was updated while preserving other properties - expect(channel.memberCount, equals(100)); - expect(channel.name, 'Test Channel'); - expect(channel.messageCount, equals(7)); - expect(channel.frozen, equals(true)); - }, - ); - - test( - 'should not update memberCount when the event omits channelMemberCount', - () async { - // Seed a known member count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 5, - ), - ); - - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); - - // An event without the field should leave the count untouched. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), - ), - ); - - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); - }, - ); - - test( - 'should provide memberCountStream for reactive updates', - () async { - final emitted = []; - final subscription = channel.memberCountStream.listen(emitted.add); - addTearDown(subscription.cancel); - await Future.delayed(Duration.zero); - - // Update memberCount multiple times, repeating one of the counts. - final counts = [1, 5, 5, 10]; - for (final (index, count) in counts.indexed) { - final event = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-$index', - user: User(id: 'user-$index'), - ), - channelMemberCount: count, - ); - - client.addEvent(event); - await Future.delayed(Duration.zero); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([0, 1, 5, 10])); - }, - ); - }); - }); - - 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(); - - 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); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); // client logger - 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, - ); - - verify( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).called(1); - }, - ); - - test( - ".markUnreadByTimestamp 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 timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - await expectLater( - channel.markUnreadByTimestamp(timestamp), - throwsA(isA()), - ); - }, - ); - - test( - '.markUnreadByTimestamp 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 timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - when( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markUnreadByTimestamp(timestamp), - completes, - ); - - verify( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).called(1); - }, - ); - - test( - ".markThreadRead 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.markThreadRead('thread-id-123'), - throwsA(isA()), - ); - }, - ); + 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); }, ); }); @@ -11971,386 +6684,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'); - }, - ); - }); } diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart index 27a3b823ac..8a82ea566b 100644 --- a/packages/stream_chat/test/src/matchers.dart +++ b/packages/stream_chat/test/src/matchers.dart @@ -1,6 +1,6 @@ import 'package:collection/collection.dart'; import 'package:dio/dio.dart' show MultipartFile; -import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/channel/channel.dart'; import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/draft_message.dart'; diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 848f26a48d..e248766ac3 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/channel/channel.dart'; import 'package:stream_chat/src/client/channel_delivery_reporter.dart'; import 'package:stream_chat/src/client/client.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; From cf3cadaf09802a0600ae14fedb8657c2be240370 Mon Sep 17 00:00:00 2001 From: Petar Velikov Date: Mon, 7 Sep 2026 19:40:48 +0200 Subject: [PATCH 6/7] fix(llc): replace live location polling with one-shot expiry timers (#2863) * fix(llc): replace live location polling with one-shot expiry timers Co-Authored-By: Claude Opus 4.8 * fix(llc): schedule expiry for live locations already expired on arrival Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 --- packages/stream_chat/CHANGELOG.md | 1 + .../client/channel/channel_client_state.dart | 67 +++++---- .../stream_chat/lib/src/client/client.dart | 55 ++++--- .../live_location_expiration_scheduler.dart | 124 ++++++++++++++++ .../channel/channel_client_state_test.dart | 79 ++++++++++ .../test/src/client/client_test.dart | 34 +++++ ...ve_location_expiration_scheduler_test.dart | 135 ++++++++++++++++++ 7 files changed, 430 insertions(+), 65 deletions(-) create mode 100644 packages/stream_chat/lib/src/client/live_location_expiration_scheduler.dart create mode 100644 packages/stream_chat/test/src/client/live_location_expiration_scheduler_test.dart diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index fe911ca2db..68347485da 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -3,6 +3,7 @@ 🐞 Fixed - Fixed `CurrentPlatform` throwing `UnimplementedError` on WebAssembly builds. +- Fixed live location expiry emitting repeated `location.expired` events for the same expired location. ## 10.4.0 diff --git a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart index 3e1b3a3624..6abd2a3ab6 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart @@ -7,6 +7,7 @@ import 'package:rxdart/rxdart.dart'; import '../../../stream_chat.dart'; import '../../core/util/utils.dart'; +import '../live_location_expiration_scheduler.dart'; import '../retry_queue.dart'; /// The class that handles the state of the channel listening to the events. @@ -99,7 +100,7 @@ class ChannelClientState { _startCleaningStalePinnedMessages(); - _startCleaningExpiredLocations(); + _startSchedulingLocationExpiration(); _listenChannelPushPreferenceUpdated(); @@ -1601,40 +1602,38 @@ class ChannelClientState { ); } - 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, - ), - ), - ); + late final _locationExpirationScheduler = LiveLocationExpirationScheduler( + onExpired: _handleLocationExpired, + ); - _channel.client.handleEvent(locationExpiredEvent); - } - }, + // Listens for changes to the active live locations and (re)schedules the + // one-shot expiry timers accordingly. + void _startSchedulingLocationExpiration() { + _subscriptions.add( + activeLiveLocationsStream.listen(_locationExpirationScheduler.schedule), + ); + } + + // Emits a synthetic `location.expired` event for the expired [location]. + void _handleLocationExpired(Location location) { + // The current user's own live locations are handled by the client-level + // scheduler in [ClientState]; skip them here. + final currentUserId = _channel.client.state.currentUser?.id; + if (currentUserId == null || location.userId == currentUserId) return; + + final lastUpdatedAt = DateTime.timestamp(); + + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: location.channelCid, + message: Message( + id: location.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: location.copyWith(updatedAt: lastUpdatedAt), + ), ); + + _channel.client.handleEvent(locationExpiredEvent); } // Listens to channel push preference update events and updates the state @@ -2134,7 +2133,7 @@ class ChannelClientState { _threadsController.close(); _staleTypingEventsCleanerTimer?.cancel(); _stalePinnedMessagesCleanerTimer?.cancel(); - _staleLiveLocationsCleanerTimer?.cancel(); + _locationExpirationScheduler.cancel(); _typingEventsController.close(); } } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 4bc98f992e..b979af801b 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -57,6 +57,7 @@ import '../ws/websocket.dart'; import 'channel/channel.dart'; import 'channel_delivery_reporter.dart'; import 'event_resolvers.dart' as event_resolvers; +import 'live_location_expiration_scheduler.dart'; import 'query_channels_result.dart'; import 'retry_policy.dart'; @@ -2654,8 +2655,6 @@ class ClientState { _listenLocationUpdated(); _listenLocationExpired(); // endregion - - _startCleaningExpiredLocations(); } /// Stops listening to the client events. @@ -2843,34 +2842,25 @@ class ClientState { ); } - Timer? _staleLiveLocationsCleanerTimer; - void _startCleaningExpiredLocations() { - _staleLiveLocationsCleanerTimer?.cancel(); - _staleLiveLocationsCleanerTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - final expired = activeLiveLocations.where((it) => it.isExpired); - if (expired.isEmpty) return; - - for (final sharedLocation in expired) { - 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, - ), - ), - ); + late final _locationExpirationScheduler = LiveLocationExpirationScheduler( + onExpired: _handleLocationExpired, + ); - _client.handleEvent(locationExpiredEvent); - } - }, + // Emits a synthetic `location.expired` event for the expired [location]. + void _handleLocationExpired(Location location) { + final lastUpdatedAt = DateTime.timestamp(); + + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: location.channelCid, + message: Message( + id: location.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: location.copyWith(updatedAt: lastUpdatedAt), + ), ); + + _client.handleEvent(locationExpiredEvent); } final StreamChatClient _client; @@ -2920,8 +2910,11 @@ class ClientState { @internal set activeLiveLocations(List locations) { // For safe-keeping, we filter out any inactive locations before update. - final activeLocations = locations.where((it) => it.isActive); - _activeLiveLocationsController.safeAdd(activeLocations.toList()); + final activeLocations = locations.where((it) => it.isActive).toList(); + _activeLiveLocationsController.safeAdd(activeLocations); + + // Reschedule the expiry timers for the updated set of active locations. + _locationExpirationScheduler.schedule(activeLocations); } /// The current unread channels count @@ -3009,7 +3002,7 @@ class ClientState { _unreadThreadsController.close(); _totalUnreadCountController.close(); _activeLiveLocationsController.close(); - _staleLiveLocationsCleanerTimer?.cancel(); + _locationExpirationScheduler.cancel(); _channelsController.close(); for (final channel in channels.values) { diff --git a/packages/stream_chat/lib/src/client/live_location_expiration_scheduler.dart b/packages/stream_chat/lib/src/client/live_location_expiration_scheduler.dart new file mode 100644 index 0000000000..ad66ddf55f --- /dev/null +++ b/packages/stream_chat/lib/src/client/live_location_expiration_scheduler.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import '../core/models/location.dart'; + +/// {@template liveLocationExpirationScheduler} +/// Schedules a one-shot [Timer] per live [Location] that fires once at the +/// location's `endAt`, invoking [onExpired] for that location. +/// +/// Timers are keyed by message id and only (re)scheduled when a location's +/// `endAt` changes, so coordinate-only updates leave the existing timers +/// untouched. +/// {@endtemplate} +class LiveLocationExpirationScheduler { + /// {@macro liveLocationExpirationScheduler} + LiveLocationExpirationScheduler({required this.onExpired}); + + /// Called once when a scheduled live location reaches its `endAt`. + final void Function(Location location) onExpired; + + // The currently running expiry timers, keyed by message id. + final _scheduledTimers = {}; + + /// Reconciles the running expiry timers with the given live [locations]. + /// + /// Timers for locations that are no longer present are cancelled; the rest + /// are reused when their `endAt` is unchanged (refreshing the coordinates) or + /// (re)armed when new or when their `endAt` changed. + void schedule(Iterable locations) { + // The live locations that should have an expiry timer, keyed by message id. + final locationsToSchedule = { + for (final location in locations) + if (_shouldSchedule(location)) location.messageId!: location, + }; + + _cancelTimersForRemovedLocations(locationsToSchedule); + for (final entry in locationsToSchedule.entries) { + _reuseOrRescheduleTimer(entry.key, entry.value); + } + } + + /// Cancels all scheduled expiry timers. + void cancel() { + for (final scheduled in _scheduledTimers.values) { + scheduled.timer.cancel(); + } + _scheduledTimers.clear(); + } + + // Whether the [location] is a live location that should get an expiry timer. + // + // Already-expired locations are scheduled too: their timer fires on the next + // macrotask, so a location that is past its endAt by the local clock when it + // arrives still emits exactly one expiration. + bool _shouldSchedule(Location location) { + return location.isLive && location.messageId != null; + } + + // Cancels the timers of locations that are no longer scheduled. + void _cancelTimersForRemovedLocations( + Map locationsToSchedule, + ) { + _scheduledTimers.removeWhere((messageId, scheduled) { + if (locationsToSchedule.containsKey(messageId)) return false; + scheduled.timer.cancel(); + return true; + }); + } + + // Reuses the existing timer when the [location]'s expiry time is unchanged, + // otherwise cancels the stale timer and arms a fresh one. + void _reuseOrRescheduleTimer(String messageId, Location location) { + final scheduled = _scheduledTimers[messageId]; + + // Same expiry time: keep the timer, just refresh the coordinates so an + // expiry reports the latest position. + if (scheduled != null && !_endAtChanged(location, scheduled)) { + scheduled.location = location; + return; + } + + // New location, or its expiry time changed: arm a fresh timer. + scheduled?.timer.cancel(); + _scheduledTimers[messageId] = _ScheduledExpiration( + location, + _createTimer(messageId, location.endAt!), + ); + } + + // Whether the [location]'s `endAt` differs from the one currently scheduled. + bool _endAtChanged(Location location, _ScheduledExpiration scheduled) { + return location.endAt != scheduled.location.endAt; + } + + Timer _createTimer(String messageId, DateTime endAt) { + final delay = endAt.difference(DateTime.now()); + return Timer( + delay.isNegative ? Duration.zero : delay, + () => _onTimerFired(messageId), + ); + } + + void _onTimerFired(String messageId) { + final scheduled = _scheduledTimers[messageId]; + if (scheduled == null) return; + + final location = scheduled.location; + // Re-check against the current clock; re-arm rather than fire if it isn't + // actually expired yet (e.g. the clock moved back or `endAt` was extended). + if (!location.isExpired) { + scheduled.timer = _createTimer(messageId, location.endAt!); + return; + } + + _scheduledTimers.remove(messageId); + onExpired(location); + } +} + +class _ScheduledExpiration { + _ScheduledExpiration(this.location, this.timer); + + Location location; + Timer timer; +} diff --git a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart index c7b5114328..3f0221fda2 100644 --- a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart @@ -2994,6 +2994,85 @@ void main() { expect(channel.state?.activeLiveLocations, isEmpty); }); + test("should auto-expire another user's live location once at endAt", () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', // Another user. + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(milliseconds: 800)), + ); + + channel.state?.addNewMessage( + Message(id: 'msg1', sharedLocation: liveLocation), + ); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Before endAt no expiry event is emitted. + await Future.delayed(const Duration(milliseconds: 200)); + verifyNever(() => client.handleEvent(any())); + + // After endAt the scheduler emits exactly one location.expired event. + await Future.delayed(const Duration(milliseconds: 900)); + final captured = verify(() => client.handleEvent(captureAny())).captured; + expect(captured, hasLength(1)); + final event = captured.single as Event; + expect(event.type, EventType.locationExpired); + expect(event.message?.id, 'msg1'); + }); + + test("should not auto-expire the current user's own live location", () async { + final ownLocation = Location( + channelCid: channel.cid, + userId: 'test-user-id', // The current user (handled by the client). + messageId: 'msg-own', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(milliseconds: 150)), + ); + + channel.state?.addNewMessage( + Message(id: 'msg-own', sharedLocation: ownLocation), + ); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // The channel scheduler skips the current user's own locations, so no + // expiry event is emitted even after endAt passes. + await Future.delayed(const Duration(milliseconds: 300)); + verifyNever(() => client.handleEvent(any())); + }); + + test("should auto-expire another user's location that arrives expired", () async { + final expiredLocation = Location( + channelCid: channel.cid, + userId: 'user1', // Another user. + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().subtract(const Duration(minutes: 5)), + ); + + // Mirrors a query/watch response whose live location is already past + // endAt by the local clock, e.g. when the device clock runs ahead of + // the server or endAt passed while the response was in flight. + channel.state?.updateChannelState( + ChannelState(messages: const [], activeLiveLocations: [expiredLocation]), + ); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // The scheduler fires straight away and emits exactly one event. + await Future.delayed(const Duration(milliseconds: 100)); + final captured = verify(() => client.handleEvent(captureAny())).captured; + expect(captured, hasLength(1)); + final event = captured.single as Event; + expect(event.type, EventType.locationExpired); + expect(event.message?.id, 'msg1'); + }); + test('should not add static location to active locations', () async { final staticLocation = Location( channelCid: channel.cid, diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 5d7ae8e9d4..d5dc7a52f6 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -4254,6 +4254,40 @@ void main() { expect(client.state.activeLiveLocations, isEmpty); }); + test('should auto-expire an active live location once at endAt', () async { + final expiredEvents = []; + final sub = client.on(EventType.locationExpired).listen(expiredEvents.add); + addTearDown(sub.cancel); + + // Setting an active location schedules a one-shot expiry timer. + client.state.activeLiveLocations = [ + Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(milliseconds: 800)), + ), + ]; + expect(client.state.activeLiveLocations, hasLength(1)); + + // Before endAt nothing is emitted and the location stays active. + await delay(200); + expect(expiredEvents, isEmpty); + expect(client.state.activeLiveLocations, hasLength(1)); + + // After endAt the timer fires once and the location is removed. + await delay(900); + expect(expiredEvents, hasLength(1)); + expect(client.state.activeLiveLocations, isEmpty); + + // The timer is one-shot: no further events are emitted. + await delay(300); + expect(expiredEvents, hasLength(1)); + }); + test('should ignore location events for other users', () async { final location = Location( channelCid: 'test-channel:123', diff --git a/packages/stream_chat/test/src/client/live_location_expiration_scheduler_test.dart b/packages/stream_chat/test/src/client/live_location_expiration_scheduler_test.dart new file mode 100644 index 0000000000..b86097b49f --- /dev/null +++ b/packages/stream_chat/test/src/client/live_location_expiration_scheduler_test.dart @@ -0,0 +1,135 @@ +import 'package:stream_chat/src/client/live_location_expiration_scheduler.dart'; +import 'package:stream_chat/src/core/models/location.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Location _location({ + String? messageId = 'msg1', + String userId = 'user1', + double latitude = 40.7128, + double longitude = -74.0060, + Duration? endsIn = const Duration(milliseconds: 200), +}) { + return Location( + channelCid: 'messaging:123', + messageId: messageId, + userId: userId, + latitude: latitude, + longitude: longitude, + endAt: endsIn == null ? null : DateTime.now().add(endsIn), + ); +} + +void main() { + group('LiveLocationExpirationScheduler', () { + late List expired; + late LiveLocationExpirationScheduler scheduler; + + setUp(() { + expired = []; + scheduler = LiveLocationExpirationScheduler(onExpired: expired.add); + }); + + tearDown(() => scheduler.cancel()); + + test('fires onExpired exactly once at endAt', () async { + scheduler.schedule([_location(endsIn: const Duration(milliseconds: 800))]); + + // Before endAt nothing fires. + await delay(200); + expect(expired, isEmpty); + + // After endAt it fires exactly once. + await delay(900); + expect(expired, hasLength(1)); + expect(expired.single.messageId, 'msg1'); + + // The timer is one-shot: no repeats even after more time passes. + await delay(300); + expect(expired, hasLength(1)); + }); + + test('ignores static and message-id-less locations', () async { + scheduler.schedule([ + _location(messageId: 'static', endsIn: null), + _location(messageId: null), + ]); + + await delay(150); + expect(expired, isEmpty); + }); + + test('fires once for a location already expired when scheduled', () async { + // A live location can already be past its endAt by the local clock when + // it arrives, e.g. when the device clock runs ahead of the server or + // endAt passed while the response was in flight. + scheduler.schedule([_location(endsIn: const Duration(milliseconds: -1))]); + + await delay(150); + expect(expired, hasLength(1)); + expect(expired.single.messageId, 'msg1'); + }); + + test('reports latest coordinates and keeps expiry time on update', () async { + // A moving live location updates its coordinates but keeps the same + // endAt. + final original = _location( + latitude: 1, + longitude: 1, + endsIn: const Duration(milliseconds: 800), + ); + scheduler.schedule([original]); + + await delay(200); + // Same messageId and endAt, only the coordinates differ. + final moved = original.copyWith(latitude: 2, longitude: 2); + scheduler.schedule([moved]); + + // Still fires once at the original endAt (the update didn't shift it)... + await delay(850); + expect(expired, hasLength(1)); + // ...and reports the latest coordinates, not the original ones. + expect(expired.single.latitude, 2); + }); + + test('reschedules when endAt changes', () async { + final original = _location(endsIn: const Duration(milliseconds: 1500)); + scheduler.schedule([original]); + + await delay(300); + final rescheduled = original.copyWith( + endAt: DateTime.now().add(const Duration(milliseconds: 500)), + ); + scheduler.schedule([rescheduled]); + + // Fires at the new (earlier) endAt, before the original one. + await delay(700); + expect(expired, hasLength(1)); + expect(expired.single.endAt, rescheduled.endAt); + + // The original timer was cancelled, so it never fires. + await delay(700); + expect(expired, hasLength(1)); + }); + + test('does not fire for a location removed before expiry', () async { + scheduler.schedule([_location(endsIn: const Duration(milliseconds: 1000))]); + + await delay(250); + scheduler.schedule([]); // Removed from the active set. + + await delay(1050); + expect(expired, isEmpty); + }); + + test('cancel() prevents pending timers from firing', () async { + scheduler + ..schedule([_location(endsIn: const Duration(milliseconds: 200))]) + ..cancel(); + + await delay(300); + expect(expired, isEmpty); + }); + }); +} From 37ea912bfb931ebfe68ef7201b630eb96b447bbc Mon Sep 17 00:00:00 2001 From: Petar Velikov Date: Mon, 7 Sep 2026 20:07:05 +0200 Subject: [PATCH 7/7] test(llc): expand channel event handling coverage (#2942) Co-authored-by: Claude Opus 5 --- .../channel/channel_client_state_test.dart | 1009 ++++++++++++++++- packages/stream_chat/test/src/fakes.dart | 3 + 2 files changed, 1010 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart index 3f0221fda2..bd5f271622 100644 --- a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart @@ -42,6 +42,80 @@ void main() { ).thenAnswer((_) async {}); }); + group('Typing 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('${EventType.typingStart} from another user is added to typingEvents', () async { + final otherUser = User(id: 'other-user'); + + client.addEvent( + Event(cid: channel.cid, type: EventType.typingStart, user: otherUser), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.typingEvents.keys.map((u) => u.id), ['other-user']); + expect(channel.state!.typingEvents[otherUser]?.type, EventType.typingStart); + }); + + test('${EventType.typingStop} removes only the stopping user', () async { + final user1 = User(id: 'other-user-1'); + final user2 = User(id: 'other-user-2'); + + client.addEvent(Event(cid: channel.cid, type: EventType.typingStart, user: user1)); + client.addEvent(Event(cid: channel.cid, type: EventType.typingStart, user: user2)); + await Future.delayed(Duration.zero); + expect(channel.state!.typingEvents, hasLength(2)); + + client.addEvent(Event(cid: channel.cid, type: EventType.typingStop, user: user1)); + await Future.delayed(Duration.zero); + + expect(channel.state!.typingEvents.keys.map((u) => u.id), ['other-user-2']); + }); + + test('${EventType.typingStart} from the current user is ignored', () async { + final currentUser = User(id: client.state.currentUser!.id); + + client.addEvent(Event(cid: channel.cid, type: EventType.typingStart, user: currentUser)); + await Future.delayed(Duration.zero); + + expect(channel.state!.typingEvents, isEmpty); + }); + + test('${EventType.typingStart} without a user is ignored', () async { + client.addEvent(Event(cid: channel.cid, type: EventType.typingStart)); + await Future.delayed(Duration.zero); + + expect(channel.state!.typingEvents, isEmpty); + }); + + test('${EventType.typingStop} from the current user is ignored', () async { + final currentUser = User(id: client.state.currentUser!.id); + + client.addEvent(Event(cid: channel.cid, type: EventType.typingStop, user: currentUser)); + await Future.delayed(Duration.zero); + + expect(channel.state!.typingEvents, isEmpty); + }); + + test('${EventType.typingStop} without a user is ignored', () async { + client.addEvent(Event(cid: channel.cid, type: EventType.typingStop)); + await Future.delayed(Duration.zero); + + expect(channel.state!.typingEvents, isEmpty); + }); + }); + group( '${EventType.messageNew} or ${EventType.notificationMessageNew}', () { @@ -540,6 +614,76 @@ void main() { expect(channel.state!.messages, hasLength(2)); }, ); + + test( + '${EventType.notificationMessageNew} adds the message and counts unread', + () async { + final message = Message( + id: 'notified-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMessageNew, + message: message, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages.map((m) => m.id), ['notified-message-id']); + expect(channel.state!.unreadCount, 1); + }, + ); + + test( + 'a channel message is not appended while the channel is not up to date', + () async { + channel.state!.isUpToDate = false; + + final message = Message( + id: 'below-window-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + client.addEvent(createNewMessageEvent(message)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.unreadCount, 1); + }, + ); + + test( + 'a thread-only reply is stored even while the channel is not up to date', + () async { + channel.state!.isUpToDate = false; + + final reply = Message( + id: 'thread-reply-id', + parentId: 'parent-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + client.addEvent(createNewMessageEvent(reply)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.threads['parent-message-id']?.map((m) => m.id), ['thread-reply-id']); + }, + ); + + test('an event without a message is ignored', () async { + client.addEvent(Event(cid: channel.cid, type: EventType.messageNew)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.lastMessageAt, initialLastMessageAt); + }); }, ); @@ -1062,8 +1206,478 @@ void main() { expect(stored.pollId, poll.id); }, ); - }, - ); + }, + ); + + group('Reaction events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageId = 'reaction-message-id'; + final createdAt = DateTime.now(); + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + Message seedMessage({String? parentId, List ownReactions = const []}) { + final message = Message( + id: messageId, + parentId: parentId, + user: User(id: 'other-user'), + text: 'react to me', + createdAt: createdAt, + ownReactions: ownReactions, + ); + channel.state!.updateMessage(message); + return message; + } + + test('${EventType.reactionNew} from the current user is added to ownReactions', () async { + seedMessage(); + + final reaction = Reaction( + type: 'like', + messageId: messageId, + user: client.state.currentUser, + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionNew, + reaction: reaction, + message: Message( + id: messageId, + user: User(id: 'other-user'), + createdAt: createdAt, + latestReactions: [reaction], + ), + ), + ); + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }); + + test('${EventType.reactionNew} updates a thread message', () async { + const parentId = 'reaction-parent-id'; + seedMessage(parentId: parentId); + + final reaction = Reaction( + type: 'like', + messageId: messageId, + user: client.state.currentUser, + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionNew, + reaction: reaction, + message: Message( + id: messageId, + parentId: parentId, + user: User(id: 'other-user'), + createdAt: createdAt, + ), + ), + ); + await Future.delayed(Duration.zero); + + final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }); + + test('${EventType.reactionUpdated} from the current user replaces ownReactions', () async { + final existing = Reaction( + type: 'love', + messageId: messageId, + user: client.state.currentUser, + ); + seedMessage(ownReactions: [existing]); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionUpdated, + reaction: Reaction( + type: 'like', + messageId: messageId, + user: client.state.currentUser, + ), + message: Message( + id: messageId, + user: User(id: 'other-user'), + createdAt: createdAt, + ), + ), + ); + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == messageId); + expect(stored.ownReactions?.map((r) => r.type), ['like']); + }); + + test('${EventType.reactionDeleted} updates a thread message', () async { + const parentId = 'reaction-parent-id'; + final removed = Reaction( + type: 'love', + messageId: messageId, + user: client.state.currentUser, + ); + seedMessage(parentId: parentId, ownReactions: [removed]); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionDeleted, + reaction: removed, + message: Message( + id: messageId, + parentId: parentId, + user: User(id: 'other-user'), + createdAt: createdAt, + ), + ), + ); + await Future.delayed(Duration.zero); + + final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == messageId); + expect(stored.ownReactions, isEmpty); + }); + }); + + group('Poll events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const pollMessageId = 'poll-message-id'; + const pollId = 'poll-id'; + final createdAt = DateTime.now(); + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + Poll createPoll({ + String name = 'Favorite color?', + List latestAnswers = const [], + List ownVotesAndAnswers = const [], + }) { + return Poll( + id: pollId, + name: name, + options: const [ + PollOption(id: 'option-a', text: 'A'), + PollOption(id: 'option-b', text: 'B'), + ], + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + } + + Message seedPollMessage({ + String? parentId, + List latestAnswers = const [], + List ownVotesAndAnswers = const [], + }) { + final message = Message( + id: pollMessageId, + parentId: parentId, + user: User(id: 'other-user'), + createdAt: createdAt, + poll: createPoll( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ), + ); + channel.state!.updateMessage(message); + return message; + } + + Message storedPollMessage() { + return channel.state!.messages.firstWhere((it) => it.id == pollMessageId); + } + + test('${EventType.pollCreated} adds the poll message', () async { + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollCreated, + message: Message( + id: pollMessageId, + user: User(id: 'other-user'), + createdAt: createdAt, + poll: createPoll(), + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(storedPollMessage().poll?.id, pollId); + }); + + test('${EventType.pollCreated} without a poll is ignored', () async { + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollCreated, + message: Message( + id: pollMessageId, + user: User(id: 'other-user'), + createdAt: createdAt, + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + }); + + test('${EventType.pollUpdated} updates the poll but preserves own votes', () async { + final ownVote = PollVote( + id: 'own-vote-id', + optionId: 'option-a', + userId: client.state.currentUser!.id, + ); + seedPollMessage(ownVotesAndAnswers: [ownVote]); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollUpdated, + poll: createPoll(name: 'Renamed'), + ), + ); + await Future.delayed(Duration.zero); + + final stored = storedPollMessage(); + expect(stored.poll?.name, 'Renamed'); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + }); + + test('${EventType.pollUpdated} for an unknown poll is ignored', () async { + seedPollMessage(); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollUpdated, + poll: Poll( + id: 'unknown-poll-id', + name: 'Renamed', + options: const [PollOption(text: 'A')], + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(storedPollMessage().poll?.name, 'Favorite color?'); + }); + + test('${EventType.pollUpdated} without a poll is ignored', () async { + seedPollMessage(); + + client.addEvent(Event(cid: channel.cid, type: EventType.pollUpdated)); + await Future.delayed(Duration.zero); + + expect(storedPollMessage().poll?.name, 'Favorite color?'); + }); + + test('${EventType.pollUpdated} updates a poll on a thread message', () async { + const parentId = 'poll-parent-id'; + seedPollMessage(parentId: parentId); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollUpdated, + poll: createPoll(name: 'Renamed'), + ), + ); + await Future.delayed(Duration.zero); + + final stored = channel.state!.threads[parentId]!.firstWhere((it) => it.id == pollMessageId); + expect(stored.poll?.name, 'Renamed'); + }); + + test('${EventType.pollClosed} closes the poll and keeps the cached data', () async { + seedPollMessage(); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollClosed, + poll: createPoll(name: 'Renamed'), + ), + ); + await Future.delayed(Duration.zero); + + final stored = storedPollMessage(); + expect(stored.poll?.isClosed, isTrue); + expect(stored.poll?.name, 'Favorite color?'); + }); + + test('${EventType.pollAnswerCasted} adds own answers only for the current user', () async { + seedPollMessage(); + + final ownAnswer = PollVote( + id: 'own-answer-id', + answerText: 'my answer', + userId: client.state.currentUser!.id, + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollAnswerCasted, + poll: createPoll(), + pollVote: ownAnswer, + ), + ); + await Future.delayed(Duration.zero); + + var stored = storedPollMessage(); + expect(stored.poll?.latestAnswers.map((v) => v.id), ['own-answer-id']); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-answer-id']); + + final otherAnswer = PollVote( + id: 'other-answer-id', + answerText: 'their answer', + userId: 'other-user', + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollAnswerCasted, + poll: createPoll(), + pollVote: otherAnswer, + ), + ); + await Future.delayed(Duration.zero); + + stored = storedPollMessage(); + expect(stored.poll?.latestAnswers.map((v) => v.id), contains('other-answer-id')); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-answer-id']); + }); + + test('${EventType.pollVoteCasted} adds own votes only for the current user', () async { + seedPollMessage(); + + final ownVote = PollVote( + id: 'own-vote-id', + optionId: 'option-a', + userId: client.state.currentUser!.id, + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollVoteCasted, + poll: createPoll(), + pollVote: ownVote, + ), + ); + await Future.delayed(Duration.zero); + + var stored = storedPollMessage(); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + + final otherVote = PollVote( + id: 'other-vote-id', + optionId: 'option-b', + userId: 'other-user', + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollVoteCasted, + poll: createPoll(), + pollVote: otherVote, + ), + ); + await Future.delayed(Duration.zero); + + stored = storedPollMessage(); + expect(stored.poll?.ownVotesAndAnswers.map((v) => v.id), ['own-vote-id']); + }); + + test('${EventType.pollVoteChanged} upserts the current user vote', () async { + seedPollMessage(); + + final changedVote = PollVote( + id: 'changed-vote-id', + optionId: 'option-b', + userId: client.state.currentUser!.id, + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollVoteChanged, + poll: createPoll(), + pollVote: changedVote, + ), + ); + await Future.delayed(Duration.zero); + + expect( + storedPollMessage().poll?.ownVotesAndAnswers.map((v) => v.id), + contains('changed-vote-id'), + ); + }); + + test('${EventType.pollAnswerRemoved} removes the answer from both lists', () async { + final answer = PollVote( + id: 'answer-id', + answerText: 'my answer', + userId: client.state.currentUser!.id, + ); + seedPollMessage(latestAnswers: [answer], ownVotesAndAnswers: [answer]); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollAnswerRemoved, + poll: createPoll(), + pollVote: answer, + ), + ); + await Future.delayed(Duration.zero); + + final stored = storedPollMessage(); + expect(stored.poll?.latestAnswers, isEmpty); + expect(stored.poll?.ownVotesAndAnswers, isEmpty); + }); + + test('${EventType.pollVoteRemoved} removes the vote from own votes', () async { + final vote = PollVote( + id: 'vote-id', + optionId: 'option-a', + userId: client.state.currentUser!.id, + ); + seedPollMessage(ownVotesAndAnswers: [vote]); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.pollVoteRemoved, + poll: createPoll(), + pollVote: vote, + ), + ); + await Future.delayed(Duration.zero); + + expect(storedPollMessage().poll?.ownVotesAndAnswers, isEmpty); + }); + }); // A `message.deleted` event for a message outside the loaded window // must not upsert a "deleted" record into the sorted list — that would @@ -1244,9 +1858,184 @@ void main() { }, ); }); + + test('an in-window hard delete removes the message', () async { + final message = Message( + id: 'doomed-message-id', + user: User(id: 'other-user'), + createdAt: DateTime.now(), + ); + channel.state!.updateMessage(message); + expect(channel.state!.messages, hasLength(1)); + + client.addEvent(createDeleteMessageEvent(message, hardDelete: true)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + }); + + test('deletedForMe is propagated to the stored message', () async { + final message = Message( + id: 'deleted-for-me-message-id', + user: User(id: 'other-user'), + createdAt: DateTime.now(), + ); + channel.state!.updateMessage(message); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageDeleted, + deletedForMe: true, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + ), + ); + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.single; + expect(stored.deletedForMe, isTrue); + expect(stored.type, MessageType.deleted); + }); }, ); + group('Channel updated 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('merges the event channel into the current channel model', () async { + client.addEvent( + Event( + cid: channel.cid, + type: EventType.channelUpdated, + channel: ChannelModel( + id: channelId, + type: channelType, + memberCount: 42, + extraData: const {'name': 'updated-name'}, + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.memberCount, 42); + expect(channel.extraData['name'], 'updated-name'); + }); + + test('replaces the member list with the event members', () async { + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + members: [ + Member(userId: 'member-1'), + Member(userId: 'member-2'), + ], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.channelUpdated, + channel: ChannelModel( + id: channelId, + type: channelType, + members: [Member(userId: 'member-3')], + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.map((m) => m.userId), ['member-3']); + }); + }); + + group('Channel truncated events', () { + 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.deleteMessageByCid(any())).thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => {}); + + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + // Reset so the remaining groups run without a persistence layer. + when(() => client.chatPersistenceClient).thenReturn(null); + }); + + Message seedMessage(String id) { + final message = Message( + id: id, + user: User(id: 'other-user'), + text: 'to be truncated', + createdAt: DateTime.now(), + ); + channel.state!.updateMessage(message); + return message; + } + + test('${EventType.channelTruncated} clears messages and wipes persistence', () async { + seedMessage('truncated-message-1'); + seedMessage('truncated-message-2'); + expect(channel.state!.messages, hasLength(2)); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.channelTruncated, + channel: ChannelModel(id: channelId, type: channelType), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + verify(() => persistenceClient.deleteMessageByCid(channel.cid!)).called(1); + }); + + test('${EventType.notificationChannelTruncated} keeps the event system message', () async { + seedMessage('truncated-message-1'); + + final systemMessage = Message( + id: 'system-message-id', + type: MessageType.system, + text: 'Channel truncated', + createdAt: DateTime.now(), + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationChannelTruncated, + channel: ChannelModel(id: channelId, type: channelType), + message: systemMessage, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages.map((m) => m.id), ['system-message-id']); + }); + }); + group('Member Events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; @@ -1356,6 +2145,166 @@ void main() { expect(channel.membership?.user?.role, equals(updatedUser?.role)); }, ); + + test('${EventType.memberAdded} appends the member', () async { + client.addEvent( + Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member(userId: 'new-member'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.map((m) => m.userId), ['new-member']); + }); + + test('${EventType.memberRemoved} removes the member', () async { + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + members: [ + Member(userId: 'member-1'), + Member(userId: 'member-2'), + ], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.memberRemoved, + user: User(id: 'member-1'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.map((m) => m.userId), ['member-2']); + }); + + test('${EventType.memberUpdated} replaces the member entry', () async { + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + members: [Member(userId: 'member-1', channelRole: 'channel_member')], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.memberUpdated, + member: Member(userId: 'member-1', channelRole: 'channel_moderator'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.single.channelRole, 'channel_moderator'); + }); + + test('an event user that is not a member is ignored', () async { + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + members: [ + Member( + userId: 'member-1', + user: User(id: 'member-1', name: 'old-name'), + ), + ], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userUpdated, + user: User(id: 'stranger', name: 'new-name'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.single.user?.name, 'old-name'); + }); + + group('user banned/unbanned events', () { + setUp(() { + clearInteractions(client); + + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + members: [ + Member( + userId: 'bad-user', + user: User(id: 'bad-user'), + channelRole: 'channel_member', + ), + ], + ), + ); + + when( + () => client.queryMembers( + channelType, + channelId: channelId, + filter: Filter.equal('id', 'bad-user'), + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryMembersResponse()..members = [Member(userId: 'bad-user', channelRole: 'channel_banned')], + ); + }); + + test('${EventType.userBanned} refreshes the member from the server', () async { + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userBanned, + user: User(id: 'bad-user'), + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.single.channelRole, 'channel_banned'); + }); + + test('${EventType.userUnbanned} refreshes the member from the server', () async { + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userUnbanned, + user: User(id: 'bad-user'), + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.single.channelRole, 'channel_banned'); + }); + + test('an app-level ban without a cid is ignored', () async { + client.addEvent( + Event( + type: EventType.userBanned, + user: User(id: 'bad-user'), + ), + ); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.members?.single.channelRole, 'channel_member'); + verifyNever( + () => client.queryMembers( + any(), + channelId: any(named: 'channelId'), + filter: any(named: 'filter'), + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ); + }); + }); }); group('Watching Events', () { @@ -1548,6 +2497,31 @@ void main() { expect(channel.state!.watcherCount, 5); }, ); + + test('${EventType.userWatchingStop} without a watcher count preserves the count', () async { + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStart, + user: User(id: 'watcher-1'), + watcherCount: 5, + ), + ); + await Future.delayed(Duration.zero); + expect(channel.state!.watcherCount, 5); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStop, + user: User(id: 'watcher-1'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.watchers, isEmpty); + expect(channel.state!.watcherCount, 5); + }); }); group('Read Events', () { @@ -2487,6 +3461,13 @@ void main() { expect(threadDraft?.message.text, 'updated thread reply'); }, ); + + test('an event without a draft is ignored', () async { + client.addEvent(Event(cid: channel.cid, type: EventType.draftUpdated)); + await Future.delayed(Duration.zero); + + expect(channel.state?.draft, isNull); + }); }); group('Reminder events', () { @@ -2823,6 +3804,23 @@ void main() { ); expect(updatedMessage?.reminder, isNull); }); + + test('an event without a reminder is ignored', () async { + const messageId = 'test-message-id'; + + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + ); + channel.state?.updateMessage(message); + + client.addEvent(Event(cid: channel.cid, type: EventType.reminderCreated)); + await Future.delayed(Duration.zero); + + final storedMessage = channel.state?.messages.firstWhere((m) => m.id == messageId); + expect(storedMessage?.reminder, isNull); + }); }); group('Location events', () { @@ -3475,6 +4473,13 @@ void main() { updatedPushPreference.disabledUntil, ); }); + + test('an event without a push preference is ignored', () async { + client.addEvent(Event(cid: channel.cid, type: EventType.channelPushPreferenceUpdated)); + await Future.delayed(Duration.zero); + + expect(channel.state?.channelState.pushPreferences, isNull); + }); }); group('User messages deleted event', () { diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index 6c2794b87c..3a919eb879 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -176,6 +176,9 @@ class FakeClientState extends Fake implements ClientState { ); } + @override + Map get users => const {}; + @override void updateUser(User? user) { if (user == null) return;