diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 37082ab963..b05126f314 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -155,6 +155,13 @@ class _MessageList extends HookConsumerWidget { : displayEntries.length - 1 - chronologicalIndex; } + double latestAlignment() { + final viewportHeight = context.size?.height ?? 0; + return viewportHeight > 0 + ? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble() + : 0.0; + } + Future scrollToLatest() async { if (!itemScrollController.isAttached || isAutoScrolling.value) return; followsLatest.value = true; @@ -163,6 +170,7 @@ class _MessageList extends HookConsumerWidget { try { await itemScrollController.scrollTo( index: 0, + alignment: latestAlignment(), duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, ); @@ -199,12 +207,15 @@ class _MessageList extends HookConsumerWidget { } 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 - // newest row away from the boundary must not snap back on live updates. + // In this reversed list, item 0's leading edge is the visible bottom + // boundary above the composer. Being merely visible is not enough: a + // user who has pulled a tall newest row away from that boundary must not + // snap back on live updates. + final boundary = latestAlignment(); return itemPositionsListener.itemPositions.value.any( (position) => - position.index == 0 && position.itemLeadingEdge.abs() < 0.01, + position.index == 0 && + (position.itemLeadingEdge - boundary).abs() < 0.01, ); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 00b9f81229..6c0e1a54fb 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1811,7 +1811,7 @@ void main() { expect(latestMessage, findsOneWidget); expect( tester.getBottomLeft(latestMessage).dy, - lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + closeTo(tester.getTopLeft(composerDock).dy, 1), ); await tester.tap(find.text('Message #general')); @@ -1823,7 +1823,7 @@ void main() { ); expect( tester.getBottomLeft(latestMessage).dy, - lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + closeTo(tester.getTopLeft(composerDock).dy, 1), ); expect( find.byKey(const ValueKey('channel-jump-to-latest')), @@ -1836,7 +1836,7 @@ void main() { expect(latestMessage, findsOneWidget); expect( tester.getBottomLeft(latestMessage).dy, - lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + closeTo(tester.getTopLeft(composerDock).dy, 1), ); expect( find.byKey(const ValueKey('channel-jump-to-latest')), @@ -1845,6 +1845,53 @@ void main() { }, ); + testWidgets('keeps a short followed tail flush through composer 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 < 3; 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 latestMessage = find.byKey( + const ValueKey('channel-message-group-msg2'), + ); + final composerDock = find.byKey(const ValueKey('channel-composer-dock')); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(latestMessage).dy, + closeTo(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 { @@ -1990,6 +2037,18 @@ void main() { await tester.pumpAndSettle(); expect(findRichText('Newest live update'), findsOneWidget); + final latestMessage = find.byKey( + const ValueKey('channel-message-group-newest'), + ); + final composerDock = find.byKey(const ValueKey('channel-composer-dock')); + expect( + tester.getBottomLeft(latestMessage).dy, + closeTo(tester.getTopLeft(composerDock).dy, 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); }); testWidgets(