feat: chat msgs

This commit is contained in:
Alejandro Gómez
2025-12-13 16:37:51 +01:00
parent e048e212f1
commit 93ee2ae012
9 changed files with 196 additions and 48 deletions

View File

@@ -3,12 +3,17 @@ interface PlainLinkProps {
}
export function PlainLink({ url }: PlainLinkProps) {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all"
onClick={handleClick}
>
{url}
</a>

View File

@@ -150,6 +150,11 @@ export function MediaEmbed({
// Audio rendering
if (mediaType === "audio") {
const handleAudioClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onAudioClick) onAudioClick();
};
return (
<div
className={cn(
@@ -158,7 +163,7 @@ export function MediaEmbed({
"cursor-crosshair hover:bg-muted/30 transition-colors",
className,
)}
onClick={onAudioClick}
onClick={onAudioClick ? handleAudioClick : undefined}
>
<Music className="w-4 h-4 text-muted-foreground flex-shrink-0" />
{!onAudioClick ? (

View File

@@ -55,6 +55,7 @@ export function QuotedEvent({
href="#"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpen(pointer);
}}
className="inline-flex items-center gap-1 text-accent underline decoration-dotted break-all"
@@ -95,7 +96,10 @@ export function QuotedEvent({
>
{/* Preview header - always visible */}
<button
onClick={() => setIsExpanded(!isExpanded)}
onClick={(e) => {
e.stopPropagation();
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">

View File

@@ -18,11 +18,41 @@ export function useDepth() {
return useContext(DepthContext);
}
/**
* Configuration options for RichText rendering behavior
*/
export interface RichTextOptions {
/** Show images inline (default: true) */
showImages?: boolean;
/** Show videos inline (default: true) */
showVideos?: boolean;
/** Show audio players inline (default: true) */
showAudio?: boolean;
/** Convenience flag to disable all media at once (default: true) */
showMedia?: boolean;
}
// Default options
const defaultOptions: Required<RichTextOptions> = {
showImages: true,
showVideos: true,
showAudio: true,
showMedia: true,
};
// Context for passing options through RichText rendering
const OptionsContext = createContext<Required<RichTextOptions>>(defaultOptions);
export function useRichTextOptions() {
return useContext(OptionsContext);
}
interface RichTextProps {
event?: NostrEvent;
content?: string;
className?: string;
depth?: number;
options?: RichTextOptions;
}
// Content node component types for rendering
@@ -45,7 +75,14 @@ export function RichText({
content,
className = "",
depth = 1,
options = {},
}: RichTextProps) {
// Merge provided options with defaults
const mergedOptions: Required<RichTextOptions> = {
...defaultOptions,
...options,
};
// Call hook unconditionally - it will handle undefined/null
const trimmedEvent = event
? {
@@ -76,9 +113,11 @@ export function RichText({
if (event) {
return (
<DepthContext.Provider value={depth}>
<div className={cn("leading-relaxed break-words", className)}>
{renderedContent}
</div>
<OptionsContext.Provider value={mergedOptions}>
<div className={cn("leading-relaxed break-words", className)}>
{renderedContent}
</div>
</OptionsContext.Provider>
</DepthContext.Provider>
);
}

View File

@@ -7,6 +7,7 @@ import {
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { PlainLink } from "../LinkPreview";
import { useRichTextOptions } from "../RichText";
interface GalleryNodeProps {
node: {
@@ -15,6 +16,7 @@ interface GalleryNodeProps {
}
export function Gallery({ node }: GalleryNodeProps) {
const options = useRichTextOptions();
const [dialogOpen, setDialogOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
@@ -26,20 +28,32 @@ export function Gallery({ node }: GalleryNodeProps) {
};
const renderLink = (url: string, index: number) => {
// Check if media should be shown
const shouldShowMedia = options.showMedia;
if (isImageURL(url)) {
return <MediaEmbed url={url} type="image" preset="inline" enableZoom />;
if (shouldShowMedia && options.showImages) {
return <MediaEmbed url={url} type="image" preset="inline" enableZoom />;
}
return <PlainLink url={url} />;
}
if (isVideoURL(url)) {
return <MediaEmbed url={url} type="video" preset="inline" />;
if (shouldShowMedia && options.showVideos) {
return <MediaEmbed url={url} type="video" preset="inline" />;
}
return <PlainLink url={url} />;
}
if (isAudioURL(url)) {
return (
<MediaEmbed
url={url}
type="audio"
onAudioClick={() => handleAudioClick(index)}
/>
);
if (shouldShowMedia && options.showAudio) {
return (
<MediaEmbed
url={url}
type="audio"
onAudioClick={() => handleAudioClick(index)}
/>
);
}
return <PlainLink url={url} />;
}
return <PlainLink url={url} />;
};

View File

@@ -7,6 +7,7 @@ import {
import { MediaDialog } from "../MediaDialog";
import { MediaEmbed } from "../MediaEmbed";
import { PlainLink } from "../LinkPreview";
import { useRichTextOptions } from "../RichText";
interface LinkNodeProps {
node: {
@@ -15,6 +16,7 @@ interface LinkNodeProps {
}
export function Link({ node }: LinkNodeProps) {
const options = useRichTextOptions();
const [dialogOpen, setDialogOpen] = useState(false);
const { href } = node;
@@ -22,47 +24,59 @@ export function Link({ node }: LinkNodeProps) {
setDialogOpen(true);
};
// Check if media should be shown
const shouldShowMedia = options.showMedia;
// Render appropriate link type
if (isImageURL(href)) {
return (
<MediaEmbed
url={href}
type="image"
preset="inline"
enableZoom
className="inline-block"
/>
);
if (shouldShowMedia && options.showImages) {
return (
<MediaEmbed
url={href}
type="image"
preset="inline"
enableZoom
className="inline-block"
/>
);
}
return <PlainLink url={href} />;
}
if (isVideoURL(href)) {
return (
<MediaEmbed
url={href}
type="video"
preset="inline"
className="inline-block"
/>
);
if (shouldShowMedia && options.showVideos) {
return (
<MediaEmbed
url={href}
type="video"
preset="inline"
className="inline-block"
/>
);
}
return <PlainLink url={href} />;
}
if (isAudioURL(href)) {
return (
<>
<MediaEmbed
url={href}
type="audio"
onAudioClick={handleAudioClick}
className="inline-block"
/>
<MediaDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
urls={[href]}
initialIndex={0}
/>
</>
);
if (shouldShowMedia && options.showAudio) {
return (
<>
<MediaEmbed
url={href}
type="audio"
onAudioClick={handleAudioClick}
className="inline-block"
/>
<MediaDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
urls={[href]}
initialIndex={0}
/>
</>
);
}
return <PlainLink url={href} />;
}
// Plain link for non-media URLs

View File

@@ -41,7 +41,9 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
pubkey={parentEvent.pubkey}
className="flex-shrink-0 text-accent"
/>
<span className="truncate">{parentEvent.content}</span>
<span className="truncate">
<RichText event={parentEvent} options={{ showMedia: false }} />
</span>
</div>
</div>
)}

View File

@@ -0,0 +1,62 @@
import { RichText } from "../RichText";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { MessageCircle } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { getTagValues } from "@/lib/nostr-utils";
import { isValidHexEventId } from "@/lib/nostr-validation";
/**
* Renderer for Kind 9 - Chat Message (NIP-C7)
* Displays chat messages with optional quoted parent message
*/
export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
const { addWindow } = useGrimoire();
// Parse 'q' tag for quoted parent message (NIP-C7 reply format)
const quotedEventIds = getTagValues(event, "q");
const quotedEventId = quotedEventIds[0]; // First q tag
const parentEvent = useNostrEvent(quotedEventId);
const handleQuoteClick = () => {
if (!parentEvent || !quotedEventId) return;
const pointer = isValidHexEventId(quotedEventId)
? {
id: quotedEventId,
}
: quotedEventId;
addWindow(
"open",
{ pointer },
`Quoted message from ${parentEvent.pubkey.slice(0, 8)}...`,
);
};
return (
<BaseEventContainer event={event}>
{/* Show quoted parent message if this is a reply */}
{quotedEventId && parentEvent && parentEvent.kind === 9 && (
<div
onClick={handleQuoteClick}
className="flex items-start gap-2 p-1 bg-muted/20 text-xs text-muted-foreground hover:bg-muted/30 cursor-crosshair rounded transition-colors"
>
<MessageCircle className="size-3 flex-shrink-0 mt-0.5" />
<div className="flex items-baseline gap-1 min-w-0 flex-1">
<UserName
pubkey={parentEvent.pubkey}
className="flex-shrink-0 text-accent"
/>
<span className="truncate">
<RichText event={parentEvent} options={{ showMedia: false }} />
</span>
</div>
</div>
)}
{/* Main message content */}
<RichText event={event} className="text-sm" depth={depth} />
</BaseEventContainer>
);
}

View File

@@ -3,6 +3,7 @@ import { Kind1Renderer } from "./Kind1Renderer";
import { Kind3Renderer } from "./Kind3Renderer";
import { RepostRenderer } from "./RepostRenderer";
import { Kind7Renderer } from "./Kind7Renderer";
import { Kind9Renderer } from "./Kind9Renderer";
import { Kind20Renderer } from "./Kind20Renderer";
import { Kind21Renderer } from "./Kind21Renderer";
import { Kind22Renderer } from "./Kind22Renderer";
@@ -26,6 +27,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
3: Kind3Renderer, // Contact List
6: RepostRenderer, // Repost
7: Kind7Renderer, // Reaction
9: Kind9Renderer, // Chat Message (NIP-C7)
16: RepostRenderer, // Generic Repost
20: Kind20Renderer, // Picture (NIP-68)
21: Kind21Renderer, // Video Event (NIP-71)
@@ -96,6 +98,7 @@ export {
Kind16Renderer,
} from "./RepostRenderer";
export { Kind7Renderer } from "./Kind7Renderer";
export { Kind9Renderer } from "./Kind9Renderer";
export { Kind20Renderer } from "./Kind20Renderer";
export { Kind21Renderer } from "./Kind21Renderer";
export { Kind22Renderer } from "./Kind22Renderer";