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 22:20:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 28a4658ac2
commit 0e9c02da2d
10 changed files with 350 additions and 148 deletions
@@ -130,13 +130,9 @@ export function MessageActionBar({
return;
}
void onReactionSelect(emoji.native)
.then(() => {
setIsReactionPickerOpen(false);
})
.catch(() => {
return;
});
void onReactionSelect(emoji.native).finally(() => {
setIsReactionPickerOpen(false);
});
}}
theme="auto"
previewPosition="none"
@@ -2,6 +2,7 @@ import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
@@ -50,10 +51,13 @@ export const MessageRow = React.memo(
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
string | null
>(null);
const [reactionPending, setReactionPending] = React.useState(false);
const {
reactions,
canToggle: canToggleReactions,
pending: reactionPending,
errorMessage: reactionErrorMessage,
select: handleReactionSelect,
} = useReactionHandler(message, onToggleReaction);
const mentionNames = React.useMemo(
() => resolveMentionNames(message.tags, profiles),
[profiles, message.tags],
@@ -120,45 +124,6 @@ export const MessageRow = React.memo(
}
};
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
if (left.count !== right.count) {
return right.count - left.count;
}
return left.emoji.localeCompare(right.emoji);
});
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
const handleReactionSelect = React.useCallback(
async (emoji: string) => {
if (!onToggleReaction || reactionPending) {
return;
}
const remove = reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.reactedByCurrentUser,
);
setReactionErrorMessage(null);
setReactionPending(true);
try {
await onToggleReaction(message, emoji, remove);
} catch (error) {
const nextMessage =
error instanceof Error
? error.message
: "Failed to update the reaction.";
setReactionErrorMessage(nextMessage);
throw error;
} finally {
setReactionPending(false);
}
},
[message, onToggleReaction, reactionPending, reactions],
);
const isThreadReplyLayout = layoutVariant === "thread-reply";
const guideBleedPx = isThreadReplyLayout ? 4 : 0;
const avatarSizeClass = isThreadReplyLayout
@@ -264,9 +229,7 @@ export const MessageRow = React.memo(
canToggle={canToggleReactions}
pending={reactionPending}
onSelect={(emoji) => {
void handleReactionSelect(emoji).catch(() => {
return;
});
void handleReactionSelect(emoji);
}}
/>
{reactionErrorMessage ? (
@@ -1,7 +1,18 @@
import { ArrowRightLeft } from "lucide-react";
import { ArrowRightLeft, SmilePlus } from "lucide-react";
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { MessageTimestamp } from "./MessageTimestamp";
type SystemMessagePayload = {
@@ -67,25 +78,36 @@ function describeSystemEvent(
}
}
export function SystemMessageRow({
body,
createdAt,
time,
export const SystemMessageRow = React.memo(function SystemMessageRow({
message,
currentPubkey,
profiles,
personaLookup,
onToggleReaction,
}: {
body: string;
createdAt: number;
time: string;
message: TimelineMessage;
currentPubkey?: string;
profiles?: UserProfileLookup;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const {
reactions,
canToggle: canToggleReactions,
pending: reactionPending,
errorMessage: reactionErrorMessage,
select: handleReactionSelect,
} = useReactionHandler(message, onToggleReaction);
let payload: SystemMessagePayload;
try {
payload = JSON.parse(body);
payload = JSON.parse(message.body);
} catch {
return null;
}
@@ -102,16 +124,110 @@ export function SystemMessageRow({
return (
<div
className="flex items-center gap-2.5 px-2 py-1"
className="group/message rounded-lg px-2 py-1"
data-testid="system-message-row"
>
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-muted">
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
<div className="flex items-center gap-2.5">
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-muted">
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">{description}</p>
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground/60">
<div className="relative">
<div className="absolute right-0 top-1/2 -translate-y-1/2">
{canToggleReactions ? (
<div
className={cn(
"overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
"max-w-0 border-0 shadow-none translate-y-1 opacity-0",
"group-hover/message:max-w-9 group-hover/message:border group-hover/message:border-border/70 group-hover/message:shadow-sm group-hover/message:translate-y-0 group-hover/message:opacity-100",
"group-focus-within/message:max-w-9 group-focus-within/message:border group-focus-within/message:border-border/70 group-focus-within/message:shadow-sm group-focus-within/message:translate-y-0 group-focus-within/message:opacity-100",
isReactionPickerOpen
? "max-w-9 border border-border/70 shadow-sm translate-y-0 opacity-100"
: "",
)}
>
<div className="flex items-center gap-1 p-1">
<Popover
onOpenChange={setIsReactionPickerOpen}
open={isReactionPickerOpen}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className="h-6 w-6 rounded-full p-0"
disabled={reactionPending}
size="sm"
type="button"
variant={
isReactionPickerOpen ? "secondary" : "ghost"
}
>
{reactionPending ? (
<Spinner className="h-3 w-3" />
) : (
<SmilePlus className="h-3 w-3" />
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>React</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
side="top"
sideOffset={10}
>
{reactionErrorMessage ? (
<div className="px-3 pt-3 pb-0">
<p className="text-xs text-destructive">
{reactionErrorMessage}
</p>
</div>
) : null}
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
void handleReactionSelect(emoji.native).finally(
() => {
setIsReactionPickerOpen(false);
},
);
}}
theme="auto"
previewPosition="none"
skinTonePosition="search"
set="native"
maxFrequentRows={2}
perLine={8}
/>
</PopoverContent>
</Popover>
</div>
</div>
) : null}
</div>
</div>
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
</div>
</div>
<p className="text-xs text-muted-foreground">{description}</p>
<span className="ml-auto text-xs text-muted-foreground/60">
<MessageTimestamp createdAt={createdAt} time={time} />
</span>
<MessageReactions
messageId={message.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
}}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
</p>
) : null}
</div>
);
}
});
@@ -75,12 +75,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
elements.push(
<SystemMessageRow
key={message.id}
body={message.body}
createdAt={message.createdAt}
message={message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
time={message.time}
/>,
);
} else {
@@ -0,0 +1,76 @@
import * as React from "react";
import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
type ReactionHandler = {
/** Reactions sorted by count (desc) then emoji (asc). */
reactions: TimelineReaction[];
/** Whether the user can currently toggle reactions. */
canToggle: boolean;
/** Whether a reaction toggle is in flight. */
pending: boolean;
/** Error message from the last failed toggle, if any. */
errorMessage: string | null;
/** Call to toggle an emoji reaction. Safe to fire-and-forget. */
select: (emoji: string) => Promise<void>;
};
/**
* Shared reaction state + toggle logic used by both MessageRow and
* SystemMessageRow. Keeps the pending/error/sorting concerns in one place.
*/
export function useReactionHandler(
message: TimelineMessage,
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>,
): ReactionHandler {
const [pending, setPending] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const reactions = React.useMemo(() => {
return [...(message.reactions ?? [])].sort((left, right) => {
if (left.count !== right.count) {
return right.count - left.count;
}
return left.emoji.localeCompare(right.emoji);
});
}, [message.reactions]);
const canToggle = Boolean(onToggleReaction && !message.pending);
const select = React.useCallback(
async (emoji: string) => {
if (!onToggleReaction || pending) {
return;
}
const remove = reactions.some(
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
);
setErrorMessage(null);
setPending(true);
try {
await onToggleReaction(message, emoji, remove);
} catch (error) {
const nextMessage =
error instanceof Error
? error.message
: "Failed to update the reaction.";
setErrorMessage(nextMessage);
throw error;
} finally {
setPending(false);
}
},
[message, onToggleReaction, pending, reactions],
);
return { reactions, canToggle, pending, errorMessage, select };
}
@@ -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),
),
],
),
@@ -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),
@@ -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)
// ---------------------------------------------------------------------------
@@ -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),
),
],
),
@@ -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,
),
);
}