Files
buzz/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.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

122 lines
4.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// How far a finger must travel downward before the keyboard is dismissed.
///
/// Large enough that reading-speed scrolling never trips it, small enough that
/// a deliberate "push it away" swipe feels immediate.
const keyboardDismissDragThreshold = 48.0;
/// Dismiss the software keyboard when the user deliberately drags downward.
///
/// Flutter only offers `ScrollViewKeyboardDismissBehavior.manual` and `.onDrag`
/// — and `.onDrag` fires the instant a drag begins, so a one-pixel scroll to
/// re-read a message would tear the keyboard away. Neither is available on
/// `ScrollablePositionedList` regardless, which is what both message lists use.
///
/// So: accumulate raw finger travel and only unfocus once a downward drag
/// clears [keyboardDismissDragThreshold]. Any upward movement resets the
/// accumulator, so a scroll that wanders up and down never adds up to a
/// dismissal.
///
/// This tracks the *finger*, not the scroll direction, so it behaves the same
/// in the reversed channel timeline and the top-anchored thread list. It is not
/// interactive dismissal — the keyboard animates away on the OS's own curve
/// rather than following the finger, which Flutter can't do without a native
/// plugin (iOS `UIScrollView.keyboardDismissMode`, Android
/// `WindowInsetsAnimationController`).
class KeyboardDismissOnDrag extends HookWidget {
final Widget child;
final VoidCallback? onUserScrollStart;
/// Called after a drag-originated scroll ends, not after programmatic scrolls.
final VoidCallback? onUserScrollEnd;
const KeyboardDismissOnDrag({
super.key,
this.onUserScrollStart,
this.onUserScrollEnd,
required this.child,
});
@override
Widget build(BuildContext context) {
// A ref, not state: accumulating travel must never trigger a rebuild of
// the message list this wraps.
final downwardTravel = useRef(0.0);
final userScrollInProgress = useRef(false);
bool handle(ScrollNotification notification) {
// This wrapper owns the directly wrapped message list. Notifications from
// scrollables inside a message (code blocks, media, reactions, and any
// future vertical descendants) bubble through here at a greater depth and
// must not affect the primary list's drag lifecycle or dismissal state.
if (notification.depth != 0) return false;
if (notification is ScrollStartNotification) {
userScrollInProgress.value = notification.dragDetails != null;
if (userScrollInProgress.value) onUserScrollStart?.call();
downwardTravel.value = 0;
return false;
}
if (notification is ScrollEndNotification) {
if (userScrollInProgress.value) onUserScrollEnd?.call();
userScrollInProgress.value = false;
downwardTravel.value = 0;
return false;
}
// Overscroll counts as much as ordinary scrolling. You focus the composer
// at the resting end of the list far more often than not, and there a
// downward drag moves the position nowhere — it is pure overscroll, and
// reports `OverscrollNotification` instead. Listening only for
// `ScrollUpdateNotification` meant the most common dismissal gesture in
// both lists produced no notification this saw at all.
final DragUpdateDetails? drag;
if (notification is ScrollUpdateNotification) {
drag = notification.dragDetails;
} else if (notification is OverscrollNotification) {
drag = notification.dragDetails;
} else {
return false;
}
// Null during a momentum fling — only a finger still on the glass should
// dismiss, so a fling that coasts downward leaves the keyboard up.
if (drag == null) {
downwardTravel.value = 0;
return false;
}
final dy = drag.delta.dy;
if (dy <= 0) {
downwardTravel.value = 0;
return false;
}
downwardTravel.value += dy;
if (downwardTravel.value < keyboardDismissDragThreshold) return false;
downwardTravel.value = 0;
dismissKeyboard(context);
return false;
}
return NotificationListener<ScrollNotification>(
onNotification: handle,
child: child,
);
}
}
/// Drop focus so the OS retracts the keyboard. No-op when nothing is focused
/// or the keyboard is already down, so callers can fire it freely.
void dismissKeyboard(BuildContext context) {
// Read the view's own insets, not MediaQuery's. `Scaffold` strips the bottom
// view inset from its body while `resizeToAvoidBottomInset` is on — it has
// already resized to account for it — so a MediaQuery read from anywhere
// inside the body reports 0 whether the keyboard is up or not, which made
// this gate a permanent no-op for both message lists and the compose bar.
if (View.of(context).viewInsets.bottom <= 0) return;
FocusManager.instance.primaryFocus?.unfocus();
}