diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index ff35082b85..a57eb6914b 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,9 +1,21 @@ ## 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` 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`. + ⚠️ Deprecated - Deprecated `StreamChatClient.unflagMessage` and `StreamChatClient.unflagUser`. The `/moderation/unflag` endpoint is no longer supported by the server and the calls have no effect; both methods will be removed in a future major release. +🐞 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. + ## 9.28.0 ✅ Added diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 3ed717da9a..5acf7bea6c 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -207,7 +207,9 @@ 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. @@ -219,7 +221,9 @@ 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. @@ -231,7 +235,9 @@ 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. @@ -244,7 +250,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. @@ -257,7 +263,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. @@ -269,7 +275,9 @@ 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 @@ -281,7 +289,9 @@ 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. @@ -322,7 +332,9 @@ 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. @@ -334,7 +346,9 @@ 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(List? messages) { @@ -387,7 +401,9 @@ 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. @@ -399,7 +415,9 @@ 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. @@ -411,7 +429,9 @@ 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. @@ -429,7 +449,9 @@ 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. @@ -496,7 +518,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. @@ -511,7 +533,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. @@ -2246,7 +2268,7 @@ class ChannelClientState { _listenChannelUpdated(); - _listenChannelMessageCount(); + _listenChannelCounts(); _listenMemberAdded(); @@ -2381,15 +2403,21 @@ 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, ), ), @@ -3265,7 +3293,7 @@ class ChannelClientState { /// Channel watcher count as a stream. Stream get watcherCountStream => - channelStateStream.map((cs) => cs.watcherCount); + channelStateStream.map((cs) => cs.watcherCount).distinct(); /// Channel watchers list. List get watchers => (_channelState.watchers ?? []) diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index dfdfb33984..c30f355b44 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2588,19 +2588,22 @@ 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/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 4b1b672691..91788b929d 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -43,6 +43,7 @@ class Event { this.reminder, this.pushPreference, this.channelPushPreference, + this.channelMemberCount, this.channelMessageCount, this.watcherCount, this.lastDeliveredAt, @@ -166,6 +167,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; @@ -222,6 +230,7 @@ class Event { 'reminder', 'push_preference', 'channel_push_preference', + 'channel_member_count', 'channel_message_count', 'watcher_count', 'last_delivered_at', @@ -269,6 +278,7 @@ class Event { MessageReminder? reminder, PushPreference? pushPreference, ChannelPushPreference? channelPushPreference, + int? channelMemberCount, int? channelMessageCount, int? watcherCount, DateTime? lastDeliveredAt, @@ -311,6 +321,7 @@ class Event { 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 863de9844c..ab7624c818 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -76,6 +76,7 @@ Event _$EventFromJson(Map json) => Event( ? null : 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 @@ -130,6 +131,8 @@ Map _$EventToJson(Event instance) => { 'push_preference': value, if (instance.channelPushPreference?.toJson() case final value?) 'channel_push_preference': value, + if (instance.channelMemberCount case final value?) + 'channel_member_count': value, if (instance.channelMessageCount case final value?) 'channel_message_count': value, if (instance.watcherCount case final value?) 'watcher_count': value, diff --git a/packages/stream_chat/test/fixtures/event.json b/packages/stream_chat/test/fixtures/event.json index b164b3aa59..b0429f5490 100644 --- a/packages/stream_chat/test/fixtures/event.json +++ b/packages/stream_chat/test/fixtures/event.json @@ -31,5 +31,6 @@ "unread_thread_messages": 2, "unread_threads": 3, "channel_last_message_at": "2019-03-27T17:40:17.155892Z", - "watcher_count": 12 + "watcher_count": 12, + "channel_member_count": 4 } \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 87ddcbcc13..58d19a0acf 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -7604,19 +7604,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'), ), @@ -7626,6 +7626,217 @@ 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])); + }, + ); + }); + + 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])); }, ); }); diff --git a/packages/stream_chat/test/src/core/models/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart index ab5c78d2fd..b430a27ab8 100644 --- a/packages/stream_chat/test/src/core/models/event_test.dart +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -20,6 +20,7 @@ void main() { expect(event.unreadThreads, 3); expect(event.channelLastMessageAt, isA()); expect(event.watcherCount, 12); + expect(event.channelMemberCount, 4); expect(event.lastReadAt, null); expect(event.unreadMessages, null); expect(event.lastReadMessageId, null); @@ -64,6 +65,7 @@ void main() { unreadThreads: 3, channelLastMessageAt: DateTime.parse('2019-03-27T17:40:17.155892Z'), watcherCount: 9, + channelMemberCount: 4, lastReadAt: DateTime.parse('2020-02-10T10:00:00.000Z'), unreadMessages: 5, lastReadMessageId: 'last-read-message-id', @@ -109,6 +111,7 @@ void main() { 'unread_thread_messages': 2, 'unread_threads': 3, 'channel_last_message_at': '2019-03-27T17:40:17.155892Z', + 'channel_member_count': 4, 'watcher_count': 9, 'last_read_at': '2020-02-10T10:00:00.000Z', 'unread_messages': 5, @@ -149,6 +152,7 @@ void main() { expect(newEvent.unreadThreads, 3); expect(newEvent.channelLastMessageAt, isA()); expect(newEvent.watcherCount, 12); + expect(newEvent.channelMemberCount, 4); expect(newEvent.lastReadAt, null); expect(newEvent.unreadMessages, null); expect(newEvent.lastReadMessageId, null); @@ -176,6 +180,7 @@ void main() { unreadThreads: 7, channelLastMessageAt: DateTime.parse('2020-01-29T03:22:47.636130Z'), watcherCount: 21, + channelMemberCount: 8, lastReadAt: DateTime.parse('2020-02-10T10:00:00.000000Z'), unreadMessages: 5, lastReadMessageId: 'last-read-message-id', @@ -202,6 +207,7 @@ void main() { ); expect(newEvent.unreadMessages, 5); expect(newEvent.watcherCount, 21); + expect(newEvent.channelMemberCount, 8); expect(newEvent.lastReadMessageId, 'last-read-message-id'); expect(newEvent.draft, isNotNull); expect(newEvent.draft, equals(draft)); diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index 7eac9585aa..e9a64f3f84 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -612,7 +612,7 @@ class StreamMessageInputState extends State _ => channel.state?.draftStream, }; - _draftStreamSubscription = draftStream?.distinct().listen(_onDraftUpdate); + _draftStreamSubscription = draftStream?.listen(_onDraftUpdate); } }