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..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 @@ -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,21 @@ 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.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..feb8b9d3b3 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/channel_scroll_view/stream_channel_list_tile_test.dart @@ -0,0 +1,168 @@ +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), + ); + + 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), + ); + 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), + ); + + await pumpWithMessages(tester, [mine]); + + expect(find.text('my shadowed'), findsOneWidget); + }, + ); + + testWidgets('hides deleted, error, and ephemeral messages', (tester) async { + final visible = Message( + text: 'visible', + user: User(id: 'other'), + createdAt: DateTime(2024), + ); + 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), + ); + + 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); + }, + ); +}