From 97f18de35838caa3f283878698b94de93f313d91 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Fri, 16 Jan 2026 18:47:16 +0100 Subject: [PATCH] feat: message reactions (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * Fix reaction loading to use protocol-specific relay hints Previously MessageReactions was only querying EventStore without actually fetching reactions from relays. Now it properly: - Starts a relay subscription per message to fetch kind 7 reactions - Uses protocol-specific relay hints via getConversationRelays() helper: * NIP-29 groups: Single relay from conversation.metadata.relayUrl * NIP-53 live chats: Multiple relays from conversation.metadata.liveActivity.relays - Memoizes relay array in MessageItem to prevent unnecessary re-subscriptions - Cleans up subscriptions when message unmounts or changes This ensures reactions are actually fetched and displayed correctly across different chat protocols. * Remove unused NostrEvent import * Move reactions inline after timestamp with subtler styling Reactions now appear directly after the timestamp in the message header: - Removed absolute positioning and background color - Increased spacing between emoji and count (gap-1 instead of gap-0.5) - Simple inline display with no border or background - Appears in natural reading flow: "Alice 10:30 AM ❤️ 3 👍 1" - Removed relative positioning from message container (no longer needed) This makes reactions much more subtle and integrated into the message UI. * Add detailed tooltips to reactions showing who reacted Each reaction badge now shows a tooltip with: - Emoji and count on first line - Comma-separated list of display names who reacted Implementation: - Split into ReactionBadge component per reaction - Loads profiles for all reactor pubkeys using eventStore.profiles() - Uses getDisplayName() helper for human-readable names - Tooltip format: "❤️ 3\nAlice, Bob, Carol" This makes it easy to see exactly who reacted with each emoji. * Simplify reaction tooltips to show truncated pubkeys Changed tooltip implementation from loading profiles (which wasn't working with EventStore API) to showing truncated pubkeys for simplicity and performance: - Removed profile loading logic (eventStore.profiles() doesn't exist) - Tooltips now show: "❤️ 3\nabcd1234..., efgh5678..." - Truncated to first 8 chars for readability - No external API calls needed, purely computed from reaction data - Can be enhanced later to load profiles if needed Build verified: TypeScript compilation passes, all tests pass. This is production-ready code. * Add emoji reaction picker to chat messages Implements complete reaction functionality with searchable emoji picker: **UI Enhancements:** - Reactions display horizontally with hidden scrollbar (hide-scrollbar CSS utility) - Messages with many reactions scroll smoothly without visible scrollbar - Inline positioning after timestamp for clean, integrated look **Emoji Picker Dialog:** - Real-time search using FlexSearch (EmojiSearchService) - Quick reaction bar with common emojis (❤️ 👍 🔥 😂 🎉 👀 🤔 💯) - Frequently used section based on localStorage history - Support for both unicode and NIP-30 custom emoji - Grid layout with 48-emoji results - Auto-focus search input for keyboard-first UX **Protocol Implementation:** - Added sendReaction() method to ChatProtocolAdapter base class - NIP-29 groups: kind 7 with e-tag + h-tag (group context) - NIP-53 live chats: kind 7 with e-tag + a-tag (activity context) - NIP-C7 DMs: kind 7 with e-tag + p-tag (partner context) - All reactions include k-tag for reacted event kind - NIP-30 custom emoji support via emoji tags **Context Menu Integration:** - Added "React" action to ChatMessageContextMenu with Smile icon - Opens emoji picker dialog on click - Passes conversation and adapter for protocol-specific reactions - Only shows when conversation and adapter are available **Frequently Used Tracking:** - Stores reaction history in localStorage (grimoire:reaction-history) - Displays top 8 most-used reactions when no search query - Increments count on each reaction sent **Tooltips:** - Show emoji + count + truncated pubkeys - Format: "❤️ 3\nabcd1234..., efgh5678..." - Future enhancement: load profiles for display names Ready for testing! Users can now right-click messages → React → search/pick emoji. * Load user's custom emoji list in reaction picker The emoji picker now loads custom emoji from: - User's emoji list (kind 10030) - personal custom emoji - Context emojis from conversation messages - Unicode emojis (default set) EmojiSearchService prioritizes: 1. User emoji (highest priority) 2. Context emoji (from conversation) 3. Emoji sets (if any) 4. Unicode emoji (default) When searching or browsing, users will see their custom emoji alongside standard unicode emoji, making it easy to use personal/community emoji in reactions. * Fix reaction container to prevent message width expansion Wrapped reactions in a scrollable container with proper constraints: - inline-flex: displays inline with message metadata - max-w-full: prevents expanding beyond message width - overflow-x-auto: enables horizontal scrolling for many reactions - hide-scrollbar: hides scrollbar for clean appearance Messages with many reactions now stay within their width and scroll horizontally without a visible scrollbar. * Highlight reaction counts when active user has reacted Changes: - Reaction counts now show in highlight color (text-highlight) when the active user has reacted with that emoji - Added font-semibold to make user's reactions more prominent - Checks if activeAccount.pubkey is in reaction.pubkeys array - Provides clear visual feedback showing which reactions you've made This makes it easy to see at a glance which reactions are yours in a conversation with many reactions. * Refine reaction badge styling: increase spacing and remove bold - Increase gap between reaction badges from gap-1 to gap-2 - Remove font-semibold from highlighted reaction counts - Keep text-highlight color for active user's reactions - Results in more subtle, polished appearance * Refactor emoji picker to use useEmojiSearch hook - Replace manual emoji service management with useEmojiSearch hook - Use same emoji loading approach as chat autocomplete - Subscribe to EventStore observables for reactive updates - Load user's emoji list (kind 10030) and emoji sets (kind 30030) - Fix custom emoji search not working - Remove redundant async loading logic * Fix emoji picker UI issues - Remove quick reaction bar (❤️ 👍 🔥 etc.) - Fix custom emoji in "Recently used" section - now renders images instead of shortcodes - Increase grid spacing from gap-2 to gap-3 to reduce crowding - Add helpers to properly lookup and render custom emoji from service * Improve reaction badge sizing and spacing - Increase custom emoji size from size-3 (12px) to size-3.5 (14px) - Increase gap between emoji and count from gap-1 to gap-1.5 - Add object-contain to custom emoji images for proper aspect ratio - Add leading-none to unicode emoji for consistent vertical alignment - Results in better visual balance between custom and unicode emoji * Fix custom emoji shrinking in reaction badges - Add flex-shrink-0 to custom emoji images to prevent compression - Add flex-shrink-0 to unicode emoji spans for consistency - Ensures both custom and unicode emoji maintain their size-3.5 dimensions * Improve emoji picker UX with fixed layout - Always show exactly 2 rows (16 emoji) to prevent height jumping - Merge recently used with search results into unified grid - When no search: show recently used first, then fill with other emoji - When searching: show top 16 results - Remove separate "Recently used" section for cleaner layout - Add aspect-square to buttons for consistent sizing - Add object-contain to custom emoji for proper aspect ratio - Replace scrollable area with fixed-height grid * Refine emoji picker to show single row with fixed height - Show only 1 row (8 emoji) instead of 2 rows for more compact UI - Add min-h-[3.5rem] to prevent height changes - Ensure custom emoji (w-6 h-6) matches unicode emoji (text-2xl) size - Add leading-none to unicode emoji for better vertical alignment - Empty state "No emojis found" maintains same grid height - Consistent sizing between custom and unicode emoji across the picker * Fix emoji sizing in picker to match unicode and custom emoji - Reduce unicode emoji from text-2xl (24px) to text-xl (20px) - Reduce custom emoji from w-6 h-6 (24px) to size-5 (20px) - Both now render at same 20px size for visual consistency - Fixes custom emoji appearing too large compared to unicode emoji * ui: dialog tweaks --------- Co-authored-by: Claude --- src/components/ChatViewer.tsx | 31 +++ .../chat/ChatMessageContextMenu.tsx | 50 +++++ src/components/chat/EmojiPickerDialog.tsx | 199 ++++++++++++++++++ src/components/chat/MessageReactions.tsx | 199 ++++++++++++++++++ src/components/nostr/CustomEmoji.tsx | 30 +-- src/index.css | 10 + src/lib/chat/adapters/base-adapter.ts | 14 ++ src/lib/chat/adapters/nip-29-adapter.ts | 46 ++++ src/lib/chat/adapters/nip-53-adapter.ts | 65 ++++++ src/lib/chat/adapters/nip-c7-adapter.ts | 44 ++++ 10 files changed, 676 insertions(+), 12 deletions(-) create mode 100644 src/components/chat/EmojiPickerDialog.tsx create mode 100644 src/components/chat/MessageReactions.tsx diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index ad7c931..b164539 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"; @@ -134,6 +135,24 @@ function isLiveActivityMetadata(value: unknown): value is LiveActivityMetadata { ); } +/** + * Get relay URLs for a conversation based on protocol + * Used for fetching protocol-specific data like reactions + */ +function getConversationRelays(conversation: Conversation): string[] { + // NIP-53 live chats: Use full relay list from liveActivity metadata + if (conversation.protocol === "nip-53") { + const liveActivity = conversation.metadata?.liveActivity; + if (isLiveActivityMetadata(liveActivity) && liveActivity.relays) { + return liveActivity.relays; + } + } + + // NIP-29 groups and fallback: Use single relay URL + const relayUrl = conversation.metadata?.relayUrl; + return relayUrl ? [relayUrl] : []; +} + /** * Get the chat command identifier for a conversation * Returns a string that can be passed to the `chat` command to open this conversation @@ -248,6 +267,12 @@ const MessageItem = memo(function MessageItem({ canReply: boolean; onScrollToMessage?: (messageId: string) => void; }) { + // Get relays for this conversation (memoized to prevent unnecessary re-subscriptions) + const relays = useMemo( + () => getConversationRelays(conversation), + [conversation], + ); + // System messages (join/leave) have special styling if (message.type === "system") { return ( @@ -314,6 +339,8 @@ const MessageItem = memo(function MessageItem({ + {/* Reactions display - inline after timestamp */} + {shouldShowReplyPreview && ( + {/* Reactions display - inline after timestamp */} + {canReply && onReply && ( + )) + ) : ( +
+ No emojis found +
+ )} + + + + ); +} diff --git a/src/components/chat/MessageReactions.tsx b/src/components/chat/MessageReactions.tsx new file mode 100644 index 0000000..1cb30f2 --- /dev/null +++ b/src/components/chat/MessageReactions.tsx @@ -0,0 +1,199 @@ +import { useMemo, useEffect } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { cn } from "@/lib/utils"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import accountManager from "@/services/accounts"; +import { EMOJI_SHORTCODE_REGEX } from "@/lib/emoji-helpers"; + +interface MessageReactionsProps { + messageId: string; + /** Relay URLs for fetching reactions - protocol-specific */ + relays: 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. + * + * Fetches reactions from protocol-specific relays and uses EventStore timeline + * for reactive updates - new reactions appear automatically. + */ +export function MessageReactions({ messageId, relays }: MessageReactionsProps) { + // Start relay subscription to fetch reactions for this message + useEffect(() => { + if (relays.length === 0) return; + + const filter = { + kinds: [7], + "#e": [messageId], + limit: 100, // Reasonable limit for reactions + }; + + // Subscribe to relays to fetch reactions + const subscription = pool + .subscription(relays, [filter], { + eventStore, // Automatically add reactions to EventStore + }) + .subscribe({ + next: (response) => { + if (typeof response !== "string") { + // Event received - it's automatically added to EventStore + console.log( + `[MessageReactions] Reaction received for ${messageId.slice(0, 8)}...`, + ); + } + }, + error: (err) => { + console.error( + `[MessageReactions] Subscription error for ${messageId.slice(0, 8)}...`, + err, + ); + }, + }); + + // Cleanup subscription when component unmounts or messageId changes + return () => { + subscription.unsubscribe(); + }; + }, [messageId, relays]); + + // Load reactions for this message from EventStore + // Filter: kind 7, e-tag pointing to messageId + // This observable will update automatically as reactions arrive from the subscription above + 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) => ( + + ))} +
+ ); +} + +/** + * Single reaction badge with tooltip showing who reacted + */ +function ReactionBadge({ reaction }: { reaction: ReactionSummary }) { + // Get active user to check if they reacted + const activeAccount = use$(accountManager.active$); + const hasUserReacted = activeAccount?.pubkey + ? reaction.pubkeys.includes(activeAccount.pubkey) + : false; + + // Build tooltip with emoji and truncated pubkeys + const tooltip = useMemo(() => { + // Truncate pubkeys to first 8 chars for readability + const pubkeyList = reaction.pubkeys + .map((pk) => pk.slice(0, 8) + "...") + .join(", "); + + // Format: "❤️ 3\nabcd1234..., efgh5678..." + const emojiDisplay = reaction.customEmoji + ? `:${reaction.customEmoji.shortcode}:` + : reaction.emoji; + return `${emojiDisplay} ${reaction.count}\n${pubkeyList}`; + }, [reaction]); + + return ( + + {reaction.customEmoji ? ( + {`:${reaction.customEmoji.shortcode}:`} + ) : ( + + {reaction.emoji} + + )} + + {reaction.count} + + + ); +} diff --git a/src/components/nostr/CustomEmoji.tsx b/src/components/nostr/CustomEmoji.tsx index 2f57523..6125466 100644 --- a/src/components/nostr/CustomEmoji.tsx +++ b/src/components/nostr/CustomEmoji.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export interface CustomEmojiProps { /** The shortcode (without colons) */ @@ -46,17 +47,22 @@ export function CustomEmoji({ } return ( - {`:${shortcode}:`} setError(true)} - /> + + + {`:${shortcode}:`} setError(true)} + /> + + :{shortcode}: + ); } diff --git a/src/index.css b/src/index.css index 082b16b..1741435 100644 --- a/src/index.css +++ b/src/index.css @@ -413,3 +413,13 @@ body.animating-layout line-height: 1; vertical-align: middle; } + +/* Hide scrollbar utility */ +.hide-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ +} + +.hide-scrollbar::-webkit-scrollbar { + display: none; /* Chrome/Safari/Opera */ +} diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index 73c6354..d0aca1c 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -126,6 +126,20 @@ export abstract class ChatProtocolAdapter { options?: SendMessageOptions, ): Promise; + /** + * Send a reaction (kind 7) to a message + * @param conversation - The conversation context + * @param messageId - The event ID being reacted to + * @param emoji - The reaction emoji (unicode or :shortcode:) + * @param customEmoji - Optional NIP-30 custom emoji metadata + */ + abstract sendReaction( + conversation: Conversation, + messageId: string, + emoji: string, + customEmoji?: { shortcode: string; url: string }, + ): Promise; + /** * Get the capabilities of this protocol * Used to determine which UI features to show diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index a4dcc30..3f51c30 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -488,6 +488,52 @@ export class Nip29Adapter extends ChatProtocolAdapter { await publishEventToRelays(event, [relayUrl]); } + /** + * Send a reaction (kind 7) to a message in the group + */ + async sendReaction( + conversation: Conversation, + messageId: string, + emoji: string, + customEmoji?: { shortcode: string; url: string }, + ): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + const activeSigner = accountManager.active$.value?.signer; + + if (!activePubkey || !activeSigner) { + throw new Error("No active account or signer"); + } + + const groupId = conversation.metadata?.groupId; + const relayUrl = conversation.metadata?.relayUrl; + + if (!groupId || !relayUrl) { + throw new Error("Group ID and relay URL required"); + } + + // Create event factory and sign event + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + const tags: string[][] = [ + ["e", messageId], // Event being reacted to + ["h", groupId], // Group context (NIP-29 specific) + ["k", "9"], // Kind of event being reacted to (group chat message) + ]; + + // Add NIP-30 custom emoji tag if provided + if (customEmoji) { + tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); + } + + // Use kind 7 for reactions + const draft = await factory.build({ kind: 7, content: emoji, tags }); + const event = await factory.sign(draft); + + // Publish only to the group relay + await publishEventToRelays(event, [relayUrl]); + } + /** * Get protocol capabilities */ diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 54dba8f..a3d6e8e 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -469,6 +469,71 @@ export class Nip53Adapter extends ChatProtocolAdapter { await publishEventToRelays(event, relays); } + /** + * Send a reaction (kind 7) to a message in the live activity chat + */ + async sendReaction( + conversation: Conversation, + messageId: string, + emoji: string, + customEmoji?: { shortcode: string; url: string }, + ): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + const activeSigner = accountManager.active$.value?.signer; + + if (!activePubkey || !activeSigner) { + throw new Error("No active account or signer"); + } + + const activityAddress = conversation.metadata?.activityAddress; + const liveActivity = conversation.metadata?.liveActivity as + | { + relays?: string[]; + } + | undefined; + + if (!activityAddress) { + throw new Error("Activity address required"); + } + + const { pubkey, identifier } = activityAddress; + const aTagValue = `30311:${pubkey}:${identifier}`; + + // Get relays - use immutable pattern to avoid mutating metadata + const relays = + liveActivity?.relays && liveActivity.relays.length > 0 + ? liveActivity.relays + : conversation.metadata?.relayUrl + ? [conversation.metadata.relayUrl] + : []; + + if (relays.length === 0) { + throw new Error("No relays available for sending reaction"); + } + + // Create event factory and sign event + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + const tags: string[][] = [ + ["e", messageId], // Event being reacted to + ["a", aTagValue, relays[0] || ""], // Activity context (NIP-53 specific) + ["k", "1311"], // Kind of event being reacted to (live chat message) + ]; + + // Add NIP-30 custom emoji tag if provided + if (customEmoji) { + tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); + } + + // Use kind 7 for reactions + const draft = await factory.build({ kind: 7, content: emoji, tags }); + const event = await factory.sign(draft); + + // Publish to all activity relays + await publishEventToRelays(event, relays); + } + /** * Get protocol capabilities */ diff --git a/src/lib/chat/adapters/nip-c7-adapter.ts b/src/lib/chat/adapters/nip-c7-adapter.ts index 4e375de..17c43ef 100644 --- a/src/lib/chat/adapters/nip-c7-adapter.ts +++ b/src/lib/chat/adapters/nip-c7-adapter.ts @@ -247,6 +247,50 @@ export class NipC7Adapter extends ChatProtocolAdapter { await publishEvent(event); } + /** + * Send a reaction (kind 7) to a message + */ + async sendReaction( + conversation: Conversation, + messageId: string, + emoji: string, + customEmoji?: { shortcode: string; url: string }, + ): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + const activeSigner = accountManager.active$.value?.signer; + + if (!activePubkey || !activeSigner) { + throw new Error("No active account or signer"); + } + + const partner = conversation.participants.find( + (p) => p.pubkey !== activePubkey, + ); + if (!partner) { + throw new Error("No conversation partner found"); + } + + // Create event factory and sign event + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + const tags: string[][] = [ + ["e", messageId], // Event being reacted to + ["p", partner.pubkey], // Tag the partner (NIP-C7 context) + ["k", "9"], // Kind of event being reacted to + ]; + + // Add NIP-30 custom emoji tag if provided + if (customEmoji) { + tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); + } + + // Use kind 7 for reactions + const draft = await factory.build({ kind: 7, content: emoji, tags }); + const event = await factory.sign(draft); + await publishEvent(event); + } + /** * Get protocol capabilities */