From 11154dfd495687da2416b4cdfc7e518656bbddc4 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 14 Aug 2026 11:20:45 +0100 Subject: [PATCH 1/5] fix(core): sort channels with disposed state last instead of crashing (#2887) --- .../stream_chat_flutter_core/CHANGELOG.md | 6 ++++ .../src/stream_channel_list_controller.dart | 13 +++++-- .../stream_channel_list_controller_test.dart | 35 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) 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; From b67ecbc8512d7742072efba4a12c4c2fde7912d9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 14 Aug 2026 13:41:13 +0200 Subject: [PATCH 2/5] fix(llc, ui): Issues found by the e2e suites Ports the two applicable fixes from #2874 to v9. `ChannelClientState.updateThreadInfo` no longer merges the parent message into a thread's reply list. A backend returning the root alongside the replies made the thread render it twice, since the parent is drawn from its own copy. `ChannelLastMessageText` drops its preserved last-known message when the row is rebound to a different channel, or when `lastMessagePredicate` stops accepting the cached message. List rows are unkeyed, so a reorder rebinds the same State to another channel and the cache could surface the previous channel's message as the fallback for an empty, not-yet-up-to-date one. FLU-669 from that PR does not apply: v9 already reads the thread-reply label from `threadReplyCountText`; the hardcoded string it fixes was introduced by master's design refresh. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_chat/CHANGELOG.md | 6 + .../stream_chat/lib/src/client/channel.dart | 6 +- .../test/src/client/channel_test.dart | 26 +++++ packages/stream_chat_flutter/CHANGELOG.md | 6 + .../stream_channel_list_tile.dart | 47 +++++--- .../stream_channel_list_tile_test.dart | 109 ++++++++++++++++++ 6 files changed, 185 insertions(+), 15 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index d79a8dd8ba..8a3ff56cec 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +🐞 Fixed + +- 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 ✅ Added diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index de0d4df28b..67bdf3f939 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -3478,7 +3478,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, @@ -3486,7 +3488,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 6504547537..59bd27c17d 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/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 92f15c5b1c..e8a8304828 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +🐞 Fixed + +- Fixed a channel-list row previewing another channel's last message: list items are unkeyed, so a reorder rebinds the same state to a different channel and the preserved last-known message was still used as a fallback. The cache is now dropped when the row is rebound, or when the `lastMessagePredicate` stops accepting the cached message. + ## 9.27.0 ✅ Added diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart index 1c7ad7bc67..fd8b97b838 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart @@ -395,6 +395,39 @@ class ChannelLastMessageText extends StatefulWidget { class _ChannelLastMessageTextState extends State { Message? _currentLastMessage; + ChannelClientState? _currentChannelState; + + /// Returns the newest message in [messages] passing the predicate, or `null` + /// when there is nothing to preview. + /// + /// While the channel is not up to date (e.g. Channel.query(idAround:) + /// truncates state mid-load), falls back to the last message seen while it + /// was, so the preview still shows the actual latest message. + Message? _resolveLastMessage( + ChannelClientState channelState, + List messages, + ) { + final predicate = widget.lastMessagePredicate; + + // List items are unkeyed, so reordering rebinds this State to another + // channel. Drop the cache so the previous channel's message can never be + // used as a fallback for this one. + if (_currentChannelState != channelState) { + _currentChannelState = channelState; + _currentLastMessage = null; + } + + // The predicate can change while the cache is held; a message it no longer + // accepts must not come back as the fallback. + if (_currentLastMessage case final cached? when !predicate(cached)) { + _currentLastMessage = null; + } + + final message = messages.lastWhereOrNull(predicate); + if (!channelState.isUpToDate) return [message, _currentLastMessage].latest; + + return _currentLastMessage = message; + } @override Widget build(BuildContext context) { @@ -420,19 +453,7 @@ class _ChannelLastMessageTextState extends State { } // Otherwise, show the channel last message if it exists. - final message = messages.lastWhereOrNull(widget.lastMessagePredicate); - // `_currentLastMessage` holds the most recent message seen while the - // channel has the latest messages (isUpToDate). - // While isUpToDate is false (e.g. Channel.query(idAround:) truncates - // state mid-load), fall back to it so the preview shows the actual - // latest message. - final Message? latestLastMessage; - if (channelState.isUpToDate) { - latestLastMessage = message; - _currentLastMessage = latestLastMessage; - } else { - latestLastMessage = [message, _currentLastMessage].latest; - } + final latestLastMessage = _resolveLastMessage(channelState, messages); if (latestLastMessage == null) { return Text( diff --git a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart index ade8e13f15..43276afeaf 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart @@ -280,6 +280,115 @@ void main() { }, ); + testWidgets( + "rebinding to a not-up-to-date empty channel shows that channel's " + 'empty state', + (tester) async { + // Same State reused across channels, but B is still loading, so the + // preserve-last-known fallback kicks in. It must not fall back to a + // message belonging to channel A. + final other = User(id: 'other'); + final msgA = Message(id: 'ma', text: 'channel-a-message', user: other); + + when(() => channelState.isUpToDate).thenReturn(true); + when(() => channelState.messages).thenReturn([msgA]); + when(() => channelState.messagesStream) + .thenAnswer((_) => Stream.value([msgA])); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelLastMessageText(channel: channel), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('channel-a-message'), findsOneWidget); + + final channelB = MockChannel(id: 'b', type: 'messaging'); + final channelStateB = MockChannelState(); + when(() => channelB.client).thenReturn(client); + when(() => channelB.state).thenReturn(channelStateB); + when(() => channelStateB.draft).thenReturn(null); + when(() => channelStateB.draftStream) + .thenAnswer((_) => Stream.value(null)); + when(() => channelStateB.channelState).thenReturn(const ChannelState()); + when(() => channelStateB.messages).thenReturn([]); + when(() => channelStateB.messagesStream) + .thenAnswer((_) => Stream.value([])); + // B has not caught up yet, so the fallback is live. + when(() => channelStateB.isUpToDate).thenReturn(false); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelLastMessageText(channel: channelB), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('channel-a-message'), findsNothing); + expect(find.text(emptyText), findsOneWidget); + }, + ); + + testWidgets( + 'drops the cached message once the predicate stops accepting it', + (tester) async { + final message = Message( + text: 'no longer previewable', + user: User(id: 'other'), + createdAt: DateTime(2024), + ); + + Future pumpWithPredicate(bool Function(Message) predicate) { + return tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelLastMessageText( + channel: channel, + lastMessagePredicate: predicate, + ), + ), + ), + ), + ); + } + + when(() => channelState.isUpToDate).thenReturn(true); + when(() => channelState.messages).thenReturn([message]); + when(() => channelState.messagesStream) + .thenAnswer((_) => Stream.value([message])); + + await pumpWithPredicate((_) => true); + await tester.pumpAndSettle(); + + expect(find.text('no longer previewable'), findsOneWidget); + + // The channel starts reloading, so the cache is what the preview would + // fall back to — but the new predicate rejects the cached message. + when(() => channelState.isUpToDate).thenReturn(false); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream) + .thenAnswer((_) => Stream.value([])); + + await pumpWithPredicate((_) => false); + await tester.pumpAndSettle(); + + expect(find.text('no longer previewable'), findsNothing); + }, + ); + testWidgets( 'shows the empty-state when the channel is truncated while up-to-date', (tester) async { From 3d0648b3eb8201edbacc64d7461c7b38e42f287c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 14 Aug 2026 15:43:50 +0200 Subject: [PATCH 3/5] style(ui): use // for the private last-message resolver doc Private members take // block comments in this repo, not ///. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_channel_list_tile.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart index fd8b97b838..d0b12d4796 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart @@ -397,12 +397,12 @@ class _ChannelLastMessageTextState extends State { Message? _currentLastMessage; ChannelClientState? _currentChannelState; - /// Returns the newest message in [messages] passing the predicate, or `null` - /// when there is nothing to preview. - /// - /// While the channel is not up to date (e.g. Channel.query(idAround:) - /// truncates state mid-load), falls back to the last message seen while it - /// was, so the preview still shows the actual latest message. + // Returns the newest message in [messages] passing the widget's + // lastMessagePredicate, or `null` when there is nothing to preview. + // + // While the channel is not up to date (e.g. Channel.query(idAround:) + // truncates state mid-load), falls back to the last message seen while it + // was, so the preview still shows the actual latest message. Message? _resolveLastMessage( ChannelClientState channelState, List messages, From c67dbd00b99cc264c62c9e5f30a1f118486829c1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 14 Aug 2026 15:47:42 +0200 Subject: [PATCH 4/5] revert(ui): drop the FLU-686 channel-preview cache guards from v9 Keeping that fix on master only. Reverts `stream_chat_flutter` to v9 as-is; the FLU-668 (llc) and FLU-692 (core) fixes are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 4 - .../stream_chat_flutter/devtools_options.yaml | 3 + .../example/devtools_options.yaml | 3 + .../xcshareddata/swiftpm/Package.resolved | 76 ++++++++++++ .../stream_channel_list_tile.dart | 47 +++----- .../stream_channel_list_tile_test.dart | 109 ------------------ 6 files changed, 95 insertions(+), 147 deletions(-) create mode 100644 packages/stream_chat_flutter/devtools_options.yaml create mode 100644 packages/stream_chat_flutter/example/devtools_options.yaml create mode 100644 packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 0a46769077..abdbff2fd7 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -4,10 +4,6 @@ - Raised the minimum `rate_limiter` version to `^1.1.1`. -🐞 Fixed - -- Fixed a channel-list row previewing another channel's last message: list items are unkeyed, so a reorder rebinds the same state to a different channel and the preserved last-known message was still used as a fallback. The cache is now dropped when the row is rebound, or when the `lastMessagePredicate` stops accepting the cached message. - ## 9.27.0 ✅ Added diff --git a/packages/stream_chat_flutter/devtools_options.yaml b/packages/stream_chat_flutter/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/packages/stream_chat_flutter/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/packages/stream_chat_flutter/example/devtools_options.yaml b/packages/stream_chat_flutter/example/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/packages/stream_chat_flutter/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..929cf79623 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,76 @@ +{ + "pins" : [ + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simolus3/CSQLite.git", + "state" : { + "revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62" + } + }, + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart index d0b12d4796..1c7ad7bc67 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart @@ -395,39 +395,6 @@ class ChannelLastMessageText extends StatefulWidget { class _ChannelLastMessageTextState extends State { Message? _currentLastMessage; - ChannelClientState? _currentChannelState; - - // Returns the newest message in [messages] passing the widget's - // lastMessagePredicate, or `null` when there is nothing to preview. - // - // While the channel is not up to date (e.g. Channel.query(idAround:) - // truncates state mid-load), falls back to the last message seen while it - // was, so the preview still shows the actual latest message. - Message? _resolveLastMessage( - ChannelClientState channelState, - List messages, - ) { - final predicate = widget.lastMessagePredicate; - - // List items are unkeyed, so reordering rebinds this State to another - // channel. Drop the cache so the previous channel's message can never be - // used as a fallback for this one. - if (_currentChannelState != channelState) { - _currentChannelState = channelState; - _currentLastMessage = null; - } - - // The predicate can change while the cache is held; a message it no longer - // accepts must not come back as the fallback. - if (_currentLastMessage case final cached? when !predicate(cached)) { - _currentLastMessage = null; - } - - final message = messages.lastWhereOrNull(predicate); - if (!channelState.isUpToDate) return [message, _currentLastMessage].latest; - - return _currentLastMessage = message; - } @override Widget build(BuildContext context) { @@ -453,7 +420,19 @@ class _ChannelLastMessageTextState extends State { } // Otherwise, show the channel last message if it exists. - final latestLastMessage = _resolveLastMessage(channelState, messages); + final message = messages.lastWhereOrNull(widget.lastMessagePredicate); + // `_currentLastMessage` holds the most recent message seen while the + // channel has the latest messages (isUpToDate). + // While isUpToDate is false (e.g. Channel.query(idAround:) truncates + // state mid-load), fall back to it so the preview shows the actual + // latest message. + final Message? latestLastMessage; + if (channelState.isUpToDate) { + latestLastMessage = message; + _currentLastMessage = latestLastMessage; + } else { + latestLastMessage = [message, _currentLastMessage].latest; + } if (latestLastMessage == null) { return Text( diff --git a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart index 43276afeaf..ade8e13f15 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart @@ -280,115 +280,6 @@ void main() { }, ); - testWidgets( - "rebinding to a not-up-to-date empty channel shows that channel's " - 'empty state', - (tester) async { - // Same State reused across channels, but B is still loading, so the - // preserve-last-known fallback kicks in. It must not fall back to a - // message belonging to channel A. - final other = User(id: 'other'); - final msgA = Message(id: 'ma', text: 'channel-a-message', user: other); - - when(() => channelState.isUpToDate).thenReturn(true); - when(() => channelState.messages).thenReturn([msgA]); - when(() => channelState.messagesStream) - .thenAnswer((_) => Stream.value([msgA])); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: Scaffold( - body: ChannelLastMessageText(channel: channel), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('channel-a-message'), findsOneWidget); - - final channelB = MockChannel(id: 'b', type: 'messaging'); - final channelStateB = MockChannelState(); - when(() => channelB.client).thenReturn(client); - when(() => channelB.state).thenReturn(channelStateB); - when(() => channelStateB.draft).thenReturn(null); - when(() => channelStateB.draftStream) - .thenAnswer((_) => Stream.value(null)); - when(() => channelStateB.channelState).thenReturn(const ChannelState()); - when(() => channelStateB.messages).thenReturn([]); - when(() => channelStateB.messagesStream) - .thenAnswer((_) => Stream.value([])); - // B has not caught up yet, so the fallback is live. - when(() => channelStateB.isUpToDate).thenReturn(false); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: Scaffold( - body: ChannelLastMessageText(channel: channelB), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('channel-a-message'), findsNothing); - expect(find.text(emptyText), findsOneWidget); - }, - ); - - testWidgets( - 'drops the cached message once the predicate stops accepting it', - (tester) async { - final message = Message( - text: 'no longer previewable', - user: User(id: 'other'), - createdAt: DateTime(2024), - ); - - Future pumpWithPredicate(bool Function(Message) predicate) { - return tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: Scaffold( - body: ChannelLastMessageText( - channel: channel, - lastMessagePredicate: predicate, - ), - ), - ), - ), - ); - } - - when(() => channelState.isUpToDate).thenReturn(true); - when(() => channelState.messages).thenReturn([message]); - when(() => channelState.messagesStream) - .thenAnswer((_) => Stream.value([message])); - - await pumpWithPredicate((_) => true); - await tester.pumpAndSettle(); - - expect(find.text('no longer previewable'), findsOneWidget); - - // The channel starts reloading, so the cache is what the preview would - // fall back to — but the new predicate rejects the cached message. - when(() => channelState.isUpToDate).thenReturn(false); - when(() => channelState.messages).thenReturn([]); - when(() => channelState.messagesStream) - .thenAnswer((_) => Stream.value([])); - - await pumpWithPredicate((_) => false); - await tester.pumpAndSettle(); - - expect(find.text('no longer previewable'), findsNothing); - }, - ); - testWidgets( 'shows the empty-state when the channel is truncated while up-to-date', (tester) async { From 7a3582c2bf64efa5016c7c737448bbd3c94ee235 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 14 Aug 2026 15:48:03 +0200 Subject: [PATCH 5/5] chore: drop accidentally committed local tooling artifacts devtools_options.yaml and swiftpm Package.resolved are generated by a local pub get; they were swept into the previous commit by a broad git add. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_chat_flutter/devtools_options.yaml | 3 - .../example/devtools_options.yaml | 3 - .../xcshareddata/swiftpm/Package.resolved | 76 ------------------- 3 files changed, 82 deletions(-) delete mode 100644 packages/stream_chat_flutter/devtools_options.yaml delete mode 100644 packages/stream_chat_flutter/example/devtools_options.yaml delete mode 100644 packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/packages/stream_chat_flutter/devtools_options.yaml b/packages/stream_chat_flutter/devtools_options.yaml deleted file mode 100644 index fa0b357c4f..0000000000 --- a/packages/stream_chat_flutter/devtools_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: This file stores settings for Dart & Flutter DevTools. -documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -extensions: diff --git a/packages/stream_chat_flutter/example/devtools_options.yaml b/packages/stream_chat_flutter/example/devtools_options.yaml deleted file mode 100644 index fa0b357c4f..0000000000 --- a/packages/stream_chat_flutter/example/devtools_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: This file stores settings for Dart & Flutter DevTools. -documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -extensions: diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 929cf79623..0000000000 --- a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,76 +0,0 @@ -{ - "pins" : [ - { - "identity" : "csqlite", - "kind" : "remoteSourceControl", - "location" : "https://github.com/simolus3/CSQLite.git", - "state" : { - "revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62" - } - }, - { - "identity" : "dkcamera", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKCamera", - "state" : { - "branch" : "master", - "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" - } - }, - { - "identity" : "dkimagepickercontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKImagePickerController", - "state" : { - "branch" : "4.3.9", - "revision" : "0bdfeacefa308545adde07bef86e349186335915" - } - }, - { - "identity" : "dkphotogallery", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKPhotoGallery", - "state" : { - "branch" : "master", - "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" - } - }, - { - "identity" : "libwebp-xcode", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", - "state" : { - "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", - "version" : "1.5.0" - } - }, - { - "identity" : "sdwebimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImage", - "state" : { - "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", - "version" : "5.21.7" - } - }, - { - "identity" : "swiftygif", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kirualex/SwiftyGif.git", - "state" : { - "revision" : "4430cbc148baa3907651d40562d96325426f409a", - "version" : "5.4.5" - } - }, - { - "identity" : "tocropviewcontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/TimOliver/TOCropViewController", - "state" : { - "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", - "version" : "2.8.0" - } - } - ], - "version" : 2 -}