Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5fd710f
test(e2e): add e2e_app, port native e2e suites, one app process per C…
testableapple Oct 23, 2026
0b8aa6d
chore(repo): ignore e2e mock server, Allure results and allurectl in …
testableapple Oct 23, 2026
b4979e4
test(repo): link every e2e test to its Allure TestOps case
testableapple Oct 23, 2026
b71afe2
Merge remote-tracking branch 'origin/master' into test/e2e-native-parity
testableapple Oct 23, 2026
b03e78e
test(repo): unmount the e2e app before resetting the client, verify c…
testableapple Oct 23, 2026
3bb8bd3
ci(repo): run PR e2e on API 36, use a preinstalled NDK, retry failed …
testableapple Oct 23, 2026
e7510b3
test(repo): retype into a field that ignored the text
testableapple Oct 23, 2026
1e75428
test(repo): keep the copied message id in the e2e app, bound the vid…
testableapple Oct 23, 2026
4b7a5fa
Merge branch 'master' into test/e2e-native-parity
testableapple Oct 23, 2026
8d9cef2
test(repo): scroll until a message is on screen, stop when the emulat…
testableapple Oct 23, 2026
0e4da80
ci(repo): give the e2e app's Gradle the sample app's memory settings
testableapple Oct 23, 2026
4a66a48
Bump timeout
testableapple Oct 24, 2026
ab90f31
test(repo): restart a dead emulator during the e2e run
testableapple Oct 24, 2026
c883599
test(repo): time-limit e2e runs so a dead emulator can't hang the job
testableapple Oct 24, 2026
87586b9
fix(repo): look up timeout through sh so the lane runs on Linux
testableapple Oct 24, 2026
d42a93a
ci(repo): split the e2e coverage nightly into 4 batches
testableapple Oct 25, 2026
8e73290
ci(repo): give the e2e runners memory headroom
testableapple Oct 25, 2026
b6acb2a
test(repo): retry the tap that opens the image gallery
testableapple Oct 25, 2026
89fb209
fix(ui): open the thread of a quoted thread reply from the channel (F…
xsahil03x Oct 27, 2026
963e292
Merge remote-tracking branch 'origin/test/e2e-native-parity' into sah…
xsahil03x Oct 27, 2026
5d3ab06
test(repo): enable the quoted thread reply e2e test (FLU-1095)
xsahil03x Oct 27, 2026
11f3e60
refactor(ui): address review on the quoted thread reply tap (FLU-1095)
xsahil03x Oct 27, 2026
41a1778
test(ui): make the thread-list quote test reach the guard (FLU-1095)
xsahil03x Oct 27, 2026
1c27a9a
fix(ui): scroll a thread opened at a reply to that reply (FLU-1095)
xsahil03x Oct 27, 2026
0a6c077
test(ui): drop an unnecessary null check (FLU-1095)
xsahil03x Oct 27, 2026
0b47478
Merge remote-tracking branch 'origin/master' into sahil/flu-1095-tapp…
xsahil03x Oct 27, 2026
31b848e
Merge remote-tracking branch 'origin/master' into sahil/flu-1095-tapp…
xsahil03x Oct 28, 2026
0285e62
fix(ui): stop the jump to a quoted thread reply once the user scrolls
xsahil03x Oct 28, 2026
711d9d1
fix(ui): let any user scroll cancel the jump to a quoted thread reply
xsahil03x Oct 28, 2026
f4b02d9
Merge remote-tracking branch 'origin/master' into sahil/flu-1095-tapp…
xsahil03x Oct 28, 2026
f398764
Merge remote-tracking branch 'origin/master' into sahil/flu-1095-tapp…
xsahil03x Oct 28, 2026
39d8cca
fix(ui): open a quoted thread reply at that reply through a custom th…
xsahil03x Oct 28, 2026
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
1 change: 0 additions & 1 deletion e2e_app/integration_test/quoted_reply_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -357,6 +360,9 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {

List<Message> messages = <Message>[];

// 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));
Expand Down Expand Up @@ -415,12 +421,12 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
// 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((_) {
Expand Down Expand Up @@ -650,17 +656,20 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
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<UserScrollNotification>(
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,
),
),
),
),
Expand All @@ -670,6 +679,14 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
Widget _buildListView(List<Message> data) {
messages = data;

if (_pendingThreadMessageId case final messageId? when messages.any((it) => it.id == messageId)) {
Comment thread
xsahil03x marked this conversation as resolved.
Resolved
_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]
Expand Down Expand Up @@ -1281,7 +1298,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
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
Expand Down Expand Up @@ -1316,7 +1333,31 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {

// 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<void> _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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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;
Expand Down Expand Up @@ -1352,10 +1393,12 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
// 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,
Expand Down
Loading
Loading