feat: Add NIP-17/59 gift-wrapped DM support with caching

Implements NIP-17 private direct messages with NIP-59 gift wraps:

Features:
- InboxViewer with mobile-friendly sidebar (matches GroupListViewer pattern)
- Shows partner's inbox relays (kind 10050) for each conversation
- Decrypt button for pending gift wraps (explicit decrypt, no auto)
- Send using applesauce SendWrappedMessage action
- Caches decrypted content in Dexie (decrypt once, cache forever)

Architecture:
- encryptedContentStorage implements applesauce's EncryptedContentCache
- Uses event.id as key, stores content strings (not parsed objects)
- persistEncryptedContent handles both gift wraps and seals
- Nip17Adapter uses applesauce helpers: isGiftWrapUnlocked, getGiftWrapRumor

Relay discovery:
- Fetches user's inbox relays from kind 10050
- Searches outbox relays (kind 10002) or aggregators for 10050
- Caches relay lists to avoid repeated fetches
This commit is contained in:
Claude
2026-01-14 12:32:38 +00:00
parent c81fdb01c0
commit a4df6d3b05
2 changed files with 81 additions and 9 deletions

View File

@@ -73,12 +73,20 @@ function useIsMobile() {
return isMobile;
}
/**
* Format relay URL for display
*/
function formatRelayForDisplay(url: string): string {
return url.replace(/^wss?:\/\//, "").replace(/\/$/, "");
}
/**
* Conversation info for display
*/
interface ConversationInfo {
id: string;
partnerPubkey: string;
inboxRelays?: string[];
lastMessage?: {
content: string;
timestamp: number;
@@ -101,12 +109,12 @@ const ConversationListItem = memo(function ConversationListItem({
return (
<div
className={cn(
"flex items-center gap-3 px-3 py-2 cursor-crosshair hover:bg-muted/50 transition-colors border-b",
"flex items-center gap-2 px-2 py-1.5 cursor-crosshair hover:bg-muted/50 transition-colors border-b",
isSelected && "bg-muted/70",
)}
onClick={onClick}
>
<UserAvatar pubkey={conversation.partnerPubkey} className="size-10" />
<UserAvatar pubkey={conversation.partnerPubkey} className="size-9" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<UserName
@@ -119,6 +127,12 @@ const ConversationListItem = memo(function ConversationListItem({
</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>
)}
{conversation.lastMessage && (
<div className="text-xs text-muted-foreground truncate">
{conversation.lastMessage.isOwn && (
@@ -209,7 +223,7 @@ export function InboxViewer() {
// State
const [selectedPartner, setSelectedPartner] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(300);
const [sidebarWidth, setSidebarWidth] = useState(280);
const [isResizing, setIsResizing] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
@@ -225,15 +239,55 @@ export function InboxViewer() {
[adapter, activePubkey],
);
// Track inbox relays for each partner
const [partnerRelays, setPartnerRelays] = useState<Map<string, string[]>>(
new Map(),
);
// Fetch inbox relays for conversation partners
useEffect(() => {
if (!conversations) return;
const fetchRelays = async () => {
const newRelays = new Map<string, string[]>();
for (const conv of conversations) {
const partner = conv.participants.find(
(p) => p.pubkey !== activePubkey,
);
if (!partner) continue;
// Skip if already fetched
if (partnerRelays.has(partner.pubkey)) {
newRelays.set(partner.pubkey, partnerRelays.get(partner.pubkey)!);
continue;
}
try {
const relays = await adapter.getInboxRelays(partner.pubkey);
newRelays.set(partner.pubkey, relays);
} catch {
newRelays.set(partner.pubkey, []);
}
}
setPartnerRelays(newRelays);
};
fetchRelays();
}, [conversations, activePubkey, adapter, partnerRelays]);
// Convert to display format
const conversationList = useMemo(() => {
if (!conversations || !activePubkey) return [];
return conversations.map((conv): ConversationInfo => {
const partner = conv.participants.find((p) => p.pubkey !== activePubkey);
const partnerPubkey = partner?.pubkey || "";
return {
id: conv.id,
partnerPubkey: partner?.pubkey || "",
partnerPubkey,
inboxRelays: partnerRelays.get(partnerPubkey),
lastMessage: conv.lastMessage
? {
content: conv.lastMessage.content,
@@ -243,7 +297,7 @@ export function InboxViewer() {
: undefined,
};
});
}, [conversations, activePubkey]);
}, [conversations, activePubkey, partnerRelays]);
// Handle conversation selection
const handleSelect = useCallback(
@@ -405,7 +459,7 @@ export function InboxViewer() {
return (
<div className="flex h-full flex-col">
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-[300px] p-0">
<SheetContent side="left" className="w-[280px] p-0">
<VisuallyHidden.Root>
<SheetTitle>Messages</SheetTitle>
</VisuallyHidden.Root>

View File

@@ -496,7 +496,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
this.cleanup(conversationId);
// Get user's private inbox relays (kind 10050)
const inboxRelays = await this.getInboxRelays(pubkey);
const inboxRelays = await this.fetchInboxRelays(pubkey);
if (inboxRelays.length === 0) {
console.warn(
"[NIP-17] No inbox relays found. Configure kind 10050 to receive DMs.",
@@ -547,10 +547,28 @@ export class Nip17Adapter extends ChatProtocolAdapter {
this.subscriptions.set(conversationId, subscription);
}
/** Cache for inbox relays */
private inboxRelayCache = new Map<string, string[]>();
/**
* Get private inbox relays for a user (kind 10050)
* Get inbox relays for a user (public API for UI display)
* Returns cached value or fetches from network
*/
private async getInboxRelays(pubkey: string): Promise<string[]> {
async getInboxRelays(pubkey: string): Promise<string[]> {
const cached = this.inboxRelayCache.get(pubkey);
if (cached) return cached;
const relays = await this.fetchInboxRelays(pubkey);
if (relays.length > 0) {
this.inboxRelayCache.set(pubkey, relays);
}
return relays;
}
/**
* Fetch private inbox relays for a user (kind 10050)
*/
private async fetchInboxRelays(pubkey: string): Promise<string[]> {
// Try to fetch from EventStore first
const existing = await firstValueFrom(
eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey, ""),