fix(mobile): keep latest messages above composer (#4981)

**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.

<img width="498" height="1008" alt="Screen Recording 2026-08-05 at 5 18
19 PM"
src="https://github.com/user-attachments/assets/a7fc1a94-3ffb-4c34-908d-9bf4f3f082b4"
/>


<details>
<summary>File changes</summary>

**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.

</details>

## 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 <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-07 11:38:25 -07:00
committed by GitHub
parent 78c87ae20e
commit 07999425dc
2 changed files with 77 additions and 7 deletions

View File

@@ -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<void> 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,
);
}

View File

@@ -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(