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:
klopez4212
2026-08-07 17:17:06 +01:00
committed by GitHub
parent 626e2c34a3
commit 8476ea05e7
9 changed files with 881 additions and 95 deletions

View File

@@ -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';

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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);
}

View 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,
),
);
}
}

View File

@@ -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,

View File

@@ -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'
'![First]($firstImage)\n'
'![Second]($secondImage)',
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 {

View File

@@ -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(