diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index ad7c931..cb52b84 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -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 ( -
+
+ {/* Reactions display - lazy loaded per message */} +
); } // Regular user messages - wrap in context menu if event exists const messageContent = ( -
+
@@ -373,6 +379,11 @@ const MessageItem = memo(function MessageItem({ )}
+ {/* Reactions display - lazy loaded per message */} +
); diff --git a/src/components/chat/MessageReactions.tsx b/src/components/chat/MessageReactions.tsx new file mode 100644 index 0000000..d0d6f06 --- /dev/null +++ b/src/components/chat/MessageReactions.tsx @@ -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(); + + 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 ( +
+ {aggregated.map((reaction) => ( + 1 ? "s" : ""}`} + > + {reaction.customEmoji ? ( + {`:${reaction.customEmoji.shortcode}:`} + ) : ( + {reaction.emoji} + )} + {reaction.count} + + ))} +
+ ); +} diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo index 75ea001..5e39d3d 100644 --- a/tsconfig.node.tsbuildinfo +++ b/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file