From bfac71e37d28f1f1727232bf5fa3d7dd828f7bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 20:41:32 +0000 Subject: [PATCH] feat: use compact event renderer for inline nostr URI previews Replace simple text preview with rich inline event preview component that reuses the compact event renderer pattern from REQ viewer. Changes: - Create InlineEventPreview component for composer inline previews - Optimized for inline display with pointer-events disabled - Fetches events from eventStore on render - Uses ReactDOM createRoot for TipTap node rendering - Shows full event details: kind badge, author, content preview, timestamp - Falls back to simple preview when event not in store - Proper cleanup with root.unmount() when node destroyed The preview now shows: - Kind-specific badge with icon - Author name (with zap sender detection for zaps) - Content preview using kind-specific renderers - Relative timestamp - All in a compact inline chip (~320px max width) This provides much richer context when sharing events in chat, while maintaining the same visual consistency as the REQ viewer. --- src/components/editor/MentionEditor.tsx | 74 +++++++++++++++------ src/components/nostr/InlineEventPreview.tsx | 70 +++++++++++++++++++ 2 files changed, 124 insertions(+), 20 deletions(-) create mode 100644 src/components/nostr/InlineEventPreview.tsx 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, +);