Files
buzz/mobile/lib/features/channels/laid_out_viewport.dart
Logan Johnson 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

71 lines
2.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Tracks the height of a viewport after Flutter has laid it out.
///
/// Create one instance for a viewport, pass it to [LaidOutViewportReporter],
/// listen to [height] for measurements, and call [dispose] when the owner is
/// removed. Measurements are reported after the frame that performed layout.
class LaidOutViewport {
/// Key used by [LaidOutViewportReporter] to locate the viewport render box.
final key = GlobalKey();
/// The latest laid-out viewport height, or zero before the first report.
final height = ValueNotifier(0.0);
/// Schedules a viewport measurement after the current frame is laid out.
///
/// Reports only changes of at least half a logical pixel. If the viewport is
/// not mounted or has not been laid out, the scheduled report does nothing.
void reportAfterLayout() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final renderObject = key.currentContext?.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return;
final nextHeight = renderObject.size.height;
if ((height.value - nextHeight).abs() < 0.5) return;
height.value = nextHeight;
});
}
/// Releases the [height] notifier owned by this viewport.
void dispose() => height.dispose();
}
/// Reports the laid-out height of [child] through [viewport].
///
/// Reports once after initial layout and again whenever the child's layout size
/// changes. The owner must keep [viewport] stable across rebuilds and dispose it
/// after this reporter is removed.
class LaidOutViewportReporter extends HookWidget {
/// Receives the laid-out height measurements.
final LaidOutViewport viewport;
/// The widget whose viewport height is measured.
final Widget child;
/// Creates a reporter for [child] backed by [viewport].
const LaidOutViewportReporter({
super.key,
required this.viewport,
required this.child,
});
@override
Widget build(BuildContext context) {
useEffect(() {
viewport.reportAfterLayout();
return null;
}, [viewport]);
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) {
viewport.reportAfterLayout();
return true;
},
child: SizeChangedLayoutNotifier(
child: KeyedSubtree(key: viewport.key, child: child),
),
);
}
}