diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 027f4edb19..125bcc9461 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,8 +1,13 @@ ## Upcoming +✅ Added + +- Added a `LastMessagePredicate` typedef for the `ChannelLastMessageText.lastMessagePredicate` filter. + 🐞 Fixed - Fixed `StreamMessageListView` firing `markThreadRead` on a reply-less parent, which produced a guaranteed 404 every time the thread view was opened before the first reply. +- Fixed shadowed messages not hidden in channel list items. ## 9.26.0 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..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,11 +349,15 @@ class ChannelLastMessageText extends StatefulWidget { super.key, required this.channel, this.textStyle, - this.lastMessagePredicate = _defaultLastMessagePredicate, - }) : assert( + LastMessagePredicate? 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; @@ -366,17 +370,23 @@ 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; - - // 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; + 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 LastMessagePredicate _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/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); 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); + }, + ); +}