From 28a2e54f6682cf82ad9c0f9a923dce361dba5b37 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 26 Jun 2026 10:17:25 +0200 Subject: [PATCH 1/3] fix(ui): Fix `shadowed` messages not hidden in the channel list --- packages/stream_chat_flutter/CHANGELOG.md | 5 + .../stream_channel_list_tile.dart | 35 ++-- .../stream_channel_list_tile_test.dart | 193 ++++++++++++++++++ 3 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 866b4076e0..b7fb0eef0b 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## Upcoming + +🐞 Fixed +- Fixed shadowed messages not hidden in channel list items. + ## 9.26.0 🐞 Fixed 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 1d950ce44f..2c8522d6ca 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 @@ -349,11 +349,15 @@ class ChannelLastMessageText extends StatefulWidget { super.key, required this.channel, this.textStyle, - this.lastMessagePredicate = _defaultLastMessagePredicate, - }) : assert( + 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; @@ -368,15 +372,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 _defaultLastMessagePredicate(Message message) { - if (message.isShadowed) return false; - if (message.isDeleted) return false; - if (message.isError) return false; - if (message.isEphemeral) return false; - - return true; + // The default predicate to determine if the message should be considered + // for the last message. Hides shadowed messages from non-senders so the + // sender's preview matches the messaging UI. + 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.isDeleted) 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_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart new file mode 100644 index 0000000000..b781ec5f5b --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart @@ -0,0 +1,193 @@ +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.draftStream).thenAnswer((_) => Stream.value(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 deleted, error, and ephemeral messages', (tester) async { + final visible = Message( + text: 'visible', + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 1), + ); + final deleted = Message( + text: 'deleted', + type: MessageType.deleted, + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 2), + ); + final errored = Message( + text: 'errored', + type: MessageType.error, + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 3), + ); + final ephemeral = Message( + text: 'ephemeral', + type: MessageType.ephemeral, + user: User(id: 'other'), + createdAt: DateTime(2024, 1, 4), + ); + + await pumpWithMessages(tester, [visible, deleted, errored, ephemeral]); + + expect(find.text('visible'), findsOneWidget); + expect(find.text('deleted'), findsNothing); + 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 ce14664f16dc485659f4cd95b7ce375a40900a34 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 26 Jun 2026 16:24:38 +0200 Subject: [PATCH 2/3] fix(ui): Remove wrong checks on Message.isShadowed. --- .../stream_channel_list_tile.dart | 1 - .../stream_channel_list_tile_test.dart | 35 +++---------------- 2 files changed, 5 insertions(+), 31 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 2c8522d6ca..99ac5c3462 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 @@ -382,7 +382,6 @@ class ChannelLastMessageText extends StatefulWidget { final isMyMessage = currentUserId != null && message.user?.id == currentUserId; if (message.shadowed && !isMyMessage) return false; - if (message.isShadowed) return false; if (message.isDeleted) return false; if (message.isError) return false; if (message.isEphemeral) return false; 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 b781ec5f5b..feb8b9d3b3 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 @@ -57,7 +57,7 @@ void main() { final message = Message( text: 'hello world', user: User(id: 'other'), - createdAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024), ); await pumpWithMessages(tester, [message]); @@ -69,7 +69,7 @@ void main() { final visible = Message( text: 'visible', user: User(id: 'other'), - createdAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024), ); final shadowed = Message( text: 'shadowed', @@ -91,7 +91,7 @@ void main() { text: 'my shadowed', shadowed: true, user: User(id: currentUserId), - createdAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024), ); await pumpWithMessages(tester, [mine]); @@ -100,36 +100,11 @@ 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 deleted, error, and ephemeral messages', (tester) async { final visible = Message( text: 'visible', user: User(id: 'other'), - createdAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024), ); final deleted = Message( text: 'deleted', @@ -165,7 +140,7 @@ void main() { text: 'shadowed by other', shadowed: true, user: User(id: 'other'), - createdAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024), ); when(() => channelState.messages).thenReturn([shadowedByOther]); From d6802eb983fa388864fcb09c07066d0de70ad697 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 10 Jul 2026 16:16:46 +0200 Subject: [PATCH 3/3] Introduce LastMessagePredicate --- packages/stream_chat_flutter/CHANGELOG.md | 4 ++++ .../channel_scroll_view/stream_channel_list_tile.dart | 6 +++--- packages/stream_chat_flutter/lib/src/utils/typedefs.dart | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index b7fb0eef0b..e3b8315513 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming +✅ Added + +- Added a `LastMessagePredicate` typedef for the `ChannelLastMessageText.lastMessagePredicate` filter. + 🐞 Fixed - Fixed shadowed messages not hidden in channel list items. 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 99ac5c3462..cbd1b91595 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 @@ -349,7 +349,7 @@ class ChannelLastMessageText extends StatefulWidget { super.key, required this.channel, this.textStyle, - bool Function(Message)? lastMessagePredicate, + LastMessagePredicate? lastMessagePredicate, }) : assert( channel.state != null, 'Channel ${channel.id} is not initialized', @@ -370,12 +370,12 @@ class ChannelLastMessageText extends StatefulWidget { /// /// This predicate is used to filter out messages that should not be /// considered for the last message. - final bool Function(Message) lastMessagePredicate; + final LastMessagePredicate lastMessagePredicate; // The default predicate to determine if the message should be considered // for the last message. Hides shadowed messages from non-senders so the // sender's preview matches the messaging UI. - static bool Function(Message) _defaultLastMessagePredicate( + static LastMessagePredicate _defaultLastMessagePredicate( String? currentUserId, ) { return (Message message) { diff --git a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart index ab6a9d318a..5584f5b3a2 100644 --- a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart +++ b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart @@ -373,3 +373,7 @@ typedef MessageRelatedBuilder = Widget Function( /// A function that returns true if the message is valid and can be sent. typedef MessageValidator = bool Function(Message message); + +/// Signature for a predicate deciding whether a [Message] should be +/// considered as a channel's last message preview. +typedef LastMessagePredicate = bool Function(Message message);