Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class StreamMessageListView extends StatefulWidget {
this.parentMessage,
this.threadBuilder,
this.onThreadTap,
this.onViewInChannelTap,
this.onEditMessageTap,
this.onReplyTap,
this.swipeToReply = false,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -451,7 +465,7 @@ class StreamMessageListView extends StatefulWidget {

class _StreamMessageListViewState extends State<StreamMessageListView> {
ItemScrollController? _scrollController;
void Function(Message)? _onThreadTap;
void Function(Message parentMessage, Message? threadMessage)? _onThreadTap;
final ValueNotifier<bool> _showScrollToBottom = ValueNotifier(false);
late final ItemPositionsListener _itemPositionListener;
int? _messageListLength;
Expand Down Expand Up @@ -520,22 +534,29 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
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,
Expand Down Expand Up @@ -585,31 +606,50 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
});
}

Future<void> _scrollToAndHighlight(
String messageId, {
Future<void> _moveToAndHighlight({
required List<Message> 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
Expand Down Expand Up @@ -1255,6 +1295,9 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
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,
Expand All @@ -1265,8 +1308,8 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
onUserMentionTap: widget.onUserMentionTap,
onQuotedMessageTap: switch (widget.onQuotedMessageTap) {
final onTap? => onTap,
_ => (quotedMessage) => _scrollToAndHighlight(
quotedMessage.id,
_ => (quotedMessage) => _moveToAndHighlight(
messageId: quotedMessage.id,
messages: messages,
),
},
Expand Down Expand Up @@ -1391,37 +1434,64 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
// 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<Message>(
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<String>(
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
/// )
/// ```
Expand Down Expand Up @@ -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,
Expand All @@ -105,6 +106,7 @@ class StreamMessageWidget extends StatelessWidget {
onMessageLinkTap: onMessageLinkTap,
onUserMentionTap: onUserMentionTap,
onThreadTap: onThreadTap,
onViewInChannelTap: onViewInChannelTap,
onReplyTap: onReplyTap,
onReactionsTap: onReactionsTap,
onQuotedMessageTap: onQuotedMessageTap,
Expand Down Expand Up @@ -159,6 +161,7 @@ class StreamMessageWidgetProps {
this.onMessageLinkTap,
this.onUserMentionTap,
this.onThreadTap,
this.onViewInChannelTap,
this.onReplyTap,
this.onReactionsTap,
this.onQuotedMessageTap,
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
),
);

Expand Down Expand Up @@ -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<void> _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');
}
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading