import { useEffect, useState } from "react"; import { use$ } from "applesauce-react/hooks"; import { Mail, MailOpen, Lock, Unlock, Loader2, AlertCircle, CheckCircle2, Clock, Radio, RefreshCw, MessageSquare, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { RelayLink } from "@/components/nostr/RelayLink"; import { UserName } from "@/components/nostr/UserName"; import giftWrapService from "@/services/gift-wrap"; import accounts from "@/services/accounts"; import { cn } from "@/lib/utils"; import { formatTimestamp } from "@/hooks/useLocale"; import type { DecryptStatus } from "@/services/gift-wrap"; import { useGrimoire } from "@/core/state"; /** * InboxViewer - Manage private messages (NIP-17/59 gift wraps) */ function InboxViewer() { const { addWindow } = useGrimoire(); const account = use$(accounts.active$); const settings = use$(giftWrapService.settings$); const syncStatus = use$(giftWrapService.syncStatus$); const giftWraps = use$(giftWrapService.giftWraps$); const decryptStates = use$(giftWrapService.decryptStates$); const conversations = use$(giftWrapService.conversations$); const inboxRelays = use$(giftWrapService.inboxRelays$); const [isDecryptingAll, setIsDecryptingAll] = useState(false); // Initialize service when account changes useEffect(() => { if (account) { giftWrapService.init(account.pubkey, account.signer ?? null); } }, [account]); // Update signer when it changes useEffect(() => { if (account?.signer) { giftWrapService.setSigner(account.signer); } }, [account?.signer]); // Calculate counts const counts = { pending: 0, decrypting: 0, success: 0, error: 0, total: giftWraps?.length ?? 0, }; if (decryptStates) { for (const state of decryptStates.values()) { switch (state.status) { case "pending": counts.pending++; break; case "decrypting": counts.decrypting++; break; case "success": counts.success++; break; case "error": counts.error++; break; } } } const handleToggleEnabled = (checked: boolean) => { giftWrapService.updateSettings({ enabled: checked }); }; const handleToggleAutoDecrypt = (checked: boolean) => { giftWrapService.updateSettings({ autoDecrypt: checked }); }; const handleDecryptAll = async () => { if (!account?.signer) { toast.error( "No signer available. Please log in with a signer that supports encryption.", ); return; } setIsDecryptingAll(true); try { const result = await giftWrapService.decryptAll(); if (result.success > 0) { toast.success(`Decrypted ${result.success} messages`); } if (result.error > 0) { toast.error(`Failed to decrypt ${result.error} messages`); } } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to decrypt messages", ); } finally { setIsDecryptingAll(false); } }; const handleRefresh = () => { giftWrapService.startSync(); }; if (!account) { return (

Log in to access private messages

); } return (
{/* Settings Section */}
Private Messages
{syncStatus === "syncing" && ( )}
{/* Enable/Disable Toggle */}
{/* Auto-decrypt Toggle */}
{/* Inbox Relays Section */} {inboxRelays && inboxRelays.length > 0 && (
DM Inbox Relays (kind 10050)
{inboxRelays.map((relay) => ( ))}
)} {inboxRelays && inboxRelays.length === 0 && settings?.enabled && (
No DM inbox relays configured (kind 10050)
)} {/* Decrypt Status Section */} {settings?.enabled && counts.total > 0 && (
Gift Wraps ({counts.total})
{/* Only show manual decrypt options when auto-decrypt is OFF */} {!settings?.autoDecrypt && (counts.pending > 0 || counts.decrypting > 0) && (
{counts.pending + counts.decrypting} messages waiting to be decrypted
)} {/* Show auto-decrypt status when enabled and there are pending messages */} {settings?.autoDecrypt && (counts.pending > 0 || counts.decrypting > 0) && (
Auto-decrypting messages...
)}
)} {/* Conversations Section */}
{settings?.enabled && conversations && conversations.length > 0 && ( <>
Recent Conversations ({conversations.length})
{conversations.map((conv) => ( { // Build chat identifier from participants // For self-chat, use $me; for others, use comma-separated npubs const others = conv.participants.filter( (p) => p !== account.pubkey, ); const identifier = others.length === 0 ? "$me" : others.join(","); addWindow("chat", { identifier, protocol: "nip-17", }); }} /> ))} )} {settings?.enabled && (!conversations || conversations.length === 0) && counts.success === 0 && (

No conversations yet

{counts.pending > 0 && (

Decrypt pending messages to see conversations

)}
)} {!settings?.enabled && (

Enable gift wrap sync to receive private messages

)}
{/* Pending Gift Wraps List (for manual decrypt) */} {settings?.enabled && !settings.autoDecrypt && counts.pending > 0 && ( { try { const result = await giftWrapService.decrypt(id); if (result) { toast.success("Message decrypted"); } else { // Decryption failed but didn't throw const state = giftWrapService.decryptStates$.value.get(id); toast.error(state?.error || "Failed to decrypt message"); } } catch (err) { toast.error( err instanceof Error ? err.message : "Decryption failed", ); } }} /> )}
); } interface StatusBadgeProps { status: "success" | "pending" | "error"; count: number; } function StatusBadge({ status, count }: StatusBadgeProps) { if (count === 0) return null; const config = { success: { icon: CheckCircle2, className: "bg-green-500/10 text-green-500 border-green-500/20", }, pending: { icon: Clock, className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", }, error: { icon: AlertCircle, className: "bg-red-500/10 text-red-500 border-red-500/20", }, }; const { icon: Icon, className } = config[status]; return ( {count} ); } interface ConversationRowProps { conversation: { id: string; participants: string[]; lastMessage?: { content: string; created_at: number; pubkey: string }; }; currentUserPubkey: string; onClick: () => void; } function ConversationRow({ conversation, currentUserPubkey, onClick, }: ConversationRowProps) { // Filter out current user from participants for display const otherParticipants = conversation.participants.filter( (p) => p !== currentUserPubkey, ); // Self-conversation (saved messages) const isSelfConversation = otherParticipants.length === 0; return (
{isSelfConversation ? ( Saved Messages ) : ( <> {otherParticipants.slice(0, 3).map((pubkey, i) => ( {i > 0 && ( , )} ))} {otherParticipants.length > 3 && ( +{otherParticipants.length - 3} )} )}
{conversation.lastMessage && (

{conversation.lastMessage.content}

)}
{conversation.lastMessage && ( {formatTimestamp(conversation.lastMessage.created_at)} )}
); } interface PendingGiftWrapsListProps { decryptStates: | Map | undefined; giftWraps: { id: string; created_at: number }[]; onDecrypt: (id: string) => Promise; } function PendingGiftWrapsList({ decryptStates, giftWraps, onDecrypt, }: PendingGiftWrapsListProps) { const [decryptingIds, setDecryptingIds] = useState>(new Set()); const pendingWraps = giftWraps.filter((gw) => { const state = decryptStates?.get(gw.id); return state?.status === "pending" || state?.status === "error"; }); if (pendingWraps.length === 0) return null; return (
Pending Decryption
{pendingWraps.slice(0, 10).map((gw) => { const state = decryptStates?.get(gw.id); const isDecrypting = decryptingIds.has(gw.id); return (
{state?.status === "error" ? (

{state.error || "Decryption failed"}

) : ( )} {gw.id.slice(0, 16)}... {formatTimestamp(gw.created_at)}
); })} {pendingWraps.length > 10 && (
And {pendingWraps.length - 10} more...
)}
); } export default InboxViewer;