mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## Summary - Add a lifted-message long-press popover with the reaction tray and grouped actions. - Use a native action surface - Preserve taps and scrolling, exclude attached reactions from the lifted preview, and align the composition to the bottom safe area. <img width="1080" height="2424" alt="Screenshot_20260814-160732" src="https://github.com/user-attachments/assets/a9d5fc91-4ee3-427c-8199-1c3be2b4e98a" /> ## Testing - `bin/just mobile-check` - `flutter test test/features/channels/message_actions_test.dart` - `flutter test` (1,364 tests) - Manual interaction review on iPhone and Pixel 10 --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
219 lines
7.1 KiB
Dart
219 lines
7.1 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
|
|
const _iosMessageLongPressDuration = Duration(milliseconds: 200);
|
|
const _maxMessageSnapshotDimension = 2048.0;
|
|
|
|
double _messageSnapshotPixelRatio(Size size, double devicePixelRatio) {
|
|
final longestSide = size.longestSide;
|
|
if (!longestSide.isFinite || longestSide <= 0) return devicePixelRatio;
|
|
return math.min(devicePixelRatio, _maxMessageSnapshotDimension / longestSide);
|
|
}
|
|
|
|
/// Geometry and snapshot controls for a completed message long press.
|
|
///
|
|
/// Call [captureSnapshot] while the source is still mounted, then use
|
|
/// [setSourceHidden] to hide it behind the lifted preview. Restore the source
|
|
/// before the preview is disposed or dismissed.
|
|
class MessageLongPressDetails {
|
|
/// The long-pressed source bounds in global logical coordinates.
|
|
final Rect anchorRect;
|
|
|
|
/// Captures the current source as an image for the lifted preview.
|
|
///
|
|
/// The returned future can fail if the source is no longer mounted.
|
|
final Future<ui.Image> Function() captureSnapshot;
|
|
|
|
/// Hides or reveals the mounted source while its preview is displayed.
|
|
final ValueChanged<bool> setSourceHidden;
|
|
|
|
/// Creates the details passed to a completed long-press callback.
|
|
const MessageLongPressDetails({
|
|
required this.anchorRect,
|
|
required this.captureSnapshot,
|
|
required this.setSourceHidden,
|
|
});
|
|
}
|
|
|
|
/// An [InkWell] whose long press is observed above interactive descendants.
|
|
class MessageLongPressInkWell extends StatelessWidget {
|
|
final VoidCallback? onTap;
|
|
final ValueChanged<Rect>? onLongPress;
|
|
|
|
/// Handles a completed long press with snapshot and source-visibility access.
|
|
///
|
|
/// When provided, this callback takes precedence over [onLongPress].
|
|
final ValueChanged<MessageLongPressDetails>? onLongPressDetails;
|
|
final BorderRadius? borderRadius;
|
|
final Color? highlightColor;
|
|
|
|
/// Identifies the [RepaintBoundary] to capture for the lifted preview.
|
|
///
|
|
/// The key's current context must resolve to a boundary covering the message
|
|
/// content. When omitted, this widget inserts and owns that boundary.
|
|
final GlobalKey? snapshotKey;
|
|
final Widget child;
|
|
|
|
const MessageLongPressInkWell({
|
|
super.key,
|
|
this.onTap,
|
|
this.onLongPress,
|
|
this.onLongPressDetails,
|
|
this.borderRadius,
|
|
this.highlightColor,
|
|
this.snapshotKey,
|
|
required this.child,
|
|
}) : assert(onLongPress != null || onLongPressDetails != null);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _MessageLongPressRegion(
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
onLongPressDetails: onLongPressDetails,
|
|
borderRadius: borderRadius,
|
|
highlightColor: highlightColor,
|
|
externalSnapshotKey: snapshotKey,
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Detects a message long press through Flutter's gesture arena.
|
|
///
|
|
/// Links, media, reactions, and other nested controls keep their normal tap
|
|
/// gestures, while a completed hold wins over descendant taps.
|
|
class _MessageLongPressRegion extends HookWidget {
|
|
final VoidCallback? onTap;
|
|
final ValueChanged<Rect>? onLongPress;
|
|
final ValueChanged<MessageLongPressDetails>? onLongPressDetails;
|
|
final BorderRadius? borderRadius;
|
|
final Color? highlightColor;
|
|
final GlobalKey? externalSnapshotKey;
|
|
final Widget child;
|
|
|
|
const _MessageLongPressRegion({
|
|
required this.onTap,
|
|
required this.onLongPress,
|
|
required this.onLongPressDetails,
|
|
required this.borderRadius,
|
|
required this.highlightColor,
|
|
required this.externalSnapshotKey,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fallbackSnapshotKey = useMemoized(GlobalKey.new, const []);
|
|
final snapshotKey = externalSnapshotKey ?? fallbackSnapshotKey;
|
|
final sourceHidden = useState(false);
|
|
|
|
void recognize() {
|
|
final renderObject = snapshotKey.currentContext?.findRenderObject();
|
|
if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
|
|
return;
|
|
}
|
|
final anchorRect =
|
|
renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
|
|
|
final detailsCallback = onLongPressDetails;
|
|
if (detailsCallback == null) {
|
|
onLongPress?.call(anchorRect);
|
|
return;
|
|
}
|
|
final maxSnapshotPixelRatio = math.min(
|
|
MediaQuery.devicePixelRatioOf(context),
|
|
2.0,
|
|
);
|
|
|
|
Future<ui.Image> captureSnapshot() async {
|
|
RenderRepaintBoundary? boundary;
|
|
final renderObject = snapshotKey.currentContext?.findRenderObject();
|
|
if (renderObject is RenderRepaintBoundary && renderObject.hasSize) {
|
|
boundary = renderObject;
|
|
}
|
|
if (boundary == null) {
|
|
throw StateError('Message snapshot is unavailable');
|
|
}
|
|
final snapshotPixelRatio = _messageSnapshotPixelRatio(
|
|
boundary.size,
|
|
maxSnapshotPixelRatio,
|
|
);
|
|
try {
|
|
return await boundary.toImage(pixelRatio: snapshotPixelRatio);
|
|
} catch (_) {
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
final retryBoundary = snapshotKey.currentContext?.findRenderObject();
|
|
if (retryBoundary is! RenderRepaintBoundary ||
|
|
!retryBoundary.hasSize) {
|
|
rethrow;
|
|
}
|
|
try {
|
|
return await retryBoundary.toImage(
|
|
pixelRatio: _messageSnapshotPixelRatio(
|
|
retryBoundary.size,
|
|
maxSnapshotPixelRatio,
|
|
),
|
|
);
|
|
} catch (_) {
|
|
if (snapshotPixelRatio <= 1) rethrow;
|
|
return retryBoundary.toImage(
|
|
pixelRatio: _messageSnapshotPixelRatio(retryBoundary.size, 1),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void setSourceHidden(bool hidden) {
|
|
if (!context.mounted) return;
|
|
sourceHidden.value = hidden;
|
|
}
|
|
|
|
detailsCallback(
|
|
MessageLongPressDetails(
|
|
anchorRect: anchorRect,
|
|
captureSnapshot: captureSnapshot,
|
|
setSourceHidden: setSourceHidden,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Semantics(
|
|
onLongPress: recognize,
|
|
child: RawGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
gestures: {
|
|
LongPressGestureRecognizer:
|
|
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
|
() => LongPressGestureRecognizer(
|
|
duration: defaultTargetPlatform == TargetPlatform.iOS
|
|
? _iosMessageLongPressDuration
|
|
: null,
|
|
),
|
|
(recognizer) {
|
|
recognizer.onLongPressStart = (_) => recognize();
|
|
},
|
|
),
|
|
},
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: borderRadius,
|
|
highlightColor: highlightColor,
|
|
child: Opacity(
|
|
opacity: sourceHidden.value ? 0 : 1,
|
|
child: externalSnapshotKey == null
|
|
? RepaintBoundary(key: snapshotKey, child: child)
|
|
: child,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|