-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix(mobile): settle hydrated threads on latest reply #4702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9f98698
707caa9
f93c2ef
c718611
03d2fb2
25cc503
3c3696b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import 'package:flutter/widgets.dart'; | ||
| import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; | ||
|
|
||
| /// Settles an ordinary thread open on the latest hydrated reply after layout. | ||
| /// | ||
| /// Scheduling again before completion invalidates callbacks aimed at an older | ||
| /// tail, allowing a rebuild with newly arrived replies to choose the target. | ||
| class InitialThreadTailSettle { | ||
| var _generation = 0; | ||
| var _isComplete = false; | ||
|
|
||
| bool get isComplete => _isComplete; | ||
|
|
||
| void schedule({ | ||
| required BuildContext context, | ||
| required ItemScrollController controller, | ||
| required ItemPositionsListener positionsListener, | ||
| required int? targetIndex, | ||
| required double hiddenBottomFraction, | ||
| }) { | ||
| if (_isComplete) return; | ||
|
|
||
| final generation = ++_generation; | ||
| if (targetIndex == null) { | ||
| _isComplete = true; | ||
| return; | ||
| } | ||
|
|
||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!context.mounted || generation != _generation) return; | ||
|
|
||
| // Let events received during hydration rebuild the list before committing | ||
| // the target. That rebuild schedules a new generation at the current tail. | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!context.mounted || | ||
| !controller.isAttached || | ||
| generation != _generation) { | ||
| return; | ||
| } | ||
| final targetIsFullyVisible = positionsListener.itemPositions.value.any( | ||
| (position) => | ||
| position.index == targetIndex && | ||
| position.itemLeadingEdge >= 0 && | ||
| position.itemTrailingEdge <= 1 - hiddenBottomFraction, | ||
| ); | ||
| // Short threads already expose their tail from the top anchor. Moving | ||
| // that fully visible target down would only add empty space above the | ||
| // head. A clipped tail still takes the measured correction path. | ||
| if (targetIsFullyVisible) { | ||
| _isComplete = true; | ||
| return; | ||
| } | ||
| controller | ||
| .scrollTo( | ||
| index: targetIndex, | ||
| alignment: 0.0, | ||
| duration: const Duration(milliseconds: 1), | ||
|
loganj marked this conversation as resolved.
loganj marked this conversation as resolved.
|
||
| ) | ||
| .whenComplete(() { | ||
| if (generation == _generation) _isComplete = true; | ||
| }); | ||
| }); | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import 'composer_dock_size_reporter.dart'; | |
| import 'date_formatters.dart'; | ||
| import 'day_divider.dart'; | ||
| import '../profile/user_profile_sheet.dart'; | ||
| import 'initial_thread_tail_settle.dart'; | ||
| import 'message_actions.dart'; | ||
| import 'message_content.dart'; | ||
| import 'reaction_row.dart'; | ||
|
|
@@ -117,10 +118,9 @@ class ThreadDetailPage extends HookConsumerWidget { | |
| final itemPositionsListener = useMemoized(ItemPositionsListener.create); | ||
| final didJumpToInitialMessage = useRef(false); | ||
| final followsThreadTail = useRef(false); | ||
| final userOptedOutOfTailFollow = useRef(false); | ||
| final pendingTailAlignment = useRef<double?>(null); | ||
| final tailRealignmentQueued = useRef(false); | ||
|
|
||
| // Item 0 is the thread head; reply `i` lives at `i + 1`. | ||
| const headIndex = 0; | ||
| int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; | ||
|
|
||
|
|
@@ -136,7 +136,9 @@ class ThreadDetailPage extends HookConsumerWidget { | |
|
|
||
| useEffect(() { | ||
| void onPositionsChanged() { | ||
| if (threadTailIsVisible()) followsThreadTail.value = true; | ||
| if (!userOptedOutOfTailFollow.value && threadTailIsVisible()) { | ||
| followsThreadTail.value = true; | ||
| } | ||
| } | ||
|
|
||
| itemPositionsListener.itemPositions.addListener(onPositionsChanged); | ||
|
|
@@ -161,9 +163,6 @@ class ThreadDetailPage extends HookConsumerWidget { | |
| if (targetIndex == null || didJumpToInitialMessage.value) return null; | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!context.mounted || !itemScrollController.isAttached) return; | ||
| // The provisional route snapshot can make the linked reply look like | ||
| // the tail. This authoritative deep-link jump intentionally leaves | ||
| // the user at an older item, so it must opt out of follow-tail first. | ||
| followsThreadTail.value = false; | ||
| pendingTailAlignment.value = null; | ||
| itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); | ||
|
|
@@ -177,15 +176,22 @@ class ThreadDetailPage extends HookConsumerWidget { | |
| // while the last item is on screen, scroll it into view. If the user has | ||
| // scrolled up to read, leave them where they are. | ||
| final hasFetchedReplies = fetchedReplies != null; | ||
| final didEstablishInitialReplies = useRef(hasFetchedReplies); | ||
| final initialTailSettle = useMemoized(InitialThreadTailSettle.new); | ||
| final previousReplyCount = useRef(replies.length); | ||
| useEffect(() { | ||
| // The first authoritative query result is hydration, not a live arrival. | ||
| // Establish the baseline without moving the user away from the head. | ||
| if (!hasFetchedReplies) return null; | ||
| if (!didEstablishInitialReplies.value) { | ||
| didEstablishInitialReplies.value = true; | ||
| if (!initialTailSettle.isComplete) { | ||
| previousReplyCount.value = replies.length; | ||
| initialTailSettle.schedule( | ||
|
loganj marked this conversation as resolved.
|
||
| context: context, | ||
| controller: itemScrollController, | ||
| positionsListener: itemPositionsListener, | ||
| targetIndex: initialMessageId == null && replies.isNotEmpty | ||
| ? indexForReply(replies.length - 1) | ||
| : null, | ||
| hiddenBottomFraction: | ||
| composerDockHeight.value / MediaQuery.sizeOf(context).height, | ||
| ); | ||
| return null; | ||
| } | ||
|
|
||
|
|
@@ -194,15 +200,12 @@ class ThreadDetailPage extends HookConsumerWidget { | |
| if (replies.length <= previous) return null; | ||
| final positions = itemPositionsListener.itemPositions.value; | ||
| final lastIndex = indexForReply(replies.length - 1); | ||
| // Positions still describe the list as it was *before* these replies, so | ||
| // compare against the old tail. Measuring against the new one only reads | ||
| // as "at the tail" when exactly one reply arrived. | ||
| final previousLastIndex = previous == 0 | ||
| ? headIndex | ||
| : indexForReply(previous - 1); | ||
| final wasAtTail = | ||
| positions.isEmpty || | ||
| positions.any((position) => position.index >= previousLastIndex); | ||
| final wasAtTail = positions.any( | ||
| (position) => position.index == previousLastIndex, | ||
| ); | ||
| final localPubkey = currentPubkey?.toLowerCase(); | ||
| final hasNewLocalReply = | ||
| localPubkey != null && | ||
|
|
@@ -264,7 +267,9 @@ class ThreadDetailPage extends HookConsumerWidget { | |
| final heightDelta = height - previousHeight; | ||
| if (heightDelta.abs() < 0.5) return; | ||
|
|
||
| final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); | ||
| final shouldFollowTail = | ||
| !userOptedOutOfTailFollow.value && | ||
| (followsThreadTail.value || threadTailIsVisible()); | ||
| if (shouldFollowTail) followsThreadTail.value = true; | ||
| composerDockHeight.value = height; | ||
| if (heightDelta <= 0 || !shouldFollowTail) { | ||
|
|
@@ -297,7 +302,9 @@ class ThreadDetailPage extends HookConsumerWidget { | |
| // keyboard appears. Re-align after that latter layout pass too, but only | ||
| // while the user was already following the thread tail. | ||
| void realignThreadTailAfterMetricsChange() { | ||
| final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); | ||
| final shouldFollowTail = | ||
| !userOptedOutOfTailFollow.value && | ||
| (followsThreadTail.value || threadTailIsVisible()); | ||
| if (!shouldFollowTail || tailRealignmentQueued.value) return; | ||
| followsThreadTail.value = true; | ||
| tailRealignmentQueued.value = true; | ||
|
|
@@ -349,6 +356,7 @@ class ThreadDetailPage extends HookConsumerWidget { | |
| Expanded( | ||
| child: KeyboardDismissOnDrag( | ||
| onUserScrollStart: () { | ||
| userOptedOutOfTailFollow.value = true; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Since this flag is set on every drag start and is never cleared, a user who scrolls up to read older replies and then manually scrolls back to the newest reply remains opted out of tail following. The new guards around composer and keyboard realignment then keep returning false even while the tail is visible, so focusing the composer after returning to the bottom can obscure the latest reply; clear the opt-out when the tail becomes visible again or distinguish drags away from drags back to the tail. Useful? React with 👍 / 👎. |
||
| followsThreadTail.value = false; | ||
| pendingTailAlignment.value = null; | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
InitialThreadTailSettlehelper exposes public members (isCompleteandschedule) without member-level documentation. The repo requires doc comments for new public API, so either make this helper/members private or add docs that explain when callers should schedule and how completion is determined.AGENTS.md reference: AGENTS.md:L113-L116
Useful? React with 👍 / 👎.