Files
buzz/mobile/lib/features/channels/initial_thread_tail_settle.dart
7634fe7456 fix(mobile): settle hydrated threads on latest reply (#4702)
🤖
## Summary

Mobile threads could open above the newest reply because the reply query
hydrates across relay pages while the list is still being laid out.
Ordinary thread opens now wait for authoritative hydration and late
layout before settling on the latest reply.

The initial settle is generation-guarded: if another reply arrives while
it is pending, the stale target is discarded and the current tail
becomes the target. Explicit deep links still own their requested
position, existing threads only follow remote replies when the previous
tail was visible, and local sends remain visible.

### Related issue

No matching issue found. This is separate from the channel
unread-navigation behavior in #4239.

Originating Buzz thread:
`buzz://message?channel=a9081ecd-9be0-400b-8bf9-2e8e0d385b80&id=bfb289fc53754f62f641fbf58bf2d7a9c181a3e6eb09a6ba762aeb6904b6cde4&thread=bfb289fc53754f62f641fbf58bf2d7a9c181a3e6eb09a6ba762aeb6904b6cde4`

### Testing

- Added a widget regression covering paginated hydration plus a live
reply arriving during the initial settle.
- Full mobile Flutter test suite passed; `flutter analyze` passed.
- GitHub CI passed, including the Mobile job.
- Built, installed, and launched the debug app on an iPad Pro 11-inch
(M4), iOS 18.6 simulator. An authenticated manual thread traversal was
not performed because the fresh app was not paired to a relay account.

---------

Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
2026-08-12 14:25:32 -07:00

89 lines
3.3 KiB
Dart

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;
/// Whether no more settling is needed.
///
/// Completion occurs when there is no tail target, the target is already
/// visible after hydration has settled, or the scheduled scroll finishes.
bool get isComplete => _isComplete;
/// Permanently abandons initial settling and invalidates queued callbacks.
///
/// This is terminal: later scheduling remains disabled even if the user
/// returns to the tail and resumes ordinary follow behavior.
void abandon() {
_generation++;
_isComplete = true;
}
/// Schedules a settle after each hydrated thread layout until [isComplete].
///
/// A later schedule replaces an earlier target while replies are still
/// arriving. The final target is left in place when already visible; otherwise
/// it scrolls into the viewport between the measured top and bottom overlays.
void schedule({
required BuildContext context,
required ItemScrollController controller,
required ItemPositionsListener positionsListener,
required int? targetIndex,
required double hiddenTopFraction,
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 >= hiddenTopFraction &&
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: hiddenTopFraction,
duration: const Duration(milliseconds: 1),
)
.whenComplete(() {
if (generation == _generation) _isComplete = true;
});
});
// A post-frame callback does not itself request the frame in which it
// runs. Slow hydration can otherwise leave this settle parked until an
// unrelated redraw.
WidgetsBinding.instance.scheduleFrame();
});
}
}