mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
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:
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
70
src/components/nostr/InlineEventPreview.tsx
Normal file
70
src/components/nostr/InlineEventPreview.tsx
Normal 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,
|
||||
);
|
||||
Reference in New Issue
Block a user