mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
feat: Add NIP-17/59 gift-wrapped DM support with caching
Implements encrypted private messaging using gift wraps: - Add event cache service (Dexie) for offline access - Add rumor storage for caching decrypted gift wrap content - Wire persistEventsToCache and persistEncryptedContent to EventStore - Create NIP-17 adapter for gift-wrapped DMs - Add InboxViewer component for DM conversation list - Add `inbox` command to open private message inbox - Register NIP-17 adapter in ChatViewer Features: - Decrypt once, cache forever - no re-decryption needed - Explicit decrypt button (user-initiated) - Conversation list derived from decrypted gift wraps - Private inbox relay discovery (kind 10050) - Send not yet implemented (TODO: use SendWrappedMessage action)
This commit is contained in:
@@ -23,6 +23,7 @@ import type {
|
||||
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
|
||||
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
||||
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
|
||||
import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import type { Message } from "@/types/chat";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
@@ -630,6 +631,8 @@ export function ChatViewer({
|
||||
addWindow("nip", { number: 29 });
|
||||
} else if (conversation?.protocol === "nip-53") {
|
||||
addWindow("nip", { number: 53 });
|
||||
} else if (conversation?.protocol === "nip-17") {
|
||||
addWindow("nip", { number: 17 });
|
||||
}
|
||||
}, [conversation?.protocol, addWindow]);
|
||||
|
||||
@@ -955,8 +958,8 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
|
||||
// return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
|
||||
// return new Nip17Adapter();
|
||||
case "nip-17":
|
||||
return new Nip17Adapter();
|
||||
// case "nip-28": // Phase 3 - Public channels (coming soon)
|
||||
// return new Nip28Adapter();
|
||||
case "nip-53":
|
||||
|
||||
445
src/components/InboxViewer.tsx
Normal file
445
src/components/InboxViewer.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Features:
|
||||
* - Lists all DM conversations from decrypted gift wraps
|
||||
* - Shows pending (undecrypted) message count
|
||||
* - Explicit decrypt button (no auto-decrypt)
|
||||
* - Opens individual DM conversations in ChatViewer
|
||||
*/
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Unlock,
|
||||
Mail,
|
||||
AlertCircle,
|
||||
PanelLeft,
|
||||
} from "lucide-react";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { ChatViewer } from "./ChatViewer";
|
||||
import type { ProtocolIdentifier } from "@/types/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Timestamp from "./Timestamp";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
|
||||
/**
|
||||
* UserAvatar - Display a user's avatar with profile data
|
||||
*/
|
||||
const UserAvatar = memo(function UserAvatar({
|
||||
pubkey,
|
||||
className,
|
||||
}: {
|
||||
pubkey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const profile = useProfile(pubkey);
|
||||
const name = getDisplayName(pubkey, profile);
|
||||
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={profile?.picture} alt={name} />
|
||||
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
});
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation info for display
|
||||
*/
|
||||
interface ConversationInfo {
|
||||
id: string;
|
||||
partnerPubkey: string;
|
||||
lastMessage?: {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isOwn: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ConversationListItem - Single conversation in the list
|
||||
*/
|
||||
const ConversationListItem = memo(function ConversationListItem({
|
||||
conversation,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
conversation: ConversationInfo;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 cursor-crosshair hover:bg-muted/50 transition-colors border-b",
|
||||
isSelected && "bg-muted/70",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<UserAvatar pubkey={conversation.partnerPubkey} className="size-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<UserName
|
||||
pubkey={conversation.partnerPubkey}
|
||||
className="text-sm font-medium truncate"
|
||||
/>
|
||||
{conversation.lastMessage && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
<Timestamp timestamp={conversation.lastMessage.timestamp} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conversation.lastMessage && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{conversation.lastMessage.isOwn && (
|
||||
<span className="text-muted-foreground/70">You: </span>
|
||||
)}
|
||||
{conversation.lastMessage.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* DecryptButton - Shows pending count and triggers decryption
|
||||
*/
|
||||
const DecryptButton = memo(function DecryptButton({
|
||||
pendingCount,
|
||||
isDecrypting,
|
||||
onDecrypt,
|
||||
}: {
|
||||
pendingCount: number;
|
||||
isDecrypting: boolean;
|
||||
onDecrypt: () => void;
|
||||
}) {
|
||||
if (pendingCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 w-full"
|
||||
onClick={onDecrypt}
|
||||
disabled={isDecrypting}
|
||||
>
|
||||
{isDecrypting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Decrypting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="size-4" />
|
||||
Decrypt {pendingCount} message{pendingCount !== 1 ? "s" : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* MemoizedChatViewer - Memoized chat viewer to prevent unnecessary re-renders
|
||||
*/
|
||||
const MemoizedChatViewer = memo(
|
||||
function MemoizedChatViewer({
|
||||
partnerPubkey,
|
||||
headerPrefix,
|
||||
}: {
|
||||
partnerPubkey: string;
|
||||
headerPrefix?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ChatViewer
|
||||
protocol="nip-17"
|
||||
identifier={
|
||||
{
|
||||
type: "dm-recipient",
|
||||
value: partnerPubkey,
|
||||
} as ProtocolIdentifier
|
||||
}
|
||||
headerPrefix={headerPrefix}
|
||||
/>
|
||||
);
|
||||
},
|
||||
(prev, next) => prev.partnerPubkey === next.partnerPubkey,
|
||||
);
|
||||
|
||||
/**
|
||||
* InboxViewer - Main inbox component
|
||||
*/
|
||||
export function InboxViewer() {
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
const activePubkey = activeAccount?.pubkey;
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// State
|
||||
const [selectedPartner, setSelectedPartner] = useState<string | null>(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(300);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
// NIP-17 adapter instance
|
||||
const adapter = useMemo(() => new Nip17Adapter(), []);
|
||||
|
||||
// Get pending count
|
||||
const pendingCount = use$(() => adapter.getPendingCount$(), [adapter]) ?? 0;
|
||||
|
||||
// Get conversations from adapter
|
||||
const conversations = use$(
|
||||
() => (activePubkey ? adapter.getConversations$() : undefined),
|
||||
[adapter, activePubkey],
|
||||
);
|
||||
|
||||
// Convert to display format
|
||||
const conversationList = useMemo(() => {
|
||||
if (!conversations || !activePubkey) return [];
|
||||
|
||||
return conversations.map((conv): ConversationInfo => {
|
||||
const partner = conv.participants.find((p) => p.pubkey !== activePubkey);
|
||||
return {
|
||||
id: conv.id,
|
||||
partnerPubkey: partner?.pubkey || "",
|
||||
lastMessage: conv.lastMessage
|
||||
? {
|
||||
content: conv.lastMessage.content,
|
||||
timestamp: conv.lastMessage.timestamp,
|
||||
isOwn: conv.lastMessage.author === activePubkey,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}, [conversations, activePubkey]);
|
||||
|
||||
// Handle conversation selection
|
||||
const handleSelect = useCallback(
|
||||
(partnerPubkey: string) => {
|
||||
setSelectedPartner(partnerPubkey);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
},
|
||||
[isMobile],
|
||||
);
|
||||
|
||||
// Handle decrypt
|
||||
const handleDecrypt = useCallback(async () => {
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const result = await adapter.decryptPending();
|
||||
console.log(
|
||||
`[Inbox] Decrypted ${result.success} messages, ${result.failed} failed`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Inbox] Decrypt error:", error);
|
||||
} finally {
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
}, [adapter]);
|
||||
|
||||
// Handle resize
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const newWidth = startWidth + deltaX;
|
||||
setSidebarWidth(Math.max(200, Math.min(500, newWidth)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
},
|
||||
[sidebarWidth],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
adapter.cleanupAll();
|
||||
};
|
||||
}, [adapter]);
|
||||
|
||||
// Not signed in
|
||||
if (!activePubkey) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground p-4">
|
||||
<Lock className="size-8" />
|
||||
<span className="text-sm">Sign in to view your encrypted messages</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sidebar content
|
||||
const sidebarContent = (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Mail className="size-5" />
|
||||
<h2 className="font-semibold">Private Messages</h2>
|
||||
</div>
|
||||
<DecryptButton
|
||||
pendingCount={pendingCount || 0}
|
||||
isDecrypting={isDecrypting}
|
||||
onDecrypt={handleDecrypt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversationList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground p-4 text-center">
|
||||
<AlertCircle className="size-6" />
|
||||
<span className="text-sm">
|
||||
{(pendingCount || 0) > 0
|
||||
? "Decrypt messages to see conversations"
|
||||
: "No conversations yet"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
conversationList.map((conv) => (
|
||||
<ConversationListItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isSelected={selectedPartner === conv.partnerPubkey}
|
||||
onClick={() => handleSelect(conv.partnerPubkey)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sidebar toggle button for mobile
|
||||
const sidebarToggle = isMobile ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeft className="size-4" />
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// Chat content
|
||||
const chatContent = selectedPartner ? (
|
||||
<MemoizedChatViewer
|
||||
partnerPubkey={selectedPartner}
|
||||
headerPrefix={sidebarToggle}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
{isMobile ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<PanelLeft className="size-4" />
|
||||
Select a conversation
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="size-8 opacity-50" />
|
||||
<span className="text-sm">Select a conversation</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="left" className="w-[300px] p-0">
|
||||
<VisuallyHidden.Root>
|
||||
<SheetTitle>Messages</SheetTitle>
|
||||
</VisuallyHidden.Root>
|
||||
<div className="flex h-full flex-col pt-10">{sidebarContent}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex-1 min-h-0">{chatContent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className="flex flex-col border-r bg-background"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-1 bg-border hover:bg-primary/50 cursor-col-resize transition-colors",
|
||||
isResizing && "bg-primary",
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
|
||||
{/* Chat panel */}
|
||||
<div className="flex-1 min-w-0">{chatContent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,9 @@ const SpellbooksViewer = lazy(() =>
|
||||
const BlossomViewer = lazy(() =>
|
||||
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
|
||||
);
|
||||
const InboxViewer = lazy(() =>
|
||||
import("./InboxViewer").then((m) => ({ default: m.InboxViewer })),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -210,6 +213,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "inbox":
|
||||
content = <InboxViewer />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
613
src/lib/chat/adapters/nip-17-adapter.ts
Normal file
613
src/lib/chat/adapters/nip-17-adapter.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* NIP-17 Adapter - Private Direct Messages (Gift Wrapped)
|
||||
*
|
||||
* Implements NIP-17 encrypted DMs using NIP-59 gift wraps:
|
||||
* - kind 1059: Gift wrap (outer encrypted layer with ephemeral key)
|
||||
* - kind 13: Seal (middle layer encrypted with sender's key)
|
||||
* - kind 14: DM rumor (inner content - the actual message)
|
||||
*
|
||||
* Privacy features:
|
||||
* - Sender identity hidden (ephemeral gift wrap key)
|
||||
* - Deniability (rumors are unsigned)
|
||||
* - Uses recipient's private inbox relays (kind 10050)
|
||||
*
|
||||
* Caching:
|
||||
* - Gift wraps are cached to Dexie events table
|
||||
* - Decrypted rumors are cached to avoid re-decryption
|
||||
*/
|
||||
import { Observable, firstValueFrom, BehaviorSubject } from "rxjs";
|
||||
import { map, first } from "rxjs/operators";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
LoadMessagesOptions,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { isNip05, resolveNip05 } from "@/lib/nip05";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { isValidHexPubkey } from "@/lib/nostr-validation";
|
||||
import { getProfileContent } from "applesauce-core/helpers";
|
||||
import {
|
||||
unlockGiftWrap,
|
||||
getConversationParticipants,
|
||||
getConversationIdentifierFromMessage,
|
||||
type Rumor,
|
||||
} from "applesauce-common/helpers";
|
||||
import {
|
||||
getDecryptedRumors,
|
||||
isGiftWrapDecrypted,
|
||||
storeDecryptedRumor,
|
||||
} from "@/services/rumor-storage";
|
||||
|
||||
/**
|
||||
* Kind constants
|
||||
*/
|
||||
const GIFT_WRAP_KIND = 1059;
|
||||
const DM_RUMOR_KIND = 14;
|
||||
const DM_RELAY_LIST_KIND = 10050;
|
||||
|
||||
/**
|
||||
* NIP-17 Adapter - Gift Wrapped Private DMs
|
||||
*/
|
||||
export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-17" as const;
|
||||
readonly type = "dm" as const;
|
||||
|
||||
/** Observable of all decrypted rumors for the current user */
|
||||
private rumors$ = new BehaviorSubject<Rumor[]>([]);
|
||||
|
||||
/** Track pending (undecrypted) gift wrap IDs */
|
||||
private pendingGiftWraps$ = new BehaviorSubject<string[]>([]);
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try bech32 decoding (npub/nprofile)
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "npub") {
|
||||
return {
|
||||
type: "dm-recipient",
|
||||
value: decoded.data,
|
||||
};
|
||||
}
|
||||
if (decoded.type === "nprofile") {
|
||||
return {
|
||||
type: "dm-recipient",
|
||||
value: decoded.data.pubkey,
|
||||
relays: decoded.data.relays,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not bech32, try other formats
|
||||
}
|
||||
|
||||
// Try hex pubkey
|
||||
if (isValidHexPubkey(input)) {
|
||||
return {
|
||||
type: "dm-recipient",
|
||||
value: input,
|
||||
};
|
||||
}
|
||||
|
||||
// Try NIP-05
|
||||
if (isNip05(input)) {
|
||||
return {
|
||||
type: "chat-partner-nip05",
|
||||
value: input,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from identifier
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
let partnerPubkey: string;
|
||||
|
||||
// Resolve NIP-05 if needed
|
||||
if (identifier.type === "chat-partner-nip05") {
|
||||
const resolved = await resolveNip05(identifier.value);
|
||||
if (!resolved) {
|
||||
throw new Error(`Failed to resolve NIP-05: ${identifier.value}`);
|
||||
}
|
||||
partnerPubkey = resolved;
|
||||
} else if (
|
||||
identifier.type === "dm-recipient" ||
|
||||
identifier.type === "chat-partner"
|
||||
) {
|
||||
partnerPubkey = identifier.value;
|
||||
} else {
|
||||
throw new Error(
|
||||
`NIP-17 adapter cannot handle identifier type: ${identifier.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
// Get display name for partner
|
||||
const metadataEvent = await this.getMetadata(partnerPubkey);
|
||||
const metadata = metadataEvent
|
||||
? getProfileContent(metadataEvent)
|
||||
: undefined;
|
||||
const title = getDisplayName(partnerPubkey, metadata);
|
||||
|
||||
// Create conversation ID from sorted participants (deterministic)
|
||||
const participants = [activePubkey, partnerPubkey].sort();
|
||||
const conversationId = `nip-17:${participants.join(",")}`;
|
||||
|
||||
return {
|
||||
id: conversationId,
|
||||
type: "dm",
|
||||
protocol: "nip-17",
|
||||
title,
|
||||
participants: [
|
||||
{ pubkey: activePubkey, role: "member" },
|
||||
{ pubkey: partnerPubkey, role: "member" },
|
||||
],
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
giftWrapped: true,
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a conversation
|
||||
* Returns decrypted rumors that match this conversation
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
_options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
// Get partner pubkey
|
||||
const partner = conversation.participants.find(
|
||||
(p) => p.pubkey !== activePubkey,
|
||||
);
|
||||
if (!partner) {
|
||||
throw new Error("No conversation partner found");
|
||||
}
|
||||
|
||||
// Expected participants for this conversation
|
||||
const expectedParticipants = [activePubkey, partner.pubkey].sort();
|
||||
|
||||
// Load initial rumors from cache
|
||||
this.loadCachedRumors(activePubkey);
|
||||
|
||||
// Subscribe to gift wraps for this user
|
||||
this.subscribeToGiftWraps(activePubkey);
|
||||
|
||||
// Filter rumors to this conversation and convert to messages
|
||||
return this.rumors$.pipe(
|
||||
map((rumors) => {
|
||||
// Filter rumors that belong to this conversation
|
||||
const conversationRumors = rumors.filter((rumor) => {
|
||||
// Only kind 14 DM rumors
|
||||
if (rumor.kind !== DM_RUMOR_KIND) return false;
|
||||
|
||||
// Get participants from rumor
|
||||
const rumorParticipants = getConversationParticipants(rumor).sort();
|
||||
|
||||
// Check if participants match
|
||||
return (
|
||||
rumorParticipants.length === expectedParticipants.length &&
|
||||
rumorParticipants.every((p, i) => p === expectedParticipants[i])
|
||||
);
|
||||
});
|
||||
|
||||
// Convert to messages and sort by timestamp
|
||||
return conversationRumors
|
||||
.map((rumor) => this.rumorToMessage(rumor, conversation.id))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
_conversation: Conversation,
|
||||
_before: number,
|
||||
): Promise<Message[]> {
|
||||
// For now, return empty - pagination to be implemented
|
||||
// Gift wraps don't paginate well since we need to decrypt all
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a gift-wrapped DM
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activePubkey || !activeSigner) {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
const partner = conversation.participants.find(
|
||||
(p) => p.pubkey !== activePubkey,
|
||||
);
|
||||
if (!partner) {
|
||||
throw new Error("No conversation partner found");
|
||||
}
|
||||
|
||||
// Build rumor tags
|
||||
const tags: string[][] = [["p", partner.pubkey]];
|
||||
if (options?.replyTo) {
|
||||
tags.push(["e", options.replyTo, "", "reply"]);
|
||||
}
|
||||
|
||||
// Get recipient's private inbox relays
|
||||
const inboxRelays = await this.getPrivateInboxRelays(partner.pubkey);
|
||||
if (inboxRelays.length === 0) {
|
||||
throw new Error(
|
||||
"Recipient has no private inbox relays configured (kind 10050)",
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Implement gift wrap creation and sending
|
||||
// 1. Create the DM rumor (kind 14, unsigned) with: activePubkey, tags, content
|
||||
// 2. Use SendWrappedMessage action from applesauce-actions to create and send gift wraps
|
||||
// 3. Publish to each recipient's private inbox relays
|
||||
void inboxRelays; // Will be used when implemented
|
||||
void tags;
|
||||
void content;
|
||||
void activePubkey;
|
||||
|
||||
throw new Error(
|
||||
"Send not yet implemented - use applesauce SendWrappedMessage action",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: true,
|
||||
supportsThreading: true, // e-tag replies
|
||||
supportsModeration: false,
|
||||
supportsRoles: false,
|
||||
supportsGroupManagement: false,
|
||||
canCreateConversations: true,
|
||||
requiresRelay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
_conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// Check if we have a cached rumor with this ID
|
||||
const rumors = this.rumors$.value;
|
||||
const rumor = rumors.find((r) => r.id === eventId);
|
||||
|
||||
if (rumor) {
|
||||
// Convert rumor to a pseudo-event for display
|
||||
return {
|
||||
...rumor,
|
||||
sig: "", // Rumors are unsigned
|
||||
} as NostrEvent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending (undecrypted) gift wraps
|
||||
*/
|
||||
getPendingCount(): number {
|
||||
return this.pendingGiftWraps$.value.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get observable of pending gift wrap count
|
||||
*/
|
||||
getPendingCount$(): Observable<number> {
|
||||
return this.pendingGiftWraps$.pipe(map((ids) => ids.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 pendingIds = this.pendingGiftWraps$.value;
|
||||
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) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt using signer
|
||||
const rumor = await unlockGiftWrap(giftWrap, signer);
|
||||
|
||||
if (rumor) {
|
||||
// Store decrypted rumor
|
||||
await storeDecryptedRumor(giftWrapId, rumor, pubkey);
|
||||
|
||||
// Add to rumors list
|
||||
const currentRumors = this.rumors$.value;
|
||||
if (!currentRumors.find((r) => r.id === rumor.id)) {
|
||||
this.rumors$.next([...currentRumors, rumor]);
|
||||
}
|
||||
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`,
|
||||
error,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear pending list for successfully decrypted
|
||||
const remainingPending = this.pendingGiftWraps$.value.filter(
|
||||
(id) => !pendingIds.includes(id) || failed > 0,
|
||||
);
|
||||
this.pendingGiftWraps$.next(remainingPending);
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversations from decrypted rumors
|
||||
*/
|
||||
getConversations$(): Observable<Conversation[]> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
return new BehaviorSubject([]);
|
||||
}
|
||||
|
||||
return this.rumors$.pipe(
|
||||
map((rumors) => {
|
||||
// Group rumors by conversation
|
||||
const conversationMap = new Map<
|
||||
string,
|
||||
{ participants: string[]; lastRumor: Rumor }
|
||||
>();
|
||||
|
||||
for (const rumor of rumors) {
|
||||
if (rumor.kind !== DM_RUMOR_KIND) continue;
|
||||
|
||||
const convId = getConversationIdentifierFromMessage(rumor);
|
||||
const participants = getConversationParticipants(rumor);
|
||||
|
||||
const existing = conversationMap.get(convId);
|
||||
if (!existing || rumor.created_at > existing.lastRumor.created_at) {
|
||||
conversationMap.set(convId, { participants, lastRumor: rumor });
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Conversation objects
|
||||
const conversations: Conversation[] = [];
|
||||
|
||||
for (const [convId, { participants, lastRumor }] of conversationMap) {
|
||||
const partner = participants.find((p) => p !== activePubkey);
|
||||
if (!partner) continue;
|
||||
|
||||
conversations.push({
|
||||
id: `nip-17:${participants.sort().join(",")}`,
|
||||
type: "dm",
|
||||
protocol: "nip-17",
|
||||
title: partner.slice(0, 8) + "...", // Will be replaced with display name
|
||||
participants: participants.map((p) => ({
|
||||
pubkey: p,
|
||||
role: "member" as const,
|
||||
})),
|
||||
metadata: { encrypted: true, giftWrapped: true },
|
||||
lastMessage: this.rumorToMessage(lastRumor, convId),
|
||||
unreadCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by last message timestamp
|
||||
conversations.sort(
|
||||
(a, b) =>
|
||||
(b.lastMessage?.timestamp || 0) - (a.lastMessage?.timestamp || 0),
|
||||
);
|
||||
|
||||
return conversations;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
/**
|
||||
* Load cached rumors from Dexie
|
||||
*/
|
||||
private async loadCachedRumors(pubkey: string): Promise<void> {
|
||||
const rumors = await getDecryptedRumors(pubkey);
|
||||
this.rumors$.next(rumors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to gift wraps for the user
|
||||
*/
|
||||
private subscribeToGiftWraps(pubkey: string): void {
|
||||
const conversationId = `nip-17:inbox:${pubkey}`;
|
||||
|
||||
// Clean up existing subscription
|
||||
this.cleanup(conversationId);
|
||||
|
||||
// Subscribe to gift wraps addressed to this user
|
||||
const filter: Filter = {
|
||||
kinds: [GIFT_WRAP_KIND],
|
||||
"#p": [pubkey],
|
||||
};
|
||||
|
||||
const subscription = pool
|
||||
.subscription([], [filter], { eventStore })
|
||||
.subscribe({
|
||||
next: async (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE
|
||||
console.log("[NIP-17] EOSE received for gift wraps");
|
||||
} else {
|
||||
// New gift wrap received
|
||||
console.log(
|
||||
`[NIP-17] Received gift wrap: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
|
||||
// Check if already decrypted
|
||||
const isDecrypted = await isGiftWrapDecrypted(response.id, pubkey);
|
||||
|
||||
if (!isDecrypted) {
|
||||
// Add to pending list
|
||||
const pending = this.pendingGiftWraps$.value;
|
||||
if (!pending.includes(response.id)) {
|
||||
this.pendingGiftWraps$.next([...pending, response.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.subscriptions.set(conversationId, subscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get private inbox relays for a user (kind 10050)
|
||||
*/
|
||||
private async getPrivateInboxRelays(pubkey: string): Promise<string[]> {
|
||||
// Try to fetch from EventStore first
|
||||
const existing = await firstValueFrom(
|
||||
eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return this.extractRelaysFromEvent(existing);
|
||||
}
|
||||
|
||||
// Fetch from relays
|
||||
const filter: Filter = {
|
||||
kinds: [DM_RELAY_LIST_KIND],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 5000);
|
||||
const sub = pool.subscription([], [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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]),
|
||||
);
|
||||
const replyTo = replyTag?.[1];
|
||||
|
||||
return {
|
||||
id: rumor.id,
|
||||
conversationId,
|
||||
author: rumor.pubkey,
|
||||
content: rumor.content,
|
||||
timestamp: rumor.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "nip-17",
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
},
|
||||
// Create a pseudo-event for the rumor (unsigned)
|
||||
event: {
|
||||
...rumor,
|
||||
sig: "",
|
||||
} as NostrEvent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a pubkey
|
||||
*/
|
||||
private async getMetadata(pubkey: string): Promise<NostrEvent | undefined> {
|
||||
return firstValueFrom(eventStore.replaceable(0, pubkey), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,37 @@ export interface LocalSpellbook {
|
||||
deletedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached Nostr event for offline access
|
||||
*/
|
||||
export interface CachedEvent {
|
||||
id: string;
|
||||
event: NostrEvent;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypted rumor from gift wrap (NIP-59)
|
||||
* Stored separately so we don't have to re-decrypt
|
||||
*/
|
||||
export interface DecryptedRumor {
|
||||
/** Gift wrap event ID */
|
||||
giftWrapId: string;
|
||||
/** The decrypted rumor (unsigned event) */
|
||||
rumor: {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
};
|
||||
/** Pubkey that decrypted this (for multi-account support) */
|
||||
decryptedBy: string;
|
||||
/** When it was decrypted */
|
||||
decryptedAt: number;
|
||||
}
|
||||
|
||||
class GrimoireDb extends Dexie {
|
||||
profiles!: Table<Profile>;
|
||||
nip05!: Table<Nip05>;
|
||||
@@ -90,6 +121,8 @@ class GrimoireDb extends Dexie {
|
||||
relayLiveness!: Table<RelayLivenessEntry>;
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
events!: Table<CachedEvent>;
|
||||
decryptedRumors!: Table<DecryptedRumor>;
|
||||
|
||||
constructor(name: string) {
|
||||
super(name);
|
||||
@@ -311,6 +344,21 @@ class GrimoireDb extends Dexie {
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
});
|
||||
|
||||
// Version 15: Add event cache and decrypted rumor storage for NIP-59 gift wraps
|
||||
this.version(15).stores({
|
||||
profiles: "&pubkey",
|
||||
nip05: "&nip05",
|
||||
nips: "&id",
|
||||
relayInfo: "&url",
|
||||
relayAuthPreferences: "&url",
|
||||
relayLists: "&pubkey, updatedAt",
|
||||
relayLiveness: "&url",
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
events: "&id, cachedAt",
|
||||
decryptedRumors: "&giftWrapId, decryptedBy",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
src/services/event-cache.ts
Normal file
130
src/services/event-cache.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Event cache service for persisting Nostr events to Dexie
|
||||
*
|
||||
* Provides:
|
||||
* - Event caching for offline access
|
||||
* - CacheRequest function for applesauce loaders
|
||||
* - Automatic persistence of events from EventStore
|
||||
*/
|
||||
import type { Filter, NostrEvent } from "nostr-tools";
|
||||
import db, { type CachedEvent } from "./db";
|
||||
import { matchFilter } from "nostr-tools";
|
||||
|
||||
/**
|
||||
* Add events to the cache
|
||||
*/
|
||||
export async function cacheEvents(events: NostrEvent[]): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
const cachedEvents: CachedEvent[] = events.map((event) => ({
|
||||
id: event.id,
|
||||
event,
|
||||
cachedAt: now,
|
||||
}));
|
||||
|
||||
// Use bulkPut to handle duplicates gracefully
|
||||
await db.events.bulkPut(cachedEvents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single event from cache by ID
|
||||
*/
|
||||
export async function getCachedEvent(
|
||||
id: string,
|
||||
): Promise<NostrEvent | undefined> {
|
||||
const cached = await db.events.get(id);
|
||||
return cached?.event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events from cache matching filters
|
||||
* This is used as a CacheRequest for applesauce loaders
|
||||
*/
|
||||
export async function getEventsForFilters(
|
||||
filters: Filter[],
|
||||
): Promise<NostrEvent[]> {
|
||||
// For simple ID lookups, use direct queries
|
||||
const idFilters = filters.filter(
|
||||
(f) => f.ids && f.ids.length > 0 && Object.keys(f).length === 1,
|
||||
);
|
||||
|
||||
if (idFilters.length === filters.length && idFilters.length > 0) {
|
||||
// All filters are simple ID lookups
|
||||
const allIds = idFilters.flatMap((f) => f.ids || []);
|
||||
const cached = await db.events.bulkGet(allIds);
|
||||
return cached
|
||||
.filter((c): c is CachedEvent => c !== undefined)
|
||||
.map((c) => c.event);
|
||||
}
|
||||
|
||||
// For complex filters, we need to scan and filter
|
||||
// This is less efficient but necessary for kind/author/tag queries
|
||||
const allEvents = await db.events.toArray();
|
||||
const matchingEvents: NostrEvent[] = [];
|
||||
|
||||
for (const cached of allEvents) {
|
||||
for (const filter of filters) {
|
||||
if (matchFilter(filter, cached.event)) {
|
||||
matchingEvents.push(cached.event);
|
||||
break; // Event matches at least one filter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit if specified (use smallest limit from filters)
|
||||
const limits = filters
|
||||
.map((f) => f.limit)
|
||||
.filter((l): l is number => l !== undefined);
|
||||
if (limits.length > 0) {
|
||||
const minLimit = Math.min(...limits);
|
||||
// Sort by created_at descending and take limit
|
||||
matchingEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return matchingEvents.slice(0, minLimit);
|
||||
}
|
||||
|
||||
return matchingEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheRequest function for applesauce loaders
|
||||
* Compatible with createTimelineLoader's cache option
|
||||
*/
|
||||
export const cacheRequest = (filters: Filter[]): Promise<NostrEvent[]> =>
|
||||
getEventsForFilters(filters);
|
||||
|
||||
/**
|
||||
* Clear old cached events (older than maxAge in milliseconds)
|
||||
* Default: 30 days
|
||||
*/
|
||||
export async function pruneEventCache(
|
||||
maxAgeMs: number = 30 * 24 * 60 * 60 * 1000,
|
||||
): Promise<number> {
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
const deleted = await db.events.where("cachedAt").below(cutoff).delete();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
export async function getCacheStats(): Promise<{
|
||||
eventCount: number;
|
||||
oldestEvent: number | null;
|
||||
newestEvent: number | null;
|
||||
}> {
|
||||
const count = await db.events.count();
|
||||
|
||||
if (count === 0) {
|
||||
return { eventCount: 0, oldestEvent: null, newestEvent: null };
|
||||
}
|
||||
|
||||
const oldest = await db.events.orderBy("cachedAt").first();
|
||||
const newest = await db.events.orderBy("cachedAt").last();
|
||||
|
||||
return {
|
||||
eventCount: count,
|
||||
oldestEvent: oldest?.cachedAt ?? null,
|
||||
newestEvent: newest?.cachedAt ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
import { EventStore } from "applesauce-core";
|
||||
import { persistEventsToCache } from "applesauce-core/helpers";
|
||||
import { persistEncryptedContent } from "applesauce-common/helpers";
|
||||
import { cacheEvents } from "./event-cache";
|
||||
import { rumorStorage, setCurrentPubkey } from "./rumor-storage";
|
||||
import accountManager from "./accounts";
|
||||
import { of } from "rxjs";
|
||||
|
||||
const eventStore = new EventStore();
|
||||
|
||||
// Persist all events to Dexie cache for offline access
|
||||
persistEventsToCache(eventStore, cacheEvents);
|
||||
|
||||
// Persist decrypted gift wrap content to Dexie
|
||||
// This ensures we don't have to re-decrypt messages on every page load
|
||||
persistEncryptedContent(eventStore, of(rumorStorage));
|
||||
|
||||
// Sync current pubkey for rumor storage when account changes
|
||||
accountManager.active$.subscribe((account) => {
|
||||
setCurrentPubkey(account?.pubkey ?? null);
|
||||
});
|
||||
|
||||
export default eventStore;
|
||||
|
||||
165
src/services/rumor-storage.ts
Normal file
165
src/services/rumor-storage.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Rumor storage service for caching decrypted gift wrap content
|
||||
*
|
||||
* When a gift wrap (kind 1059) is decrypted, the inner rumor is cached
|
||||
* so we don't have to decrypt it again. This is especially important
|
||||
* because decryption requires the signer (browser extension interaction).
|
||||
*
|
||||
* Storage format matches applesauce's persistEncryptedContent expectations:
|
||||
* - Key: `rumor:${giftWrapId}`
|
||||
* - Value: The decrypted rumor object
|
||||
*/
|
||||
import type { Rumor } from "applesauce-common/helpers";
|
||||
import db, { type DecryptedRumor } from "./db";
|
||||
import { BehaviorSubject, type Observable } from "rxjs";
|
||||
|
||||
/**
|
||||
* Current user pubkey for multi-account support
|
||||
* Set this when account changes
|
||||
*/
|
||||
const currentPubkey$ = new BehaviorSubject<string | null>(null);
|
||||
|
||||
export function setCurrentPubkey(pubkey: string | null): void {
|
||||
currentPubkey$.next(pubkey);
|
||||
}
|
||||
|
||||
export function getCurrentPubkey(): string | null {
|
||||
return currentPubkey$.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage interface compatible with applesauce's persistEncryptedContent
|
||||
*
|
||||
* The keys are in format "rumor:{giftWrapId}" or "seal:{giftWrapId}"
|
||||
*/
|
||||
export const rumorStorage = {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
const pubkey = currentPubkey$.value;
|
||||
if (!pubkey) return null;
|
||||
|
||||
// Parse key format: "rumor:{giftWrapId}" or "seal:{giftWrapId}"
|
||||
const match = key.match(/^(rumor|seal):(.+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, type, giftWrapId] = match;
|
||||
|
||||
if (type === "rumor") {
|
||||
const entry = await db.decryptedRumors.get(giftWrapId);
|
||||
if (entry && entry.decryptedBy === pubkey) {
|
||||
return JSON.stringify(entry.rumor);
|
||||
}
|
||||
}
|
||||
|
||||
// For seals, we don't cache them separately (they're intermediate)
|
||||
return null;
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
const pubkey = currentPubkey$.value;
|
||||
if (!pubkey) return;
|
||||
|
||||
// Parse key format
|
||||
const match = key.match(/^(rumor|seal):(.+)$/);
|
||||
if (!match) return;
|
||||
|
||||
const [, type, giftWrapId] = match;
|
||||
|
||||
if (type === "rumor") {
|
||||
const rumor = JSON.parse(value) as Rumor;
|
||||
const entry: DecryptedRumor = {
|
||||
giftWrapId,
|
||||
rumor,
|
||||
decryptedBy: pubkey,
|
||||
decryptedAt: Date.now(),
|
||||
};
|
||||
await db.decryptedRumors.put(entry);
|
||||
}
|
||||
|
||||
// We don't persist seals - they're just intermediate decryption steps
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
const match = key.match(/^(rumor|seal):(.+)$/);
|
||||
if (!match) return;
|
||||
|
||||
const [, , giftWrapId] = match;
|
||||
await db.decryptedRumors.delete(giftWrapId);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all decrypted rumors for the current user
|
||||
*/
|
||||
export async function getDecryptedRumors(pubkey: string): Promise<Rumor[]> {
|
||||
const entries = await db.decryptedRumors
|
||||
.where("decryptedBy")
|
||||
.equals(pubkey)
|
||||
.toArray();
|
||||
|
||||
return entries.map((e) => e.rumor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific decrypted rumor by gift wrap ID
|
||||
*/
|
||||
export async function getDecryptedRumor(
|
||||
giftWrapId: string,
|
||||
pubkey: string,
|
||||
): Promise<Rumor | null> {
|
||||
const entry = await db.decryptedRumors.get(giftWrapId);
|
||||
if (entry && entry.decryptedBy === pubkey) {
|
||||
return entry.rumor;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a gift wrap has already been decrypted
|
||||
*/
|
||||
export async function isGiftWrapDecrypted(
|
||||
giftWrapId: string,
|
||||
pubkey: string,
|
||||
): Promise<boolean> {
|
||||
const entry = await db.decryptedRumors.get(giftWrapId);
|
||||
return entry !== null && entry !== undefined && entry.decryptedBy === pubkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a decrypted rumor directly (for manual decryption flows)
|
||||
*/
|
||||
export async function storeDecryptedRumor(
|
||||
giftWrapId: string,
|
||||
rumor: Rumor,
|
||||
decryptedBy: string,
|
||||
): Promise<void> {
|
||||
const entry: DecryptedRumor = {
|
||||
giftWrapId,
|
||||
rumor,
|
||||
decryptedBy,
|
||||
decryptedAt: Date.now(),
|
||||
};
|
||||
await db.decryptedRumors.put(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of decrypted rumors for a user
|
||||
*/
|
||||
export async function getDecryptedRumorCount(pubkey: string): Promise<number> {
|
||||
return db.decryptedRumors.where("decryptedBy").equals(pubkey).count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all decrypted rumors for a user
|
||||
* Useful for "forget me" functionality
|
||||
*/
|
||||
export async function clearDecryptedRumors(pubkey: string): Promise<number> {
|
||||
return db.decryptedRumors.where("decryptedBy").equals(pubkey).delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable storage for applesauce's persistEncryptedContent
|
||||
* Returns an observable that emits the storage when pubkey is set
|
||||
*/
|
||||
export function getRumorStorage$(): Observable<typeof rumorStorage | null> {
|
||||
return new BehaviorSubject(rumorStorage);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type AppId =
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "chat"
|
||||
| "inbox"
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
|
||||
@@ -346,6 +346,18 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
name: "inbox",
|
||||
section: "1",
|
||||
synopsis: "inbox",
|
||||
description:
|
||||
"View your encrypted private messages (NIP-17 gift-wrapped DMs). Messages are encrypted using NIP-44 and wrapped in NIP-59 gift wraps for privacy. Decrypted messages are cached locally so you only decrypt once. Click 'Decrypt' to unlock pending messages.",
|
||||
examples: ["inbox Open your private message inbox"],
|
||||
seeAlso: ["chat", "profile"],
|
||||
appId: "inbox",
|
||||
category: "Nostr",
|
||||
defaultProps: {},
|
||||
},
|
||||
chat: {
|
||||
name: "chat",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user