mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user