diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 4d32b273e9..eb62b11076 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed `StreamChatClient.sync` letting a failure from its final `updateLastSyncAt` write escape to the caller; it is now handled by the method's own error handling, like every other sync failure. - Fixed `ChannelClientState` no longer handling `notification.mark_read` (regression since 9.20.0), which left `unreadCount` stale after `Channel.markRead` on channels the user isn't watching. +- Fixed `Channel.getReplies` adding the parent message to `ChannelClientState.threads` when a backend returns it alongside the replies, which rendered the thread root twice. The online path now filters it out, matching the offline one. ## 9.27.0 diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index c624dc7404..9e9906238c 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3480,7 +3480,9 @@ class ChannelClientState { List messages, { bool upsert = true, }) { - var messagesToMerge = messages; + // The parent is rendered in its own slot, so keep it out of the reply list + // even when a backend returns it alongside the replies. + var messagesToMerge = messages.where((it) => it.id != parentId).toList(); if (!upsert) { final existingThread = threads[parentId]; // Don't create a phantom entry for a thread that was never paged in, @@ -3488,7 +3490,7 @@ class ChannelClientState { if (existingThread == null) return; final existingIds = {for (final m in existingThread) m.id}; messagesToMerge = - messages.where((m) => existingIds.contains(m.id)).toList(); + messagesToMerge.where((m) => existingIds.contains(m.id)).toList(); if (messagesToMerge.isEmpty) return; } diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 4ec423f686..ef499e6e2c 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -3112,6 +3112,32 @@ void main() { verify(() => client.getReplies(parentId)).called(1); }); + test('`.getReplies` keeps the parent message out of the thread', () async { + const parentId = 'test-parent-id'; + + // Some backends return the parent as the first message of the oldest + // page. It is rendered from its own copy, so it must not also become a + // reply — otherwise the thread shows its root twice. + final messages = [ + Message(id: parentId), + ...List.generate( + 3, + (index) => Message(id: 'test-message-id-$index', parentId: parentId), + ), + ]; + + when(() => client.getReplies(parentId)).thenAnswer( + (_) async => QueryRepliesResponse()..messages = messages, + ); + + await channel.getReplies(parentId); + + final threadMessages = channel.state!.threads[parentId]; + expect(threadMessages, isNotNull); + expect(threadMessages!.length, messages.length - 1); + expect(threadMessages.any((it) => it.id == parentId), isFalse); + }); + test('`.getReactions`', () async { const messageId = 'test-message-id'; diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index d881a09a0c..83079b3d1a 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +🐞 Fixed + +- Fixed `StreamChannelListController` crashing with a null-check error when its local sort ran over a channel disposed mid-query (e.g. a client disconnect/logout racing an in-flight `loadMore`); such channels are now sorted last instead. + ## 9.27.0 ✅ Added diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index 3d385845c8..9dc1e5e993 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -155,8 +155,17 @@ class StreamChannelListController extends PagedValueNotifier { orElse: () => newValue, (success) => success.copyWith( items: success.items.sortedByCompare( - (it) => it.state!.channelState, - channelSort.compare, + // A channel loses its state when it is disposed — e.g. a client + // disconnect/logout or a channel-removal event racing an + // in-flight query — so sort stateless channels last instead of + // null-asserting on them. + (it) => it.state?.channelState, + (a, b) => switch ((a, b)) { + (null, null) => 0, + (null, _) => 1, + (_, null) => -1, + (final a?, final b?) => channelSort.compare(a, b), + }, ), ), ), diff --git a/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart index 8cea4c160c..4f07243894 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart @@ -285,6 +285,41 @@ void main() { }, ); + test( + 'local sort places channels with disposed state last instead of crashing', + () { + ChannelState channelStateFor({required DateTime createdAt}) => ChannelState( + channel: ChannelModel( + cid: 'messaging:${createdAt.millisecondsSinceEpoch}', + createdAt: createdAt, + ), + ); + + final older = MockChannel(); + when(() => older.state.channelState) + .thenReturn(channelStateFor(createdAt: DateTime(2026, 1, 1))); + + final newer = MockChannel(); + when(() => newer.state.channelState) + .thenReturn(channelStateFor(createdAt: DateTime(2026, 6, 1))); + + // A channel disposed while a query is in flight (client disconnect or + // logout, a channel-removal event) has its state nulled out. + final disposed = NonInitializedMockChannel(); + + final controller = StreamChannelListController( + client: client, + channelStateSort: defaultChannelListSort, + ); + + expect( + () => controller.value = + PagedValue(items: [disposed, older, newer]), + returnsNormally, + ); + expect(controller.value.asSuccess.items, equals([newer, older, disposed])); + }); + group('Event handling', () { late StreamController eventController;