feat: render chat root posts using feed renderer

- Add `bare` prop to BaseEventContainer and KindRenderer to render
  content without header/footer wrapper
- Update ChatViewer MessageItem to detect root posts and render them
  using KindRenderer with bare mode
- Root posts display with styled container showing author/timestamp below
- Fix protocol labels: "Thread" for NIP-10, "Comments" for NIP-22
- Add MessageSquare icon for NIP-22 comment threads
This commit is contained in:
Claude
2026-01-19 16:54:42 +00:00
parent b53f20ef4b
commit 7c033aa370
4 changed files with 58 additions and 8 deletions

View File

@@ -12,6 +12,7 @@ import {
Copy,
CopyCheck,
FileText,
MessageSquare,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { getZapRequest } from "applesauce-common/helpers/zap";
@@ -35,6 +36,7 @@ import type { ChatAction } from "@/types/chat-actions";
import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import { KindRenderer } from "./nostr/kinds";
import Timestamp from "./Timestamp";
import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
@@ -263,6 +265,7 @@ const MessageItem = memo(function MessageItem({
onReply,
canReply,
onScrollToMessage,
isRootPost = false,
}: {
message: Message;
adapter: ChatProtocolAdapter;
@@ -270,6 +273,7 @@ const MessageItem = memo(function MessageItem({
onReply?: (messageId: string) => void;
canReply: boolean;
onScrollToMessage?: (messageId: string) => void;
isRootPost?: boolean;
}) {
// Get relays for this conversation (memoized to prevent unnecessary re-subscriptions)
const relays = useMemo(
@@ -277,6 +281,22 @@ const MessageItem = memo(function MessageItem({
[conversation],
);
// Root post: render using KindRenderer with bare mode (no header/footer)
if (isRootPost && message.event) {
return (
<div className="border-b border-border/50 px-3 py-2 bg-muted/20">
<KindRenderer event={message.event} bare={true} />
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/30">
<UserName pubkey={message.author} className="text-sm font-medium" />
<span className="text-xs text-muted-foreground">
<Timestamp timestamp={message.timestamp} />
</span>
<MessageReactions messageId={message.id} relays={relays} />
</div>
</div>
);
}
// System messages (join/leave) have special styling
if (message.type === "system") {
return (
@@ -918,6 +938,11 @@ export function ChatViewer({
<FileText className="size-3" />
Thread
</span>
) : conversation.protocol === "nip-22" ? (
<span className="flex items-center gap-1 text-primary-foreground/80">
<MessageSquare className="size-3" />
Comments
</span>
) : (
<span className="capitalize text-primary-foreground/80">
{conversation.type}
@@ -1028,6 +1053,10 @@ export function ChatViewer({
</div>
);
}
// Check if this is the root post (for NIP-10/NIP-22)
const rootEventId = conversation.metadata?.rootEventId;
const isRootPost = rootEventId === item.data.id;
return (
<MessageItem
key={item.data.id}
@@ -1037,6 +1066,7 @@ export function ChatViewer({
onReply={handleReply}
canReply={canSign}
onScrollToMessage={handleScrollToMessage}
isRootPost={isRootPost}
/>
);
}}

View File

@@ -48,6 +48,11 @@ export interface BaseEventProps {
pubkey: string;
label?: string; // e.g., "Host", "Sender", "Zapper", "From"
};
/**
* If true, render content without header/footer wrapper
* Used in chat views where the container provides its own context
*/
bare?: boolean;
}
/**
@@ -363,6 +368,8 @@ export function ClickableEventTitle({
/**
* Base event container with universal header
* Kind-specific renderers can wrap their content with this
*
* @param bare - If true, render children without header/footer wrapper
*/
/**
* Format relative time (e.g., "2m ago", "3h ago", "5d ago")
@@ -372,6 +379,7 @@ export function BaseEventContainer({
event,
children,
authorOverride,
bare = false,
}: {
event: NostrEvent;
children: React.ReactNode;
@@ -379,9 +387,15 @@ export function BaseEventContainer({
pubkey: string;
label?: string;
};
bare?: boolean;
}) {
const { locale } = useGrimoire();
// If bare mode, just render children without wrapper
if (bare) {
return <>{children}</>;
}
// Format relative time for display
const relativeTime = formatTimestamp(
event.created_at,

View File

@@ -71,7 +71,11 @@ function ParentEventCard({
* Renderer for Kind 1 - Short Text Note (NIP-10 threading)
* Shows immediate parent (reply) only for cleaner display
*/
export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
export function Kind1Renderer({
event,
depth = 0,
bare = false,
}: BaseEventProps) {
const { addWindow } = useGrimoire();
// Use NIP-10 threading helpers
@@ -93,14 +97,14 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
};
return (
<BaseEventContainer event={event}>
<BaseEventContainer event={event} bare={bare}>
<TooltipProvider>
{/* Show reply event (immediate parent) */}
{replyPointer && !replyEvent && (
{/* Show reply event (immediate parent) - hide in bare mode */}
{!bare && replyPointer && !replyEvent && (
<InlineReplySkeleton icon={<Reply className="size-3" />} />
)}
{replyPointer && replyEvent && (
{!bare && replyPointer && replyEvent && (
<ParentEventCard
parentEvent={replyEvent}
icon={Reply}

View File

@@ -237,9 +237,9 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
* Default renderer for kinds without custom implementations
* Shows basic event info with raw content
*/
function DefaultKindRenderer({ event }: BaseEventProps) {
function DefaultKindRenderer({ event, bare = false }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
<BaseEventContainer event={event} bare={bare}>
<div className="text-sm text-muted-foreground">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
{event.content || "(empty content)"}
@@ -256,12 +256,14 @@ function DefaultKindRenderer({ event }: BaseEventProps) {
export function KindRenderer({
event,
depth = 0,
bare = false,
}: {
event: NostrEvent;
depth?: number;
bare?: boolean;
}) {
const Renderer = kindRenderers[event.kind] || DefaultKindRenderer;
return <Renderer event={event} depth={depth} />;
return <Renderer event={event} depth={depth} bare={bare} />;
}
/**