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
65 changes: 65 additions & 0 deletions mobile/lib/features/channels/initial_thread_tail_settle.dart
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({
Comment on lines +12 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the public settle members

The new InitialThreadTailSettle helper exposes public members (isComplete and schedule) 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 👍 / 👎.

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),
Comment thread
loganj marked this conversation as resolved.
Comment thread
loganj marked this conversation as resolved.
)
.whenComplete(() {
if (generation == _generation) _isComplete = true;
});
});
});
}
}
46 changes: 27 additions & 19 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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(
Comment thread
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;
}

Expand All @@ -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 &&
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -349,6 +356,7 @@ class ThreadDetailPage extends HookConsumerWidget {
Expanded(
child: KeyboardDismissOnDrag(
onUserScrollStart: () {
userOptedOutOfTailFollow.value = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-enable tail following when users return to tail

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;
},
Expand Down
Loading
Loading