mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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**.  ## Validation - `flutter analyze` — no issues - `flutter test` — 1,243 passed --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user