Skip to content
Open
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
41 changes: 38 additions & 3 deletions mobile/lib/features/channels/initial_thread_tail_settle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class InitialThreadTailSettle {
required BuildContext context,
required ItemScrollController controller,
required ItemPositionsListener positionsListener,
required ScrollPosition? Function() activePosition,
required int? targetIndex,
required double hiddenTopFraction,
required double hiddenBottomFraction,
Expand Down Expand Up @@ -68,6 +69,9 @@ class InitialThreadTailSettle {
// that fully visible target down would only add empty space above the
// head. A clipped tail still takes the measured correction path.
if (targetIsFullyVisible) {
// A superseded generation's placement may have left the position
// overscrolled; never reveal a viewport that is still springing.
settleThreadScrollPositionInRange(activePosition());
_isComplete = true;
onSettled();
return;
Expand All @@ -83,9 +87,24 @@ class InitialThreadTailSettle {
duration: const Duration(milliseconds: 1),
)
.whenComplete(() {
if (generation != _generation) return;
_isComplete = true;
onSettled();
// The package animates to an unclamped offset. When the target
// (plus its trailing padding) is shorter than the visible area,
// that offset lies past maxScrollExtent; iOS bouncing physics
// then lets the 1 ms drive overshoot and springs the whole
// thread back over ~600 ms. Settle the active position inside
// its range after the placement has laid out, and only then
// reveal, so the viewport first paints at rest on the tail.
// The clamp runs even for a superseded generation: it is
// idempotent, and the newer generation must not inherit a
// spring it cannot see.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
settleThreadScrollPositionInRange(activePosition());
if (generation != _generation) return;
_isComplete = true;
onSettled();
});
WidgetsBinding.instance.scheduleFrame();
});
});
// A post-frame callback does not itself request the frame in which it
Expand All @@ -95,3 +114,19 @@ class InitialThreadTailSettle {
});
}
}

/// Moves an overscrolled thread position back inside its scroll range.
///
/// Programmatic placements can leave the position beyond its extents on iOS,
/// where bouncing physics does not clamp driven scrolls; the resulting spring
/// is the visible entry bounce. A clamped jump ends the ballistic activity and
/// leaves the position idle. Returns whether a correction was applied.
@visibleForTesting
bool settleThreadScrollPositionInRange(ScrollPosition? position) {
if (position == null || !position.hasContentDimensions) return false;
if (!position.outOfRange) return false;
position.jumpTo(
position.pixels.clamp(position.minScrollExtent, position.maxScrollExtent),
);
return true;
}
6 changes: 6 additions & 0 deletions mobile/lib/features/channels/thread_detail_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Widget _trackActiveThreadScrollPosition(
},
);

/// Whether [position] is moving under a scroll activity no finger started.
bool _threadScrollIsProgrammaticallyMoving(
ScrollPosition position, {
required bool isDragging,
}) => !isDragging && position.isScrollingNotifier.value;

bool _jumpActiveThreadScrollToTail(
ObjectRef<ScrollPosition?> activePosition,
bool Function()? testOverride,
Expand Down
111 changes: 108 additions & 3 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ class ThreadDetailPage extends HookConsumerWidget {
final hidesLatestForComposerTailCorrection = useState(false);
final tailCorrectionInProgress = useRef(false);
final tailCorrectionGeneration = useRef(0);
final initialSettleRevealGeneration = useRef(0);
final idleTailRecheck = useRef<(ScrollPosition, VoidCallback)?>(null);
final activeThreadScrollPosition = useRef<ScrollPosition?>(null);
final composerHasFocus = useListenable(composerFocusNode).hasFocus;
final viewportHeight = useListenable(listViewport.height).value;
Expand All @@ -280,6 +282,18 @@ class ThreadDetailPage extends HookConsumerWidget {
// the hydrated target, then reveal the settled viewport.
final threadViewportVisible =
!relayRepliesAvailable || initialViewportReady.value;
// A deep link renders its route snapshot while the relay query is still
// in flight, then closes the viewport gate to place the hydrated target.
// Latest measured against that provisional snapshot would mount, unmount
// for the placement frames, and mount again; wait for the placement.
// Only the first attempt counts: a failed query's retry window carries
// its error while loading, and a reader browsing the snapshot through
// that window keeps their way back to the tail.
final hidesLatestForDeepLinkSnapshot =
initialMessageId != null &&
!relayRepliesAvailable &&
relayReplyState.isLoading &&
!relayReplyState.hasError;

// Item 0 is the thread head; reply `i` lives at `i + 1`.
const headIndex = 0;
Expand Down Expand Up @@ -410,10 +424,76 @@ class ThreadDetailPage extends HookConsumerWidget {
WidgetsBinding.instance.scheduleFrame();
}

void cancelIdleTailRecheck() {
final pending = idleTailRecheck.value;
if (pending == null) return;
pending.$1.isScrollingNotifier.removeListener(pending.$2);
idleTailRecheck.value = null;
}

// Re-decide the tail once a programmatic scroll on [position] goes idle.
// The final tick can land without a further position report, so the
// hysteresis in onPositionsChanged needs this to release its hold.
void scheduleIdleTailRecheck(ScrollPosition position) {
if (identical(idleTailRecheck.value?.$1, position)) return;
cancelIdleTailRecheck();
void onScrollingChanged() {
if (position.isScrollingNotifier.value) return;
cancelIdleTailRecheck();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || tailCorrectionInProgress.value) return;
// Back-to-back programmatic activities (a correction chained onto
// a spring) pass through idle for a microtask; keep holding.
if (_threadScrollIsProgrammaticallyMoving(
position,
isDragging: tailIntent.isDragging,
)) {
scheduleIdleTailRecheck(position);
return;
}
final tailIsVisible = threadTailIsVisible();
if (isAtThreadTail.value != tailIsVisible) {
isAtThreadTail.value = tailIsVisible;
}
});
WidgetsBinding.instance.scheduleFrame();
}

idleTailRecheck.value = (position, onScrollingChanged);
position.isScrollingNotifier.addListener(onScrollingChanged);
}

// Ordinary entry keeps Latest hidden until the initial settle has placed
// the tail. Positions refresh one frame after that placement, so decide
// from a fenced post-frame callback; a drag that begins first owns the
// flag instead (onUserScrollStart bumps the generation). Without this
// release a tail that ends below the composer had no way back.
void revealLatestAfterInitialSettle() {
final generation = ++initialSettleRevealGeneration.value;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted ||
generation != initialSettleRevealGeneration.value) {
return;
}
if (!tailCorrectionInProgress.value) {
isAtThreadTail.value = threadTailIsVisible();
}
hidesLatestForInitialTailSettle.value = false;
});
WidgetsBinding.instance.scheduleFrame();
}

useEffect(() => cancelIdleTailRecheck, const []);

void followThreadTailFromComposer() {
if (userDragDetachedTailFollow.value) return;
hidesLatestForComposerTailCorrection.value = true;
// Abandoning the settle here means its reveal never runs; release the
// entry gate now so a tail left below the fold still offers Latest
// once the composer blurs.
initialTailSettle.abandon();
initialSettleRevealGeneration.value++;
hidesLatestForInitialTailSettle.value = false;
initialViewportReady.value = true;
tailIntent.endDrag();
tailIntent.detach();
Expand Down Expand Up @@ -444,9 +524,24 @@ class ThreadDetailPage extends HookConsumerWidget {
followsThreadTail.value = true;
}
if (tailCorrectionInProgress.value) return;
if (isAtThreadTail.value != tailIsVisible) {
isAtThreadTail.value = tailIsVisible;
if (isAtThreadTail.value == tailIsVisible) return;
// A driven or ballistic scroll that no finger started (a placement,
// an iOS rubber-band, a correction) reports intermediate positions
// every frame. Those may hide Latest early but must not reveal it:
// the motion is heading for the tail, and mounting the native
// control per report is the visible strobe. The user's own drags
// and the idle position after the motion ends decide freely.
final position = activeThreadScrollPosition.value;
if (!tailIsVisible &&
position != null &&
_threadScrollIsProgrammaticallyMoving(
position,
isDragging: tailIntent.isDragging,
)) {
scheduleIdleTailRecheck(position);
return;
}
isAtThreadTail.value = tailIsVisible;
}

itemPositionsListener.itemPositions.addListener(onPositionsChanged);
Expand All @@ -459,6 +554,7 @@ class ThreadDetailPage extends HookConsumerWidget {
replies.length,
liveHead.createdAt,
viewportHeight,
timelineBottomInset,
],
);

Expand Down Expand Up @@ -577,6 +673,10 @@ class ThreadDetailPage extends HookConsumerWidget {
initialTargetReadyForHighlight.value = true;
initialViewportReady.value = true;
});
// A post-frame callback does not request its own frame; a slow
// relay can otherwise park the hydrated viewport until an
// unrelated redraw.
WidgetsBinding.instance.scheduleFrame();
}

itemPositionsListener.itemPositions.addListener(
Expand Down Expand Up @@ -620,14 +720,17 @@ class ThreadDetailPage extends HookConsumerWidget {
context: context,
controller: itemScrollController,
positionsListener: itemPositionsListener,
activePosition: () => activeThreadScrollPosition.value,
targetIndex: replies.isEmpty
? null
: indexForReply(replies.length - 1),
hiddenTopFraction: topOverlayFraction,
hiddenBottomFraction:
(composerDockHeight.value + settledImeLift) / viewportHeight,
onSettled: () {
if (context.mounted) initialViewportReady.value = true;
if (!context.mounted) return;
initialViewportReady.value = true;
revealLatestAfterInitialSettle();
},
);
return null;
Expand Down Expand Up @@ -855,6 +958,7 @@ class ThreadDetailPage extends HookConsumerWidget {
child: _ThreadMessageList(
viewport: listViewport,
onUserScrollStart: () {
initialSettleRevealGeneration.value++;
hidesLatestForInitialTailSettle.value = false;
hidesLatestForComposerTailCorrection.value = false;
initialTailSettle.abandon();
Expand Down Expand Up @@ -974,6 +1078,7 @@ class ThreadDetailPage extends HookConsumerWidget {
visible:
threadViewportVisible &&
hasFetchedReplies &&
!hidesLatestForDeepLinkSnapshot &&
!isNavigatingToThreadTail.value &&
!hidesLatestForInitialTailSettle.value &&
!hidesLatestForComposerTailCorrection.value &&
Expand Down
Loading