mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Mobile: add anchored reaction popover (#5025)
## Summary - replace reaction-only mobile sheets with an anchored long-press popover - add haptics, a dimmed frosted spotlight, and a spring-staggered reaction tray - preserve the full action sheet whenever other message actions are available - keep existing reaction pills outside the spotlight and make long press reliable across nested content ## Testing - `flutter analyze` - `flutter test` (1,248 tests) - signed iPhone release build installed and launched --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import 'dm_channel_labels.dart';
|
||||
import 'ephemeral_channel_display.dart';
|
||||
import 'members_sheet.dart';
|
||||
import 'message_actions.dart';
|
||||
import 'message_long_press_region.dart';
|
||||
import 'message_content.dart';
|
||||
import '../../shared/read_state/deferred_read_state_update.dart';
|
||||
import '../../shared/read_state/read_state_format.dart';
|
||||
|
||||
@@ -64,6 +64,21 @@ class _MessageBubble extends ConsumerWidget {
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: Material(
|
||||
@@ -73,8 +88,9 @@ class _MessageBubble extends ConsumerWidget {
|
||||
// gutter. InkWell still clips its ink to [borderRadius], while leaving
|
||||
// overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: InkWell(
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
// Tap opens the thread; long-press still opens the action sheet.
|
||||
@@ -93,17 +109,6 @@ class _MessageBubble extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
onLongPress: () => showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
class _SystemMessageRow extends ConsumerWidget {
|
||||
class _SystemMessageRow extends HookConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final List<TimelineMessage>? groupedMessages;
|
||||
final String channelId;
|
||||
@@ -21,6 +21,7 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final spotlightKey = useMemoized(() => GlobalKey());
|
||||
final systemEvent = message.systemEvent;
|
||||
if (systemEvent == null) return const SizedBox.shrink();
|
||||
|
||||
@@ -77,25 +78,43 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void openReactionPopover(Rect anchorRect) {
|
||||
final spotlightRenderObject = spotlightKey.currentContext
|
||||
?.findRenderObject();
|
||||
final spotlightRect =
|
||||
spotlightRenderObject is RenderBox && spotlightRenderObject.hasSize
|
||||
? spotlightRenderObject.localToGlobal(Offset.zero) &
|
||||
spotlightRenderObject.size
|
||||
: anchorRect;
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: false,
|
||||
allMessages: null,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: spotlightRect,
|
||||
popoverSpotlightPadding: EdgeInsets.fromLTRB(
|
||||
Grid.xxs,
|
||||
Grid.xxs,
|
||||
Grid.xxs,
|
||||
reactions.isEmpty ? Grid.xxs : Grid.quarter,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('system-message-row-${message.id}'),
|
||||
onLongPress: openReactionPopover,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
onLongPress: () => showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: false,
|
||||
allMessages: null,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
|
||||
@@ -104,43 +123,47 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (groupedMembership != null)
|
||||
_MembershipSystemMessageContent(
|
||||
event: groupedMembership,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
)
|
||||
else if (messageStyleActor != null &&
|
||||
messageStyleActor.isNotEmpty &&
|
||||
messageStyleAction != null)
|
||||
_MessageStyleSystemMessageContent(
|
||||
displayPubkey: messageStyleActor,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
actionSpans: [TextSpan(text: messageStyleAction)],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
_systemEventAvatar(context, systemEvent, userCache),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
systemEvent.describe(resolveLabel),
|
||||
style: systemMessageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
KeyedSubtree(
|
||||
key: spotlightKey,
|
||||
child: groupedMembership != null
|
||||
? _MembershipSystemMessageContent(
|
||||
event: groupedMembership,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
)
|
||||
: messageStyleActor != null &&
|
||||
messageStyleActor.isNotEmpty &&
|
||||
messageStyleAction != null
|
||||
? _MessageStyleSystemMessageContent(
|
||||
displayPubkey: messageStyleActor,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
actionSpans: [TextSpan(text: messageStyleAction)],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
_systemEventAvatar(context, systemEvent, userCache),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
systemEvent.describe(resolveLabel),
|
||||
style: systemMessageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
_messageTimestamp(
|
||||
context,
|
||||
message.createdAt,
|
||||
key: ValueKey(
|
||||
'system-message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_messageTimestamp(
|
||||
context,
|
||||
message.createdAt,
|
||||
key: ValueKey('system-message-timestamp-${message.id}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (reactions.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -33,6 +36,8 @@ import 'thread_detail_page.dart';
|
||||
import 'thread_follows/thread_follows_provider.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
part 'message_actions/reaction_popover.dart';
|
||||
|
||||
/// Preview length for reminder targets — matches desktop's
|
||||
/// `msg.body.slice(0, 100)`.
|
||||
const _reminderPreviewLength = 100;
|
||||
@@ -47,7 +52,21 @@ void showMessageActions({
|
||||
String? currentPubkey,
|
||||
bool isMember = false,
|
||||
bool isArchived = false,
|
||||
Rect? anchorRect,
|
||||
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
|
||||
}) {
|
||||
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
|
||||
if (anchorRect != null && hasReactionOnlyActions) {
|
||||
_showMessageReactionPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
anchorRect: anchorRect,
|
||||
spotlightPadding: popoverSpotlightPadding,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -639,11 +658,16 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
/// bottom sheet, so it must not read through the sheet's disposed ref.
|
||||
final WidgetRef pageRef;
|
||||
|
||||
/// Drives the staged glyph reveal when this row is shown in the popover.
|
||||
/// The bottom sheet leaves this null and retains its existing static row.
|
||||
final Animation<double>? presentationAnimation;
|
||||
|
||||
const _QuickReactionRow({
|
||||
required this.message,
|
||||
required this.sheetContext,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
this.presentationAnimation,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -685,31 +709,39 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
.clamp(0.0, Grid.twelve)
|
||||
.toDouble();
|
||||
final circles = <Widget>[
|
||||
for (final value in emoji)
|
||||
_QuickReactionCircle(
|
||||
key: ValueKey('quick-reaction-$value'),
|
||||
for (var index = 0; index < emoji.length; index++)
|
||||
_ReactionItemReveal(
|
||||
key: ValueKey('quick-reaction-${emoji[index]}'),
|
||||
animation: presentationAnimation,
|
||||
index: index,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
react(emoji[index]);
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: emoji[index],
|
||||
customByShortcode: customByShortcode,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ReactionItemReveal(
|
||||
key: const ValueKey('quick-reaction-more'),
|
||||
animation: presentationAnimation,
|
||||
index: emoji.length,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
react(value);
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: value,
|
||||
customByShortcode: customByShortcode,
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 24,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
_QuickReactionCircle(
|
||||
key: const ValueKey('quick-reaction-more'),
|
||||
size: circleSize,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
},
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
size: 24,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -727,6 +759,44 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionItemReveal extends StatelessWidget {
|
||||
final Animation<double>? animation;
|
||||
final int index;
|
||||
final Widget child;
|
||||
|
||||
const _ReactionItemReveal({
|
||||
super.key,
|
||||
required this.animation,
|
||||
required this.index,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parent = animation;
|
||||
if (parent == null) return child;
|
||||
|
||||
final start = 0.06 + (index * 0.11);
|
||||
final opacityEnd = math.min(1.0, start + 0.20);
|
||||
final scaleEnd = math.min(1.0, start + 0.30);
|
||||
final opacity = CurvedAnimation(
|
||||
parent: parent,
|
||||
curve: Interval(start, opacityEnd, curve: Curves.easeOutCubic),
|
||||
);
|
||||
final scale = Tween<double>(begin: 0.86, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: parent,
|
||||
curve: Interval(start, scaleEnd, curve: _reactionSpringCurve),
|
||||
),
|
||||
);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: opacity,
|
||||
child: ScaleTransition(scale: scale, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a quick-reaction value: a Unicode glyph as text, a `:shortcode:` as
|
||||
/// its custom-emoji image.
|
||||
class _QuickReactionGlyph extends StatelessWidget {
|
||||
@@ -763,7 +833,6 @@ class _QuickReactionCircle extends StatelessWidget {
|
||||
final double size;
|
||||
|
||||
const _QuickReactionCircle({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
required this.size,
|
||||
@@ -781,7 +850,11 @@ class _QuickReactionCircle extends StatelessWidget {
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
color: Color.lerp(
|
||||
context.colors.surface,
|
||||
context.colors.primaryContainer,
|
||||
0.78,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: child,
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2);
|
||||
const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2);
|
||||
const _reactionTraySpringAllowance = 16.0;
|
||||
const _reactionPopoverDuration = Duration(milliseconds: 320);
|
||||
final _reactionSpringCurve = _SpringEaseOutCurve(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
bounce: 0.22,
|
||||
);
|
||||
|
||||
/// Presents the frequently-used reaction tray over a long-pressed message.
|
||||
///
|
||||
/// The conversation is frosted around [anchorRect] so the selected message
|
||||
/// remains visually connected to the tray.
|
||||
void _showMessageReactionPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required Rect anchorRect,
|
||||
required EdgeInsets spotlightPadding,
|
||||
}) {
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss reaction picker',
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: reduceMotion ? Duration.zero : _reactionPopoverDuration,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) => child,
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
|
||||
_MessageReactionPopover(
|
||||
anchorRect: anchorRect,
|
||||
spotlightPadding: spotlightPadding,
|
||||
animation: animation,
|
||||
message: message,
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _MessageReactionPopover extends StatelessWidget {
|
||||
final Rect anchorRect;
|
||||
final EdgeInsets spotlightPadding;
|
||||
final Animation<double> animation;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
|
||||
const _MessageReactionPopover({
|
||||
required this.anchorRect,
|
||||
required this.spotlightPadding,
|
||||
required this.animation,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final safeTop = mediaQuery.padding.top + mediaQuery.viewInsets.top;
|
||||
final safeBottom =
|
||||
constraints.maxHeight -
|
||||
mediaQuery.padding.bottom -
|
||||
mediaQuery.viewInsets.bottom;
|
||||
final availableAbove = anchorRect.top - Grid.xxs - safeTop;
|
||||
final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs;
|
||||
final showAbove =
|
||||
availableAbove >= _reactionTrayMaxHeight ||
|
||||
(availableBelow < _reactionTrayMaxHeight &&
|
||||
availableAbove >= availableBelow);
|
||||
final proposedTop = showAbove
|
||||
? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs
|
||||
: anchorRect.bottom + Grid.xxs;
|
||||
final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight);
|
||||
final top = proposedTop.clamp(safeTop, maxTop).toDouble();
|
||||
final trayScaleAlignment = showAbove
|
||||
? Alignment.bottomLeft
|
||||
: Alignment.topLeft;
|
||||
final trayWidth = math.min(
|
||||
_reactionTrayMaxWidth,
|
||||
constraints.maxWidth - (Grid.xxs * 2),
|
||||
);
|
||||
final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs;
|
||||
final left = (anchorRect.center.dx - (trayWidth / 2))
|
||||
.clamp(Grid.xxs, maxLeft)
|
||||
.toDouble();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final blurProgress = const Interval(
|
||||
0,
|
||||
0.30,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final sigma = 20 * blurProgress;
|
||||
return ClipPath(
|
||||
key: const ValueKey('reaction-popover-background'),
|
||||
clipper: _OutsideAnchorClipper(
|
||||
anchorRect,
|
||||
spotlightPadding,
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||
child: ColoredBox(
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: 0.10 * blurProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
width: trayWidth + _reactionTraySpringAllowance,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: SizedBox(
|
||||
width: trayWidth,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: _QuickReactionRow(
|
||||
message: message,
|
||||
sheetContext: context,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
presentationAnimation: animation,
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.04,
|
||||
0.23,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final expansion = const Interval(
|
||||
0.16,
|
||||
0.92,
|
||||
).transform(animation.value);
|
||||
final springExpansion = _reactionSpringCurve.transform(
|
||||
expansion,
|
||||
);
|
||||
final width = lerpDouble(
|
||||
_reactionTrayMaxHeight,
|
||||
trayWidth,
|
||||
springExpansion,
|
||||
)!;
|
||||
|
||||
return Opacity(
|
||||
opacity: appearance,
|
||||
child: Transform.scale(
|
||||
alignment: trayScaleAlignment,
|
||||
scale: lerpDouble(0.95, 1, appearance)!,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
key: const ValueKey('reaction-popover-tray'),
|
||||
width: width,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Material(
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.2),
|
||||
shape: const StadiumBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: trayWidth,
|
||||
maxWidth: trayWidth,
|
||||
minHeight: _reactionTrayMaxHeight,
|
||||
maxHeight: _reactionTrayMaxHeight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutsideAnchorClipper extends CustomClipper<Path> {
|
||||
final Rect anchorRect;
|
||||
final EdgeInsets spotlightPadding;
|
||||
|
||||
const _OutsideAnchorClipper(this.anchorRect, this.spotlightPadding);
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final spotlightRect = Rect.fromLTRB(
|
||||
anchorRect.left - spotlightPadding.left,
|
||||
anchorRect.top - spotlightPadding.top,
|
||||
anchorRect.right + spotlightPadding.right,
|
||||
anchorRect.bottom + spotlightPadding.bottom,
|
||||
);
|
||||
return Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addRRect(
|
||||
RRect.fromRectAndRadius(spotlightRect, const Radius.circular(Radii.lg)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(_OutsideAnchorClipper oldClipper) =>
|
||||
oldClipper.anchorRect != anchorRect ||
|
||||
oldClipper.spotlightPadding != spotlightPadding;
|
||||
}
|
||||
|
||||
class _SpringEaseOutCurve extends Curve {
|
||||
final double _durationSeconds;
|
||||
final SpringSimulation _simulation;
|
||||
|
||||
_SpringEaseOutCurve({required Duration duration, required double bounce})
|
||||
: _durationSeconds =
|
||||
duration.inMicroseconds / Duration.microsecondsPerSecond,
|
||||
_simulation = SpringSimulation(
|
||||
SpringDescription.withDurationAndBounce(
|
||||
duration: duration,
|
||||
bounce: bounce,
|
||||
),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
snapToEnd: true,
|
||||
);
|
||||
|
||||
@override
|
||||
double transformInternal(double t) => _simulation.x(t * _durationSeconds);
|
||||
}
|
||||
112
mobile/lib/features/channels/message_long_press_region.dart
Normal file
112
mobile/lib/features/channels/message_long_press_region.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// An [InkWell] whose long press is observed above interactive descendants.
|
||||
class MessageLongPressInkWell extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final ValueChanged<Rect> onLongPress;
|
||||
final BorderRadius? borderRadius;
|
||||
final Color? highlightColor;
|
||||
final Widget child;
|
||||
|
||||
const MessageLongPressInkWell({
|
||||
super.key,
|
||||
this.onTap,
|
||||
required this.onLongPress,
|
||||
this.borderRadius,
|
||||
this.highlightColor,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _MessageLongPressRegion(
|
||||
onLongPress: onLongPress,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
highlightColor: highlightColor,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects a message long press without competing with interactive descendants.
|
||||
///
|
||||
/// Links, media, reactions, and other nested controls keep their normal tap
|
||||
/// gestures. Moving far enough to scroll cancels the timer; recognizing the
|
||||
/// hold cancels the pointer so a descendant tap cannot fire on release.
|
||||
class _MessageLongPressRegion extends HookWidget {
|
||||
final ValueChanged<Rect> onLongPress;
|
||||
final Widget child;
|
||||
|
||||
const _MessageLongPressRegion({
|
||||
required this.onLongPress,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activePointer = useRef<int?>(null);
|
||||
final origin = useRef<Offset?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
|
||||
void cancel() {
|
||||
timer.value?.cancel();
|
||||
timer.value = null;
|
||||
activePointer.value = null;
|
||||
origin.value = null;
|
||||
}
|
||||
|
||||
useEffect(() => cancel, const []);
|
||||
|
||||
void recognize() {
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||
onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size);
|
||||
}
|
||||
|
||||
void handlePointerDown(PointerDownEvent event) {
|
||||
if (activePointer.value != null) return;
|
||||
activePointer.value = event.pointer;
|
||||
origin.value = event.position;
|
||||
timer.value = Timer(kLongPressTimeout, () {
|
||||
final pointer = activePointer.value;
|
||||
if (pointer == null) return;
|
||||
timer.value = null;
|
||||
activePointer.value = null;
|
||||
origin.value = null;
|
||||
GestureBinding.instance.cancelPointer(pointer);
|
||||
recognize();
|
||||
});
|
||||
}
|
||||
|
||||
void handlePointerMove(PointerMoveEvent event) {
|
||||
if (event.pointer != activePointer.value) return;
|
||||
final start = origin.value;
|
||||
if (start == null) return;
|
||||
final delta = event.position - start;
|
||||
if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel();
|
||||
}
|
||||
|
||||
void handlePointerEnd(PointerEvent event) {
|
||||
if (event.pointer == activePointer.value) cancel();
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
onLongPress: recognize,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerEnd,
|
||||
onPointerCancel: handlePointerEnd,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import 'date_formatters.dart';
|
||||
import 'day_divider.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
import 'message_actions.dart';
|
||||
import 'message_long_press_region.dart';
|
||||
import 'message_content.dart';
|
||||
import 'reaction_row.dart';
|
||||
import '../../shared/read_state/read_state_format.dart';
|
||||
@@ -777,6 +778,21 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: DecoratedBox(
|
||||
@@ -794,21 +810,11 @@ class _ThreadMessage extends ConsumerWidget {
|
||||
// trailing gutter. InkWell still clips its ink to [borderRadius],
|
||||
// while leaving overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: InkWell(
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('thread-message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
onLongPress: () => showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
|
||||
@@ -166,6 +166,7 @@ Widget _buildTestable({
|
||||
required List<NostrEvent> messages,
|
||||
List<TypingEntry> typing = const [],
|
||||
Map<String, UserProfile> users = const {},
|
||||
_FakeUserCacheNotifier? userCacheNotifier,
|
||||
List<ChannelMember> members = const [],
|
||||
Channel? channel,
|
||||
List<Channel>? channels,
|
||||
@@ -197,7 +198,9 @@ Widget _buildTestable({
|
||||
channelTypingProvider(
|
||||
_channelId,
|
||||
).overrideWith(() => _FakeTypingNotifier(typing)),
|
||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
|
||||
userCacheProvider.overrideWith(
|
||||
() => userCacheNotifier ?? _FakeUserCacheNotifier(users),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
channelsProvider.overrideWith(() => fakeChannelsNotifier),
|
||||
channelDetailsProvider(_channelId).overrideWith(
|
||||
@@ -1023,6 +1026,245 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('long press opens the anchored reaction popover', (
|
||||
tester,
|
||||
) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'reaction-target',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('system-message-row-reaction-target')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key! as ValueKey<String>).value.startsWith(
|
||||
'quick-reaction-',
|
||||
),
|
||||
),
|
||||
findsNWidgets(6),
|
||||
);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
expect(find.text('Copy text'), findsNothing);
|
||||
expect(findRichText('joined the channel'), findsOneWidget);
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
|
||||
});
|
||||
|
||||
testWidgets('reaction popover leaves existing reactions in the blur', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'reacted-system-message',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
_reaction(
|
||||
id: 'existing-reaction',
|
||||
targetId: 'reacted-system-message',
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final messageCenter = tester.getCenter(
|
||||
findRichText('joined the channel'),
|
||||
);
|
||||
final reactionRect = tester.getRect(find.byType(ReactionRow));
|
||||
final reactionCenter = reactionRect.center;
|
||||
final reactionPillTop = Offset(
|
||||
reactionRect.center.dx,
|
||||
reactionRect.top + Grid.half + Grid.quarter,
|
||||
);
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('system-message-row-reacted-system-message')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final backgroundFinder = find.byKey(
|
||||
const ValueKey('reaction-popover-background'),
|
||||
);
|
||||
final background = tester.widget<ClipPath>(backgroundFinder);
|
||||
final backgroundOrigin = tester.getTopLeft(backgroundFinder);
|
||||
final blurPath = background.clipper!.getClip(
|
||||
tester.getSize(backgroundFinder),
|
||||
);
|
||||
|
||||
expect(blurPath.contains(messageCenter - backgroundOrigin), isFalse);
|
||||
expect(blurPath.contains(reactionPillTop - backgroundOrigin), isTrue);
|
||||
expect(blurPath.contains(reactionCenter - backgroundOrigin), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('reaction popover grows right from a fixed left edge', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'left-growth-target',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('system-message-row-left-growth-target')),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final earlyRect = tester.getRect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 80));
|
||||
final laterRect = tester.getRect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
);
|
||||
|
||||
expect(laterRect.left, moreOrLessEquals(earlyRect.left));
|
||||
expect(laterRect.width, greaterThan(earlyRect.width));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('long press survives a message rebuild during the hold', (
|
||||
tester,
|
||||
) async {
|
||||
final userCache = _FakeUserCacheNotifier({
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_systemMsg(
|
||||
id: 'rebuild-target',
|
||||
payload: {
|
||||
'type': 'member_joined',
|
||||
'actor': 'alice',
|
||||
'target': 'alice',
|
||||
},
|
||||
),
|
||||
],
|
||||
userCacheNotifier: userCache,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final target = find.byKey(
|
||||
const ValueKey('system-message-row-rebuild-target'),
|
||||
);
|
||||
final gesture = await tester.startGesture(tester.getCenter(target));
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
userCache.replace(
|
||||
const UserProfile(pubkey: 'alice', displayName: 'Alice Updated'),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('long press works over nested rich message content', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(400, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
const firstImage = 'https://example.com/media/first.png';
|
||||
const secondImage = 'https://example.com/media/second.png';
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'rich-reaction-target',
|
||||
pubkey: 'alice',
|
||||
content:
|
||||
'Gallery\n'
|
||||
'\n'
|
||||
'',
|
||||
extraTags: const [
|
||||
['imeta', 'url $firstImage', 'm image/png'],
|
||||
['imeta', 'url $secondImage', 'm image/png'],
|
||||
],
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('message-media-carousel')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
expect(find.text('Copy text'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps image galleries body-aligned and flush with the trailing edge',
|
||||
(tester) async {
|
||||
@@ -4269,6 +4511,10 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
|
||||
@override
|
||||
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
|
||||
|
||||
void replace(UserProfile profile) {
|
||||
state = {...state, profile.pubkey.toLowerCase(): profile};
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeChannelsNotifier extends ChannelsNotifier {
|
||||
|
||||
@@ -286,6 +286,66 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keeps the reaction popover on-screen when neither side fits', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 240);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
const anchorRect = Rect.fromLTWH(40, 60, 240, 100);
|
||||
const safeTop = 24.0;
|
||||
const visibleBottom = 200.0;
|
||||
const trayHeight = 68.0;
|
||||
const trayGap = Grid.xxs;
|
||||
final prefs = await _mockPrefs();
|
||||
expect(anchorRect.top - trayGap - trayHeight, lessThan(safeTop));
|
||||
expect(
|
||||
anchorRect.bottom + trayGap + trayHeight,
|
||||
greaterThan(visibleBottom),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: const EdgeInsets.only(top: safeTop),
|
||||
viewInsets: const EdgeInsets.only(bottom: 40),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: Consumer(
|
||||
builder: (context, ref, _) => TextButton(
|
||||
onPressed: () => showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: _message(isSystem: true),
|
||||
channelId: _channelId,
|
||||
canManageMessage: false,
|
||||
anchorRect: anchorRect,
|
||||
),
|
||||
child: const Text('open popover'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open popover'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final trayRect = tester.getRect(
|
||||
find.byKey(const ValueKey('reaction-popover-tray')),
|
||||
);
|
||||
expect(trayRect.top, greaterThanOrEqualTo(safeTop));
|
||||
expect(trayRect.bottom, lessThanOrEqualTo(visibleBottom));
|
||||
});
|
||||
|
||||
testWidgets('shows Edit/Delete only with manage rights', (tester) async {
|
||||
final prefs = await _mockPrefs();
|
||||
await _pumpSheet(
|
||||
|
||||
Reference in New Issue
Block a user