diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
new file mode 100644
index 0000000..da41a97
--- /dev/null
+++ b/src/components/ChatViewer.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {message.event ? (
+
+ {message.replyTo && (
+
+ )}
+
+ ) : (
+ {message.content}
+ )}
+
+
+
+ );
+});
+
+/**
+ * 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();
+
+ // 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 (
+
+ Loading conversation...
+
+ );
+ }
+
+ return (
+
+ {/* Header with conversation info and controls */}
+
+
+
+ {conversation.metadata?.icon && (
+

+ )}
+
+
+ {customTitle || conversation.title}
+
+ {conversation.metadata?.description && (
+
+ {conversation.metadata.description}
+
+ )}
+
+
+
+
+
+ {conversation.type === "group" && (
+
+ )}
+
+
+
+
+ {/* Message timeline with virtualization */}
+
+ {messages && messages.length > 0 ? (
+
(
+
+ )}
+ style={{ height: "100%" }}
+ />
+ ) : (
+
+ No messages yet. Start the conversation!
+
+ )}
+
+
+ {/* Message composer */}
+
+ {replyTo && (
+
+ Replying to {replyTo.slice(0, 8)}...
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * 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}`);
+ }
+}
diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx
index 289fca6..db94934 100644
--- a/src/components/DynamicWindowTitle.tsx
+++ b/src/components/DynamicWindowTitle.tsx
@@ -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(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,
]);
}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index d0c535b..576d35e 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -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 = ;
break;
+ case "chat":
+ content = (
+
+ );
+ break;
case "spells":
content = ;
break;
diff --git a/src/components/chat/MembersDropdown.tsx b/src/components/chat/MembersDropdown.tsx
new file mode 100644
index 0000000..18f9ad4
--- /dev/null
+++ b/src/components/chat/MembersDropdown.tsx
@@ -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 (
+
+
+
+
+
+
+ Members ({participants.length})
+
+
+
(
+
+
+ {participant.role && participant.role !== "member" && (
+
+ )}
+
+ )}
+ style={{ height: "100%" }}
+ />
+
+
+
+ );
+}
diff --git a/src/components/chat/RelaysDropdown.tsx b/src/components/chat/RelaysDropdown.tsx
new file mode 100644
index 0000000..d83be45
--- /dev/null
+++ b/src/components/chat/RelaysDropdown.tsx
@@ -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 (
+
+
+
+
+
+
+ Relays ({relays.length})
+
+
+ {relays.map((url) => {
+ const normalizedUrl = normalizedRelays[relays.indexOf(url)];
+ const state = relayStates[normalizedUrl];
+ const connIcon = getConnectionIcon(state);
+ const authIcon = getAuthIcon(state);
+
+ return (
+
+
+ {connIcon.icon}
+ {authIcon.icon}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/chat/ReplyPreview.tsx b/src/components/chat/ReplyPreview.tsx
new file mode 100644
index 0000000..2e25001
--- /dev/null
+++ b/src/components/chat/ReplyPreview.tsx
@@ -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 (
+
+ ↳ Replying to {replyToId.slice(0, 8)}...
+
+ );
+ }
+
+ return (
+
+ );
+});
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
index 4d60fc3..592d73d 100644
--- a/src/components/ui/textarea.tsx
+++ b/src/components/ui/textarea.tsx
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (