diff --git a/e2e_app/integration_test/quoted_reply_test.dart b/e2e_app/integration_test/quoted_reply_test.dart index a3bce39bed..f12b9caf6e 100644 --- a/e2e_app/integration_test/quoted_reply_test.dart +++ b/e2e_app/integration_test/quoted_reply_test.dart @@ -859,7 +859,6 @@ void main() { streamTestWithEnv( allureId: '12807', description: 'user jumps from the channel to a quoted message in a thread', - skip: 'https://linear.app/stream/issue/FLU-1095', body: (env) async { step('GIVEN the user opens a channel with a thread'); await env.backendRobot.generateChannels( diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f91d8ac3a5..d1ccfe53f5 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -29,6 +29,7 @@ - Fixed the poll creator showing the reorder handle and remove button when there is only one option. - Fixed a link that points straight to an image showing as a plain image instead of a link preview, and appearing twice in the message composer. - Fixed the keyboard closing when removing the link preview in the message composer. +- Fixed tapping a quote of a thread reply in the channel not opening the thread, and a thread opened at one of its replies not scrolling to it. - Fixed `StreamMessageComposer` always sending the default `skipEnrichUrl`, so the server could add a link preview the user removed, or a second one next to the attached preview. - Fixed the composer's command and mention suggestions staying open after tapping outside the composer, such as on the message list. - Fixed "Message deleted" in a quoted reply showing in a muted gray instead of the reply's text color. diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 751c082a62..42f18b53ae 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:rxdart/rxdart.dart'; @@ -214,7 +215,9 @@ class StreamMessageListView extends StatefulWidget { /// [StreamMessageItem] in the list. /// /// When null (the default), tapping a quoted message scrolls to it in - /// the list, loading it if necessary. + /// the list, loading it if necessary. A reply that was only sent to its + /// thread opens that thread instead, when [threadBuilder] or [onThreadTap] + /// is set. final void Function(Message quotedMessage)? onQuotedMessageTap; /// Called when a link is tapped in message text. @@ -357,6 +360,9 @@ class _StreamMessageListViewState extends State { List messages = []; + // The thread reply to scroll to once [messages] include it. + String? _pendingThreadMessageId; + // `generation` bumps on each highlight call so a re-highlight of the // same message restarts the fade animation. final _highlightState = ValueNotifier<({String? id, int generation})>((id: null, generation: 0)); @@ -415,12 +421,12 @@ class _StreamMessageListViewState extends State { // that replay is checked against. _unreadController.attach(); - final highlightInitialMessage = _config.highlightInitialMessage; - final highlightMessageId = switch ((highlightInitialMessage, _isThreadConversation)) { - (true, true) => _ThreadHighlightScope.of(context), - (true, false) => streamChannel?.initialMessageId, - _ => null, - }; + // A thread opened for one of its replies scrolls to it once the replies + // include it. Highlighting it stays opt-in. + _pendingThreadMessageId = _isThreadConversation ? _ThreadHighlightScope.of(context) : null; + + final highlightChannelMessage = _config.highlightInitialMessage && !_isThreadConversation; + final highlightMessageId = highlightChannelMessage ? streamChannel?.initialMessageId : null; if (highlightMessageId != null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -650,17 +656,20 @@ class _StreamMessageListViewState extends State { labels: const [kPortalMessageListViewLabel], child: Portal( child: ScaffoldMessenger( - child: MessageListCore( - paginationLimit: _config.paginationLimit, - maximumMessageLimit: _config.maximumMessageLimit, - retentionTrimBuffer: _config.retentionTrimBuffer, - messageFilter: widget.messageFilter, - loadingBuilder: defaultLoadingBuilder, - emptyBuilder: defaultEmptyBuilder, - messageListBuilder: defaultMessageListBuilder, - messageListController: _messageListController, - parentMessage: widget.parentMessage, - errorBuilder: defaultErrorBuilder, + child: NotificationListener( + onNotification: _onUserScroll, + child: MessageListCore( + paginationLimit: _config.paginationLimit, + maximumMessageLimit: _config.maximumMessageLimit, + retentionTrimBuffer: _config.retentionTrimBuffer, + messageFilter: widget.messageFilter, + loadingBuilder: defaultLoadingBuilder, + emptyBuilder: defaultEmptyBuilder, + messageListBuilder: defaultMessageListBuilder, + messageListController: _messageListController, + parentMessage: widget.parentMessage, + errorBuilder: defaultErrorBuilder, + ), ), ), ), @@ -670,6 +679,14 @@ class _StreamMessageListViewState extends State { Widget _buildListView(List data) { messages = data; + if (_pendingThreadMessageId case final messageId? when messages.any((it) => it.id == messageId)) { + _pendingThreadMessageId = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _scrollToMessage(messageId: messageId, highlight: _config.highlightInitialMessage); + }); + } + // Top pagination may not have finished loading the unread boundary when // the baseline was first captured; retry once this frame's layout // settles. Deferred (not synchronous) since mutating a [ValueNotifier] @@ -1281,7 +1298,7 @@ class _StreamMessageListViewState extends State { onMessageLinkTap: widget.onMessageLinkTap, onUserMentionTap: widget.onUserMentionTap, onMentionTap: widget.onMentionTap, - onQuotedMessageTap: widget.onQuotedMessageTap ?? _scrollToQuotedMessage, + onQuotedMessageTap: widget.onQuotedMessageTap ?? _onQuotedMessageTap, ); // The content kind follows from the message in [props], so it is resolved @@ -1316,7 +1333,31 @@ class _StreamMessageListViewState extends State { // Methods rather than closures, so rows built from them compare equal. void _popToMessage(Message message) => Navigator.of(context).pop(message.id); - void _scrollToQuotedMessage(Message quoted) => _scrollToMessage(messageId: quoted.id).ignore(); + void _onQuotedMessageTap(Message quoted) => _openQuotedMessage(quoted).ignore(); + + // A reply that lives only in its thread isn't in the channel list, so open + // the thread instead of scrolling. + Future _openQuotedMessage(Message quoted) async { + final onThreadTap = _onThreadTap; + final parentId = quoted.parentId; + final isThreadOnlyReply = parentId != null && quoted.showInChannel != true; + if (onThreadTap != null && isThreadOnlyReply && !_isThreadConversation) { + final parent = await streamChannel!.getMessage(parentId); + if (!mounted) return; + onThreadTap(parent, quoted); + return; + } + + await _scrollToMessage(messageId: quoted.id); + } + + // A user scroll cancels the jump to a thread reply that hasn't loaded yet. + bool _onUserScroll(UserScrollNotification notification) { + final isListScroll = notification.depth == 0; + final isUserScroll = notification.direction != ScrollDirection.idle; + if (isListScroll && isUserScroll) _pendingThreadMessageId = null; + return false; + } void _handleItemPositionsChanged() { if (!mounted) return; @@ -1352,10 +1393,12 @@ class _StreamMessageListViewState extends State { // The created callback will use widget.onThreadTap, passing the result // of widget.threadBuilder (if provided) as the second argument. (final onThreadTap?, final threadBuilder) => (Message parentMessage, Message? threadMessage) { - onThreadTap( - parentMessage, - threadBuilder?.call(context, parentMessage), - ); + var threadPage = threadBuilder?.call(context, parentMessage); + if (threadPage != null && threadMessage != null) { + threadPage = _ThreadHighlightScope(messageId: threadMessage.id, child: threadPage); + } + + onThreadTap(parentMessage, threadPage); }, // Case 2: widget.onThreadTap is null, but widget.threadBuilder is provided. // The created callback will perform the default navigation action, diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_quoted_thread_reply_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_quoted_thread_reply_test.dart new file mode 100644 index 0000000000..f00b4e7c9f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_quoted_thread_reply_test.dart @@ -0,0 +1,266 @@ +import 'dart:async'; + +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() { + final jason = User(id: 'jason', name: 'Jason Thompson'); + final maya = User(id: 'maya', name: 'Maya Ross'); + + final parent = Message( + id: 'parent', + text: 'Planning the offsite', + createdAt: DateTime(2026, 8, 26, 14), + user: jason, + state: MessageState.sent, + replyCount: 2, + ); + + Message quoting(Message quoted) => Message( + id: 'quoting', + text: 'Agreed', + createdAt: DateTime(2026, 8, 26, 16), + user: maya, + state: MessageState.sent, + quotedMessageId: quoted.id, + quotedMessage: quoted, + ); + + testWidgets('opens the thread of a quoted reply that is only in the thread', (tester) async { + final threadReply = Message( + id: 'thread-reply', + text: 'Lisbon in May', + createdAt: DateTime(2026, 8, 26, 15), + user: jason, + state: MessageState.sent, + parentId: parent.id, + ); + final openedThreads = []; + + await _pumpMessageList( + tester, + [parent, quoting(threadReply)], + onThreadTap: (opened, _) => openedThreads.add(opened), + ); + await tester.tap(find.byType(StreamQuotedMessage)); + await tester.pumpAndSettle(); + + expect(openedThreads.map((it) => it.id), [parent.id]); + }); + + testWidgets('does not open a thread for a quoted reply also sent to the channel', (tester) async { + final channelReply = Message( + id: 'channel-reply', + text: 'Lisbon in May', + createdAt: DateTime(2026, 8, 26, 15), + user: jason, + state: MessageState.sent, + parentId: parent.id, + showInChannel: true, + ); + final openedThreads = []; + + await _pumpMessageList( + tester, + [parent, channelReply, quoting(channelReply)], + onThreadTap: (opened, _) => openedThreads.add(opened), + ); + await tester.tap(find.byType(StreamQuotedMessage)); + await tester.pumpAndSettle(); + + expect(openedThreads, isEmpty); + }); + + testWidgets('does not open a thread for a quoted reply inside the thread', (tester) async { + final threadReply = Message( + id: 'thread-reply', + text: 'Lisbon in May', + createdAt: DateTime(2026, 8, 26, 15), + user: jason, + state: MessageState.sent, + parentId: parent.id, + ); + final quotingReply = quoting(threadReply).copyWith(parentId: parent.id); + final openedThreads = []; + + await _pumpMessageList( + tester, + [threadReply, quotingReply], + parentMessage: parent, + onThreadTap: (opened, _) => openedThreads.add(opened), + ); + await tester.tap(find.byType(StreamQuotedMessage)); + await tester.pumpAndSettle(); + + expect(openedThreads, isEmpty); + }); + + testWidgets('opens the thread of a quoted reply on that reply', (tester) async { + final replies = [ + for (var i = 0; i < 20; i++) + Message( + id: 'reply-$i', + text: 'Offsite idea $i', + createdAt: DateTime(2026, 8, 26, 15, i), + user: jason, + state: MessageState.sent, + parentId: parent.id, + ), + ]; + + await _pumpMessageList( + tester, + [parent, quoting(replies.first)], + replies: replies, + threadBuilder: (_, parent) => StreamMessageListView(parentMessage: parent), + ); + await tester.tap(find.byType(StreamQuotedMessage)); + await tester.pumpAndSettle(); + + final quotedReply = find.textContaining('Offsite idea 0', findRichText: true).hitTestable(); + expect(quotedReply, findsOneWidget); + }); + + testWidgets('opens the thread of a quoted reply on that reply through a custom thread tap', (tester) async { + final replies = [ + for (var i = 0; i < 20; i++) + Message( + id: 'reply-$i', + text: 'Offsite idea $i', + createdAt: DateTime(2026, 8, 26, 15, i), + user: jason, + state: MessageState.sent, + parentId: parent.id, + ), + ]; + + late NavigatorState navigator; + late Channel channel; + await _pumpMessageList( + tester, + [parent, quoting(replies.first)], + replies: replies, + threadBuilder: (_, parent) => StreamMessageListView(parentMessage: parent), + onThreadTap: (_, threadPage) => navigator.push( + MaterialPageRoute( + builder: (_) => StreamChannel.value(channel: channel, child: threadPage!), + ), + ), + ); + navigator = tester.state(find.byType(Navigator)); + channel = StreamChannel.of(tester.element(find.byType(StreamMessageListView))).channel; + await tester.tap(find.byType(StreamQuotedMessage)); + await tester.pumpAndSettle(); + + final quotedReply = find.textContaining('Offsite idea 0', findRichText: true).hitTestable(); + expect(quotedReply, findsOneWidget); + }); + + testWidgets('does not jump to a quoted reply that loads after the user scrolled the thread', (tester) async { + final replies = [ + for (var i = 0; i < 30; i++) + Message( + id: 'reply-$i', + text: 'Offsite idea $i', + createdAt: DateTime(2026, 8, 26, 15, i), + user: jason, + state: MessageState.sent, + parentId: parent.id, + ), + ]; + final latestReplies = replies.sublist(10); + final threads = StreamController>>.broadcast(); + addTearDown(threads.close); + + await _pumpMessageList( + tester, + [parent, quoting(replies.first)], + replies: latestReplies, + threadsStream: threads.stream, + threadBuilder: (_, parent) => StreamMessageListView(parentMessage: parent), + ); + await tester.tap(find.byType(StreamQuotedMessage)); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(StreamMessageListView).last, const Offset(0, 200)); + await tester.pumpAndSettle(); + + // Scrolling up loads the page with the quoted reply. + threads.add({parent.id: replies}); + await tester.pumpAndSettle(); + + final quotedReply = find.textContaining('Offsite idea 0', findRichText: true).hitTestable(); + expect(quotedReply, findsNothing); + }); +} + +Future _pumpMessageList( + WidgetTester tester, + List messages, { + ThreadTapCallback? onThreadTap, + ThreadBuilder? threadBuilder, + Message? parentMessage, + List? replies, + Stream>>? threadsStream, +}) async { + final currentUser = OwnUser(id: 'maya', name: 'Maya Ross'); + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + final threads = { + if (parentMessage != null) parentMessage.id: messages, + for (final reply in [...?replies]) reply.parentId!: replies!, + }; + when(() => channelState.threadsStream).thenAnswer((_) => threadsStream ?? Stream.value(threads)); + when(() => channelState.threads).thenReturn(threads); + when( + () => channel.getReplies( + any(), + options: any(named: 'options'), + preferOffline: any(named: 'preferOffline'), + ), + ).thenAnswer((_) async => QueryRepliesResponse()..messages = replies ?? messages); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value(messages)); + when(() => channelState.messages).thenReturn([?parentMessage, ...messages]); + when(() => channelState.isUpToDate).thenReturn(true); + when(() => channelState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelState.unreadCount).thenReturn(0); + when(() => channelState.readStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.read).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.members).thenReturn([]); + when(() => channelState.currentUserRead).thenReturn(null); + when(() => channelState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget( + MaterialApp( + // Above the navigator, so a thread page pushed from the list has it too. + builder: (context, child) => StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: child, + ), + home: StreamChannel( + channel: channel, + child: StreamMessageListView( + parentMessage: parentMessage, + onThreadTap: onThreadTap, + threadBuilder: threadBuilder, + ), + ), + ), + ); + await tester.pumpAndSettle(); +}