diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 2f12591..94e043e 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -25,6 +25,7 @@ import Timestamp from "./Timestamp"; import { ReplyPreview } from "./chat/ReplyPreview"; import { MembersDropdown } from "./chat/MembersDropdown"; import { RelaysDropdown } from "./chat/RelaysDropdown"; +import { ConversationList } from "./chat/ConversationList"; import { StatusBadge } from "./live/StatusBadge"; import { useGrimoire } from "@/core/state"; import { Button } from "./ui/button"; @@ -319,6 +320,23 @@ export function ChatViewer({ }: ChatViewerProps) { const { addWindow } = useGrimoire(); + // Handle conversation list mode + const handleSelectConversation = useCallback( + (pubkey: string) => { + // Open a new chat window with the selected conversation + addWindow("chat", { + protocol: "nip-17", + identifier: { type: "chat-partner", value: pubkey }, + }); + }, + [addWindow], + ); + + // Show conversation list if identifier is conversation-list type + if (identifier.type === "conversation-list") { + return ; + } + // Get active account const activeAccount = use$(accountManager.active$); const hasActiveAccount = !!activeAccount; diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx new file mode 100644 index 0000000..1247f44 --- /dev/null +++ b/src/components/chat/ConversationList.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState, useCallback } from "react"; +import { MessageSquare, Lock, RefreshCw, User } from "lucide-react"; +import db, { type DecryptedRumor } from "@/services/db"; +import { useProfile } from "@/hooks/useProfile"; +import { getDisplayName } from "@/lib/nostr-utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import accountManager from "@/services/accounts"; +import { use$ } from "applesauce-react/hooks"; + +/** + * Format a timestamp as relative time (e.g., "2h ago", "3d ago") + */ +function formatRelativeTime(timestamp: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (diff < 60) return "now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h`; + if (diff < 604800) return `${Math.floor(diff / 86400)}d`; + if (diff < 2592000) return `${Math.floor(diff / 604800)}w`; + return `${Math.floor(diff / 2592000)}mo`; +} + +interface ConversationSummary { + conversationId: string; + partnerPubkey: string; + lastMessage: DecryptedRumor; + messageCount: number; +} + +interface ConversationItemProps { + summary: ConversationSummary; + onClick: () => void; +} + +function ConversationItem({ summary, onClick }: ConversationItemProps) { + const profile = useProfile(summary.partnerPubkey); + const displayName = getDisplayName(summary.partnerPubkey, profile); + const isSelfChat = + summary.partnerPubkey === accountManager.active$.value?.pubkey; + + return ( + + ); +} + +interface ConversationListProps { + onSelectConversation: (pubkey: string) => void; +} + +export function ConversationList({ + onSelectConversation, +}: ConversationListProps) { + const account = use$(accountManager.active$); + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadConversations = useCallback(async () => { + if (!account?.pubkey) { + setConversations([]); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + // Get all kind 14 (DM) rumors from DB + const allRumors = await db.decryptedRumors + .where("kind") + .equals(14) + .toArray(); + + // Group by conversation ID + const conversationMap = new Map(); + + for (const rumor of allRumors) { + if (!rumor.conversationId) continue; + + const existing = conversationMap.get(rumor.conversationId); + if (existing) { + existing.push(rumor); + } else { + conversationMap.set(rumor.conversationId, [rumor]); + } + } + + // Build summaries + const summaries: ConversationSummary[] = []; + + for (const [conversationId, rumors] of conversationMap) { + // Sort by timestamp to get latest + rumors.sort((a, b) => b.createdAt - a.createdAt); + const lastMessage = rumors[0]; + + // Extract partner pubkey from conversation ID (nip-17:pubkey) + const partnerPubkey = conversationId.replace("nip-17:", ""); + + summaries.push({ + conversationId, + partnerPubkey, + lastMessage, + messageCount: rumors.length, + }); + } + + // Sort by most recent message + summaries.sort( + (a, b) => b.lastMessage.createdAt - a.lastMessage.createdAt, + ); + + setConversations(summaries); + } catch (err) { + console.error("[ConversationList] Failed to load conversations:", err); + setError("Failed to load conversations"); + } finally { + setLoading(false); + } + }, [account?.pubkey]); + + useEffect(() => { + loadConversations(); + }, [loadConversations]); + + const handleSelectConversation = useCallback( + (pubkey: string) => { + onSelectConversation(pubkey); + }, + [onSelectConversation], + ); + + if (!account) { + return ( +
+ +

Login Required

+

+ Log in to view your encrypted DM conversations. +

+
+ ); + } + + if (loading) { + return ( +
+ +

+ Loading conversations... +

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (conversations.length === 0) { + return ( +
+ +

No Conversations Yet

+

+ Start a new encrypted DM by running: +

+ + chat npub1... or chat user@domain.com + +

+ Conversations are cached locally after decryption. +

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Encrypted DMs

+ + ({conversations.length}) + +
+ +
+ + {/* Conversation list */} +
+ {conversations.map((summary) => ( + handleSelectConversation(summary.partnerPubkey)} + /> + ))} +
+ + {/* Footer hint */} +
+

+ Click a conversation to open it, or use{" "} + chat npub... for + new chats +

+
+
+ ); +} diff --git a/src/lib/chat-parser.test.ts b/src/lib/chat-parser.test.ts index d0f430c..7940042 100644 --- a/src/lib/chat-parser.test.ts +++ b/src/lib/chat-parser.test.ts @@ -55,24 +55,33 @@ describe("parseChatCommand", () => { const result = parseChatCommand(["relay.example.com'bitcoin-dev"]); expect(result.protocol).toBe("nip-29"); - expect(result.identifier.value).toBe("bitcoin-dev"); - expect(result.identifier.relays).toEqual(["wss://relay.example.com"]); + expect(result.identifier.type).toBe("group"); + if (result.identifier.type === "group") { + expect(result.identifier.value).toBe("bitcoin-dev"); + expect(result.identifier.relays).toEqual(["wss://relay.example.com"]); + } }); it("should parse NIP-29 group with different relay when split", () => { const result = parseChatCommand(["relay.example.com", "bitcoin-dev"]); expect(result.protocol).toBe("nip-29"); - expect(result.identifier.value).toBe("bitcoin-dev"); - expect(result.identifier.relays).toEqual(["wss://relay.example.com"]); + expect(result.identifier.type).toBe("group"); + if (result.identifier.type === "group") { + expect(result.identifier.value).toBe("bitcoin-dev"); + expect(result.identifier.relays).toEqual(["wss://relay.example.com"]); + } }); it("should parse NIP-29 group from nos.lol", () => { const result = parseChatCommand(["nos.lol'welcome"]); expect(result.protocol).toBe("nip-29"); - expect(result.identifier.value).toBe("welcome"); - expect(result.identifier.relays).toEqual(["wss://nos.lol"]); + expect(result.identifier.type).toBe("group"); + if (result.identifier.type === "group") { + expect(result.identifier.value).toBe("welcome"); + expect(result.identifier.relays).toEqual(["wss://nos.lol"]); + } }); }); @@ -146,16 +155,19 @@ describe("parseChatCommand", () => { const result = parseChatCommand([naddr]); expect(result.protocol).toBe("nip-53"); - expect(result.identifier.value).toEqual({ - kind: 30311, - pubkey: - "0000000000000000000000000000000000000000000000000000000000000001", - identifier: "podcast-episode-42", - }); - expect(result.identifier.relays).toEqual([ - "wss://relay1.example.com", - "wss://relay2.example.com", - ]); + expect(result.identifier.type).toBe("live-activity"); + if (result.identifier.type === "live-activity") { + expect(result.identifier.value).toEqual({ + kind: 30311, + pubkey: + "0000000000000000000000000000000000000000000000000000000000000001", + identifier: "podcast-episode-42", + }); + expect(result.identifier.relays).toEqual([ + "wss://relay1.example.com", + "wss://relay2.example.com", + ]); + } }); it("should not parse NIP-29 group naddr as NIP-53", () => { diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index dca046d..f3a9428 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -1,4 +1,4 @@ -import type { ChatCommandResult } from "@/types/chat"; +import type { ChatCommandResult, ChatProtocol } from "@/types/chat"; // import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter"; import { Nip17Adapter } from "./chat/adapters/nip-17-adapter"; import { Nip29Adapter } from "./chat/adapters/nip-29-adapter"; @@ -6,6 +6,11 @@ import { Nip53Adapter } from "./chat/adapters/nip-53-adapter"; // Import other adapters as they're implemented // import { Nip28Adapter } from "./chat/adapters/nip-28-adapter"; +/** + * Protocols that support conversation list view + */ +const LISTABLE_PROTOCOLS: ChatProtocol[] = ["nip-17"]; + /** * Parse a chat command identifier and auto-detect the protocol * @@ -16,6 +21,8 @@ import { Nip53Adapter } from "./chat/adapters/nip-53-adapter"; * 4. NIP-53 (live chat) - specific addressable format (kind 30311) * 5. NIP-C7 (simple chat) - fallback for generic pubkeys * + * Special case: `chat nip-17` shows conversation list + * * @param args - Command arguments (first arg is the identifier) * @returns Parsed result with protocol and identifier * @throws Error if no adapter can parse the identifier @@ -25,6 +32,19 @@ export function parseChatCommand(args: string[]): ChatCommandResult { throw new Error("Chat identifier required. Usage: chat "); } + // Check for conversation list mode: `chat nip-17` + const protocolArg = args[0].toLowerCase(); + if (LISTABLE_PROTOCOLS.includes(protocolArg as ChatProtocol)) { + return { + protocol: protocolArg as ChatProtocol, + identifier: { + type: "conversation-list", + protocol: protocolArg as ChatProtocol, + }, + adapter: protocolArg === "nip-17" ? new Nip17Adapter() : null, + }; + } + // Handle NIP-29 format that may be split by shell-quote // If we have 2 args and they look like relay + group-id, join them with ' let identifier = args[0]; @@ -58,6 +78,7 @@ export function parseChatCommand(args: string[]): ChatCommandResult { `Unable to determine chat protocol from identifier: ${identifier} Supported formats: + - nip-17 (show all encrypted DM conversations) - npub1... / nprofile1... (NIP-17 encrypted DM) - hex pubkey (NIP-17 encrypted DM) - user@domain.com (NIP-05 → NIP-17 encrypted DM) @@ -66,6 +87,7 @@ Supported formats: - naddr1... kind 30311 (NIP-53 live activity chat) Examples: + chat nip-17 # List all DM conversations chat npub1abc123... # DM with user chat alice@example.com # DM via NIP-05 chat relay.example.com'bitcoin # Join relay group diff --git a/src/lib/chat/adapters/nip-29-adapter.test.ts b/src/lib/chat/adapters/nip-29-adapter.test.ts index 335246d..f545f99 100644 --- a/src/lib/chat/adapters/nip-29-adapter.test.ts +++ b/src/lib/chat/adapters/nip-29-adapter.test.ts @@ -35,16 +35,25 @@ describe("Nip29Adapter", () => { it("should parse various group-id formats", () => { const result1 = adapter.parseIdentifier("relay.example.com'bitcoin-dev"); - expect(result1?.value).toBe("bitcoin-dev"); - expect(result1?.relays).toEqual(["wss://relay.example.com"]); + expect(result1?.type).toBe("group"); + if (result1?.type === "group") { + expect(result1.value).toBe("bitcoin-dev"); + expect(result1.relays).toEqual(["wss://relay.example.com"]); + } const result2 = adapter.parseIdentifier("nos.lol'welcome"); - expect(result2?.value).toBe("welcome"); - expect(result2?.relays).toEqual(["wss://nos.lol"]); + expect(result2?.type).toBe("group"); + if (result2?.type === "group") { + expect(result2.value).toBe("welcome"); + expect(result2.relays).toEqual(["wss://nos.lol"]); + } const result3 = adapter.parseIdentifier("relay.test.com'my_group_123"); - expect(result3?.value).toBe("my_group_123"); - expect(result3?.relays).toEqual(["wss://relay.test.com"]); + expect(result3?.type).toBe("group"); + if (result3?.type === "group") { + expect(result3.value).toBe("my_group_123"); + expect(result3.relays).toEqual(["wss://relay.test.com"]); + } }); it("should handle relay URLs with ports", () => { diff --git a/src/types/chat.ts b/src/types/chat.ts index 15f67c0..1defbe2 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -179,6 +179,15 @@ export interface ChannelIdentifier { relays?: string[]; } +/** + * Conversation list identifier - shows all conversations for a protocol + */ +export interface ConversationListIdentifier { + type: "conversation-list"; + /** Protocol to show conversations for */ + protocol: ChatProtocol; +} + /** * Protocol-specific identifier - discriminated union * Returned by adapter parseIdentifier() @@ -188,7 +197,8 @@ export type ProtocolIdentifier = | LiveActivityIdentifier | DMIdentifier | NIP05Identifier - | ChannelIdentifier; + | ChannelIdentifier + | ConversationListIdentifier; /** * Chat command parsing result