Skip to content

Commit cb1385c

Browse files
authored
navigation between channel and thread (#2580)
1 parent 61e418b commit cb1385c

5 files changed

Lines changed: 194 additions & 65 deletions

File tree

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
## Unreleased
2+
3+
✅ Added
4+
5+
- Added `onViewInChannelTap` callback to `StreamMessageListView` and `StreamMessageWidgetProps`.
6+
Tapping "View" on "Also sent in channel" in a thread now pops the thread and scrolls to the
7+
message in the channel. Override this callback to customise navigation (e.g. when the thread
8+
is opened from a thread list instead of a channel).
9+
10+
🛑️ Breaking
11+
12+
- **`onThreadTap` on `StreamMessageWidgetProps` now receives two parameters:**
13+
`(Message parentMessage, Message? threadMessage)` instead of just the parent message.
14+
When a reply with `showInChannel: true` is tapped, `threadMessage` contains the reply so the
15+
thread view can scroll to and highlight it. Pass `null` for `threadMessage` when not applicable.
16+
- **`onThreadTap` is no longer called from thread views.** It now only fires for channel-side
17+
taps. The "View" button in threads uses the new `onViewInChannelTap` instead.
18+
Migrate any thread-side `onThreadTap` logic to `onViewInChannelTap`.
19+
120
## 10.0.0-beta.12
221

322
🐞 Fixed

packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart

Lines changed: 115 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ class StreamMessageListView extends StatefulWidget {
110110
this.parentMessage,
111111
this.threadBuilder,
112112
this.onThreadTap,
113+
this.onViewInChannelTap,
113114
this.onEditMessageTap,
114115
this.onReplyTap,
115116
this.swipeToReply = false,
@@ -216,6 +217,19 @@ class StreamMessageListView extends StatefulWidget {
216217
/// built using [threadBuilder]
217218
final ThreadTapCallback? onThreadTap;
218219

220+
/// Called when the "View" button on the "Also sent in channel" annotation
221+
/// is tapped inside a thread view.
222+
///
223+
/// Use this to navigate to the channel screen and scroll to / highlight
224+
/// the given [Message].
225+
///
226+
/// When null and the thread was opened via the default [threadBuilder]
227+
/// navigation, the thread screen is automatically popped and the channel
228+
/// list scrolls to the message. Provide this callback to override that
229+
/// behaviour — for example when the thread is opened from a thread list
230+
/// or deep link where popping would not land on the channel screen.
231+
final void Function(Message message)? onViewInChannelTap;
232+
219233
/// {@macro onEditMessageTap}
220234
///
221235
/// If provided, the inline edit flow is used instead of the edit bottom sheet.
@@ -451,7 +465,7 @@ class StreamMessageListView extends StatefulWidget {
451465

452466
class _StreamMessageListViewState extends State<StreamMessageListView> {
453467
ItemScrollController? _scrollController;
454-
void Function(Message)? _onThreadTap;
468+
void Function(Message parentMessage, Message? threadMessage)? _onThreadTap;
455469
final ValueNotifier<bool> _showScrollToBottom = ValueNotifier(false);
456470
late final ItemPositionsListener _itemPositionListener;
457471
int? _messageListLength;
@@ -520,22 +534,29 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
520534
unreadCount = streamChannel?.channel.state?.unreadCount ?? 0;
521535
_firstUnreadMessage = streamChannel?.getFirstUnreadMessage();
522536

523-
if (widget.highlightInitialMessage) {
524-
final initialMessageId = streamChannel?.initialMessageId;
525-
if (initialMessageId != null) {
526-
_highlightedMessageId = initialMessageId;
527-
_highlightGeneration++;
528-
}
537+
final highlightMessageId = widget.highlightInitialMessage
538+
? (streamChannel?.initialMessageId ?? _ThreadHighlightScope.of(context))
539+
: null;
540+
541+
if (highlightMessageId != null) {
542+
WidgetsBinding.instance.addPostFrameCallback((_) {
543+
if (!mounted) return;
544+
_moveToAndHighlight(
545+
messages: messages,
546+
messageId: highlightMessageId,
547+
initialScrollIndex: widget.initialScrollIndex,
548+
scrollTo: false,
549+
);
550+
});
551+
} else {
552+
initialIndex = getInitialIndex(
553+
widget.initialScrollIndex,
554+
streamChannel!,
555+
widget.messageFilter,
556+
);
557+
initialAlignment = _initialAlignment;
529558
}
530559

531-
initialIndex = getInitialIndex(
532-
widget.initialScrollIndex,
533-
streamChannel!,
534-
widget.messageFilter,
535-
);
536-
537-
initialAlignment = _initialAlignment;
538-
539560
if (_scrollController?.isAttached == true) {
540561
_scrollController?.jumpTo(
541562
index: initialIndex,
@@ -585,31 +606,50 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
585606
});
586607
}
587608

588-
Future<void> _scrollToAndHighlight(
589-
String messageId, {
609+
Future<void> _moveToAndHighlight({
590610
required List<Message> messages,
611+
String? messageId,
612+
int? initialScrollIndex,
613+
bool scrollTo = true,
591614
}) async {
592-
final index = messages.indexWhere((m) => m.id == messageId);
593-
if (index >= 0) {
594-
await _scrollController?.scrollTo(
595-
index: index + 2, // +2 to account for loader and footer
596-
duration: const Duration(seconds: 1),
597-
curve: Curves.easeInOut,
598-
alignment: 0.1,
615+
if (messageId != null) {
616+
final index = messages.indexWhere((m) => m.id == messageId);
617+
618+
if (index >= 0) {
619+
if (scrollTo) {
620+
_scrollController?.scrollTo(
621+
index: index + 2, // +2 to account for loader and footer
622+
duration: const Duration(seconds: 1),
623+
curve: Curves.easeInOut,
624+
alignment: 0.1,
625+
);
626+
} else {
627+
_scrollController?.jumpTo(
628+
index: index + 2, // +2 to account for loader and footer
629+
alignment: 0.1,
630+
);
631+
}
632+
} else {
633+
await streamChannel!.loadChannelAtMessage(messageId).then((_) async {
634+
initialIndex = getInitialIndex(
635+
initialScrollIndex,
636+
streamChannel!,
637+
widget.messageFilter,
638+
messageId: messageId,
639+
);
640+
initialAlignment = 0.1;
641+
});
642+
}
643+
} else if (initialScrollIndex != null) {
644+
_scrollController?.jumpTo(
645+
index: initialScrollIndex,
646+
alignment: initialAlignment,
599647
);
600-
} else {
601-
await streamChannel!.loadChannelAtMessage(messageId).then((_) async {
602-
initialIndex = getInitialIndex(
603-
null,
604-
streamChannel!,
605-
widget.messageFilter,
606-
messageId: messageId,
607-
);
608-
initialAlignment = 0.1;
609-
});
610648
}
611649

612-
_highlightMessage(messageId);
650+
if (messageId != null) {
651+
_highlightMessage(messageId);
652+
}
613653
}
614654

615655
@override
@@ -1255,6 +1295,9 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
12551295
message: message,
12561296
swipeToReply: widget.swipeToReply,
12571297
onThreadTap: _onThreadTap,
1298+
onViewInChannelTap: _isThreadConversation
1299+
? widget.onViewInChannelTap ?? (message) => Navigator.of(context).pop(message.id)
1300+
: null,
12581301
onMessageTap: widget.onMessageTap,
12591302
onMessageLongPress: widget.onMessageLongPress,
12601303
onEditMessageTap: widget.onEditMessageTap,
@@ -1265,8 +1308,8 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
12651308
onUserMentionTap: widget.onUserMentionTap,
12661309
onQuotedMessageTap: switch (widget.onQuotedMessageTap) {
12671310
final onTap? => onTap,
1268-
_ => (quotedMessage) => _scrollToAndHighlight(
1269-
quotedMessage.id,
1311+
_ => (quotedMessage) => _moveToAndHighlight(
1312+
messageId: quotedMessage.id,
12701313
messages: messages,
12711314
),
12721315
},
@@ -1391,37 +1434,64 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
13911434
// Case 1: widget.onThreadTap is provided.
13921435
// The created callback will use widget.onThreadTap, passing the result
13931436
// of widget.threadBuilder (if provided) as the second argument.
1394-
(final onThreadTap?, final threadBuilder) => (Message message) {
1437+
(final onThreadTap?, final threadBuilder) => (Message parentMessage, Message? threadMessage) {
13951438
onThreadTap(
1396-
message,
1397-
threadBuilder?.call(context, message),
1439+
parentMessage,
1440+
threadBuilder?.call(context, parentMessage),
13981441
);
13991442
},
14001443
// Case 2: widget.onThreadTap is null, but widget.threadBuilder is provided.
14011444
// The created callback will perform the default navigation action,
14021445
// using widget.threadBuilder to build the thread page.
1403-
(null, final threadBuilder?) => (Message message) {
1404-
final threadPage = StreamChatConfiguration(
1446+
(null, final threadBuilder?) => (Message parentMessage, Message? threadMessage) async {
1447+
Widget threadPage = StreamChatConfiguration(
14051448
// This is needed to provide the nearest reaction icons to the
14061449
// StreamMessageReactionsModal.
14071450
data: StreamChatConfiguration.of(context),
14081451
child: StreamChannel(
14091452
channel: streamChannel!.channel,
14101453
child: BetterStreamBuilder<Message>(
1411-
initialData: message,
1454+
initialData: parentMessage,
14121455
stream: streamChannel!.channel.state?.messagesStream.map(
1413-
(it) => it.firstWhere((m) => m.id == message.id),
1456+
(it) => it.firstWhere((m) => m.id == parentMessage.id),
14141457
),
14151458
builder: (_, data) => threadBuilder(context, data),
14161459
),
14171460
),
14181461
);
14191462

1420-
Navigator.of(context).push(
1463+
if (threadMessage != null) {
1464+
threadPage = _ThreadHighlightScope(
1465+
messageId: threadMessage.id,
1466+
child: threadPage,
1467+
);
1468+
}
1469+
1470+
final result = await Navigator.of(context).push<String>(
14211471
MaterialPageRoute(builder: (_) => threadPage),
14221472
);
1473+
1474+
if (result != null && mounted) {
1475+
_moveToAndHighlight(messageId: result, messages: messages);
1476+
}
14231477
},
14241478
_ => null,
14251479
};
14261480
}
14271481
}
1482+
1483+
class _ThreadHighlightScope extends InheritedWidget {
1484+
const _ThreadHighlightScope({
1485+
required this.messageId,
1486+
required super.child,
1487+
});
1488+
1489+
final String messageId;
1490+
1491+
static String? of(BuildContext context) {
1492+
return context.findAncestorWidgetOfExactType<_ThreadHighlightScope>()?.messageId;
1493+
}
1494+
1495+
@override
1496+
bool updateShouldNotify(_ThreadHighlightScope oldWidget) => messageId != oldWidget.messageId;
1497+
}

packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import 'package:stream_core_flutter/stream_core_flutter.dart' as core;
5151
/// StreamMessageWidget(
5252
/// message: message,
5353
/// onMessageTap: (msg) => print('Tapped: ${msg.id}'),
54-
/// onThreadTap: (msg) => Navigator.push(...),
54+
/// onThreadTap: (parent, threadMsg) => Navigator.push(...),
5555
/// onUserAvatarTap: (user) => showProfile(user),
5656
/// )
5757
/// ```
@@ -82,7 +82,8 @@ class StreamMessageWidget extends StatelessWidget {
8282
void Function(User)? onUserAvatarTap,
8383
void Function(Message message, String url)? onMessageLinkTap,
8484
void Function(User user)? onUserMentionTap,
85-
void Function(Message)? onThreadTap,
85+
void Function(Message parentMessage, Message? threadMessage)? onThreadTap,
86+
void Function(Message)? onViewInChannelTap,
8687
void Function(Message)? onReplyTap,
8788
void Function(Message)? onReactionsTap,
8889
void Function(Message quotedMessage)? onQuotedMessageTap,
@@ -105,6 +106,7 @@ class StreamMessageWidget extends StatelessWidget {
105106
onMessageLinkTap: onMessageLinkTap,
106107
onUserMentionTap: onUserMentionTap,
107108
onThreadTap: onThreadTap,
109+
onViewInChannelTap: onViewInChannelTap,
108110
onReplyTap: onReplyTap,
109111
onReactionsTap: onReactionsTap,
110112
onQuotedMessageTap: onQuotedMessageTap,
@@ -159,6 +161,7 @@ class StreamMessageWidgetProps {
159161
this.onMessageLinkTap,
160162
this.onUserMentionTap,
161163
this.onThreadTap,
164+
this.onViewInChannelTap,
162165
this.onReplyTap,
163166
this.onReactionsTap,
164167
this.onQuotedMessageTap,
@@ -251,12 +254,23 @@ class StreamMessageWidgetProps {
251254

252255
/// Called when the thread reply indicator is tapped.
253256
///
254-
/// Receives the parent [Message] of the thread. If the message was shown
255-
/// in-channel via [Message.showInChannel], the original parent message is
256-
/// fetched before invoking the callback.
257+
/// [parentMessage] is the root message of the thread. When the tapped
258+
/// message was shown in-channel via [Message.showInChannel],
259+
/// [threadMessage] contains the original in-channel reply so that the
260+
/// caller can scroll to / highlight it inside the thread view.
261+
/// Otherwise [threadMessage] is null.
257262
///
258263
/// If null, tapping the thread indicator has no effect.
259-
final void Function(Message message)? onThreadTap;
264+
final void Function(Message parentMessage, Message? threadMessage)? onThreadTap;
265+
266+
/// Called when the "View" button on the "Also sent in channel" annotation
267+
/// is tapped inside a thread view.
268+
///
269+
/// Typically used to pop the thread screen and scroll to / highlight the
270+
/// message in the parent channel list.
271+
///
272+
/// When null, the "View" button falls back to [onThreadTap].
273+
final void Function(Message message)? onViewInChannelTap;
260274

261275
/// Called when the quoted-reply action is selected from the actions list.
262276
///
@@ -334,7 +348,8 @@ class StreamMessageWidgetProps {
334348
void Function(User)? onUserAvatarTap,
335349
void Function(Message, String)? onMessageLinkTap,
336350
void Function(User)? onUserMentionTap,
337-
void Function(Message)? onThreadTap,
351+
void Function(Message, Message?)? onThreadTap,
352+
void Function(Message)? onViewInChannelTap,
338353
void Function(Message)? onReplyTap,
339354
void Function(Message)? onReactionsTap,
340355
void Function(Message)? onQuotedMessageTap,
@@ -358,6 +373,7 @@ class StreamMessageWidgetProps {
358373
onMessageLinkTap: onMessageLinkTap ?? this.onMessageLinkTap,
359374
onUserMentionTap: onUserMentionTap ?? this.onUserMentionTap,
360375
onThreadTap: onThreadTap ?? this.onThreadTap,
376+
onViewInChannelTap: onViewInChannelTap ?? this.onViewInChannelTap,
361377
onReplyTap: onReplyTap ?? this.onReplyTap,
362378
onReactionsTap: onReactionsTap ?? this.onReactionsTap,
363379
onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap,
@@ -428,10 +444,16 @@ class DefaultStreamMessage extends StatelessWidget {
428444
);
429445
}
430446

447+
final listKind = StreamMessageLayout.listKindOf(context);
448+
final onViewTap = switch ((listKind, props.onViewInChannelTap)) {
449+
(.thread, final onTap?) => () => onTap(message),
450+
_ => () => _onViewThread(context, message),
451+
};
452+
431453
final annotationWidget = effectiveAnnotationVisibility.apply(
432454
StreamMessageAnnotations(
433455
message: message,
434-
onViewChannelTap: () => _onViewThread(context, message),
456+
onViewChannelTap: onViewTap,
435457
),
436458
);
437459

@@ -655,18 +677,18 @@ class DefaultStreamMessage extends StatelessWidget {
655677
}
656678

657679
// Resolves the thread parent (fetching if shown in-channel) and invokes
658-
// the onThreadTap callback.
680+
// the onThreadTap callback with both the parent and the original message.
659681
Future<void> _onViewThread(
660682
BuildContext context,
661683
Message message,
662684
) async {
663685
try {
664-
var threadMessage = message;
665686
if (message.showInChannel case true) {
666687
final streamChannel = StreamChannel.of(context);
667-
threadMessage = await streamChannel.getMessage(message.parentId!);
688+
final parentMessage = await streamChannel.getMessage(message.parentId!);
689+
return props.onThreadTap?.call(parentMessage, message);
668690
}
669-
return props.onThreadTap?.call(threadMessage);
691+
return props.onThreadTap?.call(message, null);
670692
} catch (e, stk) {
671693
debugPrint('Error while fetching message: $e, $stk');
672694
}
@@ -817,7 +839,7 @@ class DefaultStreamMessage extends StatelessWidget {
817839
UnpinMessage() => channel.unpinMessage(action.message),
818840
ResendMessage() => channel.retryMessage(action.message),
819841
QuotedReply() => props.onReplyTap?.call(action.message),
820-
ThreadReply() => props.onThreadTap?.call(action.message),
842+
ThreadReply() => props.onThreadTap?.call(action.message, null),
821843
};
822844

823845
// Copies the message text (with mentions replaced) to the clipboard.

0 commit comments

Comments
 (0)