From 8b291036ca8dd1c75ffc6f34e49511f5f3572081 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 25 Jun 2026 20:08:13 +0200 Subject: [PATCH 1/4] fix(ui): Fix `shadowed` messages not hidden in the channel list --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../stream_channel_list_item.dart | 26 ++- .../stream_channel_list_item_test.dart | 185 ++++++++++++++++++ 3 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 734db1228..0003eb1fa 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,6 +3,7 @@ 🐛 Fixed - Fixed a use-after-dispose race condition in `StreamAttachmentPickerController`, `StreamAudioRecorderController`, and `StreamAudioPlaylistController`: async methods could write `value` after `dispose()`, causing a `notifyListeners()` assertion throw in debug mode. All three now use the `DisposeAwareValueNotifier` mixin from `stream_chat_flutter_core`. +- Fixed shadowed messages not hidden in channel list items. ✅ Added diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart index baba31acf..cddd8f0b7 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -584,7 +584,10 @@ class _ChannelLastMessageWithStatus extends StatefulWidget { class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithStatus> { Message? _currentLastMessage; - static bool _defaultLastMessagePredicate(Message message) { + bool _defaultLastMessagePredicate(Message message) { + final currentUserId = widget.channel.client.state.currentUser?.id; + final isMyMessage = currentUserId != null && message.user?.id == currentUserId; + if (message.shadowed && !isMyMessage) return false; if (message.isShadowed) return false; if (message.isError) return false; if (message.isEphemeral) return false; @@ -675,11 +678,13 @@ class ChannelLastMessageText extends StatefulWidget { super.key, required this.channel, this.textStyle, - this.lastMessagePredicate = _defaultLastMessagePredicate, + bool Function(Message)? lastMessagePredicate, }) : assert( channel.state != null, 'Channel ${channel.id} is not initialized', - ); + ), + lastMessagePredicate = + lastMessagePredicate ?? _defaultLastMessagePredicate(channel.client.state.currentUser?.id); /// The channel to display the last message of. final Channel channel; @@ -696,12 +701,15 @@ class ChannelLastMessageText extends StatefulWidget { // The default predicate to determine if the message should be // considered for the last message. - static bool _defaultLastMessagePredicate(Message message) { - if (message.isShadowed) return false; - if (message.isError) return false; - if (message.isEphemeral) return false; - - return true; + static bool Function(Message) _defaultLastMessagePredicate(String? currentUserId) { + return (Message message) { + final isMyMessage = currentUserId != null && message.user?.id == currentUserId; + if (message.shadowed && !isMyMessage) return false; + if (message.isShadowed) return false; + if (message.isError) return false; + if (message.isEphemeral) return false; + return true; + }; } @override diff --git a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart new file mode 100644 index 000000000..f40c24ea3 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../../mocks.dart'; + +void main() { + const currentUserId = 'me'; + + late MockClient client; + late MockClientState clientState; + late MockChannel channel; + late MockChannelState channelState; + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + channel = MockChannel(); + channelState = MockChannelState(); + + final currentUser = OwnUser(id: currentUserId, name: 'Me'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream) + .thenAnswer((_) => Stream.value(currentUser)); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channelState.draft).thenReturn(null); + when(() => channelState.channelState).thenReturn(const ChannelState()); + }); + + Future pumpWithMessages( + WidgetTester tester, + List messages, + ) async { + when(() => channelState.messages).thenReturn(messages); + when(() => channelState.messagesStream) + .thenAnswer((_) => Stream.value(messages)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelLastMessageText(channel: channel), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('shows the latest message text as preview', (tester) async { + final message = Message( + text: 'hello world', + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 1), + ); + + await pumpWithMessages(tester, [message]); + + expect(find.text('hello world'), findsOneWidget); + }); + + testWidgets('hides shadowed message from another user', (tester) async { + final visible = Message( + text: 'visible', + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 1), + ); + final shadowed = Message( + text: 'shadowed', + shadowed: true, + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 2), + ); + + await pumpWithMessages(tester, [visible, shadowed]); + + expect(find.text('visible'), findsOneWidget); + expect(find.text('shadowed'), findsNothing); + }); + + testWidgets( + 'shows current user own shadowed message', + (tester) async { + final mine = Message( + text: 'my shadowed', + shadowed: true, + user: User(id: currentUserId), + createdAt: DateTime(2024, 1, 1), + ); + + await pumpWithMessages(tester, [mine]); + + expect(find.text('my shadowed'), findsOneWidget); + }, + ); + + testWidgets( + 'hides moderation V2 shadowed message regardless of sender', + (tester) async { + final visible = Message( + text: 'visible', + user: User(id: currentUserId), + createdAt: DateTime(2024, 1, 1), + ); + final v2Shadowed = Message( + text: 'v2 shadowed', + user: User(id: currentUserId), + createdAt: DateTime(2024, 1, 2), + moderation: const Moderation( + action: ModerationAction.shadow, + originalText: 'v2 shadowed', + ), + ); + + await pumpWithMessages(tester, [visible, v2Shadowed]); + + expect(find.text('visible'), findsOneWidget); + expect(find.text('v2 shadowed'), findsNothing); + }, + ); + + testWidgets('hides error and ephemeral messages', (tester) async { + final visible = Message( + text: 'visible', + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 1), + ); + final errored = Message( + text: 'errored', + type: MessageType.error, + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 2), + ); + final ephemeral = Message( + text: 'ephemeral', + type: MessageType.ephemeral, + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 3), + ); + + await pumpWithMessages(tester, [visible, errored, ephemeral]); + + expect(find.text('visible'), findsOneWidget); + expect(find.text('errored'), findsNothing); + expect(find.text('ephemeral'), findsNothing); + }); + + testWidgets( + 'custom lastMessagePredicate fully replaces the default', + (tester) async { + final shadowedByOther = Message( + text: 'shadowed by other', + shadowed: true, + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 1), + ); + + when(() => channelState.messages).thenReturn([shadowedByOther]); + when(() => channelState.messagesStream) + .thenAnswer((_) => Stream.value([shadowedByOther])); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: ChannelLastMessageText( + channel: channel, + lastMessagePredicate: (_) => true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('shadowed by other'), findsOneWidget); + }, + ); +} From 5a73c592d5ad3fa3fa7f8b1b3372f5e6852ae9b5 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 25 Jun 2026 20:19:40 +0200 Subject: [PATCH 2/4] fix(ui): Fix `shadowed` messages not hidden in the channel list --- .../stream_channel_list_item_test.dart | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart index f40c24ea3..dc3b0a535 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart @@ -23,8 +23,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(currentUser); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(currentUser)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); when(() => channelState.draft).thenReturn(null); @@ -36,8 +35,7 @@ void main() { List messages, ) async { when(() => channelState.messages).thenReturn(messages); - when(() => channelState.messagesStream) - .thenAnswer((_) => Stream.value(messages)); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value(messages)); await tester.pumpWidget( MaterialApp( @@ -161,8 +159,7 @@ void main() { ); when(() => channelState.messages).thenReturn([shadowedByOther]); - when(() => channelState.messagesStream) - .thenAnswer((_) => Stream.value([shadowedByOther])); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([shadowedByOther])); await tester.pumpWidget( MaterialApp( From 8c8bab08721b33a7da2bc942b1cf1963b27cf415 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 26 Jun 2026 16:12:21 +0200 Subject: [PATCH 3/4] fix(ui): Remove wrong checks on Message.isShadowed. --- .../stream_channel_list_item.dart | 2 -- .../stream_channel_list_item_test.dart | 25 ------------------- 2 files changed, 27 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart index cddd8f0b7..3466b36ae 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -588,7 +588,6 @@ class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithSt final currentUserId = widget.channel.client.state.currentUser?.id; final isMyMessage = currentUserId != null && message.user?.id == currentUserId; if (message.shadowed && !isMyMessage) return false; - if (message.isShadowed) return false; if (message.isError) return false; if (message.isEphemeral) return false; @@ -705,7 +704,6 @@ class ChannelLastMessageText extends StatefulWidget { return (Message message) { final isMyMessage = currentUserId != null && message.user?.id == currentUserId; if (message.shadowed && !isMyMessage) return false; - if (message.isShadowed) return false; if (message.isError) return false; if (message.isEphemeral) return false; return true; diff --git a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart index dc3b0a535..f62a79936 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart @@ -97,31 +97,6 @@ void main() { }, ); - testWidgets( - 'hides moderation V2 shadowed message regardless of sender', - (tester) async { - final visible = Message( - text: 'visible', - user: User(id: currentUserId), - createdAt: DateTime(2024, 1, 1), - ); - final v2Shadowed = Message( - text: 'v2 shadowed', - user: User(id: currentUserId), - createdAt: DateTime(2024, 1, 2), - moderation: const Moderation( - action: ModerationAction.shadow, - originalText: 'v2 shadowed', - ), - ); - - await pumpWithMessages(tester, [visible, v2Shadowed]); - - expect(find.text('visible'), findsOneWidget); - expect(find.text('v2 shadowed'), findsNothing); - }, - ); - testWidgets('hides error and ephemeral messages', (tester) async { final visible = Message( text: 'visible', From 74f56226072e7e921d751e4658a4e83300fcbe52 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 26 Jun 2026 16:21:24 +0200 Subject: [PATCH 4/4] fix(ui): Refactor. --- .../stream_channel_list_item.dart | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart index 3466b36ae..2271408e0 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -584,22 +584,13 @@ class _ChannelLastMessageWithStatus extends StatefulWidget { class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithStatus> { Message? _currentLastMessage; - bool _defaultLastMessagePredicate(Message message) { - final currentUserId = widget.channel.client.state.currentUser?.id; - final isMyMessage = currentUserId != null && message.user?.id == currentUserId; - if (message.shadowed && !isMyMessage) return false; - if (message.isError) return false; - if (message.isEphemeral) return false; - - return true; - } - @override Widget build(BuildContext context) { final channelState = widget.channel.state; if (channelState == null) return const Empty(); final currentUser = widget.channel.client.state.currentUser; + final predicate = _defaultLastMessagePredicate(currentUser?.id); return BetterStreamBuilder<(Draft?, List)>( stream: CombineLatestStream.combine2( @@ -625,7 +616,7 @@ class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithSt } // Find the last valid message. - final message = messages.lastWhereOrNull(_defaultLastMessagePredicate); + final message = messages.lastWhereOrNull(predicate); final latestLastMessage = [message, _currentLastMessage].latest; if (latestLastMessage == null) { @@ -698,22 +689,22 @@ class ChannelLastMessageText extends StatefulWidget { /// considered for the last message. final bool Function(Message) lastMessagePredicate; - // The default predicate to determine if the message should be - // considered for the last message. - static bool Function(Message) _defaultLastMessagePredicate(String? currentUserId) { - return (Message message) { - final isMyMessage = currentUserId != null && message.user?.id == currentUserId; - if (message.shadowed && !isMyMessage) return false; - if (message.isError) return false; - if (message.isEphemeral) return false; - return true; - }; - } - @override State createState() => _ChannelLastMessageTextState(); } +// The default predicate to determine if the message should be +// considered for the last message. +bool Function(Message) _defaultLastMessagePredicate(String? currentUserId) { + return (Message message) { + final isMyMessage = currentUserId != null && message.user?.id == currentUserId; + if (message.shadowed && !isMyMessage) return false; + if (message.isError) return false; + if (message.isEphemeral) return false; + return true; + }; +} + class _ChannelLastMessageTextState extends State { Message? _currentLastMessage;