From 69b74efe671fe9c07c73e9543be50fee15fe5b88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 17:55:39 +0000 Subject: [PATCH] Add emoji reaction picker to chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/ChatViewer.tsx | 2 + .../chat/ChatMessageContextMenu.tsx | 50 +++++ src/components/chat/EmojiPickerDialog.tsx | 210 ++++++++++++++++++ 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 ++++ 8 files changed, 441 insertions(+) create mode 100644 src/components/chat/EmojiPickerDialog.tsx diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 0c273ac..b164539 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -412,6 +412,8 @@ const MessageItem = memo(function MessageItem({ onReply(message.id) : undefined} + conversation={conversation} + adapter={adapter} > {messageContent} diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx index 27740c9..5042d8e 100644 --- a/src/components/chat/ChatMessageContextMenu.tsx +++ b/src/components/chat/ChatMessageContextMenu.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { NostrEvent } from "@/types/nostr"; +import type { Conversation } from "@/types/chat"; +import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import { ContextMenu, ContextMenuContent, @@ -15,20 +17,26 @@ import { ExternalLink, Reply, MessageSquare, + Smile, } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useCopy } from "@/hooks/useCopy"; import { JsonViewer } from "@/components/JsonViewer"; import { KindBadge } from "@/components/KindBadge"; +import { EmojiPickerDialog } from "./EmojiPickerDialog"; import { nip19 } from "nostr-tools"; import { getTagValue } from "applesauce-core/helpers"; import { getSeenRelays } from "applesauce-core/helpers/relays"; import { isAddressableKind } from "@/lib/nostr-kinds"; +import { getEmojiTags } from "@/lib/emoji-helpers"; +import type { EmojiTag } from "@/lib/emoji-helpers"; interface ChatMessageContextMenuProps { event: NostrEvent; children: React.ReactNode; onReply?: () => void; + conversation?: Conversation; + adapter?: ChatProtocolAdapter; } /** @@ -44,10 +52,16 @@ export function ChatMessageContextMenu({ event, children, onReply, + conversation, + adapter, }: ChatMessageContextMenuProps) { const { addWindow } = useGrimoire(); const { copy, copied } = useCopy(); const [jsonDialogOpen, setJsonDialogOpen] = useState(false); + const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); + + // Extract context emojis from the conversation + const contextEmojis = getEmojiTags(event); const openEventDetail = () => { let pointer; @@ -105,6 +119,25 @@ export function ChatMessageContextMenu({ setJsonDialogOpen(true); }; + const openReactionPicker = () => { + setEmojiPickerOpen(true); + }; + + const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => { + if (!conversation || !adapter) { + console.error( + "[ChatMessageContextMenu] Cannot send reaction: missing conversation or adapter", + ); + return; + } + + try { + await adapter.sendReaction(conversation, event.id, emoji, customEmoji); + } catch (err) { + console.error("[ChatMessageContextMenu] Failed to send reaction:", err); + } + }; + return ( <> @@ -131,6 +164,15 @@ export function ChatMessageContextMenu({ )} + {conversation && adapter && ( + <> + + + React + + + + )} Copy Text @@ -160,6 +202,14 @@ export function ChatMessageContextMenu({ onOpenChange={setJsonDialogOpen} title={`Event ${event.id.slice(0, 8)}... - Raw JSON`} /> + {conversation && adapter && ( + + )} ); } diff --git a/src/components/chat/EmojiPickerDialog.tsx b/src/components/chat/EmojiPickerDialog.tsx new file mode 100644 index 0000000..38215d0 --- /dev/null +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -0,0 +1,210 @@ +import { useState, useEffect, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; +import { EmojiSearchService } from "@/services/emoji-search"; +import { UNICODE_EMOJIS } from "@/lib/unicode-emojis"; +import type { EmojiSearchResult } from "@/services/emoji-search"; +import type { EmojiTag } from "@/lib/emoji-helpers"; + +interface EmojiPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onEmojiSelect: (emoji: string, customEmoji?: EmojiTag) => void; + /** Optional context event to extract custom emoji from */ + contextEmojis?: EmojiTag[]; +} + +// Frequently used emojis stored in localStorage +const STORAGE_KEY = "grimoire:reaction-history"; +const QUICK_REACTIONS = ["❤️", "👍", "🔥", "😂", "🎉", "👀", "🤔", "💯"]; + +function getReactionHistory(): Record { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +function updateReactionHistory(emoji: string): void { + try { + const history = getReactionHistory(); + history[emoji] = (history[emoji] || 0) + 1; + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + } catch (err) { + console.error( + "[EmojiPickerDialog] Failed to update reaction history:", + err, + ); + } +} + +/** + * EmojiPickerDialog - Searchable emoji picker for reactions + * + * Features: + * - Real-time search using FlexSearch + * - Frequently used emoji at top when no search query + * - Quick reaction bar for common emojis + * - Supports both unicode and NIP-30 custom emoji + * - Tracks usage in localStorage + */ +export function EmojiPickerDialog({ + open, + onOpenChange, + onEmojiSelect, + contextEmojis = [], +}: EmojiPickerDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [emojiService] = useState(() => new EmojiSearchService()); + + // Initialize emoji service with unicode emojis + useEffect(() => { + // Load unicode emojis + emojiService.addUnicodeEmojis(UNICODE_EMOJIS); + + // Load context emojis (from conversation messages) + for (const emoji of contextEmojis) { + emojiService.addEmoji(emoji.shortcode, emoji.url, "context"); + } + }, [emojiService, contextEmojis]); + + // Search emojis when query changes + useEffect(() => { + const search = async () => { + const results = await emojiService.search(searchQuery, { limit: 48 }); + setSearchResults(results); + }; + search(); + }, [searchQuery, emojiService]); + + // Get frequently used emojis from history + const frequentlyUsed = useMemo(() => { + if (searchQuery.trim()) return []; // Only show when no search query + + const history = getReactionHistory(); + return Object.entries(history) + .sort((a, b) => b[1] - a[1]) // Sort by count descending + .slice(0, 8) + .map(([emoji]) => emoji); + }, [searchQuery]); + + const handleEmojiClick = (result: EmojiSearchResult) => { + if (result.source === "unicode") { + // For unicode emoji, the "url" field contains the emoji character + onEmojiSelect(result.url); + updateReactionHistory(result.url); + } else { + // For custom emoji, pass the shortcode as content and emoji tag info + onEmojiSelect(`:${result.shortcode}:`, { + shortcode: result.shortcode, + url: result.url, + }); + updateReactionHistory(`:${result.shortcode}:`); + } + onOpenChange(false); + setSearchQuery(""); // Reset search on close + }; + + const handleQuickReaction = (emoji: string) => { + onEmojiSelect(emoji); + updateReactionHistory(emoji); + onOpenChange(false); + }; + + return ( + + + + React with emoji + + + {/* Quick reaction bar */} +
+ {QUICK_REACTIONS.map((emoji) => ( + + ))} +
+ + {/* Search input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9" + autoFocus + /> +
+ + {/* Frequently used section */} + {frequentlyUsed.length > 0 && ( +
+
+ Frequently used +
+
+ {frequentlyUsed.map((emoji) => ( + + ))} +
+
+ )} + + {/* Emoji grid */} +
+ {searchResults.length > 0 ? ( +
+ {searchResults.map((result) => ( + + ))} +
+ ) : ( +
+ No emojis found +
+ )} +
+
+
+ ); +} 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 */