-
+ {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;