From 3b1412f34eff40fdd4cb56426e6cf1eb0ecbb5e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Jan 2026 17:03:41 +0000 Subject: [PATCH] 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 --- src/components/ChatViewer.tsx | 16 +- src/components/InboxViewer.tsx | 131 ++++-- src/lib/chat/adapters/nip-17-adapter.ts | 526 +++--------------------- src/services/gift-wrap-service.ts | 461 +++++++++++++++++++++ 4 files changed, 615 insertions(+), 519 deletions(-) create mode 100644 src/services/gift-wrap-service.ts 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

+
+