Files
buzz/mobile/lib/features/channels/message_actions/quick_reaction_row.dart
klopez4212 78267b0c3a Polish mobile message actions (#5873)
## 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>
2026-08-18 15:00:45 -07:00

140 lines
4.8 KiB
Dart

part of '../message_actions.dart';
class _QuickReactionRow extends ConsumerWidget {
final TimelineMessage message;
/// The sheet's context, popped before the reaction fires.
final BuildContext sheetContext;
/// The long-pressed message's page context — survives the sheet pop, so the
/// picker opened from "+" isn't torn down with the sheet.
final BuildContext pageContext;
/// The long-pressed message's page ref. The picker callback outlives this
/// 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;
final Object? popResult;
/// Selects exactly one popover result and side effect. Bottom sheets leave
/// this null and retain their existing local dismissal behavior.
final void Function(Object? result, VoidCallback effect)? onSelected;
const _QuickReactionRow({
required this.message,
required this.sheetContext,
required this.pageContext,
required this.pageRef,
this.presentationAnimation,
this.popResult,
this.onSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final customEmoji = ref.watch(customEmojiListProvider);
final emoji = quickReactionEmoji(
ref.watch(recentEmojiProvider),
customShortcodes: {
for (final entry in customEmoji) entry.shortcode.toLowerCase(),
},
);
final customByShortcode = {
for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry,
};
void react(String value) {
// The generic picker is also used for composing and statuses. Record
// recency here, at the reaction call site, so only reactions drive the
// quick-reaction row.
pageRef.read(recentEmojiProvider.notifier).record(value);
// The sheet is on its way out, so the burst can't come from this tile —
// hand it to the pill that's about to appear in the timeline.
armReactionBurst(pageRef, message, value);
pageRef.read(channelActionsProvider).addReaction(message.id, value);
}
return LayoutBuilder(
builder: (context, constraints) {
const desiredCircleSize = 52.0;
const minimumCircleSize = 44.0;
final itemCount = emoji.length + 1;
final gapCount = itemCount - 1;
final circleSize =
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
.clamp(minimumCircleSize, desiredCircleSize)
.toDouble();
final gap =
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
.clamp(0.0, Grid.twelve)
.toDouble();
final circles = <Widget>[
for (var index = 0; index < emoji.length; index++)
_ReactionItemReveal(
key: ValueKey('quick-reaction-${emoji[index]}'),
animation: presentationAnimation,
index: index,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
void effect() => react(emoji[index]);
final select = onSelected;
if (select != null) {
select(popResult, effect);
} else {
Navigator.of(sheetContext).pop(popResult);
effect();
}
},
child: _QuickReactionGlyph(
value: emoji[index],
customByShortcode: customByShortcode,
),
),
),
_ReactionItemReveal(
key: const ValueKey('quick-reaction-more'),
animation: presentationAnimation,
index: emoji.length,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
void effect() =>
showEmojiPicker(context: pageContext, onSelect: react);
final select = onSelected;
if (select != null) {
select(popResult, effect);
} else {
Navigator.of(sheetContext).pop(popResult);
effect();
}
},
child: Icon(
LucideIcons.plus,
size: 24,
color: context.colors.onSurfaceVariant,
),
),
),
];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < circles.length; index++) ...[
circles[index],
if (index < circles.length - 1) SizedBox(width: gap),
],
],
);
},
);
}
}