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 && ( onReply(message.id)} @@ -383,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..72a20fd --- /dev/null +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect, useMemo } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; +import type { EmojiSearchResult } from "@/services/emoji-search"; +import type { EmojiTag } from "@/lib/emoji-helpers"; +import { useEmojiSearch } from "@/hooks/useEmojiSearch"; +import { CustomEmoji } from "../nostr/CustomEmoji"; + +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"; + +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([]); + + // Use the same emoji search hook as chat autocomplete + const { service } = useEmojiSearch(); + + // Add context emojis when they change + useEffect(() => { + if (contextEmojis.length > 0) { + for (const emoji of contextEmojis) { + service.addEmoji(emoji.shortcode, emoji.url, "context"); + } + } + }, [contextEmojis, service]); + + // Perform search when query changes + useEffect(() => { + const performSearch = async () => { + // Always fetch 8 emoji (1 row of 8) for consistent height + const results = await service.search(searchQuery, { limit: 8 }); + setSearchResults(results); + }; + performSearch(); + }, [searchQuery, service]); + + // Get frequently used emojis from history + const frequentlyUsed = useMemo(() => { + const history = getReactionHistory(); + return Object.entries(history) + .sort((a, b) => b[1] - a[1]) // Sort by count descending + .slice(0, 8) // Max 1 row + .map(([emoji]) => emoji); + }, []); + + // Combine recently used with search results for display + // When no search query: show recently used first, then fill with other emoji + // When searching: show search results + const displayEmojis = useMemo(() => { + if (searchQuery.trim()) { + // Show search results + return searchResults; + } + + // No search query: prioritize recently used, then fill with other emoji + if (frequentlyUsed.length > 0) { + const recentSet = new Set(frequentlyUsed); + // Get additional emoji to fill to 8, excluding recently used + const additional = searchResults + .filter((r) => { + const key = r.source === "unicode" ? r.url : `:${r.shortcode}:`; + return !recentSet.has(key); + }) + .slice(0, 8 - frequentlyUsed.length); + + // Combine: recently used get priority, but displayed as regular emoji + const recentResults: EmojiSearchResult[] = []; + for (const emojiStr of frequentlyUsed) { + if (emojiStr.startsWith(":") && emojiStr.endsWith(":")) { + const shortcode = emojiStr.slice(1, -1); + const customEmoji = service.getByShortcode(shortcode); + if (customEmoji) { + recentResults.push(customEmoji); + } + } else { + // Unicode emoji - find it in search results + const found = searchResults.find((r) => r.url === emojiStr); + if (found) recentResults.push(found); + } + } + + return [...recentResults, ...additional].slice(0, 8); + } + + // No history: just show top 8 emoji + return searchResults; + }, [searchQuery, searchResults, frequentlyUsed, service]); + + 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 + }; + + return ( + + + {/* Search input */} + + + setSearchQuery(e.target.value)} + className="pl-9" + autoFocus + /> + + + {/* Fixed 1-row emoji grid (8 emoji) with consistent height */} + + {displayEmojis.length > 0 ? ( + displayEmojis.map((result) => ( + handleEmojiClick(result)} + className="hover:bg-muted rounded p-2 transition-colors flex items-center justify-center aspect-square" + title={`:${result.shortcode}:`} + > + {result.source === "unicode" ? ( + {result.url} + ) : ( + + )} + + )) + ) : ( + + 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.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 ( - setError(true)} - /> + + + 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 */