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 */