From 68e718f77a7dc854f242eb7205b7a6ecd63a8e38 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 17:01:58 -0700 Subject: [PATCH 1/3] fix(mobile): keep latest messages above composer Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channel_detail_page/message_list.dart | 21 ++++++++++++++----- .../channels/channel_detail_page_test.dart | 12 +++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) 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..c7c18ea5ae 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, ); } @@ -227,7 +238,7 @@ class _MessageList extends HookConsumerWidget { // 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); + itemScrollController.jumpTo(index: 0, alignment: latestAlignment()); }); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 00b9f81229..5bafbaa3a7 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1990,6 +1990,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, + lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); }); testWidgets( From 14642b46e9f4d18faace0a873e185d881c11d733 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 21:51:34 -0700 Subject: [PATCH 2/3] fix(mobile): avoid short-channel latest overshoot Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channel_detail_page/message_list.dart | 2 +- .../channels/channel_detail_page_test.dart | 58 +++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) 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 c7c18ea5ae..b05126f314 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -238,7 +238,7 @@ class _MessageList extends HookConsumerWidget { // 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, alignment: latestAlignment()); + itemScrollController.jumpTo(index: 0); }); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 5bafbaa3a7..da8ca1d417 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,57 @@ 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')), + findsNothing, + ); + }, + ); + + 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')), @@ -1996,7 +2046,7 @@ void main() { final composerDock = find.byKey(const ValueKey('channel-composer-dock')); 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')), From 65c77bda4c5b84d36e993e62391e0245317629f3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 11:39:37 -0700 Subject: [PATCH 3/3] style(mobile): format latest offset regression test Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channel_detail_page_test.dart | 85 +++++++++---------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index da8ca1d417..6c0e1a54fb 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1845,55 +1845,52 @@ 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, - ), - ]; + 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); - await tester.pumpWidget( - _buildTestable( - messages: messages, - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, + final messages = [ + for (var i = 0; i < 3; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, ), - ); - await tester.pumpAndSettle(); + ]; - final latestMessage = find.byKey( - const ValueKey('channel-message-group-msg2'), - ); - final composerDock = find.byKey( - const ValueKey('channel-composer-dock'), - ); + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); - await tester.tap(find.text('Message #general')); - await tester.pumpAndSettle(); + final latestMessage = find.byKey( + const ValueKey('channel-message-group-msg2'), + ); + 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, - ); - }, - ); + 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',