mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
feat: add NIP-17 conversation list view with 'chat nip-17' command
Add ability to view all cached encrypted DM conversations by running
'chat nip-17' without a specific recipient.
Changes:
- Add ConversationListIdentifier type to chat types
- Update chat parser to detect 'chat nip-17' as conversation list mode
- Create ConversationList component that queries decryptedRumors DB
- Show conversations sorted by most recent message with relative timestamps
- Click a conversation to open the actual chat window
- Handle self-chat ("Notes to Self") display
- Update tests with type guards for new identifier type
This leverages the previously implemented decrypted rumors cache to show
conversations without re-decrypting gift wraps.
This commit is contained in:
@@ -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 <ConversationList onSelectConversation={handleSelectConversation} />;
|
||||
}
|
||||
|
||||
// Get active account
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
const hasActiveAccount = !!activeAccount;
|
||||
|
||||
262
src/components/chat/ConversationList.tsx
Normal file
262
src/components/chat/ConversationList.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors border-b border-border last:border-b-0 text-left"
|
||||
>
|
||||
<Avatar className="size-10 flex-shrink-0">
|
||||
<AvatarImage src={profile?.picture} alt={displayName} />
|
||||
<AvatarFallback>
|
||||
{isSelfChat ? (
|
||||
<User className="size-5" />
|
||||
) : (
|
||||
displayName.slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">
|
||||
{isSelfChat ? "Notes to Self" : displayName}
|
||||
</span>
|
||||
<Lock className="size-3 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{summary.lastMessage.content.slice(0, 100)}
|
||||
{summary.lastMessage.content.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(summary.lastMessage.createdAt)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{summary.messageCount} message{summary.messageCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationListProps {
|
||||
onSelectConversation: (pubkey: string) => void;
|
||||
}
|
||||
|
||||
export function ConversationList({
|
||||
onSelectConversation,
|
||||
}: ConversationListProps) {
|
||||
const account = use$(accountManager.active$);
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string, DecryptedRumor[]>();
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<Lock className="size-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Login Required</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in to view your encrypted DM conversations.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<RefreshCw className="size-8 text-muted-foreground animate-spin mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading conversations...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-sm text-destructive mb-4">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={loadConversations}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<MessageSquare className="size-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Conversations Yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Start a new encrypted DM by running:
|
||||
</p>
|
||||
<code className="px-3 py-2 bg-muted rounded text-sm font-mono">
|
||||
chat npub1... or chat user@domain.com
|
||||
</code>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Conversations are cached locally after decryption.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
<h2 className="font-medium">Encrypted DMs</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({conversations.length})
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={loadConversations}>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversations.map((summary) => (
|
||||
<ConversationItem
|
||||
key={summary.conversationId}
|
||||
summary={summary}
|
||||
onClick={() => handleSelectConversation(summary.partnerPubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="p-2 border-t border-border text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click a conversation to open it, or use{" "}
|
||||
<code className="px-1 py-0.5 bg-muted rounded">chat npub...</code> for
|
||||
new chats
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 <identifier>");
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user