diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 60ba2f084..a9ce37890 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,22 @@ +## Unreleased + +✅ Added + +- Added `onViewInChannelTap` callback to `StreamMessageListView` and `StreamMessageWidgetProps`. + Tapping "View" on "Also sent in channel" in a thread now pops the thread and scrolls to the + message in the channel. Override this callback to customise navigation (e.g. when the thread + is opened from a thread list instead of a channel). + +🛑️ Breaking + +- **`onThreadTap` on `StreamMessageWidgetProps` now receives two parameters:** + `(Message parentMessage, Message? threadMessage)` instead of just the parent message. + When a reply with `showInChannel: true` is tapped, `threadMessage` contains the reply so the + thread view can scroll to and highlight it. Pass `null` for `threadMessage` when not applicable. +- **`onThreadTap` is no longer called from thread views.** It now only fires for channel-side + taps. The "View" button in threads uses the new `onViewInChannelTap` instead. + Migrate any thread-side `onThreadTap` logic to `onViewInChannelTap`. + ## 10.0.0-beta.12 🐞 Fixed 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 50f438322..e9dff50cd 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 @@ -110,6 +110,7 @@ class StreamMessageListView extends StatefulWidget { this.parentMessage, this.threadBuilder, this.onThreadTap, + this.onViewInChannelTap, this.onEditMessageTap, this.onReplyTap, this.swipeToReply = false, @@ -216,6 +217,19 @@ class StreamMessageListView extends StatefulWidget { /// built using [threadBuilder] final ThreadTapCallback? onThreadTap; + /// Called when the "View" button on the "Also sent in channel" annotation + /// is tapped inside a thread view. + /// + /// Use this to navigate to the channel screen and scroll to / highlight + /// the given [Message]. + /// + /// When null and the thread was opened via the default [threadBuilder] + /// navigation, the thread screen is automatically popped and the channel + /// list scrolls to the message. Provide this callback to override that + /// behaviour — for example when the thread is opened from a thread list + /// or deep link where popping would not land on the channel screen. + final void Function(Message message)? onViewInChannelTap; + /// {@macro onEditMessageTap} /// /// If provided, the inline edit flow is used instead of the edit bottom sheet. @@ -451,7 +465,7 @@ class StreamMessageListView extends StatefulWidget { class _StreamMessageListViewState extends State { ItemScrollController? _scrollController; - void Function(Message)? _onThreadTap; + void Function(Message parentMessage, Message? threadMessage)? _onThreadTap; final ValueNotifier _showScrollToBottom = ValueNotifier(false); late final ItemPositionsListener _itemPositionListener; int? _messageListLength; @@ -520,22 +534,29 @@ class _StreamMessageListViewState extends State { unreadCount = streamChannel?.channel.state?.unreadCount ?? 0; _firstUnreadMessage = streamChannel?.getFirstUnreadMessage(); - if (widget.highlightInitialMessage) { - final initialMessageId = streamChannel?.initialMessageId; - if (initialMessageId != null) { - _highlightedMessageId = initialMessageId; - _highlightGeneration++; - } + final highlightMessageId = widget.highlightInitialMessage + ? (streamChannel?.initialMessageId ?? _ThreadHighlightScope.of(context)) + : null; + + if (highlightMessageId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _moveToAndHighlight( + messages: messages, + messageId: highlightMessageId, + initialScrollIndex: widget.initialScrollIndex, + scrollTo: false, + ); + }); + } else { + initialIndex = getInitialIndex( + widget.initialScrollIndex, + streamChannel!, + widget.messageFilter, + ); + initialAlignment = _initialAlignment; } - initialIndex = getInitialIndex( - widget.initialScrollIndex, - streamChannel!, - widget.messageFilter, - ); - - initialAlignment = _initialAlignment; - if (_scrollController?.isAttached == true) { _scrollController?.jumpTo( index: initialIndex, @@ -585,31 +606,50 @@ class _StreamMessageListViewState extends State { }); } - Future _scrollToAndHighlight( - String messageId, { + Future _moveToAndHighlight({ required List messages, + String? messageId, + int? initialScrollIndex, + bool scrollTo = true, }) async { - final index = messages.indexWhere((m) => m.id == messageId); - if (index >= 0) { - await _scrollController?.scrollTo( - index: index + 2, // +2 to account for loader and footer - duration: const Duration(seconds: 1), - curve: Curves.easeInOut, - alignment: 0.1, + if (messageId != null) { + final index = messages.indexWhere((m) => m.id == messageId); + + if (index >= 0) { + if (scrollTo) { + _scrollController?.scrollTo( + index: index + 2, // +2 to account for loader and footer + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + alignment: 0.1, + ); + } else { + _scrollController?.jumpTo( + index: index + 2, // +2 to account for loader and footer + alignment: 0.1, + ); + } + } else { + await streamChannel!.loadChannelAtMessage(messageId).then((_) async { + initialIndex = getInitialIndex( + initialScrollIndex, + streamChannel!, + widget.messageFilter, + messageId: messageId, + ); + initialAlignment = 0.1; + }); + } + } else if (initialScrollIndex != null) { + _scrollController?.jumpTo( + index: initialScrollIndex, + alignment: initialAlignment, ); - } else { - await streamChannel!.loadChannelAtMessage(messageId).then((_) async { - initialIndex = getInitialIndex( - null, - streamChannel!, - widget.messageFilter, - messageId: messageId, - ); - initialAlignment = 0.1; - }); } - _highlightMessage(messageId); + if (messageId != null) { + _highlightMessage(messageId); + } } @override @@ -1255,6 +1295,9 @@ class _StreamMessageListViewState extends State { message: message, swipeToReply: widget.swipeToReply, onThreadTap: _onThreadTap, + onViewInChannelTap: _isThreadConversation + ? widget.onViewInChannelTap ?? (message) => Navigator.of(context).pop(message.id) + : null, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, onEditMessageTap: widget.onEditMessageTap, @@ -1265,8 +1308,8 @@ class _StreamMessageListViewState extends State { onUserMentionTap: widget.onUserMentionTap, onQuotedMessageTap: switch (widget.onQuotedMessageTap) { final onTap? => onTap, - _ => (quotedMessage) => _scrollToAndHighlight( - quotedMessage.id, + _ => (quotedMessage) => _moveToAndHighlight( + messageId: quotedMessage.id, messages: messages, ), }, @@ -1391,37 +1434,64 @@ class _StreamMessageListViewState extends State { // Case 1: widget.onThreadTap is provided. // The created callback will use widget.onThreadTap, passing the result // of widget.threadBuilder (if provided) as the second argument. - (final onThreadTap?, final threadBuilder) => (Message message) { + (final onThreadTap?, final threadBuilder) => (Message parentMessage, Message? threadMessage) { onThreadTap( - message, - threadBuilder?.call(context, message), + parentMessage, + threadBuilder?.call(context, parentMessage), ); }, // Case 2: widget.onThreadTap is null, but widget.threadBuilder is provided. // The created callback will perform the default navigation action, // using widget.threadBuilder to build the thread page. - (null, final threadBuilder?) => (Message message) { - final threadPage = StreamChatConfiguration( + (null, final threadBuilder?) => (Message parentMessage, Message? threadMessage) async { + Widget threadPage = StreamChatConfiguration( // This is needed to provide the nearest reaction icons to the // StreamMessageReactionsModal. data: StreamChatConfiguration.of(context), child: StreamChannel( channel: streamChannel!.channel, child: BetterStreamBuilder( - initialData: message, + initialData: parentMessage, stream: streamChannel!.channel.state?.messagesStream.map( - (it) => it.firstWhere((m) => m.id == message.id), + (it) => it.firstWhere((m) => m.id == parentMessage.id), ), builder: (_, data) => threadBuilder(context, data), ), ), ); - Navigator.of(context).push( + if (threadMessage != null) { + threadPage = _ThreadHighlightScope( + messageId: threadMessage.id, + child: threadPage, + ); + } + + final result = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => threadPage), ); + + if (result != null && mounted) { + _moveToAndHighlight(messageId: result, messages: messages); + } }, _ => null, }; } } + +class _ThreadHighlightScope extends InheritedWidget { + const _ThreadHighlightScope({ + required this.messageId, + required super.child, + }); + + final String messageId; + + static String? of(BuildContext context) { + return context.findAncestorWidgetOfExactType<_ThreadHighlightScope>()?.messageId; + } + + @override + bool updateShouldNotify(_ThreadHighlightScope oldWidget) => messageId != oldWidget.messageId; +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index dcca4abaf..544334c9a 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -51,7 +51,7 @@ import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// StreamMessageWidget( /// message: message, /// onMessageTap: (msg) => print('Tapped: ${msg.id}'), -/// onThreadTap: (msg) => Navigator.push(...), +/// onThreadTap: (parent, threadMsg) => Navigator.push(...), /// onUserAvatarTap: (user) => showProfile(user), /// ) /// ``` @@ -82,7 +82,8 @@ class StreamMessageWidget extends StatelessWidget { void Function(User)? onUserAvatarTap, void Function(Message message, String url)? onMessageLinkTap, void Function(User user)? onUserMentionTap, - void Function(Message)? onThreadTap, + void Function(Message parentMessage, Message? threadMessage)? onThreadTap, + void Function(Message)? onViewInChannelTap, void Function(Message)? onReplyTap, void Function(Message)? onReactionsTap, void Function(Message quotedMessage)? onQuotedMessageTap, @@ -105,6 +106,7 @@ class StreamMessageWidget extends StatelessWidget { onMessageLinkTap: onMessageLinkTap, onUserMentionTap: onUserMentionTap, onThreadTap: onThreadTap, + onViewInChannelTap: onViewInChannelTap, onReplyTap: onReplyTap, onReactionsTap: onReactionsTap, onQuotedMessageTap: onQuotedMessageTap, @@ -159,6 +161,7 @@ class StreamMessageWidgetProps { this.onMessageLinkTap, this.onUserMentionTap, this.onThreadTap, + this.onViewInChannelTap, this.onReplyTap, this.onReactionsTap, this.onQuotedMessageTap, @@ -251,12 +254,23 @@ class StreamMessageWidgetProps { /// Called when the thread reply indicator is tapped. /// - /// Receives the parent [Message] of the thread. If the message was shown - /// in-channel via [Message.showInChannel], the original parent message is - /// fetched before invoking the callback. + /// [parentMessage] is the root message of the thread. When the tapped + /// message was shown in-channel via [Message.showInChannel], + /// [threadMessage] contains the original in-channel reply so that the + /// caller can scroll to / highlight it inside the thread view. + /// Otherwise [threadMessage] is null. /// /// If null, tapping the thread indicator has no effect. - final void Function(Message message)? onThreadTap; + final void Function(Message parentMessage, Message? threadMessage)? onThreadTap; + + /// Called when the "View" button on the "Also sent in channel" annotation + /// is tapped inside a thread view. + /// + /// Typically used to pop the thread screen and scroll to / highlight the + /// message in the parent channel list. + /// + /// When null, the "View" button falls back to [onThreadTap]. + final void Function(Message message)? onViewInChannelTap; /// Called when the quoted-reply action is selected from the actions list. /// @@ -334,7 +348,8 @@ class StreamMessageWidgetProps { void Function(User)? onUserAvatarTap, void Function(Message, String)? onMessageLinkTap, void Function(User)? onUserMentionTap, - void Function(Message)? onThreadTap, + void Function(Message, Message?)? onThreadTap, + void Function(Message)? onViewInChannelTap, void Function(Message)? onReplyTap, void Function(Message)? onReactionsTap, void Function(Message)? onQuotedMessageTap, @@ -358,6 +373,7 @@ class StreamMessageWidgetProps { onMessageLinkTap: onMessageLinkTap ?? this.onMessageLinkTap, onUserMentionTap: onUserMentionTap ?? this.onUserMentionTap, onThreadTap: onThreadTap ?? this.onThreadTap, + onViewInChannelTap: onViewInChannelTap ?? this.onViewInChannelTap, onReplyTap: onReplyTap ?? this.onReplyTap, onReactionsTap: onReactionsTap ?? this.onReactionsTap, onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, @@ -428,10 +444,16 @@ class DefaultStreamMessage extends StatelessWidget { ); } + final listKind = StreamMessageLayout.listKindOf(context); + final onViewTap = switch ((listKind, props.onViewInChannelTap)) { + (.thread, final onTap?) => () => onTap(message), + _ => () => _onViewThread(context, message), + }; + final annotationWidget = effectiveAnnotationVisibility.apply( StreamMessageAnnotations( message: message, - onViewChannelTap: () => _onViewThread(context, message), + onViewChannelTap: onViewTap, ), ); @@ -655,18 +677,18 @@ class DefaultStreamMessage extends StatelessWidget { } // Resolves the thread parent (fetching if shown in-channel) and invokes - // the onThreadTap callback. + // the onThreadTap callback with both the parent and the original message. Future _onViewThread( BuildContext context, Message message, ) async { try { - var threadMessage = message; if (message.showInChannel case true) { final streamChannel = StreamChannel.of(context); - threadMessage = await streamChannel.getMessage(message.parentId!); + final parentMessage = await streamChannel.getMessage(message.parentId!); + return props.onThreadTap?.call(parentMessage, message); } - return props.onThreadTap?.call(threadMessage); + return props.onThreadTap?.call(message, null); } catch (e, stk) { debugPrint('Error while fetching message: $e, $stk'); } @@ -817,7 +839,7 @@ class DefaultStreamMessage extends StatelessWidget { UnpinMessage() => channel.unpinMessage(action.message), ResendMessage() => channel.retryMessage(action.message), QuotedReply() => props.onReplyTap?.call(action.message), - ThreadReply() => props.onThreadTap?.call(action.message), + ThreadReply() => props.onThreadTap?.call(action.message, null), }; // Copies the message text (with mentions replaced) to the clipboard. diff --git a/sample_app/lib/pages/thread_list_page.dart b/sample_app/lib/pages/thread_list_page.dart index f730dde1f..4201b30e2 100644 --- a/sample_app/lib/pages/thread_list_page.dart +++ b/sample_app/lib/pages/thread_list_page.dart @@ -1,6 +1,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/thread_page.dart'; +import 'package:sample_app/routes/routes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ThreadListPage extends StatefulWidget { @@ -49,14 +51,27 @@ class _ThreadListPageState extends State { return StreamChannel( channel: channel, initialMessageId: thread.draft?.parentId, - child: BetterStreamBuilder( - stream: channel.state?.messagesStream.map( - (messages) => messages.firstWhereOrNull( - (m) => m.id == thread.parentMessage?.id, - ), - ), + child: BetterStreamBuilder( + initialData: thread.parentMessage, + stream: channel.state?.messagesStream + .map( + (messages) => messages.firstWhereOrNull( + (m) => m.id == thread.parentMessage?.id, + ), + ) + .where((msg) => msg != null) + .cast(), builder: (_, parentMessage) { - return ThreadPage(parent: parentMessage); + return ThreadPage( + parent: parentMessage, + onViewInChannelTap: (message) { + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: {'mid': message.id}, + ); + }, + ); }, ), ); diff --git a/sample_app/lib/pages/thread_page.dart b/sample_app/lib/pages/thread_page.dart index 234ec4366..c4c401e62 100644 --- a/sample_app/lib/pages/thread_page.dart +++ b/sample_app/lib/pages/thread_page.dart @@ -7,10 +7,12 @@ class ThreadPage extends StatefulWidget { required this.parent, this.initialScrollIndex, this.initialAlignment, + this.onViewInChannelTap, }); final Message parent; final int? initialScrollIndex; final double? initialAlignment; + final void Function(Message message)? onViewInChannelTap; @override State createState() => _ThreadPageState(); @@ -58,6 +60,7 @@ class _ThreadPageState extends State { messageFilter: defaultFilter, showScrollToBottom: false, highlightInitialMessage: true, + onViewInChannelTap: widget.onViewInChannelTap, ), ), if (widget.parent.type != 'deleted')