Add subtle inline reactions to chat messages

Implements NIP-25 reaction display for chat messages with per-message lazy loading:

- Created MessageReactions component that independently loads kind 7 reactions
  for each message using EventStore timeline queries
- Displays reactions as tiny inline badges in bottom-right corner (doesn't affect
  message height)
- Aggregates reactions by emoji with deduplication by pubkey
- Supports both unicode emoji and NIP-30 custom emoji with images
- Shows reaction count next to each emoji
- Integrated into both regular user messages and zap messages in ChatViewer
- Reactions load reactively - new reactions appear automatically via EventStore
  observables

No "+" button for adding reactions yet - this is display-only for now.
Works with NIP-29 groups and will work with any chat protocol that uses
kind 7 reactions with e-tags.
This commit is contained in:
Claude
2026-01-15 17:00:54 +00:00
parent d172d67584
commit 96751be135
3 changed files with 135 additions and 3 deletions

View File

@@ -37,6 +37,7 @@ import Timestamp from "./Timestamp";
import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
import { RelaysDropdown } from "./chat/RelaysDropdown";
import { MessageReactions } from "./chat/MessageReactions";
import { StatusBadge } from "./live/StatusBadge";
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
import { useGrimoire } from "@/core/state";
@@ -285,7 +286,7 @@ const MessageItem = memo(function MessageItem({
(CHAT_KINDS as readonly number[]).includes(replyEvent.kind);
return (
<div className="pl-2 my-1">
<div className="pl-2 my-1 relative">
<div
className="p-[1px] rounded"
style={{
@@ -332,13 +333,18 @@ const MessageItem = memo(function MessageItem({
)}
</div>
</div>
{/* Reactions display - lazy loaded per message */}
<MessageReactions
messageId={message.id}
relayUrl={conversation.metadata?.relayUrl}
/>
</div>
);
}
// Regular user messages - wrap in context menu if event exists
const messageContent = (
<div className="group flex items-start hover:bg-muted/50 px-3">
<div className="group relative flex items-start hover:bg-muted/50 px-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<UserName pubkey={message.author} className="font-semibold text-sm" />
@@ -373,6 +379,11 @@ const MessageItem = memo(function MessageItem({
</span>
)}
</div>
{/* Reactions display - lazy loaded per message */}
<MessageReactions
messageId={message.id}
relayUrl={conversation.metadata?.relayUrl}
/>
</div>
</div>
);

View File

@@ -0,0 +1,121 @@
import { useMemo } from "react";
import { use$ } from "applesauce-react/hooks";
import eventStore from "@/services/event-store";
import { EMOJI_SHORTCODE_REGEX } from "@/lib/emoji-helpers";
import type { NostrEvent } from "@/types/nostr";
interface MessageReactionsProps {
messageId: string;
/** Relay URL for fetching reactions (NIP-29 group relay) */
relayUrl?: string;
}
interface ReactionSummary {
emoji: string;
count: number;
pubkeys: string[];
customEmoji?: {
shortcode: string;
url: string;
};
}
/**
* MessageReactions - Lazy loads and displays reactions for a single message
*
* Loads kind 7 (reaction) events that reference the messageId via e-tag.
* Aggregates by emoji and displays as tiny inline badges in bottom-right corner.
*
* Uses EventStore timeline for reactive updates - new reactions appear automatically.
*/
export function MessageReactions({
messageId,
relayUrl,
}: MessageReactionsProps) {
// Load reactions for this message from EventStore
// Filter: kind 7, e-tag pointing to messageId
const reactions = use$(
() =>
eventStore.timeline({
kinds: [7],
"#e": [messageId],
}),
[messageId],
);
// Aggregate reactions by emoji
const aggregated = useMemo(() => {
if (!reactions || reactions.length === 0) return [];
const map = new Map<string, ReactionSummary>();
for (const reaction of reactions) {
const content = reaction.content || "❤️";
// Check for NIP-30 custom emoji tags
const emojiTag = reaction.tags.find((t) => t[0] === "emoji");
let customEmoji: { shortcode: string; url: string } | undefined;
if (emojiTag && emojiTag[1] && emojiTag[2]) {
customEmoji = {
shortcode: emojiTag[1],
url: emojiTag[2],
};
}
// Parse content for custom emoji shortcodes
const match = content.match(EMOJI_SHORTCODE_REGEX);
const emojiKey =
match && customEmoji ? `:${customEmoji.shortcode}:` : content;
const existing = map.get(emojiKey);
if (existing) {
// Deduplicate by pubkey (one reaction per user per emoji)
if (!existing.pubkeys.includes(reaction.pubkey)) {
existing.count++;
existing.pubkeys.push(reaction.pubkey);
}
} else {
map.set(emojiKey, {
emoji: content,
count: 1,
pubkeys: [reaction.pubkey],
customEmoji,
});
}
}
// Sort by count descending, then by emoji alphabetically
return Array.from(map.values()).sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.emoji.localeCompare(b.emoji);
});
}, [reactions]);
// Don't render if no reactions
if (aggregated.length === 0) return null;
return (
<div className="absolute bottom-0.5 right-1 flex gap-0.5">
{aggregated.map((reaction) => (
<span
key={reaction.customEmoji?.shortcode || reaction.emoji}
className="inline-flex items-center gap-0.5 px-1 rounded bg-muted/80 text-[10px] leading-tight"
title={`${reaction.count} reaction${reaction.count > 1 ? "s" : ""}`}
>
{reaction.customEmoji ? (
<img
src={reaction.customEmoji.url}
alt={`:${reaction.customEmoji.shortcode}:`}
className="size-3 inline-block"
/>
) : (
<span className="text-xs">{reaction.emoji}</span>
)}
<span className="text-muted-foreground">{reaction.count}</span>
</span>
))}
</div>
);
}

View File

@@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.3"}
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}