mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Use grouped message action sheets and fix date layering (#8280)
## Summary Replace message long-press popovers with the shared grouped bottom sheet: an emoji row, Reply / Copy link / Remind me tiles, and remaining actions in regular sections. Match emoji backgrounds to the sections and use the theme primary color for the selected reaction. Keep the scrolling date above the navigation blur in channels and threads. Restore composer focus when the sheet is canceled, keep section gaps consistent, and soften shared dividers to 7% opacity. The reaction details sheet shows all reactors by default. Emoji count chips toggle your own reaction, show your selected state, and update live without dismissing the sheet. Unselected reactions have visible outlines; adding a supported emoji plays the chat-style burst and respects reduced motion. ### Related issue No matching issue found. Related sheet styling: #4911. ### Testing - Full mobile checks and tests passed, including 23 reaction tests and composer-focus regression coverage. - Analysis and file-size checks passed; signed iOS build installed and launched on a physical iPhone. - Full `just ci` stopped during Rust compilation due to low disk space. - On-device visual capture unavailable. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -268,6 +268,10 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final stickyDateHeaderState = useValueNotifier(
|
||||
StickyDateHeaderState.hidden,
|
||||
[channel.id],
|
||||
);
|
||||
final composerDockHeight = useState(0.0);
|
||||
final composerFocusNode = useFocusNode();
|
||||
final restoreComposerFocus = useRef<VoidCallback?>(null);
|
||||
@@ -610,6 +614,31 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return FrostedScaffold(
|
||||
foreground:
|
||||
!resolvedChannel.isForum &&
|
||||
timelineEntries.isNotEmpty &&
|
||||
!showInitialConnectionSkeleton &&
|
||||
!showConnectionSkeleton.value &&
|
||||
!messagesState.isLoading
|
||||
? Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('channel-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
resizeToAvoidBottomInset:
|
||||
!usesFixedAndroidImeViewport || resolvedChannel.isForum,
|
||||
appBar: FrostedAppBar(
|
||||
@@ -746,6 +775,7 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
data: (events) {
|
||||
return _MessageList(
|
||||
stickyDateHeaderState: stickyDateHeaderState,
|
||||
entries: timelineEntries,
|
||||
allMessages: timelineMessages,
|
||||
initialMessageId: initialMessageId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
class _MessageList extends HookConsumerWidget {
|
||||
final ValueNotifier<StickyDateHeaderState> stickyDateHeaderState;
|
||||
final List<MainTimelineEntry> entries;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String? initialMessageId;
|
||||
@@ -21,6 +22,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
|
||||
const _MessageList({
|
||||
required this.entries,
|
||||
required this.stickyDateHeaderState,
|
||||
required this.allMessages,
|
||||
required this.initialMessageId,
|
||||
required this.initialThreadRootId,
|
||||
@@ -48,9 +50,6 @@ class _MessageList extends HookConsumerWidget {
|
||||
final displayEntries = groupMembershipTimelineEntries(entries);
|
||||
final itemScrollController = useMemoized(ItemScrollController.new);
|
||||
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
|
||||
final stickyDateHeaderState = useValueNotifier(
|
||||
StickyDateHeaderState.hidden,
|
||||
);
|
||||
final stickyDayTimestamp = useValueNotifier<int?>(null);
|
||||
final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height);
|
||||
final isLoadingOlder = useState(false);
|
||||
@@ -234,7 +233,9 @@ class _MessageList extends HookConsumerWidget {
|
||||
StickyDateHeaderState state, {
|
||||
int? activeDayTimestamp,
|
||||
}) {
|
||||
stickyDateHeaderState.value = state;
|
||||
stickyDateHeaderState.value = showUnreadNavigation
|
||||
? StickyDateHeaderState.hidden
|
||||
: state;
|
||||
stickyDayTimestamp.value = activeDayTimestamp;
|
||||
}
|
||||
|
||||
@@ -536,11 +537,12 @@ class _MessageList extends HookConsumerWidget {
|
||||
itemPositionsListener,
|
||||
appBarTitleContentHeight,
|
||||
composerBottomInset,
|
||||
showUnreadNavigation,
|
||||
stickyDateHeaderState,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
stickyDateHeaderState.value = StickyDateHeaderState.hidden;
|
||||
stickyDayTimestamp.value = null;
|
||||
return null;
|
||||
}, [channelId]);
|
||||
@@ -880,21 +882,6 @@ class _MessageList extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!showUnreadNavigation)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('channel-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
if (showUnreadNavigation)
|
||||
Positioned(
|
||||
left: 0,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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:buzz/shared/theme/buzz_icons.dart';
|
||||
@@ -23,17 +19,14 @@ import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/widgets/sheet_action_section.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/native_message_presentation.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/reminders/remind_me_later_sheet.dart';
|
||||
import '../../shared/reminders/reminder_service.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'emoji_picker.dart';
|
||||
import 'message_action_backdrop_state.dart';
|
||||
import 'message_actions/native_message_action_selection.dart';
|
||||
import 'reaction_row.dart';
|
||||
import 'recent_emoji_provider.dart';
|
||||
import '../../shared/read_state/message_read_state.dart';
|
||||
@@ -44,38 +37,30 @@ import 'thread_follows/thread_follows_provider.dart';
|
||||
import 'timeline_message.dart';
|
||||
import 'unread_badge/is_high_priority_event.dart';
|
||||
|
||||
part 'message_actions/reaction_popover.dart';
|
||||
part 'message_actions/quick_reaction_row.dart';
|
||||
part 'message_actions/message_action_popover.dart';
|
||||
part 'message_actions/message_action_popover_widgets.dart';
|
||||
part 'message_actions/message_reaction_tray.dart';
|
||||
part 'message_actions/native_actions.dart';
|
||||
|
||||
/// Preview length for reminder targets — matches desktop's
|
||||
/// `msg.body.slice(0, 100)`.
|
||||
const _reminderPreviewLength = 100;
|
||||
const _messageActionBackdropBlurSigma = 20.0;
|
||||
const _messageActionBackdropTintOpacity = 0.10;
|
||||
final _messageActionBackdropFilter = ImageFilter.blur(
|
||||
sigmaX: _messageActionBackdropBlurSigma,
|
||||
sigmaY: _messageActionBackdropBlurSigma,
|
||||
);
|
||||
|
||||
/// Presents the actions for [message] as an anchored popover when both
|
||||
/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet.
|
||||
///
|
||||
/// Popover capture is asynchronous: [captureAnchorSnapshot] must remain valid
|
||||
/// until its future completes, and the returned image becomes this function's
|
||||
/// responsibility to dispose. [onPopoverPreviewVisibilityChanged] reports
|
||||
/// whether the constrained layout actually renders the lifted preview;
|
||||
/// [onPopoverDismissed] runs after the route completes while [context] is still
|
||||
/// mounted. Neither callback runs for sheet fallback or a failed capture.
|
||||
///
|
||||
/// [composerFocusNode] remains caller-owned and must outlive the popover. If it
|
||||
/// has focus when this function is called, the popover unfocuses it and invokes
|
||||
/// [restoreComposerFocus] only after a dismissal with no selected action. The
|
||||
/// restorer must remain callable for the same lifetime and no-op if its composer
|
||||
/// is later disposed or replaced.
|
||||
// A weak, route-owned latch shared by every action, including pointers that
|
||||
// were already down when another selection started dismissing the sheet.
|
||||
final _selectedMessageActionRoutes = Expando<bool>();
|
||||
|
||||
bool _selectMessageAction(BuildContext context) {
|
||||
final route = ModalRoute.of(context);
|
||||
if (route == null ||
|
||||
!route.isCurrent ||
|
||||
_selectedMessageActionRoutes[route] == true) {
|
||||
return false;
|
||||
}
|
||||
_selectedMessageActionRoutes[route] = true;
|
||||
Navigator.of(context).pop(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Presents message reactions and grouped actions in the shared bottom sheet.
|
||||
/// Snapshot and preview callbacks are not used by the sheet presentation.
|
||||
Future<void> showMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
@@ -94,66 +79,22 @@ Future<void> showMessageActions({
|
||||
bool isArchived = false,
|
||||
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
|
||||
}) async {
|
||||
if (NativeMessagePresentation.isSupportedPlatform &&
|
||||
anchorRect != null &&
|
||||
await _showNativeMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
|
||||
if (anchorRect != null && hasReactionOnlyActions) {
|
||||
_showMessageReactionPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
anchorRect: anchorRect,
|
||||
spotlightPadding: popoverSpotlightPadding,
|
||||
);
|
||||
return;
|
||||
}
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
final restoreFocusOnCancel = composerFocusNode?.hasFocus ?? false;
|
||||
composerFocusNode?.unfocus();
|
||||
|
||||
if (_tryShowMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showBuzzModalBottomSheet<void>(
|
||||
final selectedAction = await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
showCloseButton: false,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
top: false,
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(size: 22),
|
||||
child: ConstrainedBox(
|
||||
@@ -177,10 +118,10 @@ Future<void> showMessageActions({
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
SizedBox(height: message.isSystem ? Grid.xxs : Grid.xs),
|
||||
if (!message.isSystem) ...[
|
||||
// Fast actions: respond now, hand off context, defer.
|
||||
_FastActionsRow(
|
||||
_MessageQuickActionsRow(
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
allMessages: allMessages,
|
||||
@@ -189,7 +130,8 @@ Future<void> showMessageActions({
|
||||
isArchived: isArchived,
|
||||
pageContext: context,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
// Section cards supply the other half of the 16pt gap.
|
||||
const SizedBox(height: Grid.xxs),
|
||||
// Triage: come back to this message later.
|
||||
SheetActionSection(
|
||||
children: [
|
||||
@@ -208,7 +150,7 @@ Future<void> showMessageActions({
|
||||
leading: const Icon(BuzzIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (!_selectMessageAction(sheetContext)) return;
|
||||
// Copy to clipboard
|
||||
final data = ClipboardData(text: message.content);
|
||||
Clipboard.setData(data);
|
||||
@@ -224,7 +166,7 @@ Future<void> showMessageActions({
|
||||
leading: const Icon(BuzzIcons.pencil),
|
||||
title: const Text('Edit message'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (!_selectMessageAction(sheetContext)) return;
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -243,7 +185,7 @@ Future<void> showMessageActions({
|
||||
style: TextStyle(color: sheetContext.colors.error),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (!_selectMessageAction(sheetContext)) return;
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
@@ -262,6 +204,9 @@ Future<void> showMessageActions({
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selectedAction != true && restoreFocusOnCancel && context.mounted) {
|
||||
restoreComposerFocus?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Image-focused actions shown from the full-screen viewer.
|
||||
@@ -567,7 +512,7 @@ class _MarkReadUnreadTile extends ConsumerWidget {
|
||||
leading: Icon(unread ? BuzzIcons.mailCheck : BuzzIcons.mailOpen),
|
||||
title: Text(unread ? 'Mark read' : 'Mark unread'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
// Advances the `msg:` marker and clears this message's own forced
|
||||
@@ -608,7 +553,7 @@ class _FollowThreadTile extends ConsumerWidget {
|
||||
leading: Icon(following ? BuzzIcons.bellOff : BuzzIcons.bellRing),
|
||||
title: Text(following ? 'Unfollow thread' : 'Follow thread'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
@@ -620,7 +565,7 @@ class _FollowThreadTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FastActionsRow extends ConsumerWidget {
|
||||
class _MessageQuickActionsRow extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final String channelId;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
@@ -632,7 +577,7 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
/// for the thread push and the copy-link snackbar.
|
||||
final BuildContext pageContext;
|
||||
|
||||
const _FastActionsRow({
|
||||
const _MessageQuickActionsRow({
|
||||
required this.message,
|
||||
required this.channelId,
|
||||
required this.allMessages,
|
||||
@@ -649,11 +594,11 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
|
||||
final tiles = <Widget>[
|
||||
if (messages != null)
|
||||
_FastActionTile(
|
||||
_MessageQuickActionTile(
|
||||
icon: BuzzIcons.messageSquareReply,
|
||||
label: 'Reply',
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
Navigator.of(pageContext).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
@@ -668,11 +613,11 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
_FastActionTile(
|
||||
_MessageQuickActionTile(
|
||||
icon: BuzzIcons.link2,
|
||||
label: 'Copy link',
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
copyToClipboard(
|
||||
pageContext,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
@@ -681,7 +626,7 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
if (canRemind)
|
||||
_FastActionTile(
|
||||
_MessageQuickActionTile(
|
||||
icon: BuzzIcons.clock,
|
||||
label: 'Remind me',
|
||||
onTap: () {
|
||||
@@ -689,7 +634,7 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).context;
|
||||
Navigator.of(context).pop();
|
||||
if (!_selectMessageAction(context)) return;
|
||||
showRemindMeLaterSheet(
|
||||
context: rootContext,
|
||||
ref: ref,
|
||||
@@ -706,57 +651,56 @@ class _FastActionsRow extends ConsumerWidget {
|
||||
),
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
for (var index = 0; index < tiles.length; index++) ...[
|
||||
Expanded(child: tiles[index]),
|
||||
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < tiles.length; index++) ...[
|
||||
Expanded(child: tiles[index]),
|
||||
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FastActionTile extends StatelessWidget {
|
||||
class _MessageQuickActionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FastActionTile({
|
||||
const _MessageQuickActionTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The full-width tiles deliberately contrast with the compact emoji
|
||||
// reactions immediately above them.
|
||||
Container(
|
||||
height: 68 + (Grid.xxs * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
button: true,
|
||||
child: Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 68 + (Grid.xxs * 2)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: context.colors.onSurface),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
@@ -764,51 +708,10 @@ class _FastActionTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The emoji shown are the user's own frequently-used set (desktop's
|
||||
/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so
|
||||
/// the row is full on a fresh install.
|
||||
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
|
||||
@@ -845,33 +748,48 @@ class _QuickReactionCircle extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
final double size;
|
||||
final String label;
|
||||
final bool? selected;
|
||||
|
||||
const _QuickReactionCircle({
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
required this.size,
|
||||
required this.label,
|
||||
this.selected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(
|
||||
context.colors.surface,
|
||||
context.colors.primaryContainer,
|
||||
0.78,
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
selected: selected,
|
||||
excludeSemantics: true,
|
||||
onTap: onTap,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
},
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected == true
|
||||
? context.colors.primary.withValues(alpha: 0.10)
|
||||
: context.colors.surfaceContainerHighest,
|
||||
border: selected == true
|
||||
? Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.40),
|
||||
)
|
||||
: null,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
const _messageActionRowHeight = 48.0;
|
||||
const _messageActionRowVerticalPadding = Grid.xxs;
|
||||
const _messageActionSeparatorHeight = 0.5;
|
||||
const _messageActionVerticalInset = Grid.half;
|
||||
const _messageActionMenuMaxWidth = 288.0;
|
||||
const _messageActionPreviewMaxWidth = 358.0;
|
||||
const _messageActionPreviewInset = Grid.xxs;
|
||||
const _messageActionGap = Grid.twelve;
|
||||
const _messageActionReactionSelection = '__reaction__';
|
||||
const _messageActionTransitionDuration = _reactionPopoverDuration;
|
||||
const _iosMessageActionTransitionDuration = Duration(milliseconds: 220);
|
||||
const _iosNativeMessageActionSurfaceChannel = MethodChannel(
|
||||
'buzz/native_message_action_surface',
|
||||
);
|
||||
|
||||
bool _messageActionPresentationInFlight = false;
|
||||
bool? _iosNativeMessageActionSurfaceSupported;
|
||||
|
||||
Future<bool> _supportsIosNativeMessageActionSurface() async {
|
||||
if (!Platform.isIOS) return false;
|
||||
final cached = _iosNativeMessageActionSurfaceSupported;
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final supported =
|
||||
await _iosNativeMessageActionSurfaceChannel.invokeMethod<bool>(
|
||||
'isSupported',
|
||||
) ??
|
||||
false;
|
||||
_iosNativeMessageActionSurfaceSupported = supported;
|
||||
return supported;
|
||||
} on MissingPluginException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
} on PlatformException {
|
||||
_iosNativeMessageActionSurfaceSupported = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _tryShowMessageActionsPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect? anchorRect,
|
||||
required Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
}) {
|
||||
if (anchorRect == null || captureAnchorSnapshot == null) return false;
|
||||
final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false;
|
||||
unawaited(
|
||||
_showMessageActionsPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
captureAnchorSnapshot: captureAnchorSnapshot,
|
||||
onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
onPopoverDismissed: onPopoverDismissed,
|
||||
composerFocusNode: composerFocusNode,
|
||||
restoreComposerFocus: restoreComposerFocus,
|
||||
shouldRestoreComposerFocus: shouldRestoreComposerFocus,
|
||||
).then((shown) {
|
||||
if (shown || !context.mounted) return;
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _showMessageActionsPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect anchorRect,
|
||||
required Future<ui.Image> Function() captureAnchorSnapshot,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
required bool shouldRestoreComposerFocus,
|
||||
}) async {
|
||||
if (_messageActionPresentationInFlight) return true;
|
||||
_messageActionPresentationInFlight = true;
|
||||
|
||||
try {
|
||||
final actions = _buildPopoverMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
if (actions.isEmpty) return false;
|
||||
final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface();
|
||||
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
|
||||
final ui.Image snapshot;
|
||||
try {
|
||||
snapshot = await captureAnchorSnapshot();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
final useIosNativeActionSurface = await nativeActionSurfaceSupport;
|
||||
if (!context.mounted) {
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
if (shouldRestoreComposerFocus) composerFocusNode!.unfocus();
|
||||
|
||||
messageActionBackdropActive.value = true;
|
||||
// Give the timeline one frame to replace UIKit glass platform views with
|
||||
// composable Flutter stand-ins before the full-screen blur is presented.
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!context.mounted) {
|
||||
messageActionBackdropActive.value = false;
|
||||
snapshot.dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
String? selectedActionId;
|
||||
final dialogRoute = RawDialogRoute<String>(
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Dismiss message actions',
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: isIos
|
||||
? _iosMessageActionTransitionDuration
|
||||
: _messageActionTransitionDuration,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
child,
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
|
||||
_MessageActionsPopover(
|
||||
anchorRect: anchorRect,
|
||||
anchorSnapshot: snapshot,
|
||||
animation: animation,
|
||||
message: message,
|
||||
pageContext: context,
|
||||
pageRef: ref,
|
||||
actions: actions,
|
||||
useIosNativeActionSurface: useIosNativeActionSurface,
|
||||
onPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged,
|
||||
),
|
||||
);
|
||||
var routePushed = false;
|
||||
try {
|
||||
final popResult = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(dialogRoute);
|
||||
routePushed = true;
|
||||
selectedActionId = await popResult;
|
||||
} finally {
|
||||
if (routePushed) await dialogRoute.completed;
|
||||
messageActionBackdropActive.value = false;
|
||||
snapshot.dispose();
|
||||
if (context.mounted) onPopoverDismissed?.call();
|
||||
}
|
||||
|
||||
for (final action in actions) {
|
||||
if (action.id != selectedActionId) continue;
|
||||
await Future<void>.sync(action.onSelected);
|
||||
break;
|
||||
}
|
||||
if (selectedActionId == null &&
|
||||
shouldRestoreComposerFocus &&
|
||||
context.mounted) {
|
||||
restoreComposerFocus?.call();
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
_messageActionPresentationInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<_PopoverMessageAction> _buildPopoverMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
}) {
|
||||
final actions = <_PopoverMessageAction>[];
|
||||
final messages = allMessages;
|
||||
final canRemind = ref.read(reminderServiceProvider) != null;
|
||||
|
||||
if (!message.isSystem) {
|
||||
if (messages != null) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'reply',
|
||||
title: 'Reply',
|
||||
icon: BuzzIcons.messageSquareReply,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: messages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyLink',
|
||||
title: 'Copy link',
|
||||
icon: BuzzIcons.link2,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
copyToClipboard(
|
||||
context,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
message: 'Message link copied',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (canRemind) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'remind',
|
||||
title: 'Remind me',
|
||||
icon: BuzzIcons.clock,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
showRemindMeLaterSheet(
|
||||
context: Navigator.of(context, rootNavigator: true).context,
|
||||
ref: ref,
|
||||
target: ReminderTarget(
|
||||
eventId: message.id,
|
||||
channelId: channelId,
|
||||
preview: message.content.characters
|
||||
.take(_reminderPreviewLength)
|
||||
.toString(),
|
||||
authorPubkey: message.pubkey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final readState = ref.read(readStateProvider);
|
||||
if (readState.isReady) {
|
||||
final unread = messageActionShowsUnread(
|
||||
ref,
|
||||
readState,
|
||||
channelId: channelId,
|
||||
message: message,
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: unread ? 'markRead' : 'markUnread',
|
||||
title: unread ? 'Mark read' : 'Mark unread',
|
||||
icon: unread ? BuzzIcons.mailCheck : BuzzIcons.mailOpen,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
notifier.markContextRead(
|
||||
msgContextKey(message.id),
|
||||
message.createdAt,
|
||||
);
|
||||
} else {
|
||||
notifier.markContextUnread(
|
||||
msgContextKey(message.id),
|
||||
channelId: channelId,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rootId = message.rootId ?? message.id;
|
||||
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: following ? 'unfollowThread' : 'followThread',
|
||||
title: following ? 'Unfollow thread' : 'Follow thread',
|
||||
icon: following ? BuzzIcons.bellOff : BuzzIcons.bellRing,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () {
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
} else {
|
||||
notifier.followThread(rootId);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'copyText',
|
||||
title: 'Copy text',
|
||||
icon: BuzzIcons.copy,
|
||||
group: _PopoverMessageActionGroup.utility,
|
||||
onSelected: () =>
|
||||
Clipboard.setData(ClipboardData(text: message.content)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (canManageMessage) {
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'edit',
|
||||
title: 'Edit message',
|
||||
icon: BuzzIcons.pencil,
|
||||
group: _PopoverMessageActionGroup.primary,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
actions.add(
|
||||
_PopoverMessageAction(
|
||||
id: 'delete',
|
||||
title: 'Delete message',
|
||||
icon: BuzzIcons.trash2,
|
||||
group: _PopoverMessageActionGroup.destructive,
|
||||
destructive: true,
|
||||
onSelected: () {
|
||||
if (!context.mounted) return;
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const actionOrder = {
|
||||
'reply': 0,
|
||||
'markRead': 1,
|
||||
'markUnread': 1,
|
||||
'edit': 2,
|
||||
'copyText': 3,
|
||||
'copyLink': 4,
|
||||
'remind': 5,
|
||||
'followThread': 6,
|
||||
'unfollowThread': 6,
|
||||
'delete': 7,
|
||||
};
|
||||
actions.sort(
|
||||
(left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!),
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
enum _PopoverMessageActionGroup { primary, utility, destructive }
|
||||
|
||||
class _PopoverMessageAction {
|
||||
final String id;
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final _PopoverMessageActionGroup group;
|
||||
final bool destructive;
|
||||
final FutureOr<void> Function() onSelected;
|
||||
|
||||
const _PopoverMessageAction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.group,
|
||||
required this.onSelected,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
String get iosSymbol => switch (id) {
|
||||
'reply' => 'arrowshape.turn.up.left',
|
||||
'markRead' => 'envelope.open',
|
||||
'markUnread' => 'envelope.badge',
|
||||
'edit' => 'pencil',
|
||||
'copyText' => 'doc.on.doc',
|
||||
'copyLink' => 'link',
|
||||
'remind' => 'clock',
|
||||
'followThread' => 'bell',
|
||||
'unfollowThread' => 'bell.slash',
|
||||
'delete' => 'trash',
|
||||
_ => 'ellipsis',
|
||||
};
|
||||
|
||||
Map<String, Object> toPlatformArguments() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'symbol': iosSymbol,
|
||||
'group': group.name,
|
||||
'destructive': destructive,
|
||||
};
|
||||
}
|
||||
|
||||
class _IosNativeMessageActionSurface extends HookWidget {
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final double rowHeight;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _IosNativeMessageActionSurface({
|
||||
required this.actions,
|
||||
required this.rowHeight,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewId = useState<int?>(null);
|
||||
useNativeMessageActionSelection(viewId.value, onSelected);
|
||||
|
||||
return UiKitView(
|
||||
key: const ValueKey('ios-native-message-action-surface'),
|
||||
viewType: 'buzz/native_message_action_surface',
|
||||
creationParams: <String, Object>{
|
||||
'actions': [for (final action in actions) action.toPlatformArguments()],
|
||||
'surfaceColor': context.colors.surface.toARGB32(),
|
||||
'foregroundColor': context.colors.onSurface.toARGB32(),
|
||||
'separatorColor': context.colors.outlineVariant.toARGB32(),
|
||||
'errorColor': context.colors.error.toARGB32(),
|
||||
'interfaceStyle': context.colors.brightness.name,
|
||||
'rowHeight': rowHeight,
|
||||
},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
onPlatformViewCreated: (id) => viewId.value = id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionsPopover extends HookWidget {
|
||||
final Rect anchorRect;
|
||||
final ui.Image anchorSnapshot;
|
||||
final Animation<double> animation;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final bool useIosNativeActionSurface;
|
||||
final ValueChanged<bool>? onPreviewVisibilityChanged;
|
||||
|
||||
const _MessageActionsPopover({
|
||||
required this.anchorRect,
|
||||
required this.anchorSnapshot,
|
||||
required this.animation,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
required this.actions,
|
||||
required this.useIosNativeActionSurface,
|
||||
required this.onPreviewVisibilityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final selectionStarted = useRef(false);
|
||||
|
||||
void select(Object? result, [VoidCallback? effect]) {
|
||||
if (selectionStarted.value) return;
|
||||
selectionStarted.value = true;
|
||||
Navigator.of(context).pop(result);
|
||||
effect?.call();
|
||||
}
|
||||
|
||||
void selectAction(String actionId) => select(actionId);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final safeLeft = mediaQuery.padding.left + Grid.xxs;
|
||||
final safeRight =
|
||||
constraints.maxWidth - mediaQuery.padding.right - Grid.xxs;
|
||||
final safeTop = mediaQuery.padding.top + Grid.xxs;
|
||||
final safeBottom =
|
||||
constraints.maxHeight -
|
||||
mediaQuery.padding.bottom -
|
||||
mediaQuery.viewInsets.bottom -
|
||||
Grid.xxs;
|
||||
final availableWidth = math.max(1.0, safeRight - safeLeft);
|
||||
final availableHeight = math.max(1.0, safeBottom - safeTop);
|
||||
final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth);
|
||||
final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth);
|
||||
final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
|
||||
final preferredMenuHeight = menuLayout.preferredHeight;
|
||||
final minimumMenuHeight = math.min(
|
||||
menuLayout.rowHeight,
|
||||
availableHeight,
|
||||
);
|
||||
final showReactionTray =
|
||||
availableHeight >=
|
||||
_reactionTrayMaxHeight + _messageActionGap + minimumMenuHeight;
|
||||
final trayHeight = showReactionTray ? _reactionTrayMaxHeight : 0.0;
|
||||
final trayGap = showReactionTray ? _messageActionGap : 0.0;
|
||||
final heightAfterTray = availableHeight - trayHeight - trayGap;
|
||||
final showPreview =
|
||||
showReactionTray &&
|
||||
heightAfterTray >= minimumMenuHeight + _messageActionGap + 48.0;
|
||||
final previewMinimumHeight = showPreview ? 48.0 : 0.0;
|
||||
final previewGap = showPreview ? _messageActionGap : 0.0;
|
||||
final menuBudget = math.max(
|
||||
minimumMenuHeight,
|
||||
availableHeight -
|
||||
trayHeight -
|
||||
trayGap -
|
||||
previewMinimumHeight -
|
||||
previewGap,
|
||||
);
|
||||
final menuHeight = math.min(preferredMenuHeight, menuBudget);
|
||||
final previewMaximumHeight = showPreview
|
||||
? math.max(
|
||||
previewMinimumHeight,
|
||||
availableHeight -
|
||||
trayHeight -
|
||||
trayGap -
|
||||
menuHeight -
|
||||
previewGap,
|
||||
)
|
||||
: 0.0;
|
||||
final previewInsetExtent = _messageActionPreviewInset * 2;
|
||||
final previewSize = showPreview
|
||||
? () {
|
||||
final previewWidthRatio =
|
||||
(math.min(_messageActionPreviewMaxWidth, availableWidth) -
|
||||
previewInsetExtent) /
|
||||
math.max(anchorRect.width, 1);
|
||||
final previewHeightRatio =
|
||||
(previewMaximumHeight - previewInsetExtent) /
|
||||
math.max(anchorRect.height, 1);
|
||||
final previewScale = math.min(
|
||||
1.0,
|
||||
math.max(
|
||||
0.0,
|
||||
math.min(previewWidthRatio, previewHeightRatio),
|
||||
),
|
||||
);
|
||||
return Size(
|
||||
(anchorRect.width * previewScale) + previewInsetExtent,
|
||||
(anchorRect.height * previewScale) + previewInsetExtent,
|
||||
);
|
||||
}()
|
||||
: Size.zero;
|
||||
final totalHeight =
|
||||
trayHeight + trayGap + previewSize.height + previewGap + menuHeight;
|
||||
final contentTop = math.max(safeTop, safeBottom - totalHeight);
|
||||
final surfaceLeft =
|
||||
safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2);
|
||||
final trayRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
contentTop,
|
||||
trayWidth,
|
||||
trayHeight,
|
||||
);
|
||||
final trayHostWidth = math.min(
|
||||
trayWidth + _reactionTraySpringAllowance,
|
||||
safeRight - surfaceLeft,
|
||||
);
|
||||
final previewRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
trayRect.bottom + trayGap,
|
||||
previewSize.width,
|
||||
previewSize.height,
|
||||
);
|
||||
final menuRect = Rect.fromLTWH(
|
||||
surfaceLeft,
|
||||
previewRect.bottom + previewGap,
|
||||
menuWidth,
|
||||
menuHeight,
|
||||
);
|
||||
return _MessageActionPreviewVisibility(
|
||||
visible: showPreview,
|
||||
onChanged: onPreviewVisibilityChanged,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
key: const ValueKey('message-actions-backdrop-filter'),
|
||||
filter: _messageActionBackdropFilter,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final opacity = Curves.easeOutCubic.transform(
|
||||
animation.value,
|
||||
);
|
||||
return ColoredBox(
|
||||
key: const ValueKey('message-actions-background'),
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: _messageActionBackdropTintOpacity * opacity,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('message-actions-backdrop'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => select(null),
|
||||
),
|
||||
),
|
||||
if (showPreview)
|
||||
Positioned.fromRect(
|
||||
rect: previewRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: RepaintBoundary(
|
||||
child: _LiftedMessagePreview(
|
||||
anchorSnapshot: anchorSnapshot,
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final movement =
|
||||
(defaultTargetPlatform == TargetPlatform.iOS
|
||||
? Curves.easeOutCubic
|
||||
: Curves.easeInOutCubic)
|
||||
.transform(animation.value);
|
||||
final sourceRect = anchorRect.inflate(
|
||||
_messageActionPreviewInset,
|
||||
);
|
||||
final translation = Offset(
|
||||
ui.lerpDouble(
|
||||
sourceRect.left - previewRect.left,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
ui.lerpDouble(
|
||||
sourceRect.top - previewRect.top,
|
||||
0,
|
||||
movement,
|
||||
)!,
|
||||
);
|
||||
final scaleX = ui.lerpDouble(
|
||||
sourceRect.width / previewRect.width,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
final scaleY = ui.lerpDouble(
|
||||
sourceRect.height / previewRect.height,
|
||||
1,
|
||||
movement,
|
||||
)!;
|
||||
return Transform.translate(
|
||||
offset: translation,
|
||||
child: Transform(
|
||||
alignment: Alignment.topLeft,
|
||||
transform: Matrix4.diagonal3Values(scaleX, scaleY, 1),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showReactionTray)
|
||||
Positioned(
|
||||
left: trayRect.left,
|
||||
top: trayRect.top,
|
||||
width: trayHostWidth,
|
||||
height: trayRect.height,
|
||||
child: _MessageReactionTray(
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
popResult: _messageActionReactionSelection,
|
||||
onSelected: (result, effect) => select(result, effect),
|
||||
),
|
||||
),
|
||||
Positioned.fromRect(
|
||||
rect: menuRect,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: useIosNativeActionSurface
|
||||
? _IosNativeMessageActionSurface(
|
||||
actions: actions,
|
||||
rowHeight: menuLayout.rowHeight,
|
||||
onSelected: selectAction,
|
||||
)
|
||||
: _MessageActionSurface(
|
||||
actions: actions,
|
||||
onSelected: selectAction,
|
||||
),
|
||||
builder: (context, child) {
|
||||
final appearance = const Interval(
|
||||
0.08,
|
||||
0.82,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final fadedChild = Opacity(
|
||||
opacity: appearance,
|
||||
child: child,
|
||||
);
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
return fadedChild;
|
||||
}
|
||||
return Transform.scale(
|
||||
alignment: Alignment.topLeft,
|
||||
scale: ui.lerpDouble(0.96, 1, appearance)!,
|
||||
child: fadedChild,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
class _MessageActionPreviewVisibility extends HookWidget {
|
||||
final bool visible;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final Widget child;
|
||||
|
||||
const _MessageActionPreviewVisibility({
|
||||
required this.visible,
|
||||
required this.onChanged,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) onChanged?.call(visible);
|
||||
});
|
||||
return null;
|
||||
}, [visible, onChanged]);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class _LiftedMessagePreview extends StatelessWidget {
|
||||
final ui.Image anchorSnapshot;
|
||||
|
||||
const _LiftedMessagePreview({required this.anchorSnapshot});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
key: const ValueKey('message-action-preview'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.7),
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_messageActionPreviewInset),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.xs),
|
||||
child: RawImage(
|
||||
image: anchorSnapshot,
|
||||
fit: BoxFit.fill,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionSurface extends StatelessWidget {
|
||||
final List<_PopoverMessageAction> actions;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionSurface({
|
||||
required this.actions,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final menuLayout = _MessageActionSurfaceLayout.from(context, actions);
|
||||
return Material(
|
||||
key: const ValueKey('message-action-surface'),
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 10,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.22),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.popover),
|
||||
side: BorderSide(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.55),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: _messageActionVerticalInset,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
if (index > 0 &&
|
||||
actions[index - 1].group != actions[index].group)
|
||||
Divider(
|
||||
key: ValueKey(
|
||||
'message-action-divider-${actions[index].group.name}',
|
||||
),
|
||||
height: _messageActionSeparatorHeight,
|
||||
thickness: _messageActionSeparatorHeight,
|
||||
indent: Grid.xs,
|
||||
endIndent: Grid.xs,
|
||||
),
|
||||
_MessageActionRow(
|
||||
action: actions[index],
|
||||
height: menuLayout.rowHeight,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionRow extends StatelessWidget {
|
||||
final _PopoverMessageAction action;
|
||||
final double height;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _MessageActionRow({
|
||||
required this.action,
|
||||
required this.height,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = action.destructive
|
||||
? context.colors.error
|
||||
: context.colors.onSurface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: action.title,
|
||||
excludeSemantics: true,
|
||||
child: InkWell(
|
||||
key: ValueKey('message-action-${action.id}'),
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onSelected(action.id);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Icon(action.icon, size: 22, color: foreground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: Text(
|
||||
action.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageActionSurfaceLayout {
|
||||
final double rowHeight, preferredHeight;
|
||||
|
||||
const _MessageActionSurfaceLayout({
|
||||
required this.rowHeight,
|
||||
required this.preferredHeight,
|
||||
});
|
||||
|
||||
factory _MessageActionSurfaceLayout.from(
|
||||
BuildContext context,
|
||||
List<_PopoverMessageAction> actions,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'Message action',
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final rowHeight = math.max(
|
||||
_messageActionRowHeight,
|
||||
textPainter.height + (_messageActionRowVerticalPadding * 2),
|
||||
);
|
||||
textPainter.dispose();
|
||||
|
||||
var separatorCount = 0;
|
||||
for (var index = 1; index < actions.length; index++) {
|
||||
if (actions[index - 1].group != actions[index].group) separatorCount += 1;
|
||||
}
|
||||
final preferredHeight =
|
||||
(_messageActionVerticalInset * 2) +
|
||||
(actions.length * rowHeight) +
|
||||
(separatorCount * _messageActionSeparatorHeight);
|
||||
return _MessageActionSurfaceLayout(
|
||||
rowHeight: rowHeight,
|
||||
preferredHeight: preferredHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
class _MessageReactionTray extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final Object popResult;
|
||||
final void Function(Object? result, VoidCallback effect) onSelected;
|
||||
|
||||
const _MessageReactionTray({
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
required this.popResult,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('message-action-reaction-tray'),
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: Alignment.bottomLeft,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
popResult: popResult,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
part of '../message_actions.dart';
|
||||
|
||||
Future<bool> _showNativeMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool canManageMessage,
|
||||
required List<TimelineMessage>? allMessages,
|
||||
required String? currentPubkey,
|
||||
required bool isMember,
|
||||
required bool isArchived,
|
||||
required Rect anchorRect,
|
||||
required Future<ui.Image> Function()? captureAnchorSnapshot,
|
||||
required FocusNode? composerFocusNode,
|
||||
required VoidCallback? restoreComposerFocus,
|
||||
required ValueChanged<bool>? onPopoverPreviewVisibilityChanged,
|
||||
required VoidCallback? onPopoverDismissed,
|
||||
}) async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return false;
|
||||
return NativeMessagePresentation.withLease(() async {
|
||||
if (captureAnchorSnapshot == null) return false;
|
||||
final hadComposerFocus = composerFocusNode?.hasFocus ?? false;
|
||||
final support = await NativeMessagePresentation.present(
|
||||
'supportsMessage',
|
||||
{},
|
||||
);
|
||||
if (support?['supported'] != true) return false;
|
||||
if (!context.mounted) return true;
|
||||
final community = ref.read(relayConfigProvider);
|
||||
Uint8List? previewBytes;
|
||||
try {
|
||||
final snapshot = await captureAnchorSnapshot();
|
||||
try {
|
||||
final bytes = await snapshot.toByteData(format: ui.ImageByteFormat.png);
|
||||
previewBytes = bytes?.buffer.asUint8List();
|
||||
} finally {
|
||||
snapshot.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!context.mounted || ref.read(relayConfigProvider) != community) {
|
||||
return true;
|
||||
}
|
||||
if (previewBytes == null) return false;
|
||||
final callbacks = <String, VoidCallback>{};
|
||||
final actions = <Map<String, Object?>>[];
|
||||
void action(
|
||||
String id,
|
||||
String title,
|
||||
String symbol,
|
||||
VoidCallback callback, {
|
||||
bool destructive = false,
|
||||
}) {
|
||||
callbacks[id] = callback;
|
||||
actions.add({
|
||||
'id': id,
|
||||
'title': title,
|
||||
'symbol': symbol,
|
||||
'destructive': destructive,
|
||||
});
|
||||
}
|
||||
|
||||
if (!message.isSystem) {
|
||||
if (allMessages != null) {
|
||||
action('reply', 'Reply', 'arrowshape.turn.up.left', () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
action('copyLink', 'Copy link', 'link', () {
|
||||
copyToClipboard(
|
||||
context,
|
||||
messageLinkFor(message: message, channelId: channelId),
|
||||
message: 'Message link copied',
|
||||
);
|
||||
});
|
||||
if (ref.read(reminderServiceProvider) != null) {
|
||||
action('remind', 'Remind me', 'clock', () {
|
||||
showRemindMeLaterSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
target: ReminderTarget(
|
||||
eventId: message.id,
|
||||
channelId: channelId,
|
||||
preview: message.content.characters
|
||||
.take(_reminderPreviewLength)
|
||||
.toString(),
|
||||
authorPubkey: message.pubkey,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
final readState = ref.read(readStateProvider);
|
||||
if (readState.isReady) {
|
||||
final unread = messageActionShowsUnread(
|
||||
ref,
|
||||
readState,
|
||||
channelId: channelId,
|
||||
message: message,
|
||||
);
|
||||
action('read', unread ? 'Mark read' : 'Mark unread', 'envelope', () {
|
||||
final notifier = ref.read(readStateProvider.notifier);
|
||||
if (unread) {
|
||||
notifier.markContextRead(
|
||||
msgContextKey(message.id),
|
||||
message.createdAt,
|
||||
);
|
||||
} else {
|
||||
notifier.markContextUnread(
|
||||
msgContextKey(message.id),
|
||||
channelId: channelId,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
final rootId = message.rootId ?? message.id;
|
||||
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
|
||||
action(
|
||||
'follow',
|
||||
following ? 'Unfollow thread' : 'Follow thread',
|
||||
following ? 'bell.slash' : 'bell',
|
||||
() {
|
||||
final notifier = ref.read(threadFollowsProvider.notifier);
|
||||
if (following) {
|
||||
notifier.unfollowThread(rootId);
|
||||
} else {
|
||||
notifier.followThread(rootId);
|
||||
}
|
||||
},
|
||||
);
|
||||
action('copy', 'Copy text', 'doc.on.doc', () {
|
||||
Clipboard.setData(ClipboardData(text: message.content));
|
||||
});
|
||||
}
|
||||
if (canManageMessage) {
|
||||
action('edit', 'Edit message', 'pencil', () {
|
||||
_showEditSheet(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
);
|
||||
});
|
||||
action('delete', 'Delete message', 'trash', () {
|
||||
_confirmDelete(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
messageId: message.id,
|
||||
);
|
||||
}, destructive: true);
|
||||
}
|
||||
final palette = ref.read(customEmojiListProvider);
|
||||
final emojis = quickReactionEmoji(
|
||||
ref.read(recentEmojiProvider),
|
||||
customShortcodes: {for (final e in palette) e.shortcode.toLowerCase()},
|
||||
);
|
||||
final dataset = ref.read(emojiDatasetOrEmptyProvider);
|
||||
Map<Object?, Object?>? response;
|
||||
var ownsPreview = false;
|
||||
if (hadComposerFocus) composerFocusNode!.unfocus();
|
||||
try {
|
||||
response = await NativeMessagePresentation.present(
|
||||
'message',
|
||||
{
|
||||
'x': anchorRect.left,
|
||||
'y': anchorRect.top,
|
||||
'width': anchorRect.width,
|
||||
'height': anchorRect.height,
|
||||
'dark': Theme.of(context).brightness == Brightness.dark,
|
||||
'previewLabel': message.content,
|
||||
'previewBytes': previewBytes,
|
||||
'actions': actions,
|
||||
'reactions': [
|
||||
for (final emoji in emojis)
|
||||
{
|
||||
'emoji': emoji,
|
||||
'label': dataset.displayName(emoji),
|
||||
'selected': message.reactions.any(
|
||||
(r) => r.emoji == emoji && r.reactedByCurrentUser,
|
||||
),
|
||||
if (reactionEmojiUrl(emoji, palette) case final String url) ...{
|
||||
'url': url,
|
||||
'headers': mediaGetHeadersFor(ref, url),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
onPresented: () {
|
||||
if (!context.mounted || ownsPreview) return;
|
||||
ownsPreview = true;
|
||||
onPopoverPreviewVisibilityChanged?.call(true);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (ownsPreview) {
|
||||
onPopoverPreviewVisibilityChanged?.call(false);
|
||||
if (context.mounted) onPopoverDismissed?.call();
|
||||
}
|
||||
if (hadComposerFocus &&
|
||||
response?['busy'] != true &&
|
||||
response?['action'] == null &&
|
||||
context.mounted) {
|
||||
restoreComposerFocus?.call();
|
||||
}
|
||||
}
|
||||
if (response == null) return false;
|
||||
// A native controller can outlive the Flutter page that opened it.
|
||||
if (!context.mounted || ref.read(relayConfigProvider) != community) {
|
||||
return true;
|
||||
}
|
||||
final selected = response['action'];
|
||||
if (selected == 'reaction' && emojis.contains(response['emoji'])) {
|
||||
final emoji = response['emoji']! as String;
|
||||
final existing = message.reactions.where((r) => r.emoji == emoji);
|
||||
if (existing.isNotEmpty && existing.first.reactedByCurrentUser) {
|
||||
toggleReaction(ref, message, emoji);
|
||||
} else {
|
||||
ref.read(recentEmojiProvider.notifier).record(emoji);
|
||||
armReactionBurst(ref, message, emoji);
|
||||
ref.read(channelActionsProvider).addReaction(message.id, emoji);
|
||||
}
|
||||
} else if (selected == 'more') {
|
||||
showAddReactionPicker(context: context, ref: ref, message: message);
|
||||
} else {
|
||||
callbacks[selected]?.call();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -14,28 +14,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;
|
||||
|
||||
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 dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
||||
final customEmoji = ref.watch(customEmojiListProvider);
|
||||
final emoji = quickReactionEmoji(
|
||||
ref.watch(recentEmojiProvider),
|
||||
@@ -48,6 +36,12 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
};
|
||||
|
||||
void react(String value) {
|
||||
if (message.reactions.any(
|
||||
(r) => r.emoji == value && r.reactedByCurrentUser,
|
||||
)) {
|
||||
toggleReaction(pageRef, message, value);
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
@@ -74,22 +68,17 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
.toDouble();
|
||||
final circles = <Widget>[
|
||||
for (var index = 0; index < emoji.length; index++)
|
||||
_ReactionItemReveal(
|
||||
KeyedSubtree(
|
||||
key: ValueKey('quick-reaction-${emoji[index]}'),
|
||||
animation: presentationAnimation,
|
||||
index: index,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
label: dataset.displayName(emoji[index]),
|
||||
selected: message.reactions.any(
|
||||
(r) => r.emoji == emoji[index] && r.reactedByCurrentUser,
|
||||
),
|
||||
onTap: () {
|
||||
void effect() => react(emoji[index]);
|
||||
|
||||
final select = onSelected;
|
||||
if (select != null) {
|
||||
select(popResult, effect);
|
||||
} else {
|
||||
Navigator.of(sheetContext).pop(popResult);
|
||||
effect();
|
||||
}
|
||||
if (!_selectMessageAction(context)) return;
|
||||
react(emoji[index]);
|
||||
},
|
||||
child: _QuickReactionGlyph(
|
||||
value: emoji[index],
|
||||
@@ -97,23 +86,14 @@ class _QuickReactionRow extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_ReactionItemReveal(
|
||||
KeyedSubtree(
|
||||
key: const ValueKey('quick-reaction-more'),
|
||||
animation: presentationAnimation,
|
||||
index: emoji.length,
|
||||
child: _QuickReactionCircle(
|
||||
size: circleSize,
|
||||
label: 'Add reaction',
|
||||
onTap: () {
|
||||
void effect() =>
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
|
||||
final select = onSelected;
|
||||
if (select != null) {
|
||||
select(popResult, effect);
|
||||
} else {
|
||||
Navigator.of(sheetContext).pop(popResult);
|
||||
effect();
|
||||
}
|
||||
if (!_selectMessageAction(context)) return;
|
||||
showEmojiPicker(context: pageContext, onSelect: react);
|
||||
},
|
||||
child: Icon(
|
||||
BuzzIcons.plus,
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
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(
|
||||
_presentMessageReactionPopover(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
anchorRect: anchorRect,
|
||||
spotlightPadding: spotlightPadding,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _presentMessageReactionPopover({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required Rect anchorRect,
|
||||
required EdgeInsets spotlightPadding,
|
||||
}) async {
|
||||
if (_messageActionPresentationInFlight) return;
|
||||
_messageActionPresentationInFlight = true;
|
||||
|
||||
try {
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final dialogRoute = RawDialogRoute<void>(
|
||||
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,
|
||||
),
|
||||
);
|
||||
var routePushed = false;
|
||||
messageActionBackdropActive.value = true;
|
||||
try {
|
||||
// Remove UIKit glass views from Flutter's platform-view overlay before the
|
||||
// backdrop filter paints, otherwise they leave sharp rectangular holes.
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!context.mounted) return;
|
||||
final popResult = Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(dialogRoute);
|
||||
routePushed = true;
|
||||
await popResult;
|
||||
} finally {
|
||||
if (routePushed) await dialogRoute.completed;
|
||||
messageActionBackdropActive.value = false;
|
||||
}
|
||||
} finally {
|
||||
_messageActionPresentationInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageReactionPopover extends HookWidget {
|
||||
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);
|
||||
final selectionStarted = useRef(false);
|
||||
|
||||
void select(Object? result, [VoidCallback? effect]) {
|
||||
if (selectionStarted.value) return;
|
||||
selectionStarted.value = true;
|
||||
Navigator.of(context).pop(result);
|
||||
effect?.call();
|
||||
}
|
||||
|
||||
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: ClipPath(
|
||||
key: const ValueKey('reaction-popover-background'),
|
||||
clipper: _OutsideAnchorClipper(anchorRect, spotlightPadding),
|
||||
child: BackdropFilter(
|
||||
key: const ValueKey('reaction-popover-backdrop-filter'),
|
||||
filter: _messageActionBackdropFilter,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final opacity = const Interval(
|
||||
0,
|
||||
0.30,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
return ColoredBox(
|
||||
key: const ValueKey('reaction-popover-background-tint'),
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: _messageActionBackdropTintOpacity * opacity,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => select(null),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
width: trayWidth + _reactionTraySpringAllowance,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: _AnimatedReactionTray(
|
||||
trayKey: const ValueKey('reaction-popover-tray'),
|
||||
animation: animation,
|
||||
trayWidth: trayWidth,
|
||||
scaleAlignment: trayScaleAlignment,
|
||||
message: message,
|
||||
pageContext: pageContext,
|
||||
pageRef: pageRef,
|
||||
onSelected: (result, effect) => select(result, effect),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedReactionTray extends StatelessWidget {
|
||||
final Key trayKey;
|
||||
final Animation<double> animation;
|
||||
final double trayWidth;
|
||||
final AlignmentGeometry scaleAlignment;
|
||||
final TimelineMessage message;
|
||||
final BuildContext pageContext;
|
||||
final WidgetRef pageRef;
|
||||
final Object? popResult;
|
||||
final void Function(Object? result, VoidCallback effect)? onSelected;
|
||||
|
||||
const _AnimatedReactionTray({
|
||||
required this.trayKey,
|
||||
required this.animation,
|
||||
required this.trayWidth,
|
||||
required this.scaleAlignment,
|
||||
required this.message,
|
||||
required this.pageContext,
|
||||
required this.pageRef,
|
||||
this.popResult,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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,
|
||||
popResult: popResult,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: scaleAlignment,
|
||||
scale: lerpDouble(0.95, 1, appearance)!,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: _reactionTrayMaxHeight,
|
||||
child: Material(
|
||||
key: trayKey,
|
||||
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);
|
||||
}
|
||||
@@ -49,8 +49,9 @@ const _pillGlyphSize = 16.0;
|
||||
/// Used by channel detail, thread detail, and system message rows to avoid
|
||||
/// duplicating the toggle wiring.
|
||||
void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) {
|
||||
final reaction = message.reactions.where((r) => r.emoji == emoji).firstOrNull;
|
||||
if (reaction == null) return;
|
||||
final actions = ref.read(channelActionsProvider);
|
||||
final reaction = message.reactions.firstWhere((r) => r.emoji == emoji);
|
||||
if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) {
|
||||
actions.removeReaction(reaction.currentUserReactionId!, emoji);
|
||||
} else {
|
||||
@@ -95,7 +96,7 @@ void showAddReactionPicker({
|
||||
);
|
||||
}
|
||||
|
||||
class ReactionRow extends StatelessWidget {
|
||||
class ReactionRow extends HookWidget {
|
||||
/// The message these reactions belong to. Used to match a pending burst to
|
||||
/// the right pill — the same emoji can be pending on a different message.
|
||||
final String messageId;
|
||||
@@ -126,6 +127,23 @@ class ReactionRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final liveReactions = useMemoized(() => ValueNotifier(reactions));
|
||||
useEffect(
|
||||
() => () {
|
||||
scheduleMicrotask(() {
|
||||
liveReactions.value = [];
|
||||
liveReactions.dispose();
|
||||
});
|
||||
},
|
||||
[liveReactions],
|
||||
);
|
||||
final latestToggle = useRef(onToggle)..value = onToggle;
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) liveReactions.value = reactions;
|
||||
});
|
||||
return null;
|
||||
}, [reactions]);
|
||||
// With the add button on, an empty row still renders — that affordance is
|
||||
// the point. Without it, an empty row would be dead space.
|
||||
if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink();
|
||||
@@ -144,8 +162,10 @@ class ReactionRow extends StatelessWidget {
|
||||
onLongPress: () => showReactionDetailSheet(
|
||||
context: context,
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
initialEmoji: reaction.emoji,
|
||||
reactions: liveReactions,
|
||||
onToggle: (emoji) {
|
||||
if (context.mounted) latestToggle.value(emoji);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showAddButton && onAddReaction != null)
|
||||
@@ -344,199 +364,66 @@ class _ReactionEmoji extends StatelessWidget {
|
||||
Future<void> showReactionDetailSheet({
|
||||
required BuildContext context,
|
||||
required String channelId,
|
||||
required List<TimelineReaction> reactions,
|
||||
required String initialEmoji,
|
||||
required ValueListenable<List<TimelineReaction>> reactions,
|
||||
required ValueChanged<String> onToggle,
|
||||
}) async {
|
||||
if (reactions.isEmpty) return;
|
||||
if (reactions.value.isEmpty) return;
|
||||
if (await _showNativeReactionDetails(
|
||||
context,
|
||||
channelId,
|
||||
reactions,
|
||||
initialEmoji,
|
||||
onToggle,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: context.colors.surfaceContainerHighest,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(Radii.dialog)),
|
||||
),
|
||||
builder: (_) => _AndroidReactionDetailSheet(
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
initialEmoji: initialEmoji,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showBuzzModalBottomSheet<void>(
|
||||
await showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
title: 'Reactions',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => _ReactionDetailSheet(
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
initialEmoji: initialEmoji,
|
||||
builder: (_) => ValueListenableBuilder<List<TimelineReaction>>(
|
||||
valueListenable: reactions,
|
||||
builder: (sheetContext, value, _) {
|
||||
if (value.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (sheetContext.mounted &&
|
||||
ModalRoute.of(sheetContext)?.isCurrent == true) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _ReactionDetailSheet(
|
||||
channelId: channelId,
|
||||
reactions: value,
|
||||
onToggle: onToggle,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AndroidReactionDetailSheet extends HookConsumerWidget {
|
||||
const _AndroidReactionDetailSheet({
|
||||
required this.channelId,
|
||||
required this.reactions,
|
||||
required this.initialEmoji,
|
||||
});
|
||||
|
||||
final String channelId;
|
||||
final List<TimelineReaction> reactions;
|
||||
final String initialEmoji;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cache = ref.watch(userCacheProvider);
|
||||
final names = ref.watch(channelIdentityNamesProvider(channelId));
|
||||
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
||||
final pubkeys = reactions
|
||||
.expand((reaction) => reaction.userPubkeys)
|
||||
.toSet()
|
||||
.toList();
|
||||
useEffect(() {
|
||||
if (pubkeys.isNotEmpty) {
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys);
|
||||
}
|
||||
return null;
|
||||
}, [pubkeys.join(',')]);
|
||||
final total = reactions.fold<int>(
|
||||
0,
|
||||
(sum, reaction) => sum + reaction.count,
|
||||
);
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.65;
|
||||
final desiredHeight =
|
||||
48.0 + pubkeys.length * 56.0 + MediaQuery.viewPaddingOf(context).bottom;
|
||||
return DefaultTabController(
|
||||
length: reactions.length + 1,
|
||||
initialIndex: reactions.indexWhere((r) => r.emoji == initialEmoji) + 1,
|
||||
child: SizedBox(
|
||||
key: const ValueKey('reaction-details-sheet'),
|
||||
height: desiredHeight.clamp(
|
||||
maxHeight < 340 ? maxHeight : 340,
|
||||
maxHeight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
tabAlignment: TabAlignment.start,
|
||||
dividerHeight: 0,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicatorPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
indicator: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
labelColor: context.colors.onSurface,
|
||||
unselectedLabelColor: context.colors.onSurfaceVariant,
|
||||
tabs: [
|
||||
Tab(
|
||||
key: const ValueKey('reaction-filter-all'),
|
||||
text: 'All $total',
|
||||
),
|
||||
for (final reaction in reactions)
|
||||
Tab(
|
||||
key: ValueKey('reaction-filter-${reaction.emoji}'),
|
||||
child: Tooltip(
|
||||
message: dataset.displayName(reaction.emoji),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: 20),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text('${reaction.count}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
for (var tab = 0; tab <= reactions.length; tab++)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final filtered = tab == 0
|
||||
? reactions
|
||||
: [reactions[tab - 1]];
|
||||
final rows = [
|
||||
for (final reaction in filtered)
|
||||
for (final pubkey in reaction.userPubkeys.toSet())
|
||||
(pubkey: pubkey, reaction: reaction),
|
||||
];
|
||||
return ListView.builder(
|
||||
key: PageStorageKey('reaction-page-$tab'),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewPaddingOf(context).bottom,
|
||||
),
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, index) {
|
||||
final row = rows[index];
|
||||
return _ReactorTile(
|
||||
profile: cache[row.pubkey.toLowerCase()],
|
||||
pubkey: row.pubkey,
|
||||
displayName: names.labelFor(row.pubkey),
|
||||
reaction: row.reaction,
|
||||
reactionLabel: dataset.displayName(
|
||||
row.reaction.emoji,
|
||||
),
|
||||
compact: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
final List<TimelineReaction> reactions;
|
||||
final String initialEmoji;
|
||||
final ValueChanged<String> onToggle;
|
||||
|
||||
const _ReactionDetailSheet({
|
||||
required this.channelId,
|
||||
required this.reactions,
|
||||
required this.initialEmoji,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedEmoji = useState<String?>(initialEmoji);
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final identityNames = ref.watch(channelIdentityNamesProvider(channelId));
|
||||
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
||||
final rows = [
|
||||
for (final reaction in reactions)
|
||||
if (selectedEmoji.value == null ||
|
||||
reaction.emoji == selectedEmoji.value)
|
||||
for (final pubkey in reaction.userPubkeys.toSet())
|
||||
(pubkey: pubkey, reaction: reaction),
|
||||
for (final pubkey in reaction.userPubkeys.toSet())
|
||||
(pubkey: pubkey, reaction: reaction),
|
||||
];
|
||||
final pubkeys = rows.map((row) => row.pubkey).toSet().toList();
|
||||
useEffect(() {
|
||||
@@ -545,11 +432,6 @@ class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
}
|
||||
return null;
|
||||
}, [pubkeys.join(',')]);
|
||||
final total = reactions.fold<int>(
|
||||
0,
|
||||
(sum, reaction) => sum + reaction.count,
|
||||
);
|
||||
|
||||
return ConstrainedBox(
|
||||
key: const ValueKey('reaction-details-sheet'),
|
||||
constraints: BoxConstraints(
|
||||
@@ -564,31 +446,40 @@ class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: Grid.half),
|
||||
child: ChoiceChip(
|
||||
key: const ValueKey('reaction-filter-all'),
|
||||
label: Text('All $total'),
|
||||
selected: selectedEmoji.value == null,
|
||||
onSelected: (_) => selectedEmoji.value = null,
|
||||
),
|
||||
),
|
||||
for (final reaction in reactions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: Grid.half),
|
||||
child: ChoiceChip(
|
||||
key: ValueKey('reaction-filter-${reaction.emoji}'),
|
||||
tooltip: dataset.displayName(reaction.emoji),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: 20),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text('${reaction.count}'),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (chipContext) => FilterChip(
|
||||
key: ValueKey('reaction-toggle-${reaction.emoji}'),
|
||||
tooltip: dataset.displayName(reaction.emoji),
|
||||
side: BorderSide(
|
||||
color: reaction.reactedByCurrentUser
|
||||
? context.colors.primary.withValues(alpha: 0.40)
|
||||
: context.colors.onSurface.withValues(
|
||||
alpha: 0.25,
|
||||
),
|
||||
),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionEmoji(reaction: reaction, size: 20),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text('${reaction.count}'),
|
||||
],
|
||||
),
|
||||
selected: reaction.reactedByCurrentUser,
|
||||
onSelected: (_) {
|
||||
if (!reaction.reactedByCurrentUser) {
|
||||
burstEmojiFromContext(
|
||||
ref,
|
||||
chipContext,
|
||||
reaction.emoji,
|
||||
);
|
||||
}
|
||||
onToggle(reaction.emoji);
|
||||
},
|
||||
),
|
||||
selected: reaction.emoji == selectedEmoji.value,
|
||||
onSelected: (_) => selectedEmoji.value = reaction.emoji,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -618,6 +509,7 @@ class _ReactionDetailSheet extends HookConsumerWidget {
|
||||
displayName: identityNames.labelFor(row.pubkey),
|
||||
reaction: row.reaction,
|
||||
reactionLabel: dataset.displayName(row.reaction.emoji),
|
||||
compact: defaultTargetPlatform == TargetPlatform.android,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -719,20 +611,22 @@ class _ReactorAvatar extends StatelessWidget {
|
||||
Future<bool> _showNativeReactionDetails(
|
||||
BuildContext context,
|
||||
String channelId,
|
||||
List<TimelineReaction> reactions,
|
||||
String initialEmoji,
|
||||
ValueListenable<List<TimelineReaction>> reactions,
|
||||
ValueChanged<String> onToggle,
|
||||
) async {
|
||||
if (!NativeMessagePresentation.isSupportedPlatform) return false;
|
||||
return NativeMessagePresentation.withLease(() async {
|
||||
final container = ProviderScope.containerOf(context, listen: false);
|
||||
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
final pubkeys = reactions.expand((r) => r.userPubkeys).toSet().toList();
|
||||
List<String> pubkeys() =>
|
||||
reactions.value.expand((r) => r.userPubkeys).toSet().toList();
|
||||
final sent = <String, ({String name, String? avatarUrl})>{};
|
||||
Map<String, Object?> profiles({bool onlyChanges = false}) {
|
||||
if (reactions.value.isEmpty) return {};
|
||||
final cache = container.read(userCacheProvider);
|
||||
final names = container.read(channelIdentityNamesProvider(channelId));
|
||||
final result = <String, Object?>{};
|
||||
for (final pubkey in pubkeys) {
|
||||
for (final pubkey in pubkeys()) {
|
||||
final value = (
|
||||
name: names.labelFor(pubkey),
|
||||
avatarUrl: cache[pubkey.toLowerCase()]?.avatarUrl,
|
||||
@@ -804,38 +698,59 @@ Future<bool> _showNativeReactionDetails(
|
||||
channelIdentityNamesProvider(channelId),
|
||||
(_, _) => scheduleProfiles(),
|
||||
);
|
||||
final dataset = container.read(emojiDatasetOrEmptyProvider);
|
||||
List<Map<String, Object?>> reactionData() => [
|
||||
for (final reaction in reactions.value)
|
||||
{
|
||||
'emoji': reaction.emoji,
|
||||
'label': dataset.displayName(reaction.emoji),
|
||||
'count': reaction.count,
|
||||
'selected': reaction.reactedByCurrentUser,
|
||||
'burst': isPositiveEmojiParticle(reaction.emoji),
|
||||
'users': reaction.userPubkeys.toSet().toList(),
|
||||
if (reaction.emojiUrl case final String url) ...{
|
||||
'url': url,
|
||||
'headers': container
|
||||
.read(mediaGetAuthServiceProvider)
|
||||
.headersFor(url),
|
||||
},
|
||||
},
|
||||
];
|
||||
void updateReactions() {
|
||||
if (disposed) return;
|
||||
unawaited(
|
||||
NativeMessagePresentation.present('updateReactions', {
|
||||
'requestId': requestId,
|
||||
'reactions': reactionData(),
|
||||
}),
|
||||
);
|
||||
if (reactions.value.isNotEmpty) {
|
||||
container.read(userCacheProvider.notifier).preload(pubkeys());
|
||||
scheduleProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
reactions.addListener(updateReactions);
|
||||
try {
|
||||
final dataset = container.read(emojiDatasetOrEmptyProvider);
|
||||
final sheetColors = utilitySurfaceThemeData(
|
||||
Theme.of(context),
|
||||
).colorScheme;
|
||||
final pending = NativeMessagePresentation.present('reactions', {
|
||||
'requestId': requestId,
|
||||
'initialEmoji': initialEmoji,
|
||||
'sheetColor': sheetColors.surface.toARGB32(),
|
||||
'foregroundColor': sheetColors.primary.toARGB32(),
|
||||
'outlineColor': Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(),
|
||||
'dark': Theme.of(context).brightness == Brightness.dark,
|
||||
'profiles': profiles(),
|
||||
'reactions': [
|
||||
for (final reaction in reactions)
|
||||
{
|
||||
'emoji': reaction.emoji,
|
||||
'label': dataset.displayName(reaction.emoji),
|
||||
'count': reaction.count,
|
||||
'users': reaction.userPubkeys.toSet().toList(),
|
||||
if (reaction.emojiUrl case final String url) ...{
|
||||
'url': url,
|
||||
'headers': container
|
||||
.read(mediaGetAuthServiceProvider)
|
||||
.headersFor(url),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
container.read(userCacheProvider.notifier).preload(pubkeys);
|
||||
'reactions': reactionData(),
|
||||
}, onReaction: onToggle);
|
||||
container.read(userCacheProvider.notifier).preload(pubkeys());
|
||||
return await pending != null;
|
||||
} finally {
|
||||
disposed = true;
|
||||
reactions.removeListener(updateReactions);
|
||||
timer?.cancel();
|
||||
subscription.close();
|
||||
namesSubscription.close();
|
||||
|
||||
@@ -832,6 +832,21 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
});
|
||||
|
||||
return FrostedScaffold(
|
||||
foreground: threadViewportVisible
|
||||
? Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: frostedAppBarHeight(context) + Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('thread-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
resizeToAvoidBottomInset: !usesFixedAndroidImeViewport,
|
||||
appBar: _threadAppBar(context, ref, channel, currentPubkey),
|
||||
body: Stack(
|
||||
@@ -913,16 +928,6 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (threadViewportVisible)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: frostedAppBarHeight(context) + Grid.twelve,
|
||||
child: StickyDateHeader(
|
||||
key: const ValueKey('thread-sticky-date-header'),
|
||||
state: stickyDateHeaderState,
|
||||
),
|
||||
),
|
||||
if (isMember && !isArchived)
|
||||
AndroidImeLift(
|
||||
child: Align(
|
||||
|
||||
@@ -56,7 +56,7 @@ class AppListCard extends StatelessWidget {
|
||||
// which lands them within a few levels of the card fill — invisible.
|
||||
// Tinting with the text color instead keeps the hairline readable on
|
||||
// the card in both brightnesses.
|
||||
color: context.colors.onSurface.withValues(alpha: 0.12),
|
||||
color: context.colors.onSurface.withValues(alpha: 0.07),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ class FrostedScaffold extends HookWidget {
|
||||
/// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding.
|
||||
final Widget body;
|
||||
|
||||
/// Non-scrolling content painted above the navigation bar and its blur.
|
||||
/// Position this child within the scaffold's full-screen stack.
|
||||
final Widget? foreground;
|
||||
|
||||
/// Optional floating action button, passed through to [Scaffold].
|
||||
final Widget? floatingActionButton;
|
||||
|
||||
@@ -45,6 +49,7 @@ class FrostedScaffold extends HookWidget {
|
||||
super.key,
|
||||
required this.appBar,
|
||||
required this.body,
|
||||
this.foreground,
|
||||
this.floatingActionButton,
|
||||
this.resizeToAvoidBottomInset,
|
||||
this.backgroundColor,
|
||||
@@ -248,7 +253,13 @@ class FrostedScaffold extends HookWidget {
|
||||
// only samples pixels that were already painted behind it. This is the
|
||||
// same composition as channel navigation, so top-level headers blur their
|
||||
// content rather than only the fixed gradient.
|
||||
return [...backdrop, bodyMotion, appBar];
|
||||
return [
|
||||
...backdrop,
|
||||
bodyMotion,
|
||||
appBar,
|
||||
if (foreground != null)
|
||||
Positioned.fill(child: DirectionalTransitionMotion(child: foreground!)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,20 @@ class NativeMessagePresentation {
|
||||
String method,
|
||||
Map<String, Object?> arguments, {
|
||||
VoidCallback? onPresented,
|
||||
ValueChanged<String>? onReaction,
|
||||
}) async {
|
||||
if (!isSupportedPlatform) return null;
|
||||
final requestId = 'message-${_nextRequest++}';
|
||||
if (onPresented != null) {
|
||||
_presentedCallbacks[requestId] = onPresented;
|
||||
final requestId =
|
||||
arguments['requestId'] as String? ?? 'message-${_nextRequest++}';
|
||||
if (onPresented != null || onReaction != null) {
|
||||
if (onPresented != null) _presentedCallbacks[requestId] = onPresented;
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'toggleReaction') {
|
||||
final data = call.arguments as Map;
|
||||
if (data['requestId'] == requestId && data['emoji'] is String) {
|
||||
onReaction?.call(data['emoji'] as String);
|
||||
}
|
||||
}
|
||||
if (call.method == 'messagePresented') {
|
||||
_presentedCallbacks[call.arguments]?.call();
|
||||
}
|
||||
@@ -46,7 +54,7 @@ class NativeMessagePresentation {
|
||||
try {
|
||||
return await channel.invokeMapMethod<Object?, Object?>(method, {
|
||||
...arguments,
|
||||
if (onPresented != null) 'requestId': requestId,
|
||||
'requestId': requestId,
|
||||
});
|
||||
} on MissingPluginException {
|
||||
return null;
|
||||
@@ -54,6 +62,9 @@ class NativeMessagePresentation {
|
||||
return null;
|
||||
} finally {
|
||||
_presentedCallbacks.remove(requestId);
|
||||
if (onPresented != null || onReaction != null) {
|
||||
channel.setMethodCallHandler(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user