refactor: Extract global GiftWrapService for NIP-59 gift wrap handling

Gift wraps (kind 1059) can contain any kind of event, not just DMs.
This refactor creates a centralized service for gift wrap management:

- GiftWrapService handles subscription, decryption, and state tracking
- NIP-17 adapter now delegates to GiftWrapService instead of managing state
- InboxViewer shows enable/disable prompt when gift wraps not enabled
- Gift wrap subscription is persisted to localStorage
- Pending/failed gift wrap counts tracked separately
This commit is contained in:
Claude
2026-01-14 17:03:41 +00:00
parent 00d00032f6
commit 3b1412f34e
4 changed files with 615 additions and 519 deletions

View File

@@ -15,6 +15,7 @@ import { getZapRequest } from "applesauce-common/helpers/zap";
import { toast } from "sonner";
import accountManager from "@/services/accounts";
import eventStore from "@/services/event-store";
import { giftWrapService } from "@/services/gift-wrap-service";
import type {
ChatProtocol,
ProtocolIdentifier,
@@ -372,28 +373,21 @@ export function ChatViewer({
// Get the appropriate adapter for this protocol
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
// Ensure NIP-17 subscription is active when ChatViewer mounts
useEffect(() => {
if (protocol === "nip-17") {
nip17Adapter.ensureSubscription();
}
}, [protocol]);
// NIP-17 decrypt state
// NIP-17 decrypt state - uses global GiftWrapService
const [isDecrypting, setIsDecrypting] = useState(false);
const pendingCount =
use$(
() =>
protocol === "nip-17" ? nip17Adapter.getPendingCount$() : undefined,
protocol === "nip-17" ? giftWrapService.getPendingCount$() : undefined,
[protocol],
) ?? 0;
// Handle decrypt for NIP-17
// Handle decrypt for NIP-17 - delegates to GiftWrapService
const handleDecrypt = useCallback(async () => {
if (protocol !== "nip-17") return;
setIsDecrypting(true);
try {
const result = await nip17Adapter.decryptPending();
const result = await giftWrapService.decryptPending();
console.log(
`[Chat] Decrypted ${result.success} messages, ${result.failed} failed`,
);

View File

@@ -2,9 +2,10 @@
* 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.
* Requires GiftWrapService to be enabled for subscription and decryption.
*
* Features:
* - Toggle to enable/disable gift wrap subscription
* - Lists all DM conversations from decrypted gift wraps
* - Shows pending (undecrypted) message count
* - Explicit decrypt button (no auto-decrypt)
@@ -20,8 +21,12 @@ import {
AlertCircle,
PanelLeft,
Bookmark,
Power,
PowerOff,
} from "lucide-react";
import { toast } from "sonner";
import accountManager from "@/services/accounts";
import { giftWrapService } from "@/services/gift-wrap-service";
import { ChatViewer } from "./ChatViewer";
import type { ProtocolIdentifier } from "@/types/chat";
import { cn } from "@/lib/utils";
@@ -241,6 +246,30 @@ const MemoizedChatViewer = memo(
(prev, next) => prev.partnerPubkey === next.partnerPubkey,
);
/**
* EnableGiftWrapPrompt - Shown when gift wrap is not enabled
*/
function EnableGiftWrapPrompt({ onEnable }: { onEnable: () => void }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground p-6 text-center">
<Lock className="size-12 opacity-50" />
<div className="space-y-2">
<h3 className="text-lg font-medium text-foreground">
Gift Wrap Subscription Disabled
</h3>
<p className="text-sm max-w-sm">
Enable gift wrap subscription to receive and decrypt private messages.
Gift wraps (NIP-59) are used for encrypted communication.
</p>
</div>
<Button onClick={onEnable} className="gap-2">
<Power className="size-4" />
Enable Gift Wraps
</Button>
</div>
);
}
/**
* InboxViewer - Main inbox component
*/
@@ -248,6 +277,11 @@ export function InboxViewer() {
const activeAccount = use$(accountManager.active$);
const activePubkey = activeAccount?.pubkey;
// Gift wrap service state
const isGiftWrapEnabled =
use$(() => giftWrapService.isEnabled$(), []) ?? false;
const pendingCount = use$(() => giftWrapService.getPendingCount$(), []) ?? 0;
// Mobile detection
const isMobile = useIsMobile();
@@ -258,23 +292,13 @@ export function InboxViewer() {
const [isResizing, setIsResizing] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
// NIP-17 adapter singleton instance
const adapter = nip17Adapter;
// Ensure subscription is active when component mounts
useEffect(() => {
if (activePubkey) {
adapter.ensureSubscription();
}
}, [adapter, activePubkey]);
// Get pending count
const pendingCount = use$(() => adapter.getPendingCount$(), [adapter]) ?? 0;
// Get conversations from adapter
// Get conversations from adapter (requires gift wrap service to be enabled)
const conversations = use$(
() => (activePubkey ? adapter.getConversations$() : undefined),
[adapter, activePubkey],
() =>
activePubkey && isGiftWrapEnabled
? nip17Adapter.getConversations$()
: undefined,
[activePubkey, isGiftWrapEnabled],
);
// Track inbox relays for each partner
@@ -284,7 +308,7 @@ export function InboxViewer() {
// Fetch inbox relays for conversation partners
useEffect(() => {
if (!conversations) return;
if (!conversations || !isGiftWrapEnabled) return;
const fetchRelays = async () => {
const newRelays = new Map<string, string[]>();
@@ -302,7 +326,7 @@ export function InboxViewer() {
}
try {
const relays = await adapter.getInboxRelays(partner.pubkey);
const relays = await nip17Adapter.getInboxRelays(partner.pubkey);
newRelays.set(partner.pubkey, relays);
} catch {
newRelays.set(partner.pubkey, []);
@@ -313,7 +337,7 @@ export function InboxViewer() {
};
fetchRelays();
}, [conversations, activePubkey, adapter, partnerRelays]);
}, [conversations, activePubkey, partnerRelays, isGiftWrapEnabled]);
// Convert to display format
const conversationList = useMemo(() => {
@@ -358,20 +382,43 @@ export function InboxViewer() {
[isMobile],
);
// Handle enable gift wrap
const handleEnableGiftWrap = useCallback(() => {
giftWrapService.enable();
toast.success("Gift wrap subscription enabled");
}, []);
// Handle disable gift wrap
const handleDisableGiftWrap = useCallback(() => {
giftWrapService.disable();
toast.info("Gift wrap subscription disabled");
}, []);
// Handle decrypt
const handleDecrypt = useCallback(async () => {
setIsDecrypting(true);
try {
const result = await adapter.decryptPending();
const result = await giftWrapService.decryptPending();
console.log(
`[Inbox] Decrypted ${result.success} messages, ${result.failed} failed`,
);
if (result.success > 0) {
toast.success(
`Decrypted ${result.success} message${result.success !== 1 ? "s" : ""}`,
);
}
if (result.failed > 0) {
toast.warning(
`${result.failed} message${result.failed !== 1 ? "s" : ""} failed to decrypt`,
);
}
} catch (error) {
console.error("[Inbox] Decrypt error:", error);
toast.error("Failed to decrypt messages");
} finally {
setIsDecrypting(false);
}
}, [adapter]);
}, []);
// Handle resize
const handleMouseDown = useCallback(
@@ -405,13 +452,6 @@ export function InboxViewer() {
[sidebarWidth],
);
// Cleanup on unmount
useEffect(() => {
return () => {
adapter.cleanupAll();
};
}, [adapter]);
// Not signed in
if (!activePubkey) {
return (
@@ -422,14 +462,39 @@ export function InboxViewer() {
);
}
// Gift wrap not enabled
if (!isGiftWrapEnabled) {
return <EnableGiftWrapPrompt onEnable={handleEnableGiftWrap} />;
}
// 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 className="p-3 border-b space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="size-5" />
<h2 className="font-semibold">Private Messages</h2>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 gap-1 text-xs"
onClick={() => {
if (isGiftWrapEnabled) {
handleDisableGiftWrap();
} else {
handleEnableGiftWrap();
}
}}
>
{isGiftWrapEnabled ? (
<Power className="size-3 text-green-500" />
) : (
<PowerOff className="size-3 text-muted-foreground" />
)}
</Button>
</div>
<DecryptButton
pendingCount={pendingCount || 0}

View File

@@ -1,24 +1,20 @@
/**
* 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)
* Implements NIP-17 encrypted DMs using NIP-59 gift wraps.
* Delegates gift wrap management to the global GiftWrapService.
*
* Privacy features:
* - Sender identity hidden (ephemeral gift wrap key)
* - Deniability (rumors are unsigned)
* - Uses recipient's private inbox relays (kind 10050)
* This adapter handles:
* - Parsing DM identifiers (npub, nprofile, hex, NIP-05, $me)
* - Filtering kind 14 rumors from decrypted gift wraps
* - Converting rumors to messages
* - Sending gift-wrapped messages
*
* Caching:
* - Gift wraps are cached to Dexie events table
* - Decrypted content persisted via applesauce's persistEncryptedContent
* Gift wrap subscription and decryption is managed globally by GiftWrapService.
*/
import { Observable, firstValueFrom, BehaviorSubject } from "rxjs";
import { map, first, distinctUntilChanged } from "rxjs/operators";
import { Observable, BehaviorSubject, firstValueFrom } from "rxjs";
import { map, distinctUntilChanged } from "rxjs/operators";
import { nip19 } from "nostr-tools";
import type { Filter } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
import type {
Conversation,
@@ -29,19 +25,14 @@ import type {
} 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 { hub } from "@/services/hub";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { getEventsForFilters } from "@/services/event-cache";
import { giftWrapService } from "@/services/gift-wrap-service";
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,
isGiftWrapUnlocked,
getGiftWrapRumor,
getConversationParticipants,
getConversationIdentifierFromMessage,
@@ -49,31 +40,30 @@ import {
} from "applesauce-common/helpers";
import { SendWrappedMessage } from "applesauce-actions/actions";
/**
* 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
*
* Requires GiftWrapService to be enabled for subscription and decryption.
*/
export class Nip17Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-17" as const;
readonly type = "dm" as const;
/** Track pending (undecrypted) gift wrap IDs */
private pendingGiftWraps$ = new BehaviorSubject<Set<string>>(new Set());
/**
* Check if gift wrap service is enabled (required for NIP-17)
*/
isAvailable(): boolean {
return giftWrapService.isEnabled();
}
/** Track failed (could not decrypt) gift wrap IDs */
private failedGiftWraps$ = new BehaviorSubject<Set<string>>(new Set());
/** Observable of gift wrap events from event store */
private giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
/** Track if subscription is active */
private subscriptionActive = false;
/**
* Observable of whether NIP-17 is available
*/
isAvailable$(): Observable<boolean> {
return giftWrapService.isEnabled$();
}
/**
* Parse identifier - accepts npub, nprofile, hex pubkey, NIP-05, or $me
@@ -137,6 +127,12 @@ export class Nip17Adapter extends ChatProtocolAdapter {
throw new Error("No active account");
}
if (!giftWrapService.isEnabled()) {
throw new Error(
"Gift wrap subscription is not enabled. Enable it in settings to use NIP-17 DMs.",
);
}
let partnerPubkey: string;
// Handle $me (saved messages - DMs to yourself)
@@ -167,7 +163,6 @@ export class Nip17Adapter extends ChatProtocolAdapter {
: await this.getPartnerTitle(partnerPubkey);
// Create conversation ID from sorted participants (deterministic)
// For self-conversations, it's just one participant listed twice
const participants = isSelf
? [activePubkey]
: [activePubkey, partnerPubkey].sort();
@@ -206,7 +201,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
/**
* Load messages for a conversation
* Returns decrypted rumors that match this conversation
* Filters decrypted gift wraps from GiftWrapService for kind 14 rumors
*/
loadMessages(
conversation: Conversation,
@@ -234,25 +229,19 @@ export class Nip17Adapter extends ChatProtocolAdapter {
}
// Expected participants for this conversation
// 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);
// Get rumors from unlocked gift wraps and filter to this conversation
return this.giftWraps$.pipe(
// Get decrypted gift wraps from the global service and filter to kind 14 DMs
return giftWrapService.getDecryptedGiftWraps$().pipe(
map((giftWraps) => {
const messages: Message[] = [];
for (const gift of giftWraps) {
// Skip locked gift wraps
if (!isGiftWrapUnlocked(gift)) continue;
try {
const rumor = getGiftWrapRumor(gift);
if (!rumor) continue;
// Only kind 14 DM rumors
if (rumor.kind !== DM_RUMOR_KIND) continue;
@@ -260,9 +249,8 @@ export class Nip17Adapter extends ChatProtocolAdapter {
// Get participants from rumor
const rumorParticipants = getConversationParticipants(rumor);
// For self-conversations, all participants should be the same (sender == recipient)
// For self-conversations, all participants should be the same
if (isSelfConversation) {
// Check if all participants are the same as activePubkey
const allSelf = rumorParticipants.every(
(p) => p === activePubkey,
);
@@ -312,12 +300,6 @@ export class Nip17Adapter extends ChatProtocolAdapter {
/**
* Send a gift-wrapped DM
*
* Uses applesauce's SendWrappedMessage action which:
* 1. Creates kind 14 rumor with message content
* 2. Wraps in seal (kind 13) encrypted to each participant
* 3. Wraps seal in gift wrap (kind 1059) with ephemeral key
* 4. Publishes to each participant's private inbox relays (kind 10050)
*/
async sendMessage(
conversation: Conversation,
@@ -348,17 +330,11 @@ export class Nip17Adapter extends ChatProtocolAdapter {
}
// Use applesauce's SendWrappedMessage action
// This handles:
// - Creating the wrapped message rumor
// - Gift wrapping for all participants (recipient + self)
// - Publishing to each participant's inbox relays
await hub.run(SendWrappedMessage, recipientPubkey, content);
console.log(
`[NIP-17] Sent wrapped message to ${recipientPubkey.slice(0, 8)}...${isSelfConversation ? " (saved)" : ""}`,
);
// Note: The sent gift wrap will be picked up automatically via eventStore.insert$ subscription
}
/**
@@ -367,7 +343,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: true,
supportsThreading: true, // e-tag replies
supportsThreading: true,
supportsModeration: false,
supportsRoles: false,
supportsGroupManagement: false,
@@ -383,20 +359,16 @@ export class Nip17Adapter extends ChatProtocolAdapter {
_conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
// Check if we have an unlocked gift wrap with a rumor matching this ID
const giftWraps = this.giftWraps$.value;
// Check decrypted gift wraps for a rumor matching this ID
const giftWraps = await firstValueFrom(
giftWrapService.getDecryptedGiftWraps$(),
);
for (const gift of giftWraps) {
if (!isGiftWrapUnlocked(gift)) continue;
try {
const rumor = getGiftWrapRumor(gift);
if (rumor.id === eventId) {
// Return as pseudo-event
return {
...rumor,
sig: "",
} as NostrEvent;
if (rumor && rumor.id === eventId) {
return { ...rumor, sig: "" } as NostrEvent;
}
} catch {
// Skip
@@ -407,119 +379,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
}
/**
* Get count of pending (undecrypted) gift wraps (excludes failed)
*/
getPendingCount(): number {
const pending = this.pendingGiftWraps$.value;
const failed = this.failedGiftWraps$.value;
// Only count pending that haven't failed
return Array.from(pending).filter((id) => !failed.has(id)).length;
}
/**
* Get observable of pending gift wrap count (excludes failed)
*/
getPendingCount$(): Observable<number> {
return this.pendingGiftWraps$.pipe(
map((pending) => {
const failed = this.failedGiftWraps$.value;
return Array.from(pending).filter((id) => !failed.has(id)).length;
}),
);
}
/**
* Get count of failed gift wraps
*/
getFailedCount(): number {
return this.failedGiftWraps$.value.size;
}
/**
* 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");
}
// Only try pending that haven't already failed
const failedSet = this.failedGiftWraps$.value;
const pendingIds = Array.from(this.pendingGiftWraps$.value).filter(
(id) => !failedSet.has(id),
);
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) {
// Mark as failed - couldn't find the event
this.markAsFailed(giftWrapId);
failed++;
continue;
}
// Already unlocked?
if (isGiftWrapUnlocked(giftWrap)) {
// Remove from pending
this.removeFromPending(giftWrapId);
success++;
continue;
}
// Decrypt using signer - applesauce handles caching automatically
await unlockGiftWrap(giftWrap, signer);
// Remove from pending (success)
this.removeFromPending(giftWrapId);
// Refresh gift wraps list
this.giftWraps$.next([...this.giftWraps$.value]);
success++;
} catch (error) {
console.error(
`[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`,
error,
);
// Mark as failed so we don't retry
this.markAsFailed(giftWrapId);
failed++;
}
}
return { success, failed };
}
/**
* Mark a gift wrap as failed (won't retry decryption)
*/
private markAsFailed(giftWrapId: string): void {
const failed = new Set(this.failedGiftWraps$.value);
failed.add(giftWrapId);
this.failedGiftWraps$.next(failed);
}
/**
* Remove a gift wrap from pending
*/
private removeFromPending(giftWrapId: string): void {
const pending = new Set(this.pendingGiftWraps$.value);
pending.delete(giftWrapId);
this.pendingGiftWraps$.next(pending);
}
/**
* Get all conversations from decrypted rumors
* Get all conversations from decrypted kind 14 rumors
*/
getConversations$(): Observable<Conversation[]> {
const activePubkey = accountManager.active$.value?.pubkey;
@@ -527,10 +387,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
return new BehaviorSubject([]);
}
// Start fetching gift wraps from inbox relays
this.subscribeToGiftWraps(activePubkey);
return this.giftWraps$.pipe(
return giftWrapService.getDecryptedGiftWraps$().pipe(
map((giftWraps) => {
// Group rumors by conversation
const conversationMap = new Map<
@@ -539,10 +396,9 @@ export class Nip17Adapter extends ChatProtocolAdapter {
>();
for (const gift of giftWraps) {
if (!isGiftWrapUnlocked(gift)) continue;
try {
const rumor = getGiftWrapRumor(gift);
if (!rumor) continue;
if (rumor.kind !== DM_RUMOR_KIND) continue;
const convId = getConversationIdentifierFromMessage(rumor);
@@ -561,20 +417,16 @@ export class Nip17Adapter extends ChatProtocolAdapter {
const conversations: Conversation[] = [];
for (const [convId, { participants, lastRumor }] of conversationMap) {
// 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();
@@ -585,7 +437,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
protocol: "nip-17",
title: isSelfConversation
? "Saved Messages"
: partnerPubkey.slice(0, 8) + "...", // Will be replaced with display name
: partnerPubkey.slice(0, 8) + "...",
participants: isSelfConversation
? [{ pubkey: activePubkey, role: "member" as const }]
: participants.map((p) => ({
@@ -604,12 +456,10 @@ export class Nip17Adapter extends ChatProtocolAdapter {
// 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)
);
@@ -620,277 +470,19 @@ export class Nip17Adapter extends ChatProtocolAdapter {
);
}
// ==================== Public Methods for Subscription Management ====================
/**
* Ensure gift wrap subscription is active for the current user
* Call this when InboxViewer or ChatViewer mounts
* Get inbox relays for a pubkey (delegates to GiftWrapService)
*/
ensureSubscription(): void {
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
console.warn("[NIP-17] Cannot start subscription: no active account");
return;
}
if (!this.subscriptionActive) {
console.log("[NIP-17] Starting gift wrap subscription");
this.subscribeToGiftWraps(activePubkey);
}
}
/**
* Check if subscription is currently active
*/
isSubscriptionActive(): boolean {
return this.subscriptionActive;
async getInboxRelays(pubkey: string): Promise<string[]> {
return giftWrapService.getInboxRelays(pubkey);
}
// ==================== Private Methods ====================
/**
* Subscribe to gift wraps for the user from their inbox relays
* Also subscribes to eventStore.insert$ to catch locally published gift wraps
*/
private async subscribeToGiftWraps(pubkey: string): Promise<void> {
// Don't create duplicate subscriptions
if (this.subscriptionActive) {
console.log("[NIP-17] Subscription already active, skipping");
return;
}
this.subscriptionActive = true;
// First, load any cached gift wraps from EventStore (persisted to Dexie)
// This is critical for cold start scenarios
await this.loadCachedGiftWraps(pubkey);
// Subscribe to eventStore.insert$ to catch gift wraps added locally (e.g., after sending)
// This is critical for immediate display of sent messages
const insertSub = eventStore.insert$.subscribe((event) => {
if (
event.kind === GIFT_WRAP_KIND &&
event.tags.some((t) => t[0] === "p" && t[1] === pubkey)
) {
console.log(
`[NIP-17] Detected gift wrap from eventStore.insert$: ${event.id.slice(0, 8)}...`,
);
this.handleGiftWrap(event);
}
});
this.subscriptions.set(`nip-17:insert:${pubkey}`, insertSub);
const conversationId = `nip-17:inbox:${pubkey}`;
// Get user's private inbox relays (kind 10050)
const inboxRelays = await this.fetchInboxRelays(pubkey);
if (inboxRelays.length === 0) {
console.warn(
"[NIP-17] No inbox relays found. Configure kind 10050 to receive DMs.",
);
// Still keep subscriptionActive true for insert$ subscription
return;
}
console.log(
`[NIP-17] Subscribing to ${inboxRelays.length} inbox relays:`,
inboxRelays,
);
// Subscribe to gift wraps addressed to this user from relays
const filter: Filter = {
kinds: [GIFT_WRAP_KIND],
"#p": [pubkey],
};
const relaySub = pool
.subscription(inboxRelays, [filter], { eventStore })
.subscribe({
next: (response) => {
if (typeof response === "string") {
// EOSE
console.log("[NIP-17] EOSE received for gift wraps");
} else {
// New gift wrap received from relay
console.log(
`[NIP-17] Received gift wrap from relay: ${response.id.slice(0, 8)}...`,
);
this.handleGiftWrap(response);
}
},
error: (err) => {
console.error("[NIP-17] Relay subscription error:", err);
},
complete: () => {
console.log("[NIP-17] Relay subscription completed");
},
});
this.subscriptions.set(conversationId, relaySub);
}
/**
* Load cached gift wraps from Dexie (persistent storage)
* This is called on cold start to restore previously received gift wraps
* We query Dexie directly because EventStore is in-memory and empty on cold start
*/
private async loadCachedGiftWraps(pubkey: string): Promise<void> {
try {
// Query Dexie directly for cached gift wraps addressed to this user
// EventStore is in-memory only, so on cold start it's empty
const cachedGiftWraps = await getEventsForFilters([
{ kinds: [GIFT_WRAP_KIND], "#p": [pubkey] },
]);
if (cachedGiftWraps.length > 0) {
console.log(
`[NIP-17] Loading ${cachedGiftWraps.length} cached gift wrap(s) from Dexie`,
);
for (const giftWrap of cachedGiftWraps) {
// Add to EventStore so other parts of the app can access it
eventStore.add(giftWrap);
// Handle in adapter state
this.handleGiftWrap(giftWrap);
}
} else {
console.log("[NIP-17] No cached gift wraps found in Dexie");
}
} catch (error) {
console.warn("[NIP-17] Failed to load cached gift wraps:", error);
}
}
/**
* Handle a received or sent gift wrap
*/
private handleGiftWrap(giftWrap: NostrEvent): void {
// Add to gift wraps list if not already present
const current = this.giftWraps$.value;
if (!current.find((g) => g.id === giftWrap.id)) {
this.giftWraps$.next([...current, giftWrap]);
}
// Check if unlocked (cached) or pending (skip if already failed)
if (!isGiftWrapUnlocked(giftWrap)) {
if (!this.failedGiftWraps$.value.has(giftWrap.id)) {
const pending = new Set(this.pendingGiftWraps$.value);
pending.add(giftWrap.id);
this.pendingGiftWraps$.next(pending);
}
}
}
/** Cache for inbox relays */
private inboxRelayCache = new Map<string, string[]>();
/**
* Get inbox relays for a user (public API for UI display)
* Returns cached value or fetches from network
*/
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, ""),
{ defaultValue: undefined },
);
if (existing) {
const relays = this.extractRelaysFromEvent(existing);
if (relays.length > 0) {
console.log(
`[NIP-17] Found inbox relays in store for ${pubkey.slice(0, 8)}:`,
relays,
);
return relays;
}
}
// Get user's outbox relays to search for their kind 10050
const outboxRelays = await relayListCache.getOutboxRelays(pubkey);
const searchRelays =
outboxRelays && outboxRelays.length > 0
? outboxRelays
: AGGREGATOR_RELAYS;
if (searchRelays.length === 0) {
console.warn(
`[NIP-17] No relays to search for kind 10050 for ${pubkey.slice(0, 8)}`,
);
return [];
}
console.log(
`[NIP-17] Searching ${searchRelays.length} relays for kind 10050:`,
searchRelays,
);
// Fetch from user's outbox 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(searchRelays, [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) {
const relays = this.extractRelaysFromEvent(events[0]);
console.log(
`[NIP-17] Found inbox relays from network for ${pubkey.slice(0, 8)}:`,
relays,
);
return relays;
}
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]),
);
@@ -908,11 +500,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
metadata: {
encrypted: true,
},
// Create a pseudo-event for the rumor (unsigned)
event: {
...rumor,
sig: "",
} as NostrEvent,
event: { ...rumor, sig: "" } as NostrEvent,
};
}
@@ -924,21 +512,9 @@ export class Nip17Adapter extends ChatProtocolAdapter {
defaultValue: undefined,
});
}
/**
* Add a gift wrap event directly to local state
* Used for optimistic updates after sending
*/
addGiftWrapLocally(giftWrap: NostrEvent): void {
const current = this.giftWraps$.value;
if (!current.find((g) => g.id === giftWrap.id)) {
this.giftWraps$.next([...current, giftWrap]);
}
}
}
/**
* Singleton instance for shared state across the app
* All components should use this to ensure gift wraps are shared
*/
export const nip17Adapter = new Nip17Adapter();

View File

@@ -0,0 +1,461 @@
/**
* Gift Wrap Service - Global gift wrap handling for NIP-59
*
* Gift wraps (kind 1059) can contain any kind of event, not just DMs.
* This service provides global subscription and decryption management
* that is independent of any specific protocol adapter.
*
* Features:
* - Subscribe to gift wraps from user's inbox relays
* - Track pending (encrypted), decrypted, and failed gift wraps
* - Persist decrypted content via applesauce
* - Enable/disable via settings (persisted to localStorage)
* - Exposes observables for UI components
*/
import { BehaviorSubject, Observable, Subscription } from "rxjs";
import { map, distinctUntilChanged } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "./event-store";
import pool from "./relay-pool";
import accountManager from "./accounts";
import { relayListCache } from "./relay-list-cache";
import { getEventsForFilters } from "./event-cache";
import { AGGREGATOR_RELAYS } from "./loaders";
import {
unlockGiftWrap,
isGiftWrapUnlocked,
getGiftWrapRumor,
} from "applesauce-common/helpers";
const GIFT_WRAP_KIND = 1059;
const DM_RELAY_LIST_KIND = 10050;
const STORAGE_KEY = "grimoire:gift-wrap-enabled";
/**
* Gift Wrap Service - Singleton for global gift wrap management
*/
class GiftWrapService {
/** Whether gift wrap subscription is enabled */
private enabled$ = new BehaviorSubject<boolean>(this.loadEnabledState());
/** All gift wraps we've seen */
private giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
/** Gift wrap IDs that are still encrypted (pending decryption) */
private pendingIds$ = new BehaviorSubject<Set<string>>(new Set());
/** Gift wrap IDs that failed to decrypt */
private failedIds$ = new BehaviorSubject<Set<string>>(new Set());
/** Active subscriptions */
private subscriptions = new Map<string, Subscription>();
/** Current user pubkey */
private currentPubkey: string | null = null;
/** Whether subscription is currently active */
private subscriptionActive = false;
constructor() {
// React to account changes
accountManager.active$.subscribe((account) => {
const newPubkey = account?.pubkey || null;
if (newPubkey !== this.currentPubkey) {
this.handleAccountChange(newPubkey);
}
});
// React to enabled state changes
this.enabled$.pipe(distinctUntilChanged()).subscribe((enabled) => {
this.saveEnabledState(enabled);
if (enabled && this.currentPubkey) {
this.startSubscription(this.currentPubkey);
} else if (!enabled) {
this.stopSubscription();
}
});
}
// ==================== Public API ====================
/**
* Check if gift wrap subscription is enabled
*/
isEnabled(): boolean {
return this.enabled$.value;
}
/**
* Observable of enabled state
*/
isEnabled$(): Observable<boolean> {
return this.enabled$.asObservable();
}
/**
* Enable gift wrap subscription
*/
enable(): void {
this.enabled$.next(true);
}
/**
* Disable gift wrap subscription
*/
disable(): void {
this.enabled$.next(false);
}
/**
* Toggle enabled state
*/
toggle(): void {
this.enabled$.next(!this.enabled$.value);
}
/**
* Get all gift wraps (both encrypted and decrypted)
*/
getGiftWraps$(): Observable<NostrEvent[]> {
return this.giftWraps$.asObservable();
}
/**
* Get only decrypted gift wraps
*/
getDecryptedGiftWraps$(): Observable<NostrEvent[]> {
return this.giftWraps$.pipe(
map((wraps) => wraps.filter((w) => isGiftWrapUnlocked(w))),
);
}
/**
* Get count of pending (encrypted but not failed) gift wraps
*/
getPendingCount(): number {
const pending = this.pendingIds$.value;
const failed = this.failedIds$.value;
return Array.from(pending).filter((id) => !failed.has(id)).length;
}
/**
* Observable of pending count
*/
getPendingCount$(): Observable<number> {
return this.pendingIds$.pipe(
map((pending) => {
const failed = this.failedIds$.value;
return Array.from(pending).filter((id) => !failed.has(id)).length;
}),
);
}
/**
* Get count of failed gift wraps
*/
getFailedCount(): number {
return this.failedIds$.value.size;
}
/**
* Observable of failed count
*/
getFailedCount$(): Observable<number> {
return this.failedIds$.pipe(map((failed) => failed.size));
}
/**
* 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 failedSet = this.failedIds$.value;
const pendingIds = Array.from(this.pendingIds$.value).filter(
(id) => !failedSet.has(id),
);
let success = 0;
let failed = 0;
for (const giftWrapId of pendingIds) {
try {
const giftWrap = this.giftWraps$.value.find((g) => g.id === giftWrapId);
if (!giftWrap) {
this.markAsFailed(giftWrapId);
failed++;
continue;
}
if (isGiftWrapUnlocked(giftWrap)) {
this.removeFromPending(giftWrapId);
success++;
continue;
}
// Decrypt using signer - applesauce handles caching
await unlockGiftWrap(giftWrap, signer);
this.removeFromPending(giftWrapId);
// Trigger update so observers know decryption happened
this.giftWraps$.next([...this.giftWraps$.value]);
success++;
} catch (error) {
console.error(`[GiftWrap] Failed to decrypt ${giftWrapId}:`, error);
this.markAsFailed(giftWrapId);
failed++;
}
}
return { success, failed };
}
/**
* Get decrypted rumors of a specific kind
*/
getRumorsByKind$(kind: number): Observable<NostrEvent[]> {
return this.getDecryptedGiftWraps$().pipe(
map((wraps) => {
const rumors: NostrEvent[] = [];
for (const wrap of wraps) {
try {
const rumor = getGiftWrapRumor(wrap);
if (rumor && rumor.kind === kind) {
rumors.push({ ...rumor, sig: "" } as NostrEvent);
}
} catch {
// Skip invalid
}
}
return rumors;
}),
);
}
/**
* Get inbox relays for a pubkey
*/
async getInboxRelays(pubkey: string): Promise<string[]> {
return this.fetchInboxRelays(pubkey);
}
// ==================== Private Methods ====================
private loadEnabledState(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === "true";
} catch {
return false;
}
}
private saveEnabledState(enabled: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, String(enabled));
} catch (error) {
console.warn("[GiftWrap] Failed to save enabled state:", error);
}
}
private handleAccountChange(newPubkey: string | null): void {
// Stop existing subscription
this.stopSubscription();
// Clear state
this.giftWraps$.next([]);
this.pendingIds$.next(new Set());
this.failedIds$.next(new Set());
this.currentPubkey = newPubkey;
// Start new subscription if enabled and logged in
if (this.enabled$.value && newPubkey) {
this.startSubscription(newPubkey);
}
}
private async startSubscription(pubkey: string): Promise<void> {
if (this.subscriptionActive) {
console.log("[GiftWrap] Subscription already active");
return;
}
console.log("[GiftWrap] Starting subscription for", pubkey.slice(0, 8));
this.subscriptionActive = true;
// Load cached gift wraps from Dexie
await this.loadCachedGiftWraps(pubkey);
// Subscribe to eventStore.insert$ for locally published gift wraps
const insertSub = eventStore.insert$.subscribe((event) => {
if (
event.kind === GIFT_WRAP_KIND &&
event.tags.some((t) => t[0] === "p" && t[1] === pubkey)
) {
console.log(
`[GiftWrap] New gift wrap from local: ${event.id.slice(0, 8)}`,
);
this.handleGiftWrap(event);
}
});
this.subscriptions.set("insert", insertSub);
// Get inbox relays and subscribe
const inboxRelays = await this.fetchInboxRelays(pubkey);
if (inboxRelays.length === 0) {
console.warn("[GiftWrap] No inbox relays found");
return;
}
console.log(`[GiftWrap] Subscribing to ${inboxRelays.length} inbox relays`);
const filter: Filter = {
kinds: [GIFT_WRAP_KIND],
"#p": [pubkey],
};
const relaySub = pool
.subscription(inboxRelays, [filter], { eventStore })
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[GiftWrap] EOSE received");
} else {
console.log(
`[GiftWrap] New gift wrap from relay: ${response.id.slice(0, 8)}`,
);
this.handleGiftWrap(response);
}
},
error: (err) => {
console.error("[GiftWrap] Subscription error:", err);
},
});
this.subscriptions.set("relays", relaySub);
}
private stopSubscription(): void {
console.log("[GiftWrap] Stopping subscription");
this.subscriptionActive = false;
for (const sub of this.subscriptions.values()) {
sub.unsubscribe();
}
this.subscriptions.clear();
}
private async loadCachedGiftWraps(pubkey: string): Promise<void> {
try {
const cached = await getEventsForFilters([
{ kinds: [GIFT_WRAP_KIND], "#p": [pubkey] },
]);
if (cached.length > 0) {
console.log(`[GiftWrap] Loaded ${cached.length} from cache`);
for (const giftWrap of cached) {
eventStore.add(giftWrap);
this.handleGiftWrap(giftWrap);
}
}
} catch (error) {
console.warn("[GiftWrap] Failed to load cache:", error);
}
}
private handleGiftWrap(giftWrap: NostrEvent): void {
const current = this.giftWraps$.value;
if (!current.find((g) => g.id === giftWrap.id)) {
this.giftWraps$.next([...current, giftWrap]);
}
if (!isGiftWrapUnlocked(giftWrap)) {
if (!this.failedIds$.value.has(giftWrap.id)) {
const pending = new Set(this.pendingIds$.value);
pending.add(giftWrap.id);
this.pendingIds$.next(pending);
}
}
}
private markAsFailed(id: string): void {
const failed = new Set(this.failedIds$.value);
failed.add(id);
this.failedIds$.next(failed);
}
private removeFromPending(id: string): void {
const pending = new Set(this.pendingIds$.value);
pending.delete(id);
this.pendingIds$.next(pending);
}
private async fetchInboxRelays(pubkey: string): Promise<string[]> {
// Check EventStore first
const existing = eventStore.getReplaceable(DM_RELAY_LIST_KIND, pubkey);
if (existing) {
const relays = this.extractRelaysFromEvent(existing);
if (relays.length > 0) return relays;
}
// Search on outbox relays
const outboxRelays = await relayListCache.getOutboxRelays(pubkey);
const searchRelays =
outboxRelays && outboxRelays.length > 0
? outboxRelays
: AGGREGATOR_RELAYS;
if (searchRelays.length === 0) return [];
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(searchRelays, [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 [];
}
private extractRelaysFromEvent(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]);
}
}
/**
* Singleton instance
*/
export const giftWrapService = new GiftWrapService();