Fix mobile message timeline bounce (#4862)

## Summary

Stop repeated follow-latest scrolling after layout changes in channels
and DMs.

## Validation

- `flutter analyze lib/features/channels/channel_detail_page.dart`
- `flutter test test/features/channels/channel_detail_page_test.dart`
- Full mobile pre-push suite

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-05 12:25:32 -07:00
committed by GitHub
co-authored by Wes Carl
parent 27b51144f3
commit 0c6842931b
2 changed files with 197 additions and 19 deletions
@@ -45,7 +45,7 @@ class _MessageList extends HookConsumerWidget {
initialMessageId == null && initialThreadRootId == null,
);
final isAutoScrolling = useRef(false);
final autoScrollScheduled = useRef(false);
final latestRealignmentQueued = useRef(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
@@ -198,18 +198,6 @@ class _MessageList extends HookConsumerWidget {
}
}
void scheduleAutoScrollToLatest() {
if (autoScrollScheduled.value || isAutoScrolling.value) return;
autoScrollScheduled.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
autoScrollScheduled.value = false;
if (!context.mounted || !followsLatest.value || hasUserScrolled.value) {
return;
}
scrollToLatest();
});
}
bool latestIsAtBoundary() {
// In this reversed list, item 0's leading edge is the bottom boundary.
// Being merely visible is not enough: a user who has pulled a tall
@@ -220,6 +208,29 @@ class _MessageList extends HookConsumerWidget {
);
}
void realignLatestAfterLayoutChange() {
if (latestRealignmentQueued.value ||
!followsLatest.value ||
hasUserScrolled.value) {
return;
}
latestRealignmentQueued.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
latestRealignmentQueued.value = false;
if (!context.mounted ||
!itemScrollController.isAttached ||
!followsLatest.value ||
hasUserScrolled.value ||
latestIsAtBoundary()) {
return;
}
// A dock or keyboard resize is a layout correction, not a navigation
// action. Keeping it instant avoids restarting a smooth scroll for
// every position report while the viewport settles.
itemScrollController.jumpTo(index: 0);
});
}
useEffect(() {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
@@ -232,12 +243,7 @@ class _MessageList extends HookConsumerWidget {
}
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
} else if (followsLatest.value && !hasUserScrolled.value) {
// The viewport can shrink when the composer or keyboard opens.
// Preserve auto-follow until the user scrolls the timeline.
if (!isAtLatest.value) isAtLatest.value = true;
scheduleAutoScrollToLatest();
} else if (isAtLatest.value) {
} else if (!followsLatest.value && isAtLatest.value) {
isAtLatest.value = false;
}
@@ -261,6 +267,22 @@ class _MessageList extends HookConsumerWidget {
);
}, [channelId, entries.length, itemPositionsListener]);
// Composer size changes and keyboard metrics changes arrive in separate
// layout passes. Preserve the latest-message anchor for both, but only
// while the user has not deliberately left the tail.
useEffect(() {
realignLatestAfterLayoutChange();
return null;
}, [composerBottomInset]);
useEffect(() {
final observer = _ChannelLatestMetricsObserver(
onMetricsChanged: realignLatestAfterLayoutChange,
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [itemScrollController]);
useEffect(() {
if (initialThreadRootId == null || didOpenInitialThread.value) {
return null;
@@ -588,3 +610,12 @@ class _JumpToLatestButton extends StatelessWidget {
);
}
}
class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
_ChannelLatestMetricsObserver({required this.onMetricsChanged});
@override
void didChangeMetrics() => onMetricsChanged();
}
@@ -1467,6 +1467,16 @@ void main() {
);
await tester.pumpAndSettle();
// History requests are deliberately scheduled after the current frame.
// Advance the test clock until the capped chain has settled.
for (
var frame = 0;
frame < 8 && messagesNotifier.fetchOlderCalls < 4;
frame++
) {
await tester.pump(const Duration(milliseconds: 1));
}
expect(messagesNotifier.fetchOlderCalls, 4);
final unreadButton = find.byKey(
const ValueKey('channel-jump-to-oldest-unread'),
@@ -1760,6 +1770,143 @@ void main() {
);
});
testWidgets(
'keeps the followed tail anchored through composer and keyboard resize',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.reset);
final messages = [
for (var i = 0; i < 20; i++)
_textMsg(
id: 'msg$i',
pubkey: i.isEven ? 'alice' : 'bob',
content: 'Message $i',
createdAt: 1000 + i * 1000,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final latestMessage = find.byKey(
const ValueKey('channel-message-group-msg19'),
);
final composerDock = find.byKey(
const ValueKey('channel-composer-dock'),
);
final compactDockHeight = tester.getSize(composerDock).height;
expect(latestMessage, findsOneWidget);
expect(
tester.getBottomLeft(latestMessage).dy,
lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1),
);
await tester.tap(find.text('Message #general'));
await tester.pumpAndSettle();
expect(
tester.getSize(composerDock).height,
greaterThan(compactDockHeight),
);
expect(
tester.getBottomLeft(latestMessage).dy,
lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
expect(latestMessage, findsOneWidget);
expect(
tester.getBottomLeft(latestMessage).dy,
lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
},
);
testWidgets(
'does not realign a user-detached timeline on keyboard resize',
(tester) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.reset);
final messages = [
for (var i = 0; i < 40; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
final messageList = find.byKey(const ValueKey('channel-message-list'));
await tester.drag(messageList, const Offset(0, 300));
await tester.pumpAndSettle();
expect(findRichText('Message 39'), findsNothing);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
expect(findRichText('Message 39'), findsNothing);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
final positions = tester
.widget<ScrollablePositionedList>(messageList)
.itemPositionsNotifier!
.itemPositions
.value;
expect(
positions.any(
(position) =>
position.index == 0 && position.itemLeadingEdge.abs() < 0.01,
),
isFalse,
);
},
);
testWidgets('can jump back to latest after a non-drag user scroll', (
tester,
) async {