diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 23d7c7f..7e4f289 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -6,6 +6,7 @@ import { useCallback, useRef, } from "react"; +import { createRoot } from "react-dom/client"; import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; import { Extension, Node, mergeAttributes } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; @@ -33,6 +34,9 @@ import type { EmojiSearchResult } from "@/services/emoji-search"; import type { ChatAction } from "@/types/chat-actions"; import { nip19 } from "nostr-tools"; import { getKindName } from "@/constants/kinds"; +import { eventStore } from "@/services/event-store"; +import { MemoizedInlineEventPreview } from "../nostr/InlineEventPreview"; +import type { NostrEvent } from "@/types/nostr"; /** * Represents an emoji tag for NIP-30 @@ -312,34 +316,64 @@ const EventMentionNode = Node.create({ addNodeView() { return ({ node }) => { - const { decodedType, kind, nostrUri } = node.attrs; + const { decodedType, kind, nostrUri, eventId, pubkey } = node.attrs; // Create wrapper span const dom = document.createElement("span"); - dom.className = - "event-mention inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-xs align-middle cursor-pointer hover:bg-primary/20 transition-colors"; + dom.className = "event-mention inline-block align-middle"; dom.contentEditable = "false"; - dom.title = nostrUri || "Event mention"; - // Icon based on type - const icon = document.createElement("span"); - icon.textContent = "📝"; - dom.appendChild(icon); + // Create React root for rendering + const root = createRoot(dom); - // Label showing event type - const label = document.createElement("span"); - label.className = "text-foreground font-medium"; - const kindName = kind !== null ? getKindName(kind) : "event"; - label.textContent = kindName; - dom.appendChild(label); + // Try to load the event from the store + let event: NostrEvent | undefined; - // Type indicator - const typeLabel = document.createElement("span"); - typeLabel.className = "text-muted-foreground text-[10px]"; - typeLabel.textContent = decodedType || "note"; - dom.appendChild(typeLabel); + if (eventId) { + // For note/nevent - try to get by ID + event = eventStore.event(eventId); + } else if (decodedType === "naddr" && kind !== null && pubkey) { + // For naddr - try to get replaceable event + // Get the identifier from the original naddr + try { + const decoded = nip19.decode(nostrUri.replace("nostr:", "")); + if (decoded.type === "naddr") { + const identifier = (decoded.data as nip19.AddressPointer) + .identifier; + event = eventStore.replaceable(kind, pubkey, identifier); + } + } catch { + // Failed to decode, fall through to fallback + } + } - return { dom }; + // Render the component + if (event) { + // Event found - render full preview + root.render(); + } else { + // Event not found - render compact fallback + const kindName = kind !== null ? getKindName(kind) : "event"; + root.render( + + 📝 + + {kindName} + + + {decodedType || "note"} + + , + ); + } + + return { + dom, + destroy: () => { + // Cleanup React root when node is destroyed + root.unmount(); + }, + }; }; }, diff --git a/src/components/nostr/InlineEventPreview.tsx b/src/components/nostr/InlineEventPreview.tsx new file mode 100644 index 0000000..a540cf8 --- /dev/null +++ b/src/components/nostr/InlineEventPreview.tsx @@ -0,0 +1,70 @@ +import { memo } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { kinds } from "nostr-tools"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { getZapSender } from "applesauce-common/helpers/zap"; +import { KindBadge } from "@/components/KindBadge"; +import { UserName } from "./UserName"; +import { compactRenderers, DefaultCompactPreview } from "./compact"; +import { useGrimoire } from "@/core/state"; + +interface InlineEventPreviewProps { + event: NostrEvent; +} + +/** + * Inline event preview for use in chat composer + * Similar to CompactEventRow but optimized for inline display + * - No click handlers (pointer events disabled) + * - More compact styling + * - Designed to fit in a single line within text + */ +export function InlineEventPreview({ event }: InlineEventPreviewProps) { + const { locale } = useGrimoire(); + + // Get the compact preview renderer for this kind, or use default + const PreviewRenderer = compactRenderers[event.kind] || DefaultCompactPreview; + + // Format relative time + const relativeTime = formatTimestamp( + event.created_at, + "relative", + locale.locale, + ); + + return ( + + {/* Kind badge - icon only */} + + + {/* Author */} + {event.kind === kinds.Zap && getZapSender(event) ? ( + + ) : ( + + )} + + {/* Kind-specific or default preview */} + + + + + {/* Timestamp */} + + {relativeTime} + + + ); +} + +// Memoized version +export const MemoizedInlineEventPreview = memo( + InlineEventPreview, + (prev, next) => prev.event.id === next.event.id, +);