Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
import 'message_content.dart';
import '../../shared/read_state/deferred_read_state_update.dart';
import '../../shared/read_state/read_state_format.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ class _MessageBubble extends ConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);

void openMessageActions(Rect anchorRect) {
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
);
}

return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
child: Material(
Expand All @@ -73,8 +88,9 @@ class _MessageBubble extends ConsumerWidget {
// gutter. InkWell still clips its ink to [borderRadius], while leaving
// overflowing message content visible.
clipBehavior: Clip.none,
child: InkWell(
child: MessageLongPressInkWell(
key: ValueKey('message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
// Tap opens the thread; long-press still opens the action sheet.
Expand All @@ -93,17 +109,6 @@ class _MessageBubble extends ConsumerWidget {
),
),
),
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
Expand Down
121 changes: 72 additions & 49 deletions mobile/lib/features/channels/channel_detail_page/system_rows.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
part of '../channel_detail_page.dart';

class _SystemMessageRow extends ConsumerWidget {
class _SystemMessageRow extends HookConsumerWidget {
final TimelineMessage message;
final List<TimelineMessage>? groupedMessages;
final String channelId;
Expand All @@ -21,6 +21,7 @@ class _SystemMessageRow extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final spotlightKey = useMemoized(() => GlobalKey());
final systemEvent = message.systemEvent;
if (systemEvent == null) return const SizedBox.shrink();

Expand Down Expand Up @@ -77,25 +78,43 @@ class _SystemMessageRow extends ConsumerWidget {
}
}

void openReactionPopover(Rect anchorRect) {
final spotlightRenderObject = spotlightKey.currentContext
?.findRenderObject();
final spotlightRect =
spotlightRenderObject is RenderBox && spotlightRenderObject.hasSize
? spotlightRenderObject.localToGlobal(Offset.zero) &
spotlightRenderObject.size
: anchorRect;
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: spotlightRect,
popoverSpotlightPadding: EdgeInsets.fromLTRB(
Grid.xxs,
Grid.xxs,
Grid.xxs,
reactions.isEmpty ? Grid.xxs : Grid.quarter,
),
);
}

return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
child: MessageLongPressInkWell(
key: ValueKey('system-message-row-${message.id}'),
onLongPress: openReactionPopover,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: EdgeInsets.only(
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
Expand All @@ -104,43 +123,47 @@ class _SystemMessageRow extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (groupedMembership != null)
_MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
else if (messageStyleActor != null &&
messageStyleActor.isNotEmpty &&
messageStyleAction != null)
_MessageStyleSystemMessageContent(
displayPubkey: messageStyleActor,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: [TextSpan(text: messageStyleAction)],
)
else
Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describe(resolveLabel),
style: systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
KeyedSubtree(
key: spotlightKey,
child: groupedMembership != null
? _MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
: messageStyleActor != null &&
messageStyleActor.isNotEmpty &&
messageStyleAction != null
? _MessageStyleSystemMessageContent(
displayPubkey: messageStyleActor,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: [TextSpan(text: messageStyleAction)],
)
: Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describe(resolveLabel),
style: systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
_messageTimestamp(
context,
message.createdAt,
key: ValueKey(
'system-message-timestamp-${message.id}',
),
),
],
),
),
_messageTimestamp(
context,
message.createdAt,
key: ValueKey('system-message-timestamp-${message.id}'),
),
],
),
),
if (reactions.isNotEmpty)
Padding(
padding: EdgeInsets.only(
Expand Down
115 changes: 94 additions & 21 deletions mobile/lib/features/channels/message_actions.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
Expand Down Expand Up @@ -33,6 +36,8 @@ import 'thread_detail_page.dart';
import 'thread_follows/thread_follows_provider.dart';
import 'timeline_message.dart';

part 'message_actions/reaction_popover.dart';

/// Preview length for reminder targets — matches desktop's
/// `msg.body.slice(0, 100)`.
const _reminderPreviewLength = 100;
Expand All @@ -47,7 +52,21 @@ void showMessageActions({
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
Rect? anchorRect,
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
}) {
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
if (anchorRect != null && hasReactionOnlyActions) {
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
return;
}

showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
Expand Down Expand Up @@ -639,11 +658,16 @@ class _QuickReactionRow extends ConsumerWidget {
/// bottom sheet, so it must not read through the sheet's disposed ref.
final WidgetRef pageRef;

/// Drives the staged glyph reveal when this row is shown in the popover.
/// The bottom sheet leaves this null and retains its existing static row.
final Animation<double>? presentationAnimation;

const _QuickReactionRow({
required this.message,
required this.sheetContext,
required this.pageContext,
required this.pageRef,
this.presentationAnimation,
});

@override
Expand Down Expand Up @@ -685,31 +709,39 @@ class _QuickReactionRow extends ConsumerWidget {
.clamp(0.0, Grid.twelve)
.toDouble();
final circles = <Widget>[
for (final value in emoji)
_QuickReactionCircle(
key: ValueKey('quick-reaction-$value'),
for (var index = 0; index < emoji.length; index++)
_ReactionItemReveal(
key: ValueKey('quick-reaction-${emoji[index]}'),
animation: presentationAnimation,
index: index,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
react(emoji[index]);
},
child: _QuickReactionGlyph(
value: emoji[index],
customByShortcode: customByShortcode,
),
),
),
_ReactionItemReveal(
key: const ValueKey('quick-reaction-more'),
animation: presentationAnimation,
index: emoji.length,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
react(value);
showEmojiPicker(context: pageContext, onSelect: react);
},
child: _QuickReactionGlyph(
value: value,
customByShortcode: customByShortcode,
child: Icon(
LucideIcons.plus,
size: 24,
color: context.colors.onSurfaceVariant,
),
),
_QuickReactionCircle(
key: const ValueKey('quick-reaction-more'),
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
showEmojiPicker(context: pageContext, onSelect: react);
},
child: Icon(
LucideIcons.plus,
size: 24,
color: context.colors.onSurfaceVariant,
),
),
];

Expand All @@ -727,6 +759,44 @@ class _QuickReactionRow extends ConsumerWidget {
}
}

class _ReactionItemReveal extends StatelessWidget {
final Animation<double>? animation;
final int index;
final Widget child;

const _ReactionItemReveal({
super.key,
required this.animation,
required this.index,
required this.child,
});

@override
Widget build(BuildContext context) {
final parent = animation;
if (parent == null) return child;

final start = 0.06 + (index * 0.11);
final opacityEnd = math.min(1.0, start + 0.20);
final scaleEnd = math.min(1.0, start + 0.30);
final opacity = CurvedAnimation(
parent: parent,
curve: Interval(start, opacityEnd, curve: Curves.easeOutCubic),
);
final scale = Tween<double>(begin: 0.86, end: 1).animate(
CurvedAnimation(
parent: parent,
curve: Interval(start, scaleEnd, curve: _reactionSpringCurve),
),
);

return FadeTransition(
opacity: opacity,
child: ScaleTransition(scale: scale, child: child),
);
}
}

/// Renders a quick-reaction value: a Unicode glyph as text, a `:shortcode:` as
/// its custom-emoji image.
class _QuickReactionGlyph extends StatelessWidget {
Expand Down Expand Up @@ -763,7 +833,6 @@ class _QuickReactionCircle extends StatelessWidget {
final double size;

const _QuickReactionCircle({
super.key,
required this.onTap,
required this.child,
required this.size,
Expand All @@ -781,7 +850,11 @@ class _QuickReactionCircle extends StatelessWidget {
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
color: Color.lerp(
context.colors.surface,
context.colors.primaryContainer,
0.78,
),
shape: BoxShape.circle,
),
child: child,
Expand Down
Loading