mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user