diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index e818a38..96f4b6a 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -19,6 +19,7 @@ import { Mail, AlertCircle, PanelLeft, + Bookmark, } from "lucide-react"; import accountManager from "@/services/accounts"; import { ChatViewer } from "./ChatViewer"; @@ -86,6 +87,7 @@ function formatRelayForDisplay(url: string): string { interface ConversationInfo { id: string; partnerPubkey: string; + isSavedMessages?: boolean; inboxRelays?: string[]; lastMessage?: { content: string; @@ -94,6 +96,23 @@ interface ConversationInfo { }; } +/** + * SavedMessagesAvatar - Special avatar for saved messages + */ +const SavedMessagesAvatar = memo(function SavedMessagesAvatar({ + className, +}: { + className?: string; +}) { + return ( + + + + + + ); +}); + /** * ConversationListItem - Single conversation in the list */ @@ -106,6 +125,8 @@ const ConversationListItem = memo(function ConversationListItem({ isSelected: boolean; onClick: () => void; }) { + const isSaved = conversation.isSavedMessages; + return (
- + {isSaved ? ( + + ) : ( + + )}
- + {isSaved ? ( + Saved Messages + ) : ( + + )} {conversation.lastMessage && ( )}
- {/* Inbox relays */} - {conversation.inboxRelays && conversation.inboxRelays.length > 0 && ( -
- {conversation.inboxRelays.map(formatRelayForDisplay).join(", ")} -
- )} + {/* Inbox relays - don't show for saved messages */} + {!isSaved && + conversation.inboxRelays && + conversation.inboxRelays.length > 0 && ( +
+ {conversation.inboxRelays.map(formatRelayForDisplay).join(", ")} +
+ )} {conversation.lastMessage && (
- {conversation.lastMessage.isOwn && ( + {conversation.lastMessage.isOwn && !isSaved && ( You: )} {conversation.lastMessage.content} @@ -282,12 +313,22 @@ export function InboxViewer() { if (!conversations || !activePubkey) return []; return conversations.map((conv): ConversationInfo => { - const partner = conv.participants.find((p) => p.pubkey !== activePubkey); - const partnerPubkey = partner?.pubkey || ""; + // Check if this is a saved messages conversation + const isSavedMessages = conv.metadata?.isSavedMessages === true; + + // For saved messages, partner is self; otherwise find the other participant + const partner = isSavedMessages + ? { pubkey: activePubkey } + : conv.participants.find((p) => p.pubkey !== activePubkey); + const partnerPubkey = partner?.pubkey || activePubkey; + return { id: conv.id, partnerPubkey, - inboxRelays: partnerRelays.get(partnerPubkey), + isSavedMessages, + inboxRelays: isSavedMessages + ? undefined + : partnerRelays.get(partnerPubkey), lastMessage: conv.lastMessage ? { content: conv.lastMessage.content, diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 8a0778f..1e32570 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -1,10 +1,10 @@ import type { ChatCommandResult, GroupListIdentifier } 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"; import { Nip53Adapter } from "./chat/adapters/nip-53-adapter"; import { nip19 } from "nostr-tools"; // Import other adapters as they're implemented -// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter"; // import { Nip28Adapter } from "./chat/adapters/nip-28-adapter"; /** @@ -62,11 +62,11 @@ export function parseChatCommand(args: string[]): ChatCommandResult { // Try each adapter in priority order const adapters = [ - // new Nip17Adapter(), // Phase 2 - // new Nip28Adapter(), // Phase 3 - new Nip29Adapter(), // Phase 4 - Relay groups - new Nip53Adapter(), // Phase 5 - Live activity chat - // new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now) + new Nip17Adapter(), // NIP-17 - Private DMs (gift wrapped) + // new Nip28Adapter(), // NIP-28 - Public channels (coming soon) + new Nip29Adapter(), // NIP-29 - Relay groups + new Nip53Adapter(), // NIP-53 - Live activity chat + // new NipC7Adapter(), // NIP-C7 - Simple chat (disabled for now) ]; for (const adapter of adapters) { @@ -84,22 +84,20 @@ export function parseChatCommand(args: string[]): ChatCommandResult { `Unable to determine chat protocol from identifier: ${identifier} Currently supported formats: + - npub/nprofile/hex pubkey/NIP-05/$me (NIP-17 private DMs) + Examples: + chat npub1... + chat alice@example.com + chat $me (saved messages) - relay.com'group-id (NIP-29 relay group, wss:// prefix optional) Examples: chat relay.example.com'bitcoin-dev chat wss://relay.example.com'nostr-dev - naddr1... (NIP-29 group metadata, kind 39000) - Example: - chat naddr1qqxnzdesxqmnxvpexqmny... - naddr1... (NIP-53 live activity chat, kind 30311) - Example: - chat naddr1... (live stream address) - naddr1... (Multi-room group list, kind 10009) - Example: - chat naddr1... (group list address) More formats coming soon: - - npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages) - note/nevent (NIP-28 public channels)`, ); } diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index f4aaed1..187350d 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -69,9 +69,17 @@ export class Nip17Adapter extends ChatProtocolAdapter { private giftWraps$ = new BehaviorSubject([]); /** - * Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05 + * Parse identifier - accepts npub, nprofile, hex pubkey, NIP-05, or $me */ parseIdentifier(input: string): ProtocolIdentifier | null { + // Handle $me alias for saved messages (DMs to yourself) + if (input.toLowerCase() === "$me") { + return { + type: "dm-self", + value: "$me", + }; + } + // Try bech32 decoding (npub/nprofile) try { const decoded = nip19.decode(input); @@ -117,10 +125,18 @@ export class Nip17Adapter extends ChatProtocolAdapter { async resolveConversation( identifier: ProtocolIdentifier, ): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + throw new Error("No active account"); + } + let partnerPubkey: string; - // Resolve NIP-05 if needed - if (identifier.type === "chat-partner-nip05") { + // Handle $me (saved messages - DMs to yourself) + if (identifier.type === "dm-self") { + partnerPubkey = activePubkey; + } else if (identifier.type === "chat-partner-nip05") { + // Resolve NIP-05 const resolved = await resolveNip05(identifier.value); if (!resolved) { throw new Error(`Failed to resolve NIP-05: ${identifier.value}`); @@ -137,20 +153,17 @@ export class Nip17Adapter extends ChatProtocolAdapter { ); } - const activePubkey = accountManager.active$.value?.pubkey; - if (!activePubkey) { - throw new Error("No active account"); - } - - // Get display name for partner - const metadataEvent = await this.getMetadata(partnerPubkey); - const metadata = metadataEvent - ? getProfileContent(metadataEvent) - : undefined; - const title = getDisplayName(partnerPubkey, metadata); + // Check if this is a self-conversation (saved messages) + const isSelf = partnerPubkey === activePubkey; + const title = isSelf + ? "Saved Messages" + : await this.getPartnerTitle(partnerPubkey); // Create conversation ID from sorted participants (deterministic) - const participants = [activePubkey, partnerPubkey].sort(); + // For self-conversations, it's just one participant listed twice + const participants = isSelf + ? [activePubkey] + : [activePubkey, partnerPubkey].sort(); const conversationId = `nip-17:${participants.join(",")}`; return { @@ -158,18 +171,32 @@ export class Nip17Adapter extends ChatProtocolAdapter { type: "dm", protocol: "nip-17", title, - participants: [ - { pubkey: activePubkey, role: "member" }, - { pubkey: partnerPubkey, role: "member" }, - ], + participants: isSelf + ? [{ pubkey: activePubkey, role: "member" }] + : [ + { pubkey: activePubkey, role: "member" }, + { pubkey: partnerPubkey, role: "member" }, + ], metadata: { encrypted: true, giftWrapped: true, + isSavedMessages: isSelf, }, unreadCount: 0, }; } + /** + * Get display name for a partner pubkey + */ + private async getPartnerTitle(pubkey: string): Promise { + const metadataEvent = await this.getMetadata(pubkey); + const metadata = metadataEvent + ? getProfileContent(metadataEvent) + : undefined; + return getDisplayName(pubkey, metadata); + } + /** * Load messages for a conversation * Returns decrypted rumors that match this conversation @@ -183,16 +210,27 @@ export class Nip17Adapter extends ChatProtocolAdapter { throw new Error("No active account"); } - // Get partner pubkey - const partner = conversation.participants.find( - (p) => p.pubkey !== activePubkey, - ); - if (!partner) { + // Check if this is a self-conversation (saved messages) + const isSelfConversation = + conversation.metadata?.isSavedMessages || + (conversation.participants.length === 1 && + conversation.participants[0].pubkey === activePubkey); + + // Get partner pubkey (for self-conversation, partner is self) + const partnerPubkey = isSelfConversation + ? activePubkey + : conversation.participants.find((p) => p.pubkey !== activePubkey) + ?.pubkey; + + if (!partnerPubkey) { throw new Error("No conversation partner found"); } // Expected participants for this conversation - const expectedParticipants = [activePubkey, partner.pubkey].sort(); + // For self-conversations, both sender and recipient are the same + const expectedParticipants = isSelfConversation + ? [activePubkey] + : [activePubkey, partnerPubkey].sort(); // Subscribe to gift wraps for this user this.subscribeToGiftWraps(activePubkey); @@ -213,14 +251,27 @@ export class Nip17Adapter extends ChatProtocolAdapter { if (rumor.kind !== DM_RUMOR_KIND) continue; // Get participants from rumor - const rumorParticipants = getConversationParticipants(rumor).sort(); + const rumorParticipants = getConversationParticipants(rumor); - // Check if participants match this conversation - if ( - rumorParticipants.length !== expectedParticipants.length || - !rumorParticipants.every((p, i) => p === expectedParticipants[i]) - ) { - continue; + // For self-conversations, all participants should be the same (sender == recipient) + if (isSelfConversation) { + // Check if all participants are the same as activePubkey + const allSelf = rumorParticipants.every( + (p) => p === activePubkey, + ); + if (!allSelf) continue; + } else { + // Check if participants match this conversation + const sortedRumorParticipants = rumorParticipants.sort(); + if ( + sortedRumorParticipants.length !== + expectedParticipants.length || + !sortedRumorParticipants.every( + (p, i) => p === expectedParticipants[i], + ) + ) { + continue; + } } messages.push(this.rumorToMessage(rumor, conversation.id)); @@ -273,22 +324,31 @@ export class Nip17Adapter extends ChatProtocolAdapter { throw new Error("No active account or signer"); } - const partner = conversation.participants.find( - (p) => p.pubkey !== activePubkey, - ); - if (!partner) { - throw new Error("No conversation partner found"); + // Check if this is a self-conversation (saved messages) + const isSelfConversation = + conversation.metadata?.isSavedMessages || + (conversation.participants.length === 1 && + conversation.participants[0].pubkey === activePubkey); + + // Get recipient pubkey (for self-conversation, it's ourselves) + const recipientPubkey = isSelfConversation + ? activePubkey + : conversation.participants.find((p) => p.pubkey !== activePubkey) + ?.pubkey; + + if (!recipientPubkey) { + throw new Error("No conversation recipient found"); } // Use applesauce's SendWrappedMessage action // This handles: // - Creating the wrapped message rumor - // - Gift wrapping for all participants (partner + self) + // - Gift wrapping for all participants (recipient + self) // - Publishing to each participant's inbox relays - await hub.run(SendWrappedMessage, partner.pubkey, content); + await hub.run(SendWrappedMessage, recipientPubkey, content); console.log( - `[NIP-17] Sent wrapped message to ${partner.pubkey.slice(0, 8)}...`, + `[NIP-17] Sent wrapped message to ${recipientPubkey.slice(0, 8)}...${isSelfConversation ? " (saved)" : ""}`, ); } @@ -455,29 +515,59 @@ export class Nip17Adapter extends ChatProtocolAdapter { const conversations: Conversation[] = []; for (const [convId, { participants, lastRumor }] of conversationMap) { - const partner = participants.find((p) => p !== activePubkey); - if (!partner) continue; + // Check if this is a self-conversation (all participants are activePubkey) + const isSelfConversation = participants.every( + (p) => p === activePubkey, + ); + + // Get partner pubkey (for self-conversation, use self) + const partnerPubkey = isSelfConversation + ? activePubkey + : participants.find((p) => p !== activePubkey); + + // Skip if we can't determine partner (shouldn't happen) + if (!partnerPubkey) continue; + + // Create unique participant list for conversation ID + const uniqueParticipants = isSelfConversation + ? [activePubkey] + : participants.sort(); conversations.push({ - id: `nip-17:${participants.sort().join(",")}`, + id: `nip-17:${uniqueParticipants.join(",")}`, type: "dm", protocol: "nip-17", - title: partner.slice(0, 8) + "...", // Will be replaced with display name - participants: participants.map((p) => ({ - pubkey: p, - role: "member" as const, - })), - metadata: { encrypted: true, giftWrapped: true }, + title: isSelfConversation + ? "Saved Messages" + : partnerPubkey.slice(0, 8) + "...", // Will be replaced with display name + participants: isSelfConversation + ? [{ pubkey: activePubkey, role: "member" as const }] + : participants.map((p) => ({ + pubkey: p, + role: "member" as const, + })), + metadata: { + encrypted: true, + giftWrapped: true, + isSavedMessages: isSelfConversation, + }, lastMessage: this.rumorToMessage(lastRumor, convId), unreadCount: 0, }); } - // Sort by last message timestamp - conversations.sort( - (a, b) => - (b.lastMessage?.timestamp || 0) - (a.lastMessage?.timestamp || 0), - ); + // Sort: Saved Messages at top, then by last message timestamp + conversations.sort((a, b) => { + // Saved Messages always first + if (a.metadata?.isSavedMessages && !b.metadata?.isSavedMessages) + return -1; + if (!a.metadata?.isSavedMessages && b.metadata?.isSavedMessages) + return 1; + // Then by timestamp + return ( + (b.lastMessage?.timestamp || 0) - (a.lastMessage?.timestamp || 0) + ); + }); return conversations; }), diff --git a/src/types/chat.ts b/src/types/chat.ts index 51b7b45..0127637 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -64,6 +64,7 @@ export interface ConversationMetadata { // NIP-17 DM encrypted?: boolean; giftWrapped?: boolean; + isSavedMessages?: boolean; // True if this is a self-conversation (DMs to yourself) } /** @@ -156,6 +157,17 @@ export interface DMIdentifier { relays?: string[]; } +/** + * Self DM identifier (saved messages - DMs to yourself) + */ +export interface DMSelfIdentifier { + type: "dm-self"; + /** Placeholder value ($me resolved at runtime) */ + value: string; + /** Relay hints (unused for self-DMs) */ + relays?: string[]; +} + /** * NIP-C7 NIP-05 identifier (needs resolution) */ @@ -202,6 +214,7 @@ export type ProtocolIdentifier = | GroupIdentifier | LiveActivityIdentifier | DMIdentifier + | DMSelfIdentifier | NIP05Identifier | ChannelIdentifier | GroupListIdentifier;