);
}
// 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.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