feat: Add Saved Messages ($me) support for NIP-17 gift wrapped DMs

- Add dm-self identifier type for self-conversations
- Add isSavedMessages flag to ConversationMetadata
- Update NIP-17 adapter to handle self-conversations:
  - parseIdentifier recognizes $me alias
  - resolveConversation handles dm-self type
  - loadMessages filters for self-conversations correctly
  - sendMessage works for sending DMs to yourself
  - getConversations$ includes and prioritizes saved messages
- Update InboxViewer to display Saved Messages:
  - Special bookmark icon for saved messages avatar
  - "Saved Messages" title instead of username
  - Sorted to top of conversation list
This commit is contained in:
Claude
2026-01-14 12:44:55 +00:00
parent a4df6d3b05
commit 52541dc6d4
4 changed files with 224 additions and 82 deletions

View File

@@ -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 (
<Avatar className={className}>
<AvatarFallback className="bg-primary/20 text-primary">
<Bookmark className="size-4" />
</AvatarFallback>
</Avatar>
);
});
/**
* ConversationListItem - Single conversation in the list
*/
@@ -106,6 +125,8 @@ const ConversationListItem = memo(function ConversationListItem({
isSelected: boolean;
onClick: () => void;
}) {
const isSaved = conversation.isSavedMessages;
return (
<div
className={cn(
@@ -114,28 +135,38 @@ const ConversationListItem = memo(function ConversationListItem({
)}
onClick={onClick}
>
<UserAvatar pubkey={conversation.partnerPubkey} className="size-9" />
{isSaved ? (
<SavedMessagesAvatar className="size-9" />
) : (
<UserAvatar pubkey={conversation.partnerPubkey} className="size-9" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<UserName
pubkey={conversation.partnerPubkey}
className="text-sm font-medium truncate"
/>
{isSaved ? (
<span className="text-sm font-medium truncate">Saved Messages</span>
) : (
<UserName
pubkey={conversation.partnerPubkey}
className="text-sm font-medium truncate"
/>
)}
{conversation.lastMessage && (
<span className="text-xs text-muted-foreground flex-shrink-0">
<Timestamp timestamp={conversation.lastMessage.timestamp} />
</span>
)}
</div>
{/* Inbox relays */}
{conversation.inboxRelays && conversation.inboxRelays.length > 0 && (
<div className="text-[10px] text-muted-foreground/60 truncate">
{conversation.inboxRelays.map(formatRelayForDisplay).join(", ")}
</div>
)}
{/* Inbox relays - don't show for saved messages */}
{!isSaved &&
conversation.inboxRelays &&
conversation.inboxRelays.length > 0 && (
<div className="text-[10px] text-muted-foreground/60 truncate">
{conversation.inboxRelays.map(formatRelayForDisplay).join(", ")}
</div>
)}
{conversation.lastMessage && (
<div className="text-xs text-muted-foreground truncate">
{conversation.lastMessage.isOwn && (
{conversation.lastMessage.isOwn && !isSaved && (
<span className="text-muted-foreground/70">You: </span>
)}
{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,

View File

@@ -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)`,
);
}

View File

@@ -69,9 +69,17 @@ export class Nip17Adapter extends ChatProtocolAdapter {
private giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
/**
* 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<Conversation> {
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<string> {
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;
}),

View File

@@ -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;