feat: add reaction support to system events on desktop and mobile (#432)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-29 23:20:08 -06:00
committed by GitHub
parent 28a4658ac2
commit 0e9c02da2d
10 changed files with 350 additions and 148 deletions

View File

@@ -489,7 +489,14 @@ class _MessageList extends HookConsumerWidget {
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
if (message.isSystem)
_SystemMessageRow(message: message)
_SystemMessageRow(
message: message,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
@@ -549,8 +556,20 @@ class _MessageList extends HookConsumerWidget {
class _SystemMessageRow extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
final String? currentPubkey;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
const _SystemMessageRow({required this.message});
const _SystemMessageRow({
required this.message,
required this.channelId,
this.currentPubkey,
this.allMessages,
this.isMember = false,
this.isArchived = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -569,39 +588,66 @@ class _SystemMessageRow extends ConsumerWidget {
final description = systemEvent.describe(resolveLabel);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
isOwnMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.arrowLeftRight,
size: 12,
color: context.colors.outline,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
description,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
],
),
child: Icon(
LucideIcons.arrowLeftRight,
size: 12,
color: context.colors.outline,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
description,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
if (message.reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 28),
child: ReactionRow(
reactions: message.reactions,
onToggle: (emoji) => toggleReaction(ref, message, emoji),
),
),
),
),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
],
],
),
),
);
}
@@ -830,21 +876,7 @@ class _MessageBubble extends ConsumerWidget {
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) {
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 {
actions.addReaction(message.id, emoji);
}
},
onToggle: (emoji) => toggleReaction(ref, message, emoji),
),
],
),

View File

@@ -97,7 +97,7 @@ void showMessageActions({
],
),
const SizedBox(height: Grid.xs),
if (allMessages != null)
if (allMessages != null && !message.isSystem)
ListTile(
leading: const Icon(LucideIcons.messageSquareReply),
title: const Text('Reply in thread'),
@@ -117,16 +117,17 @@ void showMessageActions({
);
},
),
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
if (!message.isSystem)
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
if (isOwnMessage) ...[
ListTile(
leading: const Icon(LucideIcons.pencil),

View File

@@ -5,8 +5,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_management_provider.dart';
import 'timeline_message.dart';
/// Toggle a reaction on [message]. If the current user already reacted with
/// [emoji], removes the reaction; otherwise adds it.
///
/// 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 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 {
actions.addReaction(message.id, emoji);
}
}
// ---------------------------------------------------------------------------
// Reaction pills row (shared between channel + thread detail pages)
// ---------------------------------------------------------------------------

View File

@@ -7,7 +7,6 @@ import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_management_provider.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channels_provider.dart';
@@ -442,21 +441,7 @@ class _ThreadMessage extends ConsumerWidget {
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) {
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 {
actions.addReaction(message.id, emoji);
}
},
onToggle: (emoji) => toggleReaction(ref, message, emoji),
),
],
),

View File

@@ -265,6 +265,23 @@ List<TimelineMessage> formatTimeline(
if (event.kind == EventKind.systemMessage) {
final systemEvent = SystemEvent.fromContent(event.content);
if (systemEvent != null) {
final emojiMap = reactionMap[event.id];
final reactions = <TimelineReaction>[
if (emojiMap != null)
for (final entry in emojiMap.entries)
TimelineReaction(
emoji: entry.key,
count: entry.value.length,
reactedByCurrentUser:
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
),
];
result.add(
TimelineMessage(
id: event.id,
@@ -274,6 +291,7 @@ List<TimelineMessage> formatTimeline(
tags: event.tags,
isSystem: true,
systemEvent: systemEvent,
reactions: reactions,
),
);
}