mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-15 01:46:53 +02:00
feat: Add inbox for NIP-17 private direct messages
Implements private message inbox with gift wrap (NIP-59) support: - Add GiftWrapService for managing encrypted messages (NIP-17/NIP-59) - Track decrypt states (pending/decrypting/success/error) - Load inbox relays from kind 10050 (DM relay list) - Auto-decrypt option with settings persistence - Group messages into conversations - Add InboxViewer component with: - Enable/disable private messages toggle - Auto-decrypt toggle - Inbox relay display - Decrypt status badges (pending/success/error) - Conversation list with last message preview - Click to decrypt individual messages - Add encrypted content persistence using Dexie (version 16) - Add "inbox" command to man pages - Add "Private Messages" menu item to user menu
This commit is contained in:
529
src/components/InboxViewer.tsx
Normal file
529
src/components/InboxViewer.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import {
|
||||
Mail,
|
||||
MailOpen,
|
||||
Lock,
|
||||
Unlock,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Users,
|
||||
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";
|
||||
|
||||
/**
|
||||
* InboxViewer - Manage private messages (NIP-17/59 gift wraps)
|
||||
*/
|
||||
function InboxViewer() {
|
||||
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 (
|
||||
<div className="h-full w-full flex items-center justify-center bg-background text-foreground">
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
<Lock className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Log in to access private messages</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background text-foreground">
|
||||
{/* Settings Section */}
|
||||
<div className="border-b border-border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="size-5 text-muted-foreground" />
|
||||
<span className="font-semibold">Private Messages</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{syncStatus === "syncing" && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={!settings?.enabled || syncStatus === "syncing"}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="inbox-enabled"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
/>
|
||||
<label htmlFor="inbox-enabled" className="text-sm cursor-pointer">
|
||||
Enable gift wrap sync
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto-decrypt Toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-decrypt"
|
||||
checked={settings?.autoDecrypt ?? false}
|
||||
onCheckedChange={handleToggleAutoDecrypt}
|
||||
disabled={!settings?.enabled}
|
||||
/>
|
||||
<label
|
||||
htmlFor="auto-decrypt"
|
||||
className={cn(
|
||||
"text-sm cursor-pointer",
|
||||
!settings?.enabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
Auto-decrypt messages
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inbox Relays Section */}
|
||||
{inboxRelays && inboxRelays.length > 0 && (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<Radio className="size-3.5" />
|
||||
<span>DM Inbox Relays (kind 10050)</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 space-y-1">
|
||||
{inboxRelays.map((relay) => (
|
||||
<RelayLink
|
||||
key={relay}
|
||||
url={relay}
|
||||
className="text-sm"
|
||||
iconClassname="size-4"
|
||||
urlClassname="text-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inboxRelays && inboxRelays.length === 0 && settings?.enabled && (
|
||||
<div className="border-b border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 text-yellow-500" />
|
||||
<span>No DM inbox relays configured (kind 10050)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decrypt Status Section */}
|
||||
{settings?.enabled && counts.total > 0 && (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground flex items-center justify-between">
|
||||
<span>Gift Wraps ({counts.total})</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status="success" count={counts.success} />
|
||||
<StatusBadge
|
||||
status="pending"
|
||||
count={counts.pending + counts.decrypting}
|
||||
/>
|
||||
<StatusBadge status="error" count={counts.error} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(counts.pending > 0 || counts.decrypting > 0) && (
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{counts.pending + counts.decrypting} messages waiting to be
|
||||
decrypted
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleDecryptAll}
|
||||
disabled={isDecryptingAll || !account?.signer}
|
||||
>
|
||||
{isDecryptingAll ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Decrypting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="size-4 mr-2" />
|
||||
Decrypt All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversations Section */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{settings?.enabled && conversations && conversations.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<MessageSquare className="size-3.5" />
|
||||
<span>Recent Conversations ({conversations.length})</span>
|
||||
</div>
|
||||
{conversations.map((conv) => (
|
||||
<ConversationRow
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
currentUserPubkey={account.pubkey}
|
||||
onClick={() => {
|
||||
// Open chat window - for now just show a toast
|
||||
// In future, this would open the conversation in a chat viewer
|
||||
toast.info("Chat viewer coming soon");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{settings?.enabled &&
|
||||
(!conversations || conversations.length === 0) &&
|
||||
counts.success === 0 && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-8">
|
||||
<MailOpen className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No conversations yet</p>
|
||||
{counts.pending > 0 && (
|
||||
<p className="text-xs mt-2">
|
||||
Decrypt pending messages to see conversations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!settings?.enabled && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-8">
|
||||
<Mail className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Enable gift wrap sync to receive private messages</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Gift Wraps List (for manual decrypt) */}
|
||||
{settings?.enabled && !settings.autoDecrypt && counts.pending > 0 && (
|
||||
<PendingGiftWrapsList
|
||||
decryptStates={decryptStates}
|
||||
giftWraps={giftWraps ?? []}
|
||||
onDecrypt={async (id) => {
|
||||
try {
|
||||
await giftWrapService.decrypt(id);
|
||||
toast.success("Message decrypted");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Decryption failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Badge variant="outline" className={cn("gap-1", className)}>
|
||||
<Icon className="size-3" />
|
||||
{count}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-border px-4 py-3 hover:bg-muted/30 cursor-crosshair transition-colors"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{otherParticipants.length === 1 ? (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{otherParticipants.slice(0, 3).map((pubkey, i) => (
|
||||
<span key={pubkey}>
|
||||
{i > 0 && <span className="text-muted-foreground">, </span>}
|
||||
<UserName pubkey={pubkey} className="text-sm" />
|
||||
</span>
|
||||
))}
|
||||
{otherParticipants.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{otherParticipants.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conversation.lastMessage && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
{conversation.lastMessage.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conversation.lastMessage && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatTimestamp(conversation.lastMessage.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PendingGiftWrapsListProps {
|
||||
decryptStates:
|
||||
| Map<string, { status: DecryptStatus; error?: string }>
|
||||
| undefined;
|
||||
giftWraps: { id: string; created_at: number }[];
|
||||
onDecrypt: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function PendingGiftWrapsList({
|
||||
decryptStates,
|
||||
giftWraps,
|
||||
onDecrypt,
|
||||
}: PendingGiftWrapsListProps) {
|
||||
const [decryptingIds, setDecryptingIds] = useState<Set<string>>(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 (
|
||||
<div className="border-t border-border max-h-48 overflow-y-auto">
|
||||
<div className="px-4 py-2 bg-muted/30 text-xs font-semibold text-muted-foreground">
|
||||
Pending Decryption
|
||||
</div>
|
||||
{pendingWraps.slice(0, 10).map((gw) => {
|
||||
const state = decryptStates?.get(gw.id);
|
||||
const isDecrypting = decryptingIds.has(gw.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={gw.id}
|
||||
className="border-b border-border px-4 py-2 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{state?.status === "error" ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertCircle className="size-4 text-red-500 flex-shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{state.error || "Decryption failed"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Lock className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{gw.id.slice(0, 16)}...
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(gw.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
disabled={isDecrypting}
|
||||
onClick={async () => {
|
||||
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 ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{pendingWraps.length > 10 && (
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground text-center">
|
||||
And {pendingWraps.length - 10} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InboxViewer;
|
||||
@@ -43,6 +43,7 @@ const BlossomViewer = lazy(() =>
|
||||
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
|
||||
);
|
||||
const CountViewer = lazy(() => import("./CountViewer"));
|
||||
const InboxViewer = lazy(() => import("./InboxViewer"));
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -220,6 +221,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "inbox":
|
||||
content = <InboxViewer />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User, HardDrive, Palette } from "lucide-react";
|
||||
import { User, HardDrive, Palette, Mail } from "lucide-react";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
@@ -158,6 +158,15 @@ export default function UserMenu() {
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-crosshair"
|
||||
onClick={() => {
|
||||
addWindow("inbox", {}, "Inbox");
|
||||
}}
|
||||
>
|
||||
<Mail className="size-4 mr-2" />
|
||||
Private Messages
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-crosshair">
|
||||
<Palette className="size-4 mr-2" />
|
||||
|
||||
@@ -61,6 +61,12 @@ export interface CachedBlossomServerList {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface EncryptedContentEntry {
|
||||
id: string; // Event ID (gift wrap or seal)
|
||||
plaintext: string; // Decrypted content
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export interface LocalSpell {
|
||||
id: string; // UUID for local-only spells, or event ID for published spells
|
||||
alias?: string; // Optional local-only quick name (e.g., "btc")
|
||||
@@ -98,6 +104,7 @@ class GrimoireDb extends Dexie {
|
||||
blossomServers!: Table<CachedBlossomServerList>;
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
encryptedContent!: Table<EncryptedContentEntry>;
|
||||
|
||||
constructor(name: string) {
|
||||
super(name);
|
||||
@@ -333,6 +340,21 @@ class GrimoireDb extends Dexie {
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
});
|
||||
|
||||
// Version 16: Add encrypted content cache for gift wraps (NIP-17/59)
|
||||
this.version(16).stores({
|
||||
profiles: "&pubkey",
|
||||
nip05: "&nip05",
|
||||
nips: "&id",
|
||||
relayInfo: "&url",
|
||||
relayAuthPreferences: "&url",
|
||||
relayLists: "&pubkey, updatedAt",
|
||||
relayLiveness: "&url",
|
||||
blossomServers: "&pubkey, updatedAt",
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
encryptedContent: "&id, savedAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,4 +382,23 @@ export const relayLivenessStorage = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dexie storage adapter for encrypted content persistence (NIP-17/59 gift wraps)
|
||||
* Implements the EncryptedContentCache interface expected by applesauce-common
|
||||
*/
|
||||
export const encryptedContentStorage = {
|
||||
async getItem(id: string): Promise<string | null> {
|
||||
const entry = await db.encryptedContent.get(id);
|
||||
return entry?.plaintext ?? null;
|
||||
},
|
||||
|
||||
async setItem(id: string, plaintext: string): Promise<void> {
|
||||
await db.encryptedContent.put({
|
||||
id,
|
||||
plaintext,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default db;
|
||||
|
||||
470
src/services/gift-wrap.ts
Normal file
470
src/services/gift-wrap.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import { BehaviorSubject, Subject, Subscription, filter, map } from "rxjs";
|
||||
import { kinds } from "applesauce-core/helpers/event";
|
||||
import {
|
||||
isGiftWrapUnlocked,
|
||||
getGiftWrapRumor,
|
||||
unlockGiftWrap,
|
||||
} from "applesauce-common/helpers/gift-wrap";
|
||||
import {
|
||||
getConversationIdentifierFromMessage,
|
||||
getConversationParticipants,
|
||||
} from "applesauce-common/helpers/messages";
|
||||
import { persistEncryptedContent } from "applesauce-common/helpers/encrypted-content-cache";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { ISigner } from "applesauce-signers";
|
||||
import eventStore from "./event-store";
|
||||
import pool from "./relay-pool";
|
||||
import { encryptedContentStorage } from "./db";
|
||||
|
||||
/** Kind 10050: DM relay list (NIP-17) */
|
||||
const DM_RELAY_LIST_KIND = 10050;
|
||||
|
||||
/** Kind 14: Private direct message (NIP-17) */
|
||||
const PRIVATE_DM_KIND = 14;
|
||||
|
||||
/** Rumor is an unsigned event - used for gift wrap contents */
|
||||
interface Rumor {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Status of a gift wrap decryption */
|
||||
export type DecryptStatus = "pending" | "decrypting" | "success" | "error";
|
||||
|
||||
export interface DecryptState {
|
||||
status: DecryptStatus;
|
||||
error?: string;
|
||||
decryptedAt?: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
participants: string[];
|
||||
lastMessage?: Rumor;
|
||||
lastGiftWrap?: NostrEvent;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
/** Settings for the inbox service */
|
||||
export interface InboxSettings {
|
||||
enabled: boolean;
|
||||
autoDecrypt: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = "grimoire-inbox-settings";
|
||||
|
||||
function loadSettings(): InboxSettings {
|
||||
try {
|
||||
const saved = localStorage.getItem(SETTINGS_KEY);
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { enabled: false, autoDecrypt: false };
|
||||
}
|
||||
|
||||
function saveSettings(settings: InboxSettings) {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
class GiftWrapService {
|
||||
/** Current user's pubkey */
|
||||
private userPubkey: string | null = null;
|
||||
/** Current signer for decryption */
|
||||
private signer: ISigner | null = null;
|
||||
|
||||
/** Map of gift wrap ID -> decrypt state */
|
||||
private decryptStates = new Map<string, DecryptState>();
|
||||
/** Observable for decrypt state changes */
|
||||
readonly decryptStates$ = new BehaviorSubject<Map<string, DecryptState>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
/** All gift wraps for the current user */
|
||||
private giftWraps: NostrEvent[] = [];
|
||||
readonly giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
|
||||
|
||||
/** Conversations grouped by participants */
|
||||
readonly conversations$ = new BehaviorSubject<Conversation[]>([]);
|
||||
|
||||
/** Inbox relays (kind 10050) */
|
||||
readonly inboxRelays$ = new BehaviorSubject<string[]>([]);
|
||||
|
||||
/** Settings */
|
||||
readonly settings$ = new BehaviorSubject<InboxSettings>(loadSettings());
|
||||
|
||||
/** Sync status */
|
||||
readonly syncStatus$ = new BehaviorSubject<
|
||||
"idle" | "syncing" | "error" | "disabled"
|
||||
>("idle");
|
||||
|
||||
/** Event emitter for decrypt events */
|
||||
readonly decryptEvent$ = new Subject<{
|
||||
giftWrapId: string;
|
||||
status: DecryptStatus;
|
||||
rumor?: Rumor;
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
private subscriptions: Subscription[] = [];
|
||||
private relaySubscription: Subscription | null = null;
|
||||
private persistenceCleanup: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Start encrypted content persistence
|
||||
this.persistenceCleanup = persistEncryptedContent(
|
||||
eventStore,
|
||||
encryptedContentStorage,
|
||||
);
|
||||
}
|
||||
|
||||
/** Initialize the service with user pubkey and signer */
|
||||
init(pubkey: string, signer: ISigner | null) {
|
||||
this.cleanup();
|
||||
this.userPubkey = pubkey;
|
||||
this.signer = signer;
|
||||
this.decryptStates.clear();
|
||||
this.decryptStates$.next(new Map());
|
||||
|
||||
// Load inbox relays (kind 10050)
|
||||
this.loadInboxRelays();
|
||||
|
||||
// If enabled, start syncing
|
||||
if (this.settings$.value.enabled) {
|
||||
this.startSync();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update settings */
|
||||
updateSettings(settings: Partial<InboxSettings>) {
|
||||
const newSettings = { ...this.settings$.value, ...settings };
|
||||
this.settings$.next(newSettings);
|
||||
saveSettings(newSettings);
|
||||
|
||||
// Handle enabled state change
|
||||
if (settings.enabled !== undefined) {
|
||||
if (settings.enabled) {
|
||||
this.startSync();
|
||||
} else {
|
||||
this.stopSync();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auto-decrypt change
|
||||
if (settings.autoDecrypt && this.signer) {
|
||||
this.autoDecryptPending();
|
||||
}
|
||||
}
|
||||
|
||||
/** Load inbox relays from kind 10050 event */
|
||||
private async loadInboxRelays() {
|
||||
if (!this.userPubkey) return;
|
||||
|
||||
const sub = eventStore
|
||||
.replaceable(DM_RELAY_LIST_KIND, this.userPubkey)
|
||||
.pipe(
|
||||
filter((e) => e !== undefined),
|
||||
map((event) => {
|
||||
if (!event) return [];
|
||||
// Extract relay URLs from tags
|
||||
return event.tags
|
||||
.filter((tag) => tag[0] === "relay")
|
||||
.map((tag) => tag[1])
|
||||
.filter(Boolean);
|
||||
}),
|
||||
)
|
||||
.subscribe((relays) => {
|
||||
this.inboxRelays$.next(relays);
|
||||
});
|
||||
|
||||
this.subscriptions.push(sub);
|
||||
}
|
||||
|
||||
/** Start syncing gift wraps from inbox relays */
|
||||
startSync() {
|
||||
if (!this.userPubkey) {
|
||||
this.syncStatus$.next("disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = this.inboxRelays$.value;
|
||||
if (relays.length === 0) {
|
||||
// Use default relays if no inbox relays set
|
||||
this.syncStatus$.next("syncing");
|
||||
this.subscribeToGiftWraps([]);
|
||||
} else {
|
||||
this.syncStatus$.next("syncing");
|
||||
this.subscribeToGiftWraps(relays);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop syncing */
|
||||
stopSync() {
|
||||
this.syncStatus$.next("disabled");
|
||||
if (this.relaySubscription) {
|
||||
this.relaySubscription.unsubscribe();
|
||||
this.relaySubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to gift wraps for current user */
|
||||
private subscribeToGiftWraps(relays: string[]) {
|
||||
if (!this.userPubkey) return;
|
||||
|
||||
// Subscribe to gift wraps addressed to this user
|
||||
const reqFilter = {
|
||||
kinds: [kinds.GiftWrap],
|
||||
"#p": [this.userPubkey],
|
||||
};
|
||||
|
||||
// Use timeline observable for reactive updates
|
||||
const sub = eventStore
|
||||
.timeline(reqFilter)
|
||||
.pipe(map((events) => events.sort((a, b) => b.created_at - a.created_at)))
|
||||
.subscribe((giftWraps) => {
|
||||
this.giftWraps = giftWraps;
|
||||
this.giftWraps$.next(giftWraps);
|
||||
|
||||
// Update decrypt states for new gift wraps
|
||||
for (const gw of giftWraps) {
|
||||
if (!this.decryptStates.has(gw.id)) {
|
||||
const isUnlocked = isGiftWrapUnlocked(gw);
|
||||
this.decryptStates.set(gw.id, {
|
||||
status: isUnlocked ? "success" : "pending",
|
||||
decryptedAt: isUnlocked ? Date.now() : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
|
||||
// Update conversations
|
||||
this.updateConversations();
|
||||
|
||||
// Auto-decrypt if enabled
|
||||
if (this.settings$.value.autoDecrypt && this.signer) {
|
||||
this.autoDecryptPending();
|
||||
}
|
||||
|
||||
this.syncStatus$.next("idle");
|
||||
});
|
||||
|
||||
this.relaySubscription = sub;
|
||||
|
||||
// Also request from relays
|
||||
if (relays.length > 0) {
|
||||
pool.request(relays, [reqFilter], { eventStore }).subscribe({
|
||||
next: () => {
|
||||
// Events are automatically added to eventStore via the options
|
||||
},
|
||||
error: (err) => {
|
||||
console.warn(`[GiftWrap] Error fetching from relays:`, err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Update conversations from decrypted gift wraps */
|
||||
private updateConversations() {
|
||||
const conversationMap = new Map<string, Conversation>();
|
||||
|
||||
for (const gw of this.giftWraps) {
|
||||
if (!isGiftWrapUnlocked(gw)) continue;
|
||||
|
||||
const rumor = getGiftWrapRumor(gw);
|
||||
if (!rumor || rumor.kind !== PRIVATE_DM_KIND) continue;
|
||||
|
||||
const convId = getConversationIdentifierFromMessage(rumor);
|
||||
const existing = conversationMap.get(convId);
|
||||
|
||||
if (
|
||||
!existing ||
|
||||
rumor.created_at > (existing.lastMessage?.created_at ?? 0)
|
||||
) {
|
||||
conversationMap.set(convId, {
|
||||
id: convId,
|
||||
participants: getConversationParticipants(rumor),
|
||||
lastMessage: rumor,
|
||||
lastGiftWrap: gw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const conversations = Array.from(conversationMap.values()).sort(
|
||||
(a, b) =>
|
||||
(b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0),
|
||||
);
|
||||
|
||||
this.conversations$.next(conversations);
|
||||
}
|
||||
|
||||
/** Decrypt a single gift wrap */
|
||||
async decrypt(giftWrapId: string): Promise<Rumor | null> {
|
||||
if (!this.signer) {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
|
||||
const gw = this.giftWraps.find((g) => g.id === giftWrapId);
|
||||
if (!gw) {
|
||||
throw new Error("Gift wrap not found");
|
||||
}
|
||||
|
||||
// Check if already decrypted
|
||||
if (isGiftWrapUnlocked(gw)) {
|
||||
return getGiftWrapRumor(gw) ?? null;
|
||||
}
|
||||
|
||||
// Update state to decrypting
|
||||
this.decryptStates.set(giftWrapId, { status: "decrypting" });
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
|
||||
try {
|
||||
const rumor = await unlockGiftWrap(gw, this.signer);
|
||||
|
||||
// Update state to success
|
||||
this.decryptStates.set(giftWrapId, {
|
||||
status: "success",
|
||||
decryptedAt: Date.now(),
|
||||
});
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
|
||||
// Emit decrypt event
|
||||
this.decryptEvent$.next({
|
||||
giftWrapId,
|
||||
status: "success",
|
||||
rumor,
|
||||
});
|
||||
|
||||
// Update conversations
|
||||
this.updateConversations();
|
||||
|
||||
return rumor;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
// Update state to error
|
||||
this.decryptStates.set(giftWrapId, { status: "error", error });
|
||||
this.decryptStates$.next(new Map(this.decryptStates));
|
||||
|
||||
// Emit decrypt event
|
||||
this.decryptEvent$.next({
|
||||
giftWrapId,
|
||||
status: "error",
|
||||
error,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt all pending gift wraps */
|
||||
async decryptAll(): Promise<{ success: number; error: number }> {
|
||||
if (!this.signer) {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let error = 0;
|
||||
|
||||
const pending = this.giftWraps.filter(
|
||||
(gw) =>
|
||||
!isGiftWrapUnlocked(gw) &&
|
||||
this.decryptStates.get(gw.id)?.status !== "decrypting",
|
||||
);
|
||||
|
||||
for (const gw of pending) {
|
||||
try {
|
||||
await this.decrypt(gw.id);
|
||||
success++;
|
||||
} catch {
|
||||
error++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success, error };
|
||||
}
|
||||
|
||||
/** Auto-decrypt pending gift wraps (called when auto-decrypt is enabled) */
|
||||
private async autoDecryptPending() {
|
||||
if (!this.signer || !this.settings$.value.autoDecrypt) return;
|
||||
|
||||
const pending = this.giftWraps.filter((gw) => {
|
||||
const state = this.decryptStates.get(gw.id);
|
||||
return state?.status === "pending";
|
||||
});
|
||||
|
||||
for (const gw of pending) {
|
||||
try {
|
||||
await this.decrypt(gw.id);
|
||||
} catch {
|
||||
// Errors are already tracked in decryptStates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get counts by status */
|
||||
getCounts(): {
|
||||
pending: number;
|
||||
success: number;
|
||||
error: number;
|
||||
total: number;
|
||||
} {
|
||||
let pending = 0;
|
||||
let success = 0;
|
||||
let error = 0;
|
||||
|
||||
for (const state of this.decryptStates.values()) {
|
||||
switch (state.status) {
|
||||
case "pending":
|
||||
case "decrypting":
|
||||
pending++;
|
||||
break;
|
||||
case "success":
|
||||
success++;
|
||||
break;
|
||||
case "error":
|
||||
error++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, success, error, total: this.giftWraps.length };
|
||||
}
|
||||
|
||||
/** Update signer (when user logs in/out or changes) */
|
||||
setSigner(signer: ISigner | null) {
|
||||
this.signer = signer;
|
||||
|
||||
// Auto-decrypt if enabled and signer is available
|
||||
if (signer && this.settings$.value.autoDecrypt) {
|
||||
this.autoDecryptPending();
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanup subscriptions */
|
||||
cleanup() {
|
||||
this.subscriptions.forEach((s) => s.unsubscribe());
|
||||
this.subscriptions = [];
|
||||
if (this.relaySubscription) {
|
||||
this.relaySubscription.unsubscribe();
|
||||
this.relaySubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full destroy (call when app unmounts) */
|
||||
destroy() {
|
||||
this.cleanup();
|
||||
if (this.persistenceCleanup) {
|
||||
this.persistenceCleanup();
|
||||
this.persistenceCleanup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const giftWrapService = new GiftWrapService();
|
||||
|
||||
export default giftWrapService;
|
||||
@@ -21,6 +21,7 @@ export type AppId =
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
| "inbox"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -641,6 +641,18 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
category: "Nostr",
|
||||
defaultProps: {},
|
||||
},
|
||||
inbox: {
|
||||
name: "inbox",
|
||||
section: "1",
|
||||
synopsis: "inbox",
|
||||
description:
|
||||
"Manage private messages using NIP-17/NIP-59 gift wraps. View and configure your DM inbox relays (kind 10050), enable/disable gift wrap sync, track decryption status, and browse recent conversations. Supports auto-decrypt mode for hands-free message decryption.",
|
||||
examples: ["inbox Open the private message inbox manager"],
|
||||
seeAlso: ["chat", "profile", "conn"],
|
||||
appId: "inbox",
|
||||
category: "Nostr",
|
||||
defaultProps: {},
|
||||
},
|
||||
blossom: {
|
||||
name: "blossom",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user