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
66 changes: 64 additions & 2 deletions mobile/lib/features/channels/channel_detail_page/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class _MessageList extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final displayEntries = groupMembershipTimelineEntries(entries);
final itemScrollController = useMemoized(ItemScrollController.new);
final scrollOffsetController = useMemoized(ScrollOffsetController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
Expand All @@ -40,6 +41,13 @@ class _MessageList extends HookConsumerWidget {
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
final didJumpToInitialMessage = useRef(false);
final reversedEntryIds = [
for (final group in displayEntries.reversed) group.first.message.id,
];
final visibleEntryPositions = useRef(<String, ItemPosition>{});
final pendingAnchorRestore = useRef<({String id, double leadingEdge})?>(
null,
);

int? reversedIndexOf(String? messageId) {
if (messageId == null) return null;
Expand Down Expand Up @@ -96,6 +104,38 @@ class _MessageList extends HookConsumerWidget {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
visibleEntryPositions.value = {
for (final position in positions)
if (position.index < reversedEntryIds.length)
reversedEntryIds[position.index]: position,
};
final pendingAnchor = pendingAnchorRestore.value;
if (pendingAnchor != null &&
(followsLatest.value || latestIsAtBoundary())) {
pendingAnchorRestore.value = null;
} else if (pendingAnchor != null) {
final currentAnchor = visibleEntryPositions.value[pendingAnchor.id];
final viewportExtent = context.size?.height;
if (currentAnchor != null &&
viewportExtent != null &&
viewportExtent > 0) {
pendingAnchorRestore.value = null;
final offset =
(currentAnchor.itemLeadingEdge - pendingAnchor.leadingEdge) *
viewportExtent;
if (offset.abs() >= 0.5) {
// The reversed list retains its numeric index when index 0 is
// prepended, which shifts the user's visible message. Restore
// the stable message ID by exactly the measured viewport delta.
unawaited(
scrollOffsetController.animateScroll(
offset: offset,
duration: const Duration(milliseconds: 1),
),
);
}
}
}
final nextIsAtLatest = latestIsAtBoundary();
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
Expand Down Expand Up @@ -179,10 +219,31 @@ class _MessageList extends HookConsumerWidget {
previousLatestEntryId.value = latestEntryId;
if (previous == null ||
latestEntryId == null ||
previous == latestEntryId ||
!isAtLatest.value) {
previous == latestEntryId) {
return null;
}

if (!isAtLatest.value) {
MapEntry<String, ItemPosition>? anchor;
for (final entry in visibleEntryPositions.value.entries) {
final position = entry.value;
if (position.itemLeadingEdge < 0 ||
position.itemLeadingEdge >= 1 ||
(anchor != null && position.index >= anchor.value.index)) {
continue;
}
anchor = entry;
}
if (anchor == null) return null;

if (!reversedEntryIds.contains(anchor.key)) return null;
pendingAnchorRestore.value = (
id: anchor.key,
leadingEdge: anchor.value.itemLeadingEdge,
);
return null;
}

WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) scrollToLatest();
});
Expand Down Expand Up @@ -250,6 +311,7 @@ class _MessageList extends HookConsumerWidget {
child: ScrollablePositionedList.builder(
key: const ValueKey('channel-message-list'),
itemScrollController: itemScrollController,
scrollOffsetController: scrollOffsetController,
itemPositionsListener: itemPositionsListener,
reverse: true,
padding: EdgeInsets.only(
Expand Down
28 changes: 27 additions & 1 deletion mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,9 @@ void main() {
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
final tallMessageRectBeforeUpdate = tester.getRect(
findRichText('Newest message line 0'),
);

messagesNotifier.setMessages([
...initialMessages,
Expand All @@ -1196,11 +1199,34 @@ void main() {
]);
await tester.pumpAndSettle();

expect(findRichText('Newest live update'), findsNothing);
final newestLiveUpdate = findRichText('Newest live update');
expect(newestLiveUpdate.hitTestable(), findsNothing);
final tallMessageRectAfterUpdate = tester.getRect(
findRichText('Newest message line 0'),
);
expect(
tallMessageRectAfterUpdate.top,
closeTo(tallMessageRectBeforeUpdate.top, 1),
);
expect(
tallMessageRectAfterUpdate.bottom,
closeTo(tallMessageRectBeforeUpdate.bottom, 1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest')));
await tester.pumpAndSettle();

expect(
findRichText('Newest live update').hitTestable(),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
},
);

Expand Down
Loading