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

Implements encrypted private messaging using gift wraps:

- Add event cache service (Dexie) for offline access
- Add rumor storage for caching decrypted gift wrap content
- Wire persistEventsToCache and persistEncryptedContent to EventStore
- Create NIP-17 adapter for gift-wrapped DMs
- Add InboxViewer component for DM conversation list
- Add `inbox` command to open private message inbox
- Register NIP-17 adapter in ChatViewer

Features:
- Decrypt once, cache forever - no re-decryption needed
- Explicit decrypt button (user-initiated)
- Conversation list derived from decrypted gift wraps
- Private inbox relay discovery (kind 10050)
- Send not yet implemented (TODO: use SendWrappedMessage action)
This commit is contained in:
Claude
2026-01-14 11:39:08 +00:00
parent 998944fdf7
commit cd052d657f
10 changed files with 1443 additions and 2 deletions

View File

@@ -23,6 +23,7 @@ import type {
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
import type { Message } from "@/types/chat";
import type { ChatAction } from "@/types/chat-actions";
@@ -630,6 +631,8 @@ export function ChatViewer({
addWindow("nip", { number: 29 });
} else if (conversation?.protocol === "nip-53") {
addWindow("nip", { number: 53 });
} else if (conversation?.protocol === "nip-17") {
addWindow("nip", { number: 17 });
}
}, [conversation?.protocol, addWindow]);
@@ -955,8 +958,8 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
// return new NipC7Adapter();
case "nip-29":
return new Nip29Adapter();
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
// return new Nip17Adapter();
case "nip-17":
return new Nip17Adapter();
// case "nip-28": // Phase 3 - Public channels (coming soon)
// return new Nip28Adapter();
case "nip-53":

View File

@@ -0,0 +1,445 @@
/**
* InboxViewer - Private DM Inbox (NIP-17/59 Gift Wrapped Messages)
*
* Displays list of encrypted DM conversations using gift wraps.
* Messages are cached after decryption to avoid re-decryption on page load.
*
* Features:
* - Lists all DM conversations from decrypted gift wraps
* - Shows pending (undecrypted) message count
* - Explicit decrypt button (no auto-decrypt)
* - Opens individual DM conversations in ChatViewer
*/
import { useState, useMemo, memo, useCallback, useEffect } from "react";
import { use$ } from "applesauce-react/hooks";
import {
Loader2,
Lock,
Unlock,
Mail,
AlertCircle,
PanelLeft,
} from "lucide-react";
import accountManager from "@/services/accounts";
import { ChatViewer } from "./ChatViewer";
import type { ProtocolIdentifier } from "@/types/chat";
import { cn } from "@/lib/utils";
import Timestamp from "./Timestamp";
import { UserName } from "./nostr/UserName";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
/**
* UserAvatar - Display a user's avatar with profile data
*/
const UserAvatar = memo(function UserAvatar({
pubkey,
className,
}: {
pubkey: string;
className?: string;
}) {
const profile = useProfile(pubkey);
const name = getDisplayName(pubkey, profile);
return (
<Avatar className={className}>
<AvatarImage src={profile?.picture} alt={name} />
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
});
const MOBILE_BREAKPOINT = 768;
function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return isMobile;
}
/**
* Conversation info for display
*/
interface ConversationInfo {
id: string;
partnerPubkey: string;
lastMessage?: {
content: string;
timestamp: number;
isOwn: boolean;
};
}
/**
* ConversationListItem - Single conversation in the list
*/
const ConversationListItem = memo(function ConversationListItem({
conversation,
isSelected,
onClick,
}: {
conversation: ConversationInfo;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={cn(
"flex items-center gap-3 px-3 py-2 cursor-crosshair hover:bg-muted/50 transition-colors border-b",
isSelected && "bg-muted/70",
)}
onClick={onClick}
>
<UserAvatar pubkey={conversation.partnerPubkey} className="size-10" />
<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"
/>
{conversation.lastMessage && (
<span className="text-xs text-muted-foreground flex-shrink-0">
<Timestamp timestamp={conversation.lastMessage.timestamp} />
</span>
)}
</div>
{conversation.lastMessage && (
<div className="text-xs text-muted-foreground truncate">
{conversation.lastMessage.isOwn && (
<span className="text-muted-foreground/70">You: </span>
)}
{conversation.lastMessage.content}
</div>
)}
</div>
</div>
);
});
/**
* DecryptButton - Shows pending count and triggers decryption
*/
const DecryptButton = memo(function DecryptButton({
pendingCount,
isDecrypting,
onDecrypt,
}: {
pendingCount: number;
isDecrypting: boolean;
onDecrypt: () => void;
}) {
if (pendingCount === 0) return null;
return (
<Button
variant="outline"
size="sm"
className="gap-2 w-full"
onClick={onDecrypt}
disabled={isDecrypting}
>
{isDecrypting ? (
<>
<Loader2 className="size-4 animate-spin" />
Decrypting...
</>
) : (
<>
<Unlock className="size-4" />
Decrypt {pendingCount} message{pendingCount !== 1 ? "s" : ""}
</>
)}
</Button>
);
});
/**
* MemoizedChatViewer - Memoized chat viewer to prevent unnecessary re-renders
*/
const MemoizedChatViewer = memo(
function MemoizedChatViewer({
partnerPubkey,
headerPrefix,
}: {
partnerPubkey: string;
headerPrefix?: React.ReactNode;
}) {
return (
<ChatViewer
protocol="nip-17"
identifier={
{
type: "dm-recipient",
value: partnerPubkey,
} as ProtocolIdentifier
}
headerPrefix={headerPrefix}
/>
);
},
(prev, next) => prev.partnerPubkey === next.partnerPubkey,
);
/**
* InboxViewer - Main inbox component
*/
export function InboxViewer() {
const activeAccount = use$(accountManager.active$);
const activePubkey = activeAccount?.pubkey;
// Mobile detection
const isMobile = useIsMobile();
// State
const [selectedPartner, setSelectedPartner] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(300);
const [isResizing, setIsResizing] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
// NIP-17 adapter instance
const adapter = useMemo(() => new Nip17Adapter(), []);
// Get pending count
const pendingCount = use$(() => adapter.getPendingCount$(), [adapter]) ?? 0;
// Get conversations from adapter
const conversations = use$(
() => (activePubkey ? adapter.getConversations$() : undefined),
[adapter, activePubkey],
);
// 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);
return {
id: conv.id,
partnerPubkey: partner?.pubkey || "",
lastMessage: conv.lastMessage
? {
content: conv.lastMessage.content,
timestamp: conv.lastMessage.timestamp,
isOwn: conv.lastMessage.author === activePubkey,
}
: undefined,
};
});
}, [conversations, activePubkey]);
// Handle conversation selection
const handleSelect = useCallback(
(partnerPubkey: string) => {
setSelectedPartner(partnerPubkey);
if (isMobile) {
setSidebarOpen(false);
}
},
[isMobile],
);
// Handle decrypt
const handleDecrypt = useCallback(async () => {
setIsDecrypting(true);
try {
const result = await adapter.decryptPending();
console.log(
`[Inbox] Decrypted ${result.success} messages, ${result.failed} failed`,
);
} catch (error) {
console.error("[Inbox] Decrypt error:", error);
} finally {
setIsDecrypting(false);
}
}, [adapter]);
// Handle resize
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = sidebarWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const newWidth = startWidth + deltaX;
setSidebarWidth(Math.max(200, Math.min(500, newWidth)));
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
},
[sidebarWidth],
);
// Cleanup on unmount
useEffect(() => {
return () => {
adapter.cleanupAll();
};
}, [adapter]);
// Not signed in
if (!activePubkey) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground p-4">
<Lock className="size-8" />
<span className="text-sm">Sign in to view your encrypted messages</span>
</div>
);
}
// Sidebar content
const sidebarContent = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-3 border-b">
<div className="flex items-center gap-2 mb-3">
<Mail className="size-5" />
<h2 className="font-semibold">Private Messages</h2>
</div>
<DecryptButton
pendingCount={pendingCount || 0}
isDecrypting={isDecrypting}
onDecrypt={handleDecrypt}
/>
</div>
{/* Conversation list */}
<div className="flex-1 overflow-y-auto">
{conversationList.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground p-4 text-center">
<AlertCircle className="size-6" />
<span className="text-sm">
{(pendingCount || 0) > 0
? "Decrypt messages to see conversations"
: "No conversations yet"}
</span>
</div>
) : (
conversationList.map((conv) => (
<ConversationListItem
key={conv.id}
conversation={conv}
isSelected={selectedPartner === conv.partnerPubkey}
onClick={() => handleSelect(conv.partnerPubkey)}
/>
))
)}
</div>
</div>
);
// Sidebar toggle button for mobile
const sidebarToggle = isMobile ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0"
onClick={() => setSidebarOpen(true)}
>
<PanelLeft className="size-4" />
<span className="sr-only">Toggle sidebar</span>
</Button>
) : null;
// Chat content
const chatContent = selectedPartner ? (
<MemoizedChatViewer
partnerPubkey={selectedPartner}
headerPrefix={sidebarToggle}
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
{isMobile ? (
<Button
variant="outline"
onClick={() => setSidebarOpen(true)}
className="gap-2"
>
<PanelLeft className="size-4" />
Select a conversation
</Button>
) : (
<>
<Mail className="size-8 opacity-50" />
<span className="text-sm">Select a conversation</span>
</>
)}
</div>
);
// Mobile layout
if (isMobile) {
return (
<div className="flex h-full flex-col">
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-[300px] p-0">
<VisuallyHidden.Root>
<SheetTitle>Messages</SheetTitle>
</VisuallyHidden.Root>
<div className="flex h-full flex-col pt-10">{sidebarContent}</div>
</SheetContent>
</Sheet>
<div className="flex-1 min-h-0">{chatContent}</div>
</div>
);
}
// Desktop layout
return (
<div className="flex h-full">
{/* Sidebar */}
<aside
className="flex flex-col border-r bg-background"
style={{ width: sidebarWidth }}
>
{sidebarContent}
</aside>
{/* Resize handle */}
<div
className={cn(
"w-1 bg-border hover:bg-primary/50 cursor-col-resize transition-colors",
isResizing && "bg-primary",
)}
onMouseDown={handleMouseDown}
/>
{/* Chat panel */}
<div className="flex-1 min-w-0">{chatContent}</div>
</div>
);
}

View File

@@ -42,6 +42,9 @@ const SpellbooksViewer = lazy(() =>
const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const InboxViewer = lazy(() =>
import("./InboxViewer").then((m) => ({ default: m.InboxViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -210,6 +213,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "inbox":
content = <InboxViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -0,0 +1,613 @@
/**
* NIP-17 Adapter - Private Direct Messages (Gift Wrapped)
*
* Implements NIP-17 encrypted DMs using NIP-59 gift wraps:
* - kind 1059: Gift wrap (outer encrypted layer with ephemeral key)
* - kind 13: Seal (middle layer encrypted with sender's key)
* - kind 14: DM rumor (inner content - the actual message)
*
* Privacy features:
* - Sender identity hidden (ephemeral gift wrap key)
* - Deniability (rumors are unsigned)
* - Uses recipient's private inbox relays (kind 10050)
*
* Caching:
* - Gift wraps are cached to Dexie events table
* - Decrypted rumors are cached to avoid re-decryption
*/
import { Observable, firstValueFrom, BehaviorSubject } from "rxjs";
import { map, first } from "rxjs/operators";
import { nip19 } from "nostr-tools";
import type { Filter } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
import type {
Conversation,
Message,
ProtocolIdentifier,
ChatCapabilities,
LoadMessagesOptions,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import accountManager from "@/services/accounts";
import { isNip05, resolveNip05 } from "@/lib/nip05";
import { getDisplayName } from "@/lib/nostr-utils";
import { isValidHexPubkey } from "@/lib/nostr-validation";
import { getProfileContent } from "applesauce-core/helpers";
import {
unlockGiftWrap,
getConversationParticipants,
getConversationIdentifierFromMessage,
type Rumor,
} from "applesauce-common/helpers";
import {
getDecryptedRumors,
isGiftWrapDecrypted,
storeDecryptedRumor,
} from "@/services/rumor-storage";
/**
* Kind constants
*/
const GIFT_WRAP_KIND = 1059;
const DM_RUMOR_KIND = 14;
const DM_RELAY_LIST_KIND = 10050;
/**
* NIP-17 Adapter - Gift Wrapped Private DMs
*/
export class Nip17Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-17" as const;
readonly type = "dm" as const;
/** Observable of all decrypted rumors for the current user */
private rumors$ = new BehaviorSubject<Rumor[]>([]);
/** Track pending (undecrypted) gift wrap IDs */
private pendingGiftWraps$ = new BehaviorSubject<string[]>([]);
/**
* Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Try bech32 decoding (npub/nprofile)
try {
const decoded = nip19.decode(input);
if (decoded.type === "npub") {
return {
type: "dm-recipient",
value: decoded.data,
};
}
if (decoded.type === "nprofile") {
return {
type: "dm-recipient",
value: decoded.data.pubkey,
relays: decoded.data.relays,
};
}
} catch {
// Not bech32, try other formats
}
// Try hex pubkey
if (isValidHexPubkey(input)) {
return {
type: "dm-recipient",
value: input,
};
}
// Try NIP-05
if (isNip05(input)) {
return {
type: "chat-partner-nip05",
value: input,
};
}
return null;
}
/**
* Resolve conversation from identifier
*/
async resolveConversation(
identifier: ProtocolIdentifier,
): Promise<Conversation> {
let partnerPubkey: string;
// Resolve NIP-05 if needed
if (identifier.type === "chat-partner-nip05") {
const resolved = await resolveNip05(identifier.value);
if (!resolved) {
throw new Error(`Failed to resolve NIP-05: ${identifier.value}`);
}
partnerPubkey = resolved;
} else if (
identifier.type === "dm-recipient" ||
identifier.type === "chat-partner"
) {
partnerPubkey = identifier.value;
} else {
throw new Error(
`NIP-17 adapter cannot handle identifier type: ${identifier.type}`,
);
}
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);
// Create conversation ID from sorted participants (deterministic)
const participants = [activePubkey, partnerPubkey].sort();
const conversationId = `nip-17:${participants.join(",")}`;
return {
id: conversationId,
type: "dm",
protocol: "nip-17",
title,
participants: [
{ pubkey: activePubkey, role: "member" },
{ pubkey: partnerPubkey, role: "member" },
],
metadata: {
encrypted: true,
giftWrapped: true,
},
unreadCount: 0,
};
}
/**
* Load messages for a conversation
* Returns decrypted rumors that match this conversation
*/
loadMessages(
conversation: Conversation,
_options?: LoadMessagesOptions,
): Observable<Message[]> {
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
throw new Error("No active account");
}
// Get partner pubkey
const partner = conversation.participants.find(
(p) => p.pubkey !== activePubkey,
);
if (!partner) {
throw new Error("No conversation partner found");
}
// Expected participants for this conversation
const expectedParticipants = [activePubkey, partner.pubkey].sort();
// Load initial rumors from cache
this.loadCachedRumors(activePubkey);
// Subscribe to gift wraps for this user
this.subscribeToGiftWraps(activePubkey);
// Filter rumors to this conversation and convert to messages
return this.rumors$.pipe(
map((rumors) => {
// Filter rumors that belong to this conversation
const conversationRumors = rumors.filter((rumor) => {
// Only kind 14 DM rumors
if (rumor.kind !== DM_RUMOR_KIND) return false;
// Get participants from rumor
const rumorParticipants = getConversationParticipants(rumor).sort();
// Check if participants match
return (
rumorParticipants.length === expectedParticipants.length &&
rumorParticipants.every((p, i) => p === expectedParticipants[i])
);
});
// Convert to messages and sort by timestamp
return conversationRumors
.map((rumor) => this.rumorToMessage(rumor, conversation.id))
.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
/**
* Load more historical messages (pagination)
*/
async loadMoreMessages(
_conversation: Conversation,
_before: number,
): Promise<Message[]> {
// For now, return empty - pagination to be implemented
// Gift wraps don't paginate well since we need to decrypt all
return [];
}
/**
* Send a gift-wrapped DM
*/
async sendMessage(
conversation: Conversation,
content: string,
options?: SendMessageOptions,
): Promise<void> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
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");
}
// Build rumor tags
const tags: string[][] = [["p", partner.pubkey]];
if (options?.replyTo) {
tags.push(["e", options.replyTo, "", "reply"]);
}
// Get recipient's private inbox relays
const inboxRelays = await this.getPrivateInboxRelays(partner.pubkey);
if (inboxRelays.length === 0) {
throw new Error(
"Recipient has no private inbox relays configured (kind 10050)",
);
}
// TODO: Implement gift wrap creation and sending
// 1. Create the DM rumor (kind 14, unsigned) with: activePubkey, tags, content
// 2. Use SendWrappedMessage action from applesauce-actions to create and send gift wraps
// 3. Publish to each recipient's private inbox relays
void inboxRelays; // Will be used when implemented
void tags;
void content;
void activePubkey;
throw new Error(
"Send not yet implemented - use applesauce SendWrappedMessage action",
);
}
/**
* Get protocol capabilities
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: true,
supportsThreading: true, // e-tag replies
supportsModeration: false,
supportsRoles: false,
supportsGroupManagement: false,
canCreateConversations: true,
requiresRelay: false,
};
}
/**
* Load a replied-to message
*/
async loadReplyMessage(
_conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
// Check if we have a cached rumor with this ID
const rumors = this.rumors$.value;
const rumor = rumors.find((r) => r.id === eventId);
if (rumor) {
// Convert rumor to a pseudo-event for display
return {
...rumor,
sig: "", // Rumors are unsigned
} as NostrEvent;
}
return null;
}
/**
* Get count of pending (undecrypted) gift wraps
*/
getPendingCount(): number {
return this.pendingGiftWraps$.value.length;
}
/**
* Get observable of pending gift wrap count
*/
getPendingCount$(): Observable<number> {
return this.pendingGiftWraps$.pipe(map((ids) => ids.length));
}
/**
* Decrypt all pending gift wraps
*/
async decryptPending(): Promise<{ success: number; failed: number }> {
const signer = accountManager.active$.value?.signer;
const pubkey = accountManager.active$.value?.pubkey;
if (!signer || !pubkey) {
throw new Error("No active account");
}
const pendingIds = this.pendingGiftWraps$.value;
let success = 0;
let failed = 0;
for (const giftWrapId of pendingIds) {
try {
// Get the gift wrap event
const giftWrap = await firstValueFrom(
eventStore.event(giftWrapId).pipe(first()),
);
if (!giftWrap) {
failed++;
continue;
}
// Decrypt using signer
const rumor = await unlockGiftWrap(giftWrap, signer);
if (rumor) {
// Store decrypted rumor
await storeDecryptedRumor(giftWrapId, rumor, pubkey);
// Add to rumors list
const currentRumors = this.rumors$.value;
if (!currentRumors.find((r) => r.id === rumor.id)) {
this.rumors$.next([...currentRumors, rumor]);
}
success++;
} else {
failed++;
}
} catch (error) {
console.error(
`[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`,
error,
);
failed++;
}
}
// Clear pending list for successfully decrypted
const remainingPending = this.pendingGiftWraps$.value.filter(
(id) => !pendingIds.includes(id) || failed > 0,
);
this.pendingGiftWraps$.next(remainingPending);
return { success, failed };
}
/**
* Get all conversations from decrypted rumors
*/
getConversations$(): Observable<Conversation[]> {
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
return new BehaviorSubject([]);
}
return this.rumors$.pipe(
map((rumors) => {
// Group rumors by conversation
const conversationMap = new Map<
string,
{ participants: string[]; lastRumor: Rumor }
>();
for (const rumor of rumors) {
if (rumor.kind !== DM_RUMOR_KIND) continue;
const convId = getConversationIdentifierFromMessage(rumor);
const participants = getConversationParticipants(rumor);
const existing = conversationMap.get(convId);
if (!existing || rumor.created_at > existing.lastRumor.created_at) {
conversationMap.set(convId, { participants, lastRumor: rumor });
}
}
// Convert to Conversation objects
const conversations: Conversation[] = [];
for (const [convId, { participants, lastRumor }] of conversationMap) {
const partner = participants.find((p) => p !== activePubkey);
if (!partner) continue;
conversations.push({
id: `nip-17:${participants.sort().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 },
lastMessage: this.rumorToMessage(lastRumor, convId),
unreadCount: 0,
});
}
// Sort by last message timestamp
conversations.sort(
(a, b) =>
(b.lastMessage?.timestamp || 0) - (a.lastMessage?.timestamp || 0),
);
return conversations;
}),
);
}
// ==================== Private Methods ====================
/**
* Load cached rumors from Dexie
*/
private async loadCachedRumors(pubkey: string): Promise<void> {
const rumors = await getDecryptedRumors(pubkey);
this.rumors$.next(rumors);
}
/**
* Subscribe to gift wraps for the user
*/
private subscribeToGiftWraps(pubkey: string): void {
const conversationId = `nip-17:inbox:${pubkey}`;
// Clean up existing subscription
this.cleanup(conversationId);
// Subscribe to gift wraps addressed to this user
const filter: Filter = {
kinds: [GIFT_WRAP_KIND],
"#p": [pubkey],
};
const subscription = pool
.subscription([], [filter], { eventStore })
.subscribe({
next: async (response) => {
if (typeof response === "string") {
// EOSE
console.log("[NIP-17] EOSE received for gift wraps");
} else {
// New gift wrap received
console.log(
`[NIP-17] Received gift wrap: ${response.id.slice(0, 8)}...`,
);
// Check if already decrypted
const isDecrypted = await isGiftWrapDecrypted(response.id, pubkey);
if (!isDecrypted) {
// Add to pending list
const pending = this.pendingGiftWraps$.value;
if (!pending.includes(response.id)) {
this.pendingGiftWraps$.next([...pending, response.id]);
}
}
}
},
});
this.subscriptions.set(conversationId, subscription);
}
/**
* Get private inbox relays for a user (kind 10050)
*/
private async getPrivateInboxRelays(pubkey: string): Promise<string[]> {
// Try to fetch from EventStore first
const existing = await firstValueFrom(
eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey, ""),
{ defaultValue: undefined },
);
if (existing) {
return this.extractRelaysFromEvent(existing);
}
// Fetch from relays
const filter: Filter = {
kinds: [DM_RELAY_LIST_KIND],
authors: [pubkey],
limit: 1,
};
const events: NostrEvent[] = [];
await new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 5000);
const sub = pool.subscription([], [filter], { eventStore }).subscribe({
next: (response) => {
if (typeof response === "string") {
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
events.push(response);
}
},
error: () => {
clearTimeout(timeout);
resolve();
},
});
});
if (events.length > 0) {
return this.extractRelaysFromEvent(events[0]);
}
return [];
}
/**
* Extract relay URLs from kind 10050 event
*/
private extractRelaysFromEvent(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]);
}
/**
* Convert a rumor to a Message
*/
private rumorToMessage(rumor: Rumor, conversationId: string): Message {
// Check for reply reference
const replyTag = rumor.tags.find(
(t) => t[0] === "e" && (t[3] === "reply" || !t[3]),
);
const replyTo = replyTag?.[1];
return {
id: rumor.id,
conversationId,
author: rumor.pubkey,
content: rumor.content,
timestamp: rumor.created_at,
type: "user",
replyTo,
protocol: "nip-17",
metadata: {
encrypted: true,
},
// Create a pseudo-event for the rumor (unsigned)
event: {
...rumor,
sig: "",
} as NostrEvent,
};
}
/**
* Get metadata for a pubkey
*/
private async getMetadata(pubkey: string): Promise<NostrEvent | undefined> {
return firstValueFrom(eventStore.replaceable(0, pubkey), {
defaultValue: undefined,
});
}
}

View File

@@ -80,6 +80,37 @@ export interface LocalSpellbook {
deletedAt?: number;
}
/**
* Cached Nostr event for offline access
*/
export interface CachedEvent {
id: string;
event: NostrEvent;
cachedAt: number;
}
/**
* Decrypted rumor from gift wrap (NIP-59)
* Stored separately so we don't have to re-decrypt
*/
export interface DecryptedRumor {
/** Gift wrap event ID */
giftWrapId: string;
/** The decrypted rumor (unsigned event) */
rumor: {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
};
/** Pubkey that decrypted this (for multi-account support) */
decryptedBy: string;
/** When it was decrypted */
decryptedAt: number;
}
class GrimoireDb extends Dexie {
profiles!: Table<Profile>;
nip05!: Table<Nip05>;
@@ -90,6 +121,8 @@ class GrimoireDb extends Dexie {
relayLiveness!: Table<RelayLivenessEntry>;
spells!: Table<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
events!: Table<CachedEvent>;
decryptedRumors!: Table<DecryptedRumor>;
constructor(name: string) {
super(name);
@@ -311,6 +344,21 @@ class GrimoireDb extends Dexie {
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
});
// Version 15: Add event cache and decrypted rumor storage for NIP-59 gift wraps
this.version(15).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
events: "&id, cachedAt",
decryptedRumors: "&giftWrapId, decryptedBy",
});
}
}

130
src/services/event-cache.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* Event cache service for persisting Nostr events to Dexie
*
* Provides:
* - Event caching for offline access
* - CacheRequest function for applesauce loaders
* - Automatic persistence of events from EventStore
*/
import type { Filter, NostrEvent } from "nostr-tools";
import db, { type CachedEvent } from "./db";
import { matchFilter } from "nostr-tools";
/**
* Add events to the cache
*/
export async function cacheEvents(events: NostrEvent[]): Promise<void> {
if (events.length === 0) return;
const now = Date.now();
const cachedEvents: CachedEvent[] = events.map((event) => ({
id: event.id,
event,
cachedAt: now,
}));
// Use bulkPut to handle duplicates gracefully
await db.events.bulkPut(cachedEvents);
}
/**
* Get a single event from cache by ID
*/
export async function getCachedEvent(
id: string,
): Promise<NostrEvent | undefined> {
const cached = await db.events.get(id);
return cached?.event;
}
/**
* Get events from cache matching filters
* This is used as a CacheRequest for applesauce loaders
*/
export async function getEventsForFilters(
filters: Filter[],
): Promise<NostrEvent[]> {
// For simple ID lookups, use direct queries
const idFilters = filters.filter(
(f) => f.ids && f.ids.length > 0 && Object.keys(f).length === 1,
);
if (idFilters.length === filters.length && idFilters.length > 0) {
// All filters are simple ID lookups
const allIds = idFilters.flatMap((f) => f.ids || []);
const cached = await db.events.bulkGet(allIds);
return cached
.filter((c): c is CachedEvent => c !== undefined)
.map((c) => c.event);
}
// For complex filters, we need to scan and filter
// This is less efficient but necessary for kind/author/tag queries
const allEvents = await db.events.toArray();
const matchingEvents: NostrEvent[] = [];
for (const cached of allEvents) {
for (const filter of filters) {
if (matchFilter(filter, cached.event)) {
matchingEvents.push(cached.event);
break; // Event matches at least one filter
}
}
}
// Apply limit if specified (use smallest limit from filters)
const limits = filters
.map((f) => f.limit)
.filter((l): l is number => l !== undefined);
if (limits.length > 0) {
const minLimit = Math.min(...limits);
// Sort by created_at descending and take limit
matchingEvents.sort((a, b) => b.created_at - a.created_at);
return matchingEvents.slice(0, minLimit);
}
return matchingEvents;
}
/**
* CacheRequest function for applesauce loaders
* Compatible with createTimelineLoader's cache option
*/
export const cacheRequest = (filters: Filter[]): Promise<NostrEvent[]> =>
getEventsForFilters(filters);
/**
* Clear old cached events (older than maxAge in milliseconds)
* Default: 30 days
*/
export async function pruneEventCache(
maxAgeMs: number = 30 * 24 * 60 * 60 * 1000,
): Promise<number> {
const cutoff = Date.now() - maxAgeMs;
const deleted = await db.events.where("cachedAt").below(cutoff).delete();
return deleted;
}
/**
* Get cache statistics
*/
export async function getCacheStats(): Promise<{
eventCount: number;
oldestEvent: number | null;
newestEvent: number | null;
}> {
const count = await db.events.count();
if (count === 0) {
return { eventCount: 0, oldestEvent: null, newestEvent: null };
}
const oldest = await db.events.orderBy("cachedAt").first();
const newest = await db.events.orderBy("cachedAt").last();
return {
eventCount: count,
oldestEvent: oldest?.cachedAt ?? null,
newestEvent: newest?.cachedAt ?? null,
};
}

View File

@@ -1,5 +1,23 @@
import { EventStore } from "applesauce-core";
import { persistEventsToCache } from "applesauce-core/helpers";
import { persistEncryptedContent } from "applesauce-common/helpers";
import { cacheEvents } from "./event-cache";
import { rumorStorage, setCurrentPubkey } from "./rumor-storage";
import accountManager from "./accounts";
import { of } from "rxjs";
const eventStore = new EventStore();
// Persist all events to Dexie cache for offline access
persistEventsToCache(eventStore, cacheEvents);
// Persist decrypted gift wrap content to Dexie
// This ensures we don't have to re-decrypt messages on every page load
persistEncryptedContent(eventStore, of(rumorStorage));
// Sync current pubkey for rumor storage when account changes
accountManager.active$.subscribe((account) => {
setCurrentPubkey(account?.pubkey ?? null);
});
export default eventStore;

View File

@@ -0,0 +1,165 @@
/**
* Rumor storage service for caching decrypted gift wrap content
*
* When a gift wrap (kind 1059) is decrypted, the inner rumor is cached
* so we don't have to decrypt it again. This is especially important
* because decryption requires the signer (browser extension interaction).
*
* Storage format matches applesauce's persistEncryptedContent expectations:
* - Key: `rumor:${giftWrapId}`
* - Value: The decrypted rumor object
*/
import type { Rumor } from "applesauce-common/helpers";
import db, { type DecryptedRumor } from "./db";
import { BehaviorSubject, type Observable } from "rxjs";
/**
* Current user pubkey for multi-account support
* Set this when account changes
*/
const currentPubkey$ = new BehaviorSubject<string | null>(null);
export function setCurrentPubkey(pubkey: string | null): void {
currentPubkey$.next(pubkey);
}
export function getCurrentPubkey(): string | null {
return currentPubkey$.value;
}
/**
* Storage interface compatible with applesauce's persistEncryptedContent
*
* The keys are in format "rumor:{giftWrapId}" or "seal:{giftWrapId}"
*/
export const rumorStorage = {
async getItem(key: string): Promise<string | null> {
const pubkey = currentPubkey$.value;
if (!pubkey) return null;
// Parse key format: "rumor:{giftWrapId}" or "seal:{giftWrapId}"
const match = key.match(/^(rumor|seal):(.+)$/);
if (!match) return null;
const [, type, giftWrapId] = match;
if (type === "rumor") {
const entry = await db.decryptedRumors.get(giftWrapId);
if (entry && entry.decryptedBy === pubkey) {
return JSON.stringify(entry.rumor);
}
}
// For seals, we don't cache them separately (they're intermediate)
return null;
},
async setItem(key: string, value: string): Promise<void> {
const pubkey = currentPubkey$.value;
if (!pubkey) return;
// Parse key format
const match = key.match(/^(rumor|seal):(.+)$/);
if (!match) return;
const [, type, giftWrapId] = match;
if (type === "rumor") {
const rumor = JSON.parse(value) as Rumor;
const entry: DecryptedRumor = {
giftWrapId,
rumor,
decryptedBy: pubkey,
decryptedAt: Date.now(),
};
await db.decryptedRumors.put(entry);
}
// We don't persist seals - they're just intermediate decryption steps
},
async removeItem(key: string): Promise<void> {
const match = key.match(/^(rumor|seal):(.+)$/);
if (!match) return;
const [, , giftWrapId] = match;
await db.decryptedRumors.delete(giftWrapId);
},
};
/**
* Get all decrypted rumors for the current user
*/
export async function getDecryptedRumors(pubkey: string): Promise<Rumor[]> {
const entries = await db.decryptedRumors
.where("decryptedBy")
.equals(pubkey)
.toArray();
return entries.map((e) => e.rumor);
}
/**
* Get a specific decrypted rumor by gift wrap ID
*/
export async function getDecryptedRumor(
giftWrapId: string,
pubkey: string,
): Promise<Rumor | null> {
const entry = await db.decryptedRumors.get(giftWrapId);
if (entry && entry.decryptedBy === pubkey) {
return entry.rumor;
}
return null;
}
/**
* Check if a gift wrap has already been decrypted
*/
export async function isGiftWrapDecrypted(
giftWrapId: string,
pubkey: string,
): Promise<boolean> {
const entry = await db.decryptedRumors.get(giftWrapId);
return entry !== null && entry !== undefined && entry.decryptedBy === pubkey;
}
/**
* Store a decrypted rumor directly (for manual decryption flows)
*/
export async function storeDecryptedRumor(
giftWrapId: string,
rumor: Rumor,
decryptedBy: string,
): Promise<void> {
const entry: DecryptedRumor = {
giftWrapId,
rumor,
decryptedBy,
decryptedAt: Date.now(),
};
await db.decryptedRumors.put(entry);
}
/**
* Get count of decrypted rumors for a user
*/
export async function getDecryptedRumorCount(pubkey: string): Promise<number> {
return db.decryptedRumors.where("decryptedBy").equals(pubkey).count();
}
/**
* Clear all decrypted rumors for a user
* Useful for "forget me" functionality
*/
export async function clearDecryptedRumors(pubkey: string): Promise<number> {
return db.decryptedRumors.where("decryptedBy").equals(pubkey).delete();
}
/**
* Observable storage for applesauce's persistEncryptedContent
* Returns an observable that emits the storage when pubkey is set
*/
export function getRumorStorage$(): Observable<typeof rumorStorage | null> {
return new BehaviorSubject(rumorStorage);
}

View File

@@ -17,6 +17,7 @@ export type AppId =
| "debug"
| "conn"
| "chat"
| "inbox"
| "spells"
| "spellbooks"
| "blossom"

View File

@@ -346,6 +346,18 @@ export const manPages: Record<string, ManPageEntry> = {
return parsed;
},
},
inbox: {
name: "inbox",
section: "1",
synopsis: "inbox",
description:
"View your encrypted private messages (NIP-17 gift-wrapped DMs). Messages are encrypted using NIP-44 and wrapped in NIP-59 gift wraps for privacy. Decrypted messages are cached locally so you only decrypt once. Click 'Decrypt' to unlock pending messages.",
examples: ["inbox Open your private message inbox"],
seeAlso: ["chat", "profile"],
appId: "inbox",
category: "Nostr",
defaultProps: {},
},
chat: {
name: "chat",
section: "1",