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 @@ -437,8 +437,13 @@ class _MessageList extends HookConsumerWidget {
) -
composerBottomInset,
);
final hidesJumpToLatest = shouldHideChannelJumpToLatest(
isAtLatest: latestIsAtBoundary(),
followsLatest: followsLatest.value,
userHasDetached: hasUserScrolled.value,
);
final shouldShow =
!latestIsAtBoundary() &&
!hidesJumpToLatest &&
(hasUnseenLatestEntry.value ||
!latestIsVisible ||
distanceFromLatest.value > visiblePageHeight);
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/features/channels/jump_to_latest_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class JumpToLatestButton extends HookWidget {
final nativeChannel = useState<MethodChannel?>(null);
final onPressedRef = useRef(onPressed)..value = onPressed;
final brightness = context.theme.brightness.name;
final routeIsCurrent = ModalRoute.isCurrentOf(context) ?? true;

useEffect(() {
final channel = nativeChannel.value;
Expand Down Expand Up @@ -101,6 +102,7 @@ class JumpToLatestButton extends HookWidget {
child: ValueListenableBuilder<bool>(
valueListenable: messageActionBackdropActive,
builder: (context, backdropActive, _) {
if (!routeIsCurrent) return const SizedBox.shrink();
if (!usesNativeIosGlass || backdropActive) {
return buildFlutterSurface();
}
Expand Down
32 changes: 25 additions & 7 deletions mobile/lib/features/channels/jump_to_latest_switcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import 'package:flutter/material.dart';

import 'jump_to_latest_button.dart';

/// Whether channel Latest should remain hidden at the effective timeline tail.
///
/// Composer and keyboard layout updates can briefly make item-position
/// measurements stale. Preserve an active tail-follow intent through those
/// frames unless the user explicitly detached from the tail.
bool shouldHideChannelJumpToLatest({
required bool isAtLatest,
required bool followsLatest,
required bool userHasDetached,
}) => isAtLatest || (followsLatest && !userHasDetached);

/// Shared channel/thread visibility transition for [JumpToLatestButton].
class JumpToLatestSwitcher extends StatelessWidget {
final String id;
Expand All @@ -18,6 +29,19 @@ class JumpToLatestSwitcher extends StatelessWidget {
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final child = visible
? JumpToLatestButton(
key: ValueKey('$id-jump-to-latest'),
id: id,
onPressed: onPressed,
)
: SizedBox.shrink(key: ValueKey('$id-jump-to-latest-hidden'));
if (Theme.of(context).platform == TargetPlatform.iOS) {
return KeyedSubtree(
key: ValueKey('$id-jump-to-latest-switcher'),
child: child,
);
}
return AnimatedSwitcher(
key: ValueKey('$id-jump-to-latest-switcher'),
duration: reduceMotion
Expand All @@ -36,13 +60,7 @@ class JumpToLatestSwitcher extends StatelessWidget {
child: child,
),
),
child: visible
? JumpToLatestButton(
key: ValueKey('$id-jump-to-latest'),
id: id,
onPressed: onPressed,
)
: SizedBox.shrink(key: ValueKey('$id-jump-to-latest-hidden')),
child: child,
);
}
}
Expand Down
59 changes: 54 additions & 5 deletions mobile/lib/features/channels/thread_detail_helpers.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,66 @@
part of 'thread_detail_page.dart';

/// Returns whether a bounded tail correction reached the effective end.
const _threadTailScrollTolerance = 0.5;

// Keep the direct-position correction finite in case the viewport cannot
// expose its tail (for example, continuously changing media dimensions).
const _latestTailCorrectionLimit = 8;

Widget _trackActiveThreadScrollPosition(
Widget child,
ObjectRef<ScrollPosition?> activePosition,
) => Builder(
builder: (context) {
activePosition.value = Scrollable.of(context).position;
return child;
},
);

bool _jumpActiveThreadScrollToTail(
ObjectRef<ScrollPosition?> activePosition,
bool Function()? testOverride,
) {
if (testOverride != null) return testOverride();
final position = activePosition.value;
if (position == null || !position.hasContentDimensions) return false;
// Moving the one active viewport avoids a second list and its visible bounce.
position.jumpTo(position.maxScrollExtent);
return true;
}

Future<bool> _animateActiveThreadScrollToTail(
BuildContext context,
ObjectRef<ScrollPosition?> activePosition,
) async {
final position = activePosition.value;
if (position == null || !position.hasContentDimensions) return false;
if (MediaQuery.disableAnimationsOf(context)) {
position.jumpTo(position.maxScrollExtent);
return true;
}
await position.animateTo(
position.maxScrollExtent,
duration: jumpToLatestScrollDuration,
curve: jumpToLatestScrollCurve,
);
return true;
}

/// Returns whether the thread is at its effective scroll end.
///
/// Item positions can lag the active scroll position by a frame, so an exact
/// end-of-scroll measurement is sufficient even while the tail item still
/// Item positions can lag or briefly oscillate during lazy layout, so an exact
/// end-of-scroll measurement remains authoritative even while the tail item
/// reports outside the visible boundary.
@visibleForTesting
bool threadTailCorrectionReachedEnd({
bool threadTailIsAtEffectiveEnd({
required bool tailIsLaidOut,
required bool tailIsVisible,
required double? extentAfter,
}) =>
tailIsVisible ||
(extentAfter != null && extentAfter <= _threadTailScrollTolerance);
(tailIsLaidOut &&
extentAfter != null &&
extentAfter <= _threadTailScrollTolerance);

int _threadTailIndex(int replyCount) => replyCount;

Expand Down
113 changes: 55 additions & 58 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,6 @@ const _landingHighlightDuration = Duration(seconds: 3);
const _landingHighlightDelay = Duration(milliseconds: 50);
const _landingHighlightTransitionDuration = Duration(milliseconds: 300);
const _landingHighlightOpacity = 0.12;
const _threadTailScrollTolerance = 0.5;

// Keep the direct-position correction finite in case the viewport cannot
// expose its tail (for example, continuously changing media dimensions).
const _latestTailCorrectionLimit = 8;

/// Full-screen thread detail page.
///
Expand All @@ -79,6 +74,10 @@ class ThreadDetailPage extends HookConsumerWidget {
final bool isArchived;
final String? initialMessageId;

/// Overrides the tail jump only in deterministic lazy-layout tests.
@visibleForTesting
final bool Function()? jumpThreadTailForTesting;

const ThreadDetailPage({
super.key,
required this.threadHead,
Expand All @@ -88,6 +87,7 @@ class ThreadDetailPage extends HookConsumerWidget {
required this.isMember,
required this.isArchived,
this.initialMessageId,
this.jumpThreadTailForTesting,
});

@override
Expand Down Expand Up @@ -252,9 +252,15 @@ class ThreadDetailPage extends HookConsumerWidget {
final initialTailSettle = useMemoized(InitialThreadTailSettle.new);
final isAtThreadTail = useState(true);
final isNavigatingToThreadTail = useState(false);
// Ordinary thread entry owns a tail-follow intent before lazy item
// positions settle. Keep Latest suppressed through those initial layout
// frames; a deep-link entry must expose it for its older target instead.
final hidesLatestForInitialTailSettle = useState(initialMessageId == null);
final hidesLatestForComposerTailCorrection = useState(false);
final tailCorrectionInProgress = useRef(false);
final tailCorrectionGeneration = useRef(0);
final activeThreadScrollPosition = useRef<ScrollPosition?>(null);
final composerHasFocus = useListenable(composerFocusNode).hasFocus;
final viewportHeight = useListenable(listViewport.height).value;
final previousViewportHeight = useRef(viewportHeight);
final settledImeLift = usesFixedAndroidImeViewport
Expand Down Expand Up @@ -321,51 +327,34 @@ class ThreadDetailPage extends HookConsumerWidget {
final trailingBoundary =
1 - ((Grid.xs + timelineBottomInset) / viewportHeight) + 0.001;
final lastMessageIndex = _threadTailIndex(replies.length);
return itemPositionsListener.itemPositions.value.any(
(position) =>
position.index == lastMessageIndex &&
position.itemTrailingEdge <= trailingBoundary,
var tailItemIsLaidOut = false;
var tailItemIsVisible = false;
for (final item in itemPositionsListener.itemPositions.value) {
if (item.index != lastMessageIndex) continue;
tailItemIsLaidOut = true;
tailItemIsVisible = item.itemTrailingEdge <= trailingBoundary;
break;
}
final position = activeThreadScrollPosition.value;
return threadTailIsAtEffectiveEnd(
tailIsLaidOut: tailItemIsLaidOut,
tailIsVisible: tailItemIsVisible,
extentAfter: position != null && position.hasContentDimensions
? position.extentAfter
: null,
);
}

Widget trackActiveScrollPosition(Widget child) {
return Builder(
builder: (itemContext) {
activeThreadScrollPosition.value = Scrollable.of(
itemContext,
).position;
return child;
},
);
}
Widget trackActiveScrollPosition(Widget child) =>
_trackActiveThreadScrollPosition(child, activeThreadScrollPosition);

bool jumpActiveScrollPositionToTail() {
final position = activeThreadScrollPosition.value;
if (position == null || !position.hasContentDimensions) return false;
// Move the one active viewport to its exact end. Unlike indexed
// jumpTo/scrollTo, this does not reset or cross-fade through a second
// list, so iOS never exposes the intermediate top-of-thread frame.
position.jumpTo(position.maxScrollExtent);
return true;
}
bool jumpActiveScrollPositionToTail() => _jumpActiveThreadScrollToTail(
activeThreadScrollPosition,
jumpThreadTailForTesting,
);

Future<bool> animateActiveScrollPositionToTail() async {
final position = activeThreadScrollPosition.value;
if (position == null || !position.hasContentDimensions) return false;
if (MediaQuery.disableAnimationsOf(context)) {
position.jumpTo(position.maxScrollExtent);
return true;
}
// Match the channel's visible Latest glide while moving only the active
// thread viewport. The indexed-list animation path can create a temporary
// second list for distant targets, which caused the old top-frame bounce.
await position.animateTo(
position.maxScrollExtent,
duration: jumpToLatestScrollDuration,
curve: jumpToLatestScrollCurve,
);
return true;
}
Future<bool> animateActiveScrollPositionToTail() =>
_animateActiveThreadScrollToTail(context, activeThreadScrollPosition);

void finishThreadTailCorrection({
required bool revealViewport,
Expand All @@ -380,14 +369,14 @@ class ThreadDetailPage extends HookConsumerWidget {
tailIntent.isDragging ||
userOptedOutOfTailFollow.value) {
tailCorrectionInProgress.value = false;
hidesLatestForComposerTailCorrection.value = false;
isNavigatingToThreadTail.value = false;
isAtThreadTail.value = threadTailIsVisible();
return;
}
final reachedTail = threadTailIsVisible();
// Lazy children can revise maxScrollExtent for several frames. Keep
// moving the same active position until the measured tail is visible;
// the cap only guards pathological layouts that never stabilize.
if (!reachedTail && corrections < _latestTailCorrectionLimit) {
jumpActiveScrollPositionToTail();
WidgetsBinding.instance.addPostFrameCallback(
Expand All @@ -401,19 +390,10 @@ class ThreadDetailPage extends HookConsumerWidget {
return;
}
tailCorrectionInProgress.value = false;
hidesLatestForComposerTailCorrection.value = false;
isNavigatingToThreadTail.value = false;
if (revealViewport) initialViewportReady.value = true;
// Item positions can trail the ScrollPosition by a frame after an
// animated jump. Once the bounded lazy-layout correction is exhausted,
// trust an exact end-of-scroll position too: there is nowhere further
// for Latest to navigate, so leaving the control visible is misleading.
final position = activeThreadScrollPosition.value;
isAtThreadTail.value = threadTailCorrectionReachedEnd(
tailIsVisible: reachedTail,
extentAfter: position != null && position.hasContentDimensions
? position.extentAfter
: null,
);
isAtThreadTail.value = reachedTail;
}

void correctThreadTailInstantly() {
Expand All @@ -432,6 +412,7 @@ class ThreadDetailPage extends HookConsumerWidget {

void followThreadTailFromComposer() {
if (userDragDetachedTailFollow.value) return;
hidesLatestForComposerTailCorrection.value = true;
initialTailSettle.abandon();
initialViewportReady.value = true;
tailIntent.endDrag();
Expand All @@ -440,9 +421,20 @@ class ThreadDetailPage extends HookConsumerWidget {
followsThreadTail.value = true;
final reachedTail = threadTailIsVisible();
isAtThreadTail.value = reachedTail;
if (!reachedTail) correctThreadTailInstantly();
if (reachedTail) {
hidesLatestForComposerTailCorrection.value = false;
} else {
correctThreadTailInstantly();
}
}

useEffect(() {
if (!composerHasFocus) {
hidesLatestForComposerTailCorrection.value = false;
}
return null;
}, [composerHasFocus]);

useEffect(
() {
void onPositionsChanged() {
Expand Down Expand Up @@ -863,6 +855,8 @@ class ThreadDetailPage extends HookConsumerWidget {
child: _ThreadMessageList(
viewport: listViewport,
onUserScrollStart: () {
hidesLatestForInitialTailSettle.value = false;
hidesLatestForComposerTailCorrection.value = false;
initialTailSettle.abandon();
initialViewportReady.value = true;
tailCorrectionInProgress.value = false;
Expand Down Expand Up @@ -981,6 +975,9 @@ class ThreadDetailPage extends HookConsumerWidget {
threadViewportVisible &&
hasFetchedReplies &&
!isNavigatingToThreadTail.value &&
!hidesLatestForInitialTailSettle.value &&
!hidesLatestForComposerTailCorrection.value &&
!(composerHasFocus && !userDragDetachedTailFollow.value) &&
!isAtThreadTail.value,
onPressed: scrollToThreadLatest,
),
Expand Down
Loading
Loading