From 49b84a95b319dba19ce7287ada8d5324bed2a097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 10 Dec 2025 17:27:28 +0100 Subject: [PATCH] feat: quoted events --- src/components/nostr/QuotedEvent.tsx | 123 ++++++++++++++++++ src/components/nostr/RichText.tsx | 24 +++- src/components/nostr/RichText/EventEmbed.tsx | 59 ++------- src/components/nostr/RichText/Mention.tsx | 9 +- .../nostr/kinds/BaseEventRenderer.tsx | 1 + src/components/nostr/kinds/Kind1Renderer.tsx | 8 +- src/components/nostr/kinds/index.tsx | 4 +- tsconfig.app.tsbuildinfo | 2 +- 8 files changed, 174 insertions(+), 56 deletions(-) create mode 100644 src/components/nostr/QuotedEvent.tsx diff --git a/src/components/nostr/QuotedEvent.tsx b/src/components/nostr/QuotedEvent.tsx new file mode 100644 index 0000000..1e8b8f0 --- /dev/null +++ b/src/components/nostr/QuotedEvent.tsx @@ -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 ( + { + e.preventDefault(); + onOpen(pointer); + }} + className="inline-flex items-center gap-1 text-accent underline decoration-dotted break-all" + > + {displayText} + + ); + } + + return ( + + Loading event... + + ); + } + + // For depth 0-1: Show full content inline by default + if (depth < 2) { + return ( +
+ +
+ ); + } + + // For depth 2+: Show expandable preview + const previewText = event.content?.slice(0, 100) || ""; + const hasMore = event.content?.length > 100; + + return ( +
+ {/* Preview header - always visible */} + + + {/* Full content - shown when expanded */} + {isExpanded && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index 22efc49..abe7c84 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -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(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 ( -
- {renderedContent} -
+ +
+ {renderedContent} +
+
); } diff --git a/src/components/nostr/RichText/EventEmbed.tsx b/src/components/nostr/RichText/EventEmbed.tsx index 4b5fa28..1742b5d 100644 --- a/src/components/nostr/RichText/EventEmbed.tsx +++ b/src/components/nostr/RichText/EventEmbed.tsx @@ -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 ( -
- - - {isExpanded && ( - - )} -
+ ); } diff --git a/src/components/nostr/RichText/Mention.tsx b/src/components/nostr/RichText/Mention.tsx index fc98c80..17fedcd 100644 --- a/src/components/nostr/RichText/Mention.tsx +++ b/src/components/nostr/RichText/Mention.tsx @@ -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 ; + return ; } if (node.decoded?.type === "nevent") { const pointer: EventPointer = node.decoded.data; - return ; + return ; } if (node.decoded?.type === "naddr") { const pointer: AddressPointer = node.decoded.data; - return ; + return ; } return null; diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index bbba9c1..ffd2478 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -20,6 +20,7 @@ import { JsonViewer } from "@/components/JsonViewer"; export interface BaseEventProps { event: NostrEvent; showTimestamp?: boolean; + depth?: number; } /** diff --git a/src/components/nostr/kinds/Kind1Renderer.tsx b/src/components/nostr/kinds/Kind1Renderer.tsx index b33e364..cce4711 100644 --- a/src/components/nostr/kinds/Kind1Renderer.tsx +++ b/src/components/nostr/kinds/Kind1Renderer.tsx @@ -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) { )} - + ); } diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 4440dda..caba42f 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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 ; + return ; } /** diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index a56aa74..cee3c77 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file