feat: implement unified chat system with NIP-C7 and NIP-29 support

Core Architecture:
- Protocol adapter pattern for chat implementations
- Base adapter interface with protocol-specific implementations
- Auto-detection of protocol from identifier format
- Reactive message loading via EventStore observables

Protocol Implementations:
- NIP-C7 adapter: Simple chat (kind 9) with npub/nprofile support
- NIP-29 adapter: Relay-based groups with member roles and moderation
- Protocol-aware reply message loading with relay hints
- Proper NIP-29 members/admins fetching using #d tags

UI Components:
- ChatViewer: Main chat interface with virtualized message timeline
- ChatMessage: Message rendering with reply preview
- ReplyPreview: Auto-loading replied-to messages from relays
- MembersDropdown: Virtualized member list with role labels
- RelaysDropdown: Connection status for chat relays
- ChatComposer: Message input with send functionality

Command System:
- chat command with identifier parsing and auto-detection
- Support for npub, nprofile, NIP-05, and relay'group-id formats
- Integration with window system and dynamic titles

NIP-29 Specific:
- Fetch kind:39000 (metadata), kind:39001 (admins), kind:39002 (members)
- Extract roles from p tags: ["p", "<pubkey>", "<role1>", "<role2>"]
- Role normalization (admin, moderator, host, member)
- Single group relay connection management

Testing:
- Comprehensive chat parser tests
- Protocol adapter test structure
- All tests passing (704 tests)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2026-01-11 21:38:23 +01:00
parent 84b5ac88aa
commit 6d01ee33ef
18 changed files with 1992 additions and 1 deletions

View File

@@ -0,0 +1,255 @@
import { useMemo, useState, memo, useCallback } from "react";
import { use$ } from "applesauce-react/hooks";
import { from } from "rxjs";
import { Virtuoso } from "react-virtuoso";
import type {
ChatProtocol,
ProtocolIdentifier,
Conversation,
} from "@/types/chat";
import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
import type { Message } from "@/types/chat";
import { UserName } from "./nostr/UserName";
import { RichText } from "./nostr/RichText";
import Timestamp from "./Timestamp";
import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
import { RelaysDropdown } from "./chat/RelaysDropdown";
import { useGrimoire } from "@/core/state";
import { Button } from "./ui/button";
interface ChatViewerProps {
protocol: ChatProtocol;
identifier: ProtocolIdentifier;
customTitle?: string;
}
/**
* MessageItem - Memoized message component for performance
*/
const MessageItem = memo(function MessageItem({
message,
adapter,
conversation,
}: {
message: Message;
adapter: ChatProtocolAdapter;
conversation: Conversation;
}) {
return (
<div className="group flex items-start hover:bg-muted/50">
<div className="">
<div className="flex items-center gap-2">
<UserName pubkey={message.author} className="font-semibold text-sm" />
<span className="text-xs text-muted-foreground">
<Timestamp timestamp={message.timestamp} />
</span>
</div>
<div className="text-sm leading-relaxed">
{message.event ? (
<RichText event={message.event}>
{message.replyTo && (
<ReplyPreview
replyToId={message.replyTo}
adapter={adapter}
conversation={conversation}
/>
)}
</RichText>
) : (
<span className="whitespace-pre-wrap">{message.content}</span>
)}
</div>
</div>
</div>
);
});
/**
* ChatViewer - Main chat interface component
*
* Provides protocol-agnostic chat UI that works across all Nostr messaging protocols.
* Uses adapter pattern to handle protocol-specific logic while providing consistent UX.
*/
export function ChatViewer({
protocol,
identifier,
customTitle,
}: ChatViewerProps) {
const { addWindow } = useGrimoire();
// Get the appropriate adapter for this protocol
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
// Resolve conversation from identifier (async operation)
const conversation = use$(
() => from(adapter.resolveConversation(identifier)),
[adapter, identifier],
);
// Load messages for this conversation (reactive)
const messages = use$(
() => (conversation ? adapter.loadMessages(conversation) : undefined),
[adapter, conversation],
);
// Track reply context (which message is being replied to)
const [replyTo, setReplyTo] = useState<string | undefined>();
// Handle sending messages
const handleSend = async (content: string, replyToId?: string) => {
if (!conversation) return;
await adapter.sendMessage(conversation, content, replyToId);
setReplyTo(undefined); // Clear reply context after sending
};
// Handle NIP badge click
const handleNipClick = useCallback(() => {
if (conversation?.protocol === "nip-29") {
addWindow("nip", { number: 29 });
}
}, [conversation?.protocol, addWindow]);
if (!conversation) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
Loading conversation...
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Header with conversation info and controls */}
<div className="px-1 border-b w-full">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-1 min-w-0 items-center gap-2">
{conversation.metadata?.icon && (
<img
src={conversation.metadata.icon}
alt={conversation.title}
className="h-4 w-4 object-cover flex-shrink-0"
/>
)}
<div className="flex-1 flex flex-row gap-2 items-baseline min-w-0">
<h2 className="truncate text-base font-semibold">
{customTitle || conversation.title}
</h2>
{conversation.metadata?.description && (
<p className="text-xs text-muted-foreground line-clamp-1">
{conversation.metadata.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
<MembersDropdown participants={conversation.participants} />
<RelaysDropdown conversation={conversation} />
{conversation.type === "group" && (
<button
onClick={handleNipClick}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
>
{conversation.protocol.toUpperCase()}
</button>
)}
</div>
</div>
</div>
{/* Message timeline with virtualization */}
<div className="flex-1 overflow-hidden">
{messages && messages.length > 0 ? (
<Virtuoso
data={messages}
initialTopMostItemIndex={messages.length - 1}
followOutput="smooth"
itemContent={(_index, message) => (
<MessageItem
key={message.id}
message={message}
adapter={adapter}
conversation={conversation}
/>
)}
style={{ height: "100%" }}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No messages yet. Start the conversation!
</div>
)}
</div>
{/* Message composer */}
<div className="">
{replyTo && (
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs">
<span>Replying to {replyTo.slice(0, 8)}...</span>
<button
onClick={() => setReplyTo(undefined)}
className="ml-auto text-muted-foreground hover:text-foreground"
>
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
const input = form.elements.namedItem(
"message",
) as HTMLTextAreaElement;
if (input.value.trim()) {
handleSend(input.value, replyTo);
input.value = "";
}
}}
className="flex gap-2"
>
<textarea
name="message"
autoFocus
placeholder="Type a message..."
className="flex-1 resize-none bg-background px-2 py-1.5 text-sm"
rows={1}
onKeyDown={(e) => {
// Submit on Enter (without Shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
/>
<Button type="submit" variant="secondary">
Send
</Button>
</form>
</div>
</div>
);
}
/**
* Get the appropriate adapter for a protocol
* TODO: Add other adapters as they're implemented
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
case "nip-c7":
return new NipC7Adapter();
case "nip-29":
return new Nip29Adapter();
// case "nip-17":
// return new Nip17Adapter();
// case "nip-28":
// return new Nip28Adapter();
// case "nip-53":
// return new Nip53Adapter();
default:
throw new Error(`Unsupported protocol: ${protocol}`);
}
}

View File

@@ -26,6 +26,10 @@ import { getTagValues } from "@/lib/nostr-utils";
import { getLiveHost } from "@/lib/live-activity";
import type { NostrEvent } from "@/types/nostr";
import { getZapSender } from "applesauce-common/helpers/zap";
import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat";
import { useState, useEffect } from "react";
export interface WindowTitleData {
title: string | ReactElement;
@@ -546,6 +550,46 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
return `Relay Pool (${connectedCount}/${relayList.length})`;
}, [appId, relays]);
// Chat viewer title - resolve conversation to get partner name
const [chatTitle, setChatTitle] = useState<string | null>(null);
useEffect(() => {
if (appId !== "chat") {
setChatTitle(null);
return;
}
const protocol = props.protocol as ChatProtocol;
const identifier = props.identifier as ProtocolIdentifier;
// Get adapter and resolve conversation
const getAdapter = () => {
switch (protocol) {
case "nip-c7":
return new NipC7Adapter();
case "nip-29":
return new Nip29Adapter();
default:
return null;
}
};
const adapter = getAdapter();
if (!adapter) {
setChatTitle("Chat");
return;
}
// Resolve conversation asynchronously
adapter
.resolveConversation(identifier)
.then((conversation) => {
setChatTitle(conversation.title);
})
.catch(() => {
setChatTitle("Chat");
});
}, [appId, props]);
// Generate final title data with icon and tooltip
return useMemo(() => {
let title: ReactElement | string;
@@ -619,6 +663,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
title = connTitle;
icon = getCommandIcon("conn");
tooltip = rawCommand;
} else if (chatTitle && appId === "chat") {
title = chatTitle;
icon = getCommandIcon("chat");
tooltip = rawCommand;
} else {
title = staticTitle || appId.toUpperCase();
tooltip = rawCommand;
@@ -642,6 +690,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
kindsTitle,
debugTitle,
connTitle,
chatTitle,
staticTitle,
]);
}

View File

@@ -27,6 +27,9 @@ const DebugViewer = lazy(() =>
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
);
const ConnViewer = lazy(() => import("./ConnViewer"));
const ChatViewer = lazy(() =>
import("./ChatViewer").then((m) => ({ default: m.ChatViewer })),
);
const SpellsViewer = lazy(() =>
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
);
@@ -169,6 +172,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "conn":
content = <ConnViewer />;
break;
case "chat":
content = (
<ChatViewer
protocol={window.props.protocol}
identifier={window.props.identifier}
customTitle={window.customTitle}
/>
);
break;
case "spells":
content = <SpellsViewer />;
break;

View File

@@ -0,0 +1,58 @@
import { Users2 } from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { UserName } from "@/components/nostr/UserName";
import { Label } from "@/components/ui/label";
import type { Participant } from "@/types/chat";
interface MembersDropdownProps {
participants: Participant[];
}
/**
* MembersDropdown - Shows member count and list with roles
* Similar to relay indicators in ReqViewer
*/
export function MembersDropdown({ participants }: MembersDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Users2 className="size-3" />
<span>{participants.length}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Members ({participants.length})
</div>
<div style={{ height: "300px" }}>
<Virtuoso
data={participants}
itemContent={(_index, participant) => (
<div
key={participant.pubkey}
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
>
<UserName
pubkey={participant.pubkey}
className="text-sm truncate flex-1 min-w-0"
/>
{participant.role && participant.role !== "member" && (
<Label size="sm" className="flex-shrink-0">
{participant.role}
</Label>
)}
</div>
)}
style={{ height: "100%" }}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,92 @@
import { Wifi } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RelayLink } from "@/components/nostr/RelayLink";
import { useRelayState } from "@/hooks/useRelayState";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import { normalizeRelayURL } from "@/lib/relay-url";
import type { Conversation } from "@/types/chat";
interface RelaysDropdownProps {
conversation: Conversation;
}
/**
* RelaysDropdown - Shows relay count and list with connection status
* Similar to relay indicators in ReqViewer
*/
export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
const { relays: relayStates } = useRelayState();
// Get relays for this conversation
const relays: string[] = [];
// NIP-29: Single group relay
if (conversation.metadata?.relayUrl) {
relays.push(conversation.metadata.relayUrl);
}
// Normalize URLs for state lookup
const normalizedRelays = relays.map((url) => {
try {
return normalizeRelayURL(url);
} catch {
return url;
}
});
// Count connected relays
const connectedCount = normalizedRelays.filter((url) => {
const state = relayStates[url];
return state?.connectionState === "connected";
}).length;
if (relays.length === 0) {
return null; // Don't show if no relays
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Wifi className="size-3" />
<span>
{connectedCount}/{relays.length}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Relays ({relays.length})
</div>
<div className="space-y-1 p-1">
{relays.map((url) => {
const normalizedUrl = normalizedRelays[relays.indexOf(url)];
const state = relayStates[normalizedUrl];
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<div
key={url}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-1 flex-shrink-0">
{connIcon.icon}
{authIcon.icon}
</div>
<RelayLink
url={url}
className="text-sm truncate flex-1 min-w-0"
/>
</div>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,59 @@
import { memo, useEffect } from "react";
import { use$ } from "applesauce-react/hooks";
import eventStore from "@/services/event-store";
import { UserName } from "../nostr/UserName";
import { RichText } from "../nostr/RichText";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
import type { Conversation } from "@/types/chat";
interface ReplyPreviewProps {
replyToId: string;
adapter: ChatProtocolAdapter;
conversation: Conversation;
}
/**
* ReplyPreview - Shows who is being replied to with truncated message content
* Automatically fetches missing events from protocol-specific relays
*/
export const ReplyPreview = memo(function ReplyPreview({
replyToId,
adapter,
conversation,
}: ReplyPreviewProps) {
// Load the event being replied to (reactive - updates when event arrives)
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
// Fetch event from relays if not in store
useEffect(() => {
if (!replyEvent) {
adapter.loadReplyMessage(conversation, replyToId).catch((err) => {
console.error(
`[ReplyPreview] Failed to load reply ${replyToId.slice(0, 8)}:`,
err,
);
});
}
}, [replyEvent, adapter, conversation, replyToId]);
if (!replyEvent) {
return (
<div className="text-xs text-muted-foreground mb-0.5">
Replying to {replyToId.slice(0, 8)}...
</div>
);
}
return (
<div className="text-xs text-muted-foreground flex items-baseline gap-1 mb-0.5 overflow-hidden">
<span className="flex-shrink-0"></span>
<UserName
pubkey={replyEvent.pubkey}
className="font-medium flex-shrink-0"
/>
<div className="line-clamp-1 overflow-hidden flex-1 min-w-0">
<RichText event={replyEvent} />
</div>
</div>
);
});

View File

@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex bg-transparent p-2 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}