Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class _MessageList extends HookConsumerWidget {
initialMessageId == null && initialThreadRootId == null,
);
final isAutoScrolling = useRef(false);
final autoScrollScheduled = useRef(false);
final latestRealignmentQueued = useRef(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
Expand Down Expand Up @@ -198,18 +198,6 @@ class _MessageList extends HookConsumerWidget {
}
}

void scheduleAutoScrollToLatest() {
if (autoScrollScheduled.value || isAutoScrolling.value) return;
autoScrollScheduled.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
autoScrollScheduled.value = false;
if (!context.mounted || !followsLatest.value || hasUserScrolled.value) {
return;
}
scrollToLatest();
});
}

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
Expand All @@ -220,6 +208,29 @@ class _MessageList extends HookConsumerWidget {
);
}

void realignLatestAfterLayoutChange() {
if (latestRealignmentQueued.value ||
!followsLatest.value ||
hasUserScrolled.value) {
return;
}
latestRealignmentQueued.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
latestRealignmentQueued.value = false;
if (!context.mounted ||
!itemScrollController.isAttached ||
!followsLatest.value ||
hasUserScrolled.value ||
latestIsAtBoundary()) {
return;
}
// 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);
});
}

useEffect(() {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
Expand All @@ -232,12 +243,7 @@ class _MessageList extends HookConsumerWidget {
}
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
} else if (followsLatest.value && !hasUserScrolled.value) {
// The viewport can shrink when the composer or keyboard opens.
// Preserve auto-follow until the user scrolls the timeline.
if (!isAtLatest.value) isAtLatest.value = true;
scheduleAutoScrollToLatest();
} else if (isAtLatest.value) {
} else if (!followsLatest.value && isAtLatest.value) {
isAtLatest.value = false;
}

Expand All @@ -261,6 +267,22 @@ class _MessageList extends HookConsumerWidget {
);
}, [channelId, entries.length, itemPositionsListener]);

// Composer size changes and keyboard metrics changes arrive in separate
// layout passes. Preserve the latest-message anchor for both, but only
// while the user has not deliberately left the tail.
useEffect(() {
realignLatestAfterLayoutChange();
return null;
}, [composerBottomInset]);

useEffect(() {
final observer = _ChannelLatestMetricsObserver(
onMetricsChanged: realignLatestAfterLayoutChange,
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [itemScrollController]);

useEffect(() {
if (initialThreadRootId == null || didOpenInitialThread.value) {
return null;
Expand Down Expand Up @@ -588,3 +610,12 @@ class _JumpToLatestButton extends StatelessWidget {
);
}
}

class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;

_ChannelLatestMetricsObserver({required this.onMetricsChanged});

@override
void didChangeMetrics() => onMetricsChanged();
}
147 changes: 147 additions & 0 deletions mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,16 @@ void main() {
);
await tester.pumpAndSettle();

// History requests are deliberately scheduled after the current frame.
// Advance the test clock until the capped chain has settled.
for (
var frame = 0;
frame < 8 && messagesNotifier.fetchOlderCalls < 4;
frame++
) {
await tester.pump(const Duration(milliseconds: 1));
}

expect(messagesNotifier.fetchOlderCalls, 4);
final unreadButton = find.byKey(
const ValueKey('channel-jump-to-oldest-unread'),
Expand Down Expand Up @@ -1758,6 +1768,143 @@ void main() {
);
});

testWidgets(
'keeps the followed tail anchored through composer and keyboard 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 < 20; i++)
_textMsg(
id: 'msg$i',
pubkey: i.isEven ? 'alice' : 'bob',
content: 'Message $i',
createdAt: 1000 + i * 1000,
),
];

await tester.pumpWidget(
_buildTestable(
messages: messages,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();

final latestMessage = find.byKey(
const ValueKey('channel-message-group-msg19'),
);
final composerDock = find.byKey(
const ValueKey('channel-composer-dock'),
);
final compactDockHeight = tester.getSize(composerDock).height;

expect(latestMessage, findsOneWidget);
expect(
tester.getBottomLeft(latestMessage).dy,
lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1),
);

await tester.tap(find.text('Message #general'));
await tester.pumpAndSettle();

expect(
tester.getSize(composerDock).height,
greaterThan(compactDockHeight),
);
expect(
tester.getBottomLeft(latestMessage).dy,
lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);

tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();

expect(latestMessage, findsOneWidget);
expect(
tester.getBottomLeft(latestMessage).dy,
lessThanOrEqualTo(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 {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.reset);

final messages = [
for (var i = 0; i < 40; 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 messageList = find.byKey(const ValueKey('channel-message-list'));
await tester.drag(messageList, const Offset(0, 300));
await tester.pumpAndSettle();

expect(findRichText('Message 39'), findsNothing);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);

tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();

expect(findRichText('Message 39'), findsNothing);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
final positions = tester
.widget<ScrollablePositionedList>(messageList)
.itemPositionsNotifier!
.itemPositions
.value;
expect(
positions.any(
(position) =>
position.index == 0 && position.itemLeadingEdge.abs() < 0.01,
),
isFalse,
);
},
);

testWidgets('can jump back to latest after a non-drag user scroll', (
tester,
) async {
Expand Down
Loading