From 07999425dca4a94ca0dea4f47d674661ea52fac3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 7 Aug 2026 11:38:25 -0700 Subject: [PATCH] fix(mobile): keep latest messages above composer (#4981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Mobile users who jump to Latest now see the newest message fully above the composer instead of partially hidden behind it. **Problem:** The channel message list treated the raw viewport bottom as the latest boundary even though the composer occupies part of that viewport. Latest jumps and follow-mode corrections could therefore place the newest message underneath the composer. **Solution:** Derive the latest alignment from the measured composer inset and use that same boundary for scrolling, follow detection, and layout correction. Screen Recording 2026-08-05 at 5 18
19 PM
File changes **mobile/lib/features/channels/channel_detail_page/message_list.dart** Aligns Latest navigation and follow-mode correction with the visible bottom edge above the composer, and evaluates boundary state against the same geometry. **mobile/test/features/channels/channel_detail_page_test.dart** Adds a regression assertion that the newest live message clears the composer and that the Latest control disappears after navigation.
## Reproduction steps 1. Open a mobile channel with enough messages to scroll away from the newest message. 2. Tap **Latest**. 3. Confirm the newest message is fully visible immediately above the composer and the **Latest** control disappears. 4. Resize the composer or keyboard while following latest and confirm the newest message remains above the composer. ## Tested fix The newest message remains fully visible above the composer after jumping to **Latest**. ![Tested fix: latest message remains above the composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4981/latest-above-composer-tested.gif) ## Validation - `flutter analyze` — no issues - `flutter test` — 1,243 passed --------- Signed-off-by: Taylor Ho --- .../channel_detail_page/message_list.dart | 19 ++++-- .../channels/channel_detail_page_test.dart | 65 ++++++++++++++++++- 2 files changed, 77 insertions(+), 7 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..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 6c59f3ccc6..15cf79a313 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2053,7 +2053,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')); @@ -2065,7 +2065,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')), @@ -2078,7 +2078,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')), @@ -2087,6 +2087,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 { @@ -2232,6 +2279,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(