diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
index f41eb10..a1a12bb 100644
--- a/src/components/ChatViewer.tsx
+++ b/src/components/ChatViewer.tsx
@@ -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`,
);
diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx
index ae16100..7cea47f 100644
--- a/src/components/InboxViewer.tsx
+++ b/src/components/InboxViewer.tsx
@@ -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 (
+
+
+
+
+ Gift Wrap Subscription Disabled
+
+
+ Enable gift wrap subscription to receive and decrypt private messages.
+ Gift wraps (NIP-59) are used for encrypted communication.
+
+
+
+
+ );
+}
+
/**
* 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();
@@ -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 ;
+ }
+
// Sidebar content
const sidebarContent = (
{/* Header */}
-
-
-
-
Private Messages
+
+
+
+
+
Private Messages
+
+
>(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>(new Set());
-
- /** Observable of gift wrap events from event store */
- private giftWraps$ = new BehaviorSubject([]);
-
- /** Track if subscription is active */
- private subscriptionActive = false;
+ /**
+ * Observable of whether NIP-17 is available
+ */
+ isAvailable$(): Observable {
+ 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 {
- // 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 {
- 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 {
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 {
+ 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 {
- // 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 {
- 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();
-
- /**
- * Get inbox relays for a user (public API for UI display)
- * Returns cached value or fetches from network
- */
- async getInboxRelays(pubkey: string): Promise {
- 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 {
- // 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((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();
diff --git a/src/services/gift-wrap-service.ts b/src/services/gift-wrap-service.ts
new file mode 100644
index 0000000..67431d4
--- /dev/null
+++ b/src/services/gift-wrap-service.ts
@@ -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(this.loadEnabledState());
+
+ /** All gift wraps we've seen */
+ private giftWraps$ = new BehaviorSubject([]);
+
+ /** Gift wrap IDs that are still encrypted (pending decryption) */
+ private pendingIds$ = new BehaviorSubject>(new Set());
+
+ /** Gift wrap IDs that failed to decrypt */
+ private failedIds$ = new BehaviorSubject>(new Set());
+
+ /** Active subscriptions */
+ private subscriptions = new Map();
+
+ /** 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 {
+ 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 {
+ return this.giftWraps$.asObservable();
+ }
+
+ /**
+ * Get only decrypted gift wraps
+ */
+ getDecryptedGiftWraps$(): Observable {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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((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();