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..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,20 +584,13 @@ class _ChannelLastMessageWithStatus extends StatefulWidget { class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithStatus> { Message? _currentLastMessage; - static bool _defaultLastMessagePredicate(Message message) { - if (message.isShadowed) 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( @@ -623,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) { @@ -675,11 +668,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; @@ -694,18 +689,20 @@ 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; + @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; - } - - @override - State createState() => _ChannelLastMessageTextState(); + }; } class _ChannelLastMessageTextState extends State { 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..f62a79936 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_item_test.dart @@ -0,0 +1,157 @@ +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 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); + }, + ); +}