mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
feat: chat msgs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
62
src/components/nostr/kinds/Kind9Renderer.tsx
Normal file
62
src/components/nostr/kinds/Kind9Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user