mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
🤖 ## 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>
89 lines
3.3 KiB
Dart
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();
|
|
});
|
|
}
|
|
}
|