feat: quoted events

This commit is contained in:
Alejandro Gómez
2025-12-10 17:27:28 +01:00
parent 88e34441b8
commit 49b84a95b3
8 changed files with 174 additions and 56 deletions

View File

@@ -0,0 +1,123 @@
import { useState } from "react";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./kinds";
import { UserName } from "./UserName";
import { ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
interface QuotedEventProps {
/** Event ID string for regular events */
eventId?: string;
/** AddressPointer for addressable/replaceable events */
addressPointer?: { kind: number; pubkey: string; identifier: string };
/** Callback when user clicks to open the event in new window */
onOpen?: (
id: string | { kind: number; pubkey: string; identifier: string },
) => void;
/** Depth level for nesting (0 = root, 1 = first quote, 2+ = nested) */
depth?: number;
/** Optional className for container */
className?: string;
}
/**
* QuotedEvent component with depth-aware rendering
* - depth 0-1: Show full content inline by default
* - depth 2+: Show expandable preview only
*/
export function QuotedEvent({
eventId,
addressPointer,
onOpen,
depth = 1,
className,
}: QuotedEventProps) {
const [isExpanded, setIsExpanded] = useState(depth < 2);
// Determine pointer to use
const pointer = eventId || addressPointer;
// Load the event
const event = useNostrEvent(pointer);
// Loading state
if (!event) {
if (onOpen && pointer) {
const displayText =
typeof eventId === "string"
? `@${eventId.slice(0, 8)}...`
: addressPointer
? `@${addressPointer.identifier || addressPointer.kind}`
: "@event";
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
onOpen(pointer);
}}
className="inline-flex items-center gap-1 text-accent underline decoration-dotted break-all"
>
<span>{displayText}</span>
</a>
);
}
return (
<span className="text-sm text-muted-foreground italic">
Loading event...
</span>
);
}
// For depth 0-1: Show full content inline by default
if (depth < 2) {
return (
<div
className={cn("my-2 border-l-2 border-muted pl-3 text-sm", className)}
>
<KindRenderer event={event} depth={depth + 1} />
</div>
);
}
// For depth 2+: Show expandable preview
const previewText = event.content?.slice(0, 100) || "";
const hasMore = event.content?.length > 100;
return (
<div
className={cn(
"my-2 border border-muted rounded-lg overflow-hidden",
className,
)}
>
{/* Preview header - always visible */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between gap-2 p-2 bg-muted/20 hover:bg-muted/40 transition-colors text-left"
>
<div className="flex items-center gap-2 min-w-0">
<UserName pubkey={event.pubkey} className="text-xs font-medium" />
<span className="text-xs text-muted-foreground truncate">
{previewText}
{hasMore && "..."}
</span>
</div>
{isExpanded ? (
<ChevronUp className="size-3 flex-shrink-0" />
) : (
<ChevronDown className="size-3 flex-shrink-0" />
)}
</button>
{/* Full content - shown when expanded */}
{isExpanded && (
<div className="p-3 border-t border-muted">
<KindRenderer event={event} depth={depth + 1} />
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils";
import { Hooks } from "applesauce-react";
import { createContext, useContext } from "react";
import { Text } from "./RichText/Text";
import { Hashtag } from "./RichText/Hashtag";
import { Mention } from "./RichText/Mention";
@@ -10,10 +11,18 @@ import type { NostrEvent } from "@/types/nostr";
const { useRenderedContent } = Hooks;
// Context for passing depth through RichText rendering
const DepthContext = createContext<number>(1);
export function useDepth() {
return useContext(DepthContext);
}
interface RichTextProps {
event?: NostrEvent;
content?: string;
className?: string;
depth?: number;
}
// Content node component types for rendering
@@ -31,7 +40,12 @@ const contentComponents = {
* Supports mentions, hashtags, links, emojis, and galleries
* Can also render plain text without requiring a full event
*/
export function RichText({ event, content, className = "" }: RichTextProps) {
export function RichText({
event,
content,
className = "",
depth = 1,
}: RichTextProps) {
// If plain content is provided, just render it
if (content && !event) {
const lines = content.trim().split("\n");
@@ -54,9 +68,11 @@ export function RichText({ event, content, className = "" }: RichTextProps) {
};
const renderedContent = useRenderedContent(trimmedEvent, contentComponents);
return (
<div className={cn("leading-relaxed break-words", className)}>
{renderedContent}
</div>
<DepthContext.Provider value={depth}>
<div className={cn("leading-relaxed break-words", className)}>
{renderedContent}
</div>
</DepthContext.Provider>
);
}

View File

@@ -1,58 +1,27 @@
import { useState } from "react";
import { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { Plus, Minus } from "lucide-react";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { QuotedEvent } from "../QuotedEvent";
interface EventEmbedNodeProps {
node: {
pointer: EventPointer | AddressPointer;
};
depth?: number;
}
function isEventPointer(
pointer: EventPointer | AddressPointer,
): pointer is EventPointer {
return "id" in pointer;
}
export function EventEmbed({ node }: EventEmbedNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
/**
* EventEmbed component for rendering quoted/embedded Nostr events
* Uses QuotedEvent with depth tracking for smart expand/collapse behavior
*/
export function EventEmbed({ node, depth = 1 }: EventEmbedNodeProps) {
const { pointer } = node;
// Determine the type label and short identifier
const isEvent = isEventPointer(pointer);
const label = isEvent ? "nevent" : "naddr";
const identifier = isEvent
? pointer.id.slice(0, 8)
: pointer.identifier || pointer.pubkey.slice(0, 8);
return (
<div className="flex flex-col w-full">
<button onClick={() => setIsExpanded(!isExpanded)}>
<div
className="flex flex-row items-center gap-1 w-full
text-muted-foreground hover:text-foreground
cursor-crosshair"
>
{isExpanded ? (
<Minus className="h-3 w-3" />
) : (
<Plus className="h-3 w-3" />
)}
<span className="">
[{label}: {identifier}...]
</span>
</div>
</button>
{isExpanded && (
<EmbeddedEvent
eventId={"id" in pointer ? pointer.id : undefined}
addressPointer={
"kind" in pointer && "pubkey" in pointer ? pointer : undefined
}
/>
)}
</div>
<QuotedEvent
eventId={"id" in pointer ? pointer.id : undefined}
addressPointer={
"kind" in pointer && "pubkey" in pointer ? pointer : undefined
}
depth={depth}
/>
);
}

View File

@@ -2,6 +2,7 @@ import { kinds } from "nostr-tools";
import { UserName } from "../UserName";
import { EventEmbed } from "./EventEmbed";
import { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useDepth } from "../RichText";
interface MentionNodeProps {
node: {
@@ -14,6 +15,8 @@ interface MentionNodeProps {
}
export function Mention({ node }: MentionNodeProps) {
const depth = useDepth();
if (node.decoded?.type === "npub") {
const pubkey = node.decoded.data;
return (
@@ -43,17 +46,17 @@ export function Mention({ node }: MentionNodeProps) {
kind: kinds.ShortTextNote,
relays: [],
};
return <EventEmbed node={{ pointer }} />;
return <EventEmbed node={{ pointer }} depth={depth} />;
}
if (node.decoded?.type === "nevent") {
const pointer: EventPointer = node.decoded.data;
return <EventEmbed node={{ pointer }} />;
return <EventEmbed node={{ pointer }} depth={depth} />;
}
if (node.decoded?.type === "naddr") {
const pointer: AddressPointer = node.decoded.data;
return <EventEmbed node={{ pointer }} />;
return <EventEmbed node={{ pointer }} depth={depth} />;
}
return null;

View File

@@ -20,6 +20,7 @@ import { JsonViewer } from "@/components/JsonViewer";
export interface BaseEventProps {
event: NostrEvent;
showTimestamp?: boolean;
depth?: number;
}
/**

View File

@@ -9,7 +9,11 @@ import { useGrimoire } from "@/core/state";
/**
* Renderer for Kind 1 - Short Text Note
*/
export function Kind1Renderer({ event, showTimestamp }: BaseEventProps) {
export function Kind1Renderer({
event,
showTimestamp,
depth = 0,
}: BaseEventProps) {
const { addWindow } = useGrimoire();
const refs = getNip10References(event);
const hasReply = refs.reply?.e || refs.reply?.a;
@@ -49,7 +53,7 @@ export function Kind1Renderer({ event, showTimestamp }: BaseEventProps) {
</div>
</div>
)}
<RichText event={event} className="text-sm" />
<RichText event={event} className="text-sm" depth={depth} />
</BaseEventContainer>
);
}

View File

@@ -57,12 +57,14 @@ function DefaultKindRenderer({ event, showTimestamp }: BaseEventProps) {
export function KindRenderer({
event,
showTimestamp = false,
depth = 0,
}: {
event: NostrEvent;
showTimestamp?: boolean;
depth?: number;
}) {
const Renderer = kindRenderers[event.kind] || DefaultKindRenderer;
return <Renderer event={event} showTimestamp={showTimestamp} />;
return <Renderer event={event} showTimestamp={showTimestamp} depth={depth} />;
}
/**

View File

@@ -1 +1 @@
{"root":["./src/main.tsx","./src/root.tsx","./src/vite-env.d.ts","./src/components/command.tsx","./src/components/commandlauncher.tsx","./src/components/decodeviewer.tsx","./src/components/encodeviewer.tsx","./src/components/eventdetailviewer.tsx","./src/components/grimoirewelcome.tsx","./src/components/home.tsx","./src/components/jsonviewer.tsx","./src/components/kindbadge.tsx","./src/components/kindrenderer.tsx","./src/components/manpage.tsx","./src/components/markdown.tsx","./src/components/niprenderer.tsx","./src/components/profileviewer.tsx","./src/components/reqviewer.tsx","./src/components/tabbar.tsx","./src/components/timestamp.tsx","./src/components/winviewer.tsx","./src/components/windowtoolbar.tsx","./src/components/nostr/embeddedevent.tsx","./src/components/nostr/feed.tsx","./src/components/nostr/mediadialog.tsx","./src/components/nostr/mediaembed.tsx","./src/components/nostr/richtext.tsx","./src/components/nostr/username.tsx","./src/components/nostr/index.ts","./src/components/nostr/nip05.tsx","./src/components/nostr/npub.tsx","./src/components/nostr/relay-pool.tsx","./src/components/nostr/user-menu.tsx","./src/components/nostr/linkpreview/audiolink.tsx","./src/components/nostr/linkpreview/imagelink.tsx","./src/components/nostr/linkpreview/plainlink.tsx","./src/components/nostr/linkpreview/videolink.tsx","./src/components/nostr/linkpreview/index.ts","./src/components/nostr/richtext/emoji.tsx","./src/components/nostr/richtext/eventembed.tsx","./src/components/nostr/richtext/gallery.tsx","./src/components/nostr/richtext/hashtag.tsx","./src/components/nostr/richtext/link.tsx","./src/components/nostr/richtext/mention.tsx","./src/components/nostr/richtext/text.tsx","./src/components/nostr/richtext/index.ts","./src/components/nostr/kinds/baseeventrenderer.tsx","./src/components/nostr/kinds/kind0detailrenderer.tsx","./src/components/nostr/kinds/kind0renderer.tsx","./src/components/nostr/kinds/kind1063renderer.tsx","./src/components/nostr/kinds/kind1renderer.tsx","./src/components/nostr/kinds/kind20renderer.tsx","./src/components/nostr/kinds/kind21renderer.tsx","./src/components/nostr/kinds/kind22renderer.tsx","./src/components/nostr/kinds/kind30023detailrenderer.tsx","./src/components/nostr/kinds/kind30023renderer.tsx","./src/components/nostr/kinds/kind3renderer.tsx","./src/components/nostr/kinds/kind6renderer.tsx","./src/components/nostr/kinds/kind7renderer.tsx","./src/components/nostr/kinds/kind9735renderer.tsx","./src/components/nostr/kinds/kind9802detailrenderer.tsx","./src/components/nostr/kinds/kind9802renderer.tsx","./src/components/nostr/kinds/index.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/constants/kinds.ts","./src/constants/nips.ts","./src/core/logic.ts","./src/core/state.ts","./src/hooks/useaccountsync.ts","./src/hooks/usenip.ts","./src/hooks/usenip05.ts","./src/hooks/usenostrevent.ts","./src/hooks/useprofile.ts","./src/hooks/usereqtimeline.ts","./src/hooks/usetimeline.ts","./src/lib/decode-parser.ts","./src/lib/encode-parser.ts","./src/lib/imeta.ts","./src/lib/nip-kinds.ts","./src/lib/nip05.ts","./src/lib/nostr-utils.ts","./src/lib/open-parser.ts","./src/lib/profile-parser.ts","./src/lib/req-parser.ts","./src/lib/utils.ts","./src/services/accounts.ts","./src/services/db.ts","./src/services/event-store.ts","./src/services/loaders.ts","./src/services/relay-pool.ts","./src/types/app.ts","./src/types/man.ts","./src/types/nostr.ts","./src/types/profile.ts"],"version":"5.6.3"}
{"root":["./src/main.tsx","./src/root.tsx","./src/vite-env.d.ts","./src/components/command.tsx","./src/components/commandlauncher.tsx","./src/components/decodeviewer.tsx","./src/components/encodeviewer.tsx","./src/components/eventdetailviewer.tsx","./src/components/grimoirewelcome.tsx","./src/components/home.tsx","./src/components/jsonviewer.tsx","./src/components/kindbadge.tsx","./src/components/kindrenderer.tsx","./src/components/manpage.tsx","./src/components/markdown.tsx","./src/components/niprenderer.tsx","./src/components/profileviewer.tsx","./src/components/reqviewer.tsx","./src/components/tabbar.tsx","./src/components/timestamp.tsx","./src/components/winviewer.tsx","./src/components/windowtoolbar.tsx","./src/components/nostr/embeddedevent.tsx","./src/components/nostr/feed.tsx","./src/components/nostr/mediadialog.tsx","./src/components/nostr/mediaembed.tsx","./src/components/nostr/quotedevent.tsx","./src/components/nostr/richtext.tsx","./src/components/nostr/username.tsx","./src/components/nostr/index.ts","./src/components/nostr/nip05.tsx","./src/components/nostr/npub.tsx","./src/components/nostr/relay-pool.tsx","./src/components/nostr/user-menu.tsx","./src/components/nostr/linkpreview/audiolink.tsx","./src/components/nostr/linkpreview/imagelink.tsx","./src/components/nostr/linkpreview/plainlink.tsx","./src/components/nostr/linkpreview/videolink.tsx","./src/components/nostr/linkpreview/index.ts","./src/components/nostr/richtext/emoji.tsx","./src/components/nostr/richtext/eventembed.tsx","./src/components/nostr/richtext/gallery.tsx","./src/components/nostr/richtext/hashtag.tsx","./src/components/nostr/richtext/link.tsx","./src/components/nostr/richtext/mention.tsx","./src/components/nostr/richtext/text.tsx","./src/components/nostr/richtext/index.ts","./src/components/nostr/kinds/baseeventrenderer.tsx","./src/components/nostr/kinds/kind0detailrenderer.tsx","./src/components/nostr/kinds/kind0renderer.tsx","./src/components/nostr/kinds/kind1063renderer.tsx","./src/components/nostr/kinds/kind1renderer.tsx","./src/components/nostr/kinds/kind20renderer.tsx","./src/components/nostr/kinds/kind21renderer.tsx","./src/components/nostr/kinds/kind22renderer.tsx","./src/components/nostr/kinds/kind30023detailrenderer.tsx","./src/components/nostr/kinds/kind30023renderer.tsx","./src/components/nostr/kinds/kind3renderer.tsx","./src/components/nostr/kinds/kind6renderer.tsx","./src/components/nostr/kinds/kind7renderer.tsx","./src/components/nostr/kinds/kind9735renderer.tsx","./src/components/nostr/kinds/kind9802detailrenderer.tsx","./src/components/nostr/kinds/kind9802renderer.tsx","./src/components/nostr/kinds/index.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/constants/kinds.ts","./src/constants/nips.ts","./src/core/logic.ts","./src/core/state.ts","./src/hooks/useaccountsync.ts","./src/hooks/usenip.ts","./src/hooks/usenip05.ts","./src/hooks/usenostrevent.ts","./src/hooks/useprofile.ts","./src/hooks/usereqtimeline.ts","./src/hooks/usetimeline.ts","./src/lib/decode-parser.ts","./src/lib/encode-parser.ts","./src/lib/imeta.ts","./src/lib/nip-kinds.ts","./src/lib/nip05.ts","./src/lib/nostr-utils.ts","./src/lib/open-parser.ts","./src/lib/profile-parser.ts","./src/lib/req-parser.ts","./src/lib/utils.ts","./src/services/accounts.ts","./src/services/db.ts","./src/services/event-store.ts","./src/services/loaders.ts","./src/services/relay-pool.ts","./src/types/app.ts","./src/types/man.ts","./src/types/nostr.ts","./src/types/profile.ts"],"version":"5.6.3"}