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.
This commit is contained in:
Claude
2026-01-19 20:41:32 +00:00
parent 1c611f66f6
commit bfac71e37d
2 changed files with 124 additions and 20 deletions

View File

@@ -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(<MemoizedInlineEventPreview event={event} />);
} else {
// Event not found - render compact fallback
const kindName = kind !== null ? getKindName(kind) : "event";
root.render(
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-xs align-middle pointer-events-none">
<span>📝</span>
<span className="text-foreground font-medium text-[10px]">
{kindName}
</span>
<span className="text-muted-foreground text-[9px]">
{decodedType || "note"}
</span>
</span>,
);
}
return {
dom,
destroy: () => {
// Cleanup React root when node is destroyed
root.unmount();
},
};
};
},

View File

@@ -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 (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-xs align-middle max-w-[320px] pointer-events-none">
{/* Kind badge - icon only */}
<KindBadge kind={event.kind} variant="compact" className="shrink-0" />
{/* Author */}
{event.kind === kinds.Zap && getZapSender(event) ? (
<UserName
pubkey={getZapSender(event) as string}
className="shrink-0 truncate text-foreground font-medium text-[10px]"
/>
) : (
<UserName
pubkey={event.pubkey}
className="shrink-0 truncate text-foreground font-medium text-[10px]"
/>
)}
{/* Kind-specific or default preview */}
<span className="flex-1 min-w-0 truncate text-muted-foreground text-[10px]">
<PreviewRenderer event={event} />
</span>
{/* Timestamp */}
<span className="text-[9px] text-muted-foreground/70 shrink-0">
{relativeTime}
</span>
</span>
);
}
// Memoized version
export const MemoizedInlineEventPreview = memo(
InlineEventPreview,
(prev, next) => prev.event.id === next.event.id,
);