Skip to content
Open
Show file tree
Hide file tree
Changes from all 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