mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
feat: quoted events
This commit is contained in:
123
src/components/nostr/QuotedEvent.tsx
Normal file
123
src/components/nostr/QuotedEvent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { JsonViewer } from "@/components/JsonViewer";
|
||||
export interface BaseEventProps {
|
||||
event: NostrEvent;
|
||||
showTimestamp?: boolean;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user