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 */}
Enable gift wrap sync
{/* Auto-decrypt Toggle */}
Auto-decrypt messages
{/* 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
{isDecryptingAll ? (
<>
Decrypting...
>
) : (
<>
Decrypt All
>
)}
)}
{/* 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)}
{
setDecryptingIds((prev) => new Set([...prev, gw.id]));
try {
await onDecrypt(gw.id);
} finally {
setDecryptingIds((prev) => {
const next = new Set(prev);
next.delete(gw.id);
return next;
});
}
}}
>
{isDecrypting ? (
) : (
)}
);
})}
{pendingWraps.length > 10 && (
And {pendingWraps.length - 10} more...
)}
);
}
export default InboxViewer;