mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
refactor: Extract global GiftWrapService for NIP-59 gift wrap handling
Gift wraps (kind 1059) can contain any kind of event, not just DMs. This refactor creates a centralized service for gift wrap management: - GiftWrapService handles subscription, decryption, and state tracking - NIP-17 adapter now delegates to GiftWrapService instead of managing state - InboxViewer shows enable/disable prompt when gift wraps not enabled - Gift wrap subscription is persisted to localStorage - Pending/failed gift wrap counts tracked separately
This commit is contained in:
@@ -15,6 +15,7 @@ import { getZapRequest } from "applesauce-common/helpers/zap";
|
||||
import { toast } from "sonner";
|
||||
import accountManager from "@/services/accounts";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { giftWrapService } from "@/services/gift-wrap-service";
|
||||
import type {
|
||||
ChatProtocol,
|
||||
ProtocolIdentifier,
|
||||
@@ -372,28 +373,21 @@ export function ChatViewer({
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
// Ensure NIP-17 subscription is active when ChatViewer mounts
|
||||
useEffect(() => {
|
||||
if (protocol === "nip-17") {
|
||||
nip17Adapter.ensureSubscription();
|
||||
}
|
||||
}, [protocol]);
|
||||
|
||||
// NIP-17 decrypt state
|
||||
// NIP-17 decrypt state - uses global GiftWrapService
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const pendingCount =
|
||||
use$(
|
||||
() =>
|
||||
protocol === "nip-17" ? nip17Adapter.getPendingCount$() : undefined,
|
||||
protocol === "nip-17" ? giftWrapService.getPendingCount$() : undefined,
|
||||
[protocol],
|
||||
) ?? 0;
|
||||
|
||||
// Handle decrypt for NIP-17
|
||||
// Handle decrypt for NIP-17 - delegates to GiftWrapService
|
||||
const handleDecrypt = useCallback(async () => {
|
||||
if (protocol !== "nip-17") return;
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const result = await nip17Adapter.decryptPending();
|
||||
const result = await giftWrapService.decryptPending();
|
||||
console.log(
|
||||
`[Chat] Decrypted ${result.success} messages, ${result.failed} failed`,
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* InboxViewer - Private DM Inbox (NIP-17/59 Gift Wrapped Messages)
|
||||
*
|
||||
* Displays list of encrypted DM conversations using gift wraps.
|
||||
* Messages are cached after decryption to avoid re-decryption on page load.
|
||||
* Requires GiftWrapService to be enabled for subscription and decryption.
|
||||
*
|
||||
* Features:
|
||||
* - Toggle to enable/disable gift wrap subscription
|
||||
* - Lists all DM conversations from decrypted gift wraps
|
||||
* - Shows pending (undecrypted) message count
|
||||
* - Explicit decrypt button (no auto-decrypt)
|
||||
@@ -20,8 +21,12 @@ import {
|
||||
AlertCircle,
|
||||
PanelLeft,
|
||||
Bookmark,
|
||||
Power,
|
||||
PowerOff,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { giftWrapService } from "@/services/gift-wrap-service";
|
||||
import { ChatViewer } from "./ChatViewer";
|
||||
import type { ProtocolIdentifier } from "@/types/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -241,6 +246,30 @@ const MemoizedChatViewer = memo(
|
||||
(prev, next) => prev.partnerPubkey === next.partnerPubkey,
|
||||
);
|
||||
|
||||
/**
|
||||
* EnableGiftWrapPrompt - Shown when gift wrap is not enabled
|
||||
*/
|
||||
function EnableGiftWrapPrompt({ onEnable }: { onEnable: () => void }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-muted-foreground p-6 text-center">
|
||||
<Lock className="size-12 opacity-50" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Gift Wrap Subscription Disabled
|
||||
</h3>
|
||||
<p className="text-sm max-w-sm">
|
||||
Enable gift wrap subscription to receive and decrypt private messages.
|
||||
Gift wraps (NIP-59) are used for encrypted communication.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onEnable} className="gap-2">
|
||||
<Power className="size-4" />
|
||||
Enable Gift Wraps
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InboxViewer - Main inbox component
|
||||
*/
|
||||
@@ -248,6 +277,11 @@ export function InboxViewer() {
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
const activePubkey = activeAccount?.pubkey;
|
||||
|
||||
// Gift wrap service state
|
||||
const isGiftWrapEnabled =
|
||||
use$(() => giftWrapService.isEnabled$(), []) ?? false;
|
||||
const pendingCount = use$(() => giftWrapService.getPendingCount$(), []) ?? 0;
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -258,23 +292,13 @@ export function InboxViewer() {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
// NIP-17 adapter singleton instance
|
||||
const adapter = nip17Adapter;
|
||||
|
||||
// Ensure subscription is active when component mounts
|
||||
useEffect(() => {
|
||||
if (activePubkey) {
|
||||
adapter.ensureSubscription();
|
||||
}
|
||||
}, [adapter, activePubkey]);
|
||||
|
||||
// Get pending count
|
||||
const pendingCount = use$(() => adapter.getPendingCount$(), [adapter]) ?? 0;
|
||||
|
||||
// Get conversations from adapter
|
||||
// Get conversations from adapter (requires gift wrap service to be enabled)
|
||||
const conversations = use$(
|
||||
() => (activePubkey ? adapter.getConversations$() : undefined),
|
||||
[adapter, activePubkey],
|
||||
() =>
|
||||
activePubkey && isGiftWrapEnabled
|
||||
? nip17Adapter.getConversations$()
|
||||
: undefined,
|
||||
[activePubkey, isGiftWrapEnabled],
|
||||
);
|
||||
|
||||
// Track inbox relays for each partner
|
||||
@@ -284,7 +308,7 @@ export function InboxViewer() {
|
||||
|
||||
// Fetch inbox relays for conversation partners
|
||||
useEffect(() => {
|
||||
if (!conversations) return;
|
||||
if (!conversations || !isGiftWrapEnabled) return;
|
||||
|
||||
const fetchRelays = async () => {
|
||||
const newRelays = new Map<string, string[]>();
|
||||
@@ -302,7 +326,7 @@ export function InboxViewer() {
|
||||
}
|
||||
|
||||
try {
|
||||
const relays = await adapter.getInboxRelays(partner.pubkey);
|
||||
const relays = await nip17Adapter.getInboxRelays(partner.pubkey);
|
||||
newRelays.set(partner.pubkey, relays);
|
||||
} catch {
|
||||
newRelays.set(partner.pubkey, []);
|
||||
@@ -313,7 +337,7 @@ export function InboxViewer() {
|
||||
};
|
||||
|
||||
fetchRelays();
|
||||
}, [conversations, activePubkey, adapter, partnerRelays]);
|
||||
}, [conversations, activePubkey, partnerRelays, isGiftWrapEnabled]);
|
||||
|
||||
// Convert to display format
|
||||
const conversationList = useMemo(() => {
|
||||
@@ -358,20 +382,43 @@ export function InboxViewer() {
|
||||
[isMobile],
|
||||
);
|
||||
|
||||
// Handle enable gift wrap
|
||||
const handleEnableGiftWrap = useCallback(() => {
|
||||
giftWrapService.enable();
|
||||
toast.success("Gift wrap subscription enabled");
|
||||
}, []);
|
||||
|
||||
// Handle disable gift wrap
|
||||
const handleDisableGiftWrap = useCallback(() => {
|
||||
giftWrapService.disable();
|
||||
toast.info("Gift wrap subscription disabled");
|
||||
}, []);
|
||||
|
||||
// Handle decrypt
|
||||
const handleDecrypt = useCallback(async () => {
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const result = await adapter.decryptPending();
|
||||
const result = await giftWrapService.decryptPending();
|
||||
console.log(
|
||||
`[Inbox] Decrypted ${result.success} messages, ${result.failed} failed`,
|
||||
);
|
||||
if (result.success > 0) {
|
||||
toast.success(
|
||||
`Decrypted ${result.success} message${result.success !== 1 ? "s" : ""}`,
|
||||
);
|
||||
}
|
||||
if (result.failed > 0) {
|
||||
toast.warning(
|
||||
`${result.failed} message${result.failed !== 1 ? "s" : ""} failed to decrypt`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Inbox] Decrypt error:", error);
|
||||
toast.error("Failed to decrypt messages");
|
||||
} finally {
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
}, [adapter]);
|
||||
}, []);
|
||||
|
||||
// Handle resize
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -405,13 +452,6 @@ export function InboxViewer() {
|
||||
[sidebarWidth],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
adapter.cleanupAll();
|
||||
};
|
||||
}, [adapter]);
|
||||
|
||||
// Not signed in
|
||||
if (!activePubkey) {
|
||||
return (
|
||||
@@ -422,14 +462,39 @@ export function InboxViewer() {
|
||||
);
|
||||
}
|
||||
|
||||
// Gift wrap not enabled
|
||||
if (!isGiftWrapEnabled) {
|
||||
return <EnableGiftWrapPrompt onEnable={handleEnableGiftWrap} />;
|
||||
}
|
||||
|
||||
// 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 className="p-3 border-b space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="size-5" />
|
||||
<h2 className="font-semibold">Private Messages</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 gap-1 text-xs"
|
||||
onClick={() => {
|
||||
if (isGiftWrapEnabled) {
|
||||
handleDisableGiftWrap();
|
||||
} else {
|
||||
handleEnableGiftWrap();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGiftWrapEnabled ? (
|
||||
<Power className="size-3 text-green-500" />
|
||||
) : (
|
||||
<PowerOff className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<DecryptButton
|
||||
pendingCount={pendingCount || 0}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
/**
|
||||
* 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)
|
||||
* Implements NIP-17 encrypted DMs using NIP-59 gift wraps.
|
||||
* Delegates gift wrap management to the global GiftWrapService.
|
||||
*
|
||||
* Privacy features:
|
||||
* - Sender identity hidden (ephemeral gift wrap key)
|
||||
* - Deniability (rumors are unsigned)
|
||||
* - Uses recipient's private inbox relays (kind 10050)
|
||||
* This adapter handles:
|
||||
* - Parsing DM identifiers (npub, nprofile, hex, NIP-05, $me)
|
||||
* - Filtering kind 14 rumors from decrypted gift wraps
|
||||
* - Converting rumors to messages
|
||||
* - Sending gift-wrapped messages
|
||||
*
|
||||
* Caching:
|
||||
* - Gift wraps are cached to Dexie events table
|
||||
* - Decrypted content persisted via applesauce's persistEncryptedContent
|
||||
* Gift wrap subscription and decryption is managed globally by GiftWrapService.
|
||||
*/
|
||||
import { Observable, firstValueFrom, BehaviorSubject } from "rxjs";
|
||||
import { map, first, distinctUntilChanged } from "rxjs/operators";
|
||||
import { Observable, BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { map, distinctUntilChanged } from "rxjs/operators";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
@@ -29,19 +25,14 @@ import type {
|
||||
} 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 { hub } from "@/services/hub";
|
||||
import { relayListCache } from "@/services/relay-list-cache";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { getEventsForFilters } from "@/services/event-cache";
|
||||
import { giftWrapService } from "@/services/gift-wrap-service";
|
||||
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,
|
||||
isGiftWrapUnlocked,
|
||||
getGiftWrapRumor,
|
||||
getConversationParticipants,
|
||||
getConversationIdentifierFromMessage,
|
||||
@@ -49,31 +40,30 @@ import {
|
||||
} from "applesauce-common/helpers";
|
||||
import { SendWrappedMessage } from "applesauce-actions/actions";
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Requires GiftWrapService to be enabled for subscription and decryption.
|
||||
*/
|
||||
export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-17" as const;
|
||||
readonly type = "dm" as const;
|
||||
|
||||
/** Track pending (undecrypted) gift wrap IDs */
|
||||
private pendingGiftWraps$ = new BehaviorSubject<Set<string>>(new Set());
|
||||
/**
|
||||
* Check if gift wrap service is enabled (required for NIP-17)
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return giftWrapService.isEnabled();
|
||||
}
|
||||
|
||||
/** Track failed (could not decrypt) gift wrap IDs */
|
||||
private failedGiftWraps$ = new BehaviorSubject<Set<string>>(new Set());
|
||||
|
||||
/** Observable of gift wrap events from event store */
|
||||
private giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
|
||||
|
||||
/** Track if subscription is active */
|
||||
private subscriptionActive = false;
|
||||
/**
|
||||
* Observable of whether NIP-17 is available
|
||||
*/
|
||||
isAvailable$(): Observable<boolean> {
|
||||
return giftWrapService.isEnabled$();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts npub, nprofile, hex pubkey, NIP-05, or $me
|
||||
@@ -137,6 +127,12 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
if (!giftWrapService.isEnabled()) {
|
||||
throw new Error(
|
||||
"Gift wrap subscription is not enabled. Enable it in settings to use NIP-17 DMs.",
|
||||
);
|
||||
}
|
||||
|
||||
let partnerPubkey: string;
|
||||
|
||||
// Handle $me (saved messages - DMs to yourself)
|
||||
@@ -167,7 +163,6 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
: await this.getPartnerTitle(partnerPubkey);
|
||||
|
||||
// Create conversation ID from sorted participants (deterministic)
|
||||
// For self-conversations, it's just one participant listed twice
|
||||
const participants = isSelf
|
||||
? [activePubkey]
|
||||
: [activePubkey, partnerPubkey].sort();
|
||||
@@ -206,7 +201,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
|
||||
/**
|
||||
* Load messages for a conversation
|
||||
* Returns decrypted rumors that match this conversation
|
||||
* Filters decrypted gift wraps from GiftWrapService for kind 14 rumors
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
@@ -234,25 +229,19 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
|
||||
// Expected participants for this conversation
|
||||
// For self-conversations, both sender and recipient are the same
|
||||
const expectedParticipants = isSelfConversation
|
||||
? [activePubkey]
|
||||
: [activePubkey, partnerPubkey].sort();
|
||||
|
||||
// Subscribe to gift wraps for this user
|
||||
this.subscribeToGiftWraps(activePubkey);
|
||||
|
||||
// Get rumors from unlocked gift wraps and filter to this conversation
|
||||
return this.giftWraps$.pipe(
|
||||
// Get decrypted gift wraps from the global service and filter to kind 14 DMs
|
||||
return giftWrapService.getDecryptedGiftWraps$().pipe(
|
||||
map((giftWraps) => {
|
||||
const messages: Message[] = [];
|
||||
|
||||
for (const gift of giftWraps) {
|
||||
// Skip locked gift wraps
|
||||
if (!isGiftWrapUnlocked(gift)) continue;
|
||||
|
||||
try {
|
||||
const rumor = getGiftWrapRumor(gift);
|
||||
if (!rumor) continue;
|
||||
|
||||
// Only kind 14 DM rumors
|
||||
if (rumor.kind !== DM_RUMOR_KIND) continue;
|
||||
@@ -260,9 +249,8 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
// Get participants from rumor
|
||||
const rumorParticipants = getConversationParticipants(rumor);
|
||||
|
||||
// For self-conversations, all participants should be the same (sender == recipient)
|
||||
// For self-conversations, all participants should be the same
|
||||
if (isSelfConversation) {
|
||||
// Check if all participants are the same as activePubkey
|
||||
const allSelf = rumorParticipants.every(
|
||||
(p) => p === activePubkey,
|
||||
);
|
||||
@@ -312,12 +300,6 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
|
||||
/**
|
||||
* Send a gift-wrapped DM
|
||||
*
|
||||
* Uses applesauce's SendWrappedMessage action which:
|
||||
* 1. Creates kind 14 rumor with message content
|
||||
* 2. Wraps in seal (kind 13) encrypted to each participant
|
||||
* 3. Wraps seal in gift wrap (kind 1059) with ephemeral key
|
||||
* 4. Publishes to each participant's private inbox relays (kind 10050)
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
@@ -348,17 +330,11 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
|
||||
// Use applesauce's SendWrappedMessage action
|
||||
// This handles:
|
||||
// - Creating the wrapped message rumor
|
||||
// - Gift wrapping for all participants (recipient + self)
|
||||
// - Publishing to each participant's inbox relays
|
||||
await hub.run(SendWrappedMessage, recipientPubkey, content);
|
||||
|
||||
console.log(
|
||||
`[NIP-17] Sent wrapped message to ${recipientPubkey.slice(0, 8)}...${isSelfConversation ? " (saved)" : ""}`,
|
||||
);
|
||||
|
||||
// Note: The sent gift wrap will be picked up automatically via eventStore.insert$ subscription
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -367,7 +343,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: true,
|
||||
supportsThreading: true, // e-tag replies
|
||||
supportsThreading: true,
|
||||
supportsModeration: false,
|
||||
supportsRoles: false,
|
||||
supportsGroupManagement: false,
|
||||
@@ -383,20 +359,16 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
_conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// Check if we have an unlocked gift wrap with a rumor matching this ID
|
||||
const giftWraps = this.giftWraps$.value;
|
||||
// Check decrypted gift wraps for a rumor matching this ID
|
||||
const giftWraps = await firstValueFrom(
|
||||
giftWrapService.getDecryptedGiftWraps$(),
|
||||
);
|
||||
|
||||
for (const gift of giftWraps) {
|
||||
if (!isGiftWrapUnlocked(gift)) continue;
|
||||
|
||||
try {
|
||||
const rumor = getGiftWrapRumor(gift);
|
||||
if (rumor.id === eventId) {
|
||||
// Return as pseudo-event
|
||||
return {
|
||||
...rumor,
|
||||
sig: "",
|
||||
} as NostrEvent;
|
||||
if (rumor && rumor.id === eventId) {
|
||||
return { ...rumor, sig: "" } as NostrEvent;
|
||||
}
|
||||
} catch {
|
||||
// Skip
|
||||
@@ -407,119 +379,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending (undecrypted) gift wraps (excludes failed)
|
||||
*/
|
||||
getPendingCount(): number {
|
||||
const pending = this.pendingGiftWraps$.value;
|
||||
const failed = this.failedGiftWraps$.value;
|
||||
// Only count pending that haven't failed
|
||||
return Array.from(pending).filter((id) => !failed.has(id)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get observable of pending gift wrap count (excludes failed)
|
||||
*/
|
||||
getPendingCount$(): Observable<number> {
|
||||
return this.pendingGiftWraps$.pipe(
|
||||
map((pending) => {
|
||||
const failed = this.failedGiftWraps$.value;
|
||||
return Array.from(pending).filter((id) => !failed.has(id)).length;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of failed gift wraps
|
||||
*/
|
||||
getFailedCount(): number {
|
||||
return this.failedGiftWraps$.value.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
// Only try pending that haven't already failed
|
||||
const failedSet = this.failedGiftWraps$.value;
|
||||
const pendingIds = Array.from(this.pendingGiftWraps$.value).filter(
|
||||
(id) => !failedSet.has(id),
|
||||
);
|
||||
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) {
|
||||
// Mark as failed - couldn't find the event
|
||||
this.markAsFailed(giftWrapId);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already unlocked?
|
||||
if (isGiftWrapUnlocked(giftWrap)) {
|
||||
// Remove from pending
|
||||
this.removeFromPending(giftWrapId);
|
||||
success++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt using signer - applesauce handles caching automatically
|
||||
await unlockGiftWrap(giftWrap, signer);
|
||||
|
||||
// Remove from pending (success)
|
||||
this.removeFromPending(giftWrapId);
|
||||
|
||||
// Refresh gift wraps list
|
||||
this.giftWraps$.next([...this.giftWraps$.value]);
|
||||
|
||||
success++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`,
|
||||
error,
|
||||
);
|
||||
// Mark as failed so we don't retry
|
||||
this.markAsFailed(giftWrapId);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a gift wrap as failed (won't retry decryption)
|
||||
*/
|
||||
private markAsFailed(giftWrapId: string): void {
|
||||
const failed = new Set(this.failedGiftWraps$.value);
|
||||
failed.add(giftWrapId);
|
||||
this.failedGiftWraps$.next(failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a gift wrap from pending
|
||||
*/
|
||||
private removeFromPending(giftWrapId: string): void {
|
||||
const pending = new Set(this.pendingGiftWraps$.value);
|
||||
pending.delete(giftWrapId);
|
||||
this.pendingGiftWraps$.next(pending);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversations from decrypted rumors
|
||||
* Get all conversations from decrypted kind 14 rumors
|
||||
*/
|
||||
getConversations$(): Observable<Conversation[]> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
@@ -527,10 +387,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
return new BehaviorSubject([]);
|
||||
}
|
||||
|
||||
// Start fetching gift wraps from inbox relays
|
||||
this.subscribeToGiftWraps(activePubkey);
|
||||
|
||||
return this.giftWraps$.pipe(
|
||||
return giftWrapService.getDecryptedGiftWraps$().pipe(
|
||||
map((giftWraps) => {
|
||||
// Group rumors by conversation
|
||||
const conversationMap = new Map<
|
||||
@@ -539,10 +396,9 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
>();
|
||||
|
||||
for (const gift of giftWraps) {
|
||||
if (!isGiftWrapUnlocked(gift)) continue;
|
||||
|
||||
try {
|
||||
const rumor = getGiftWrapRumor(gift);
|
||||
if (!rumor) continue;
|
||||
if (rumor.kind !== DM_RUMOR_KIND) continue;
|
||||
|
||||
const convId = getConversationIdentifierFromMessage(rumor);
|
||||
@@ -561,20 +417,16 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
const conversations: Conversation[] = [];
|
||||
|
||||
for (const [convId, { participants, lastRumor }] of conversationMap) {
|
||||
// Check if this is a self-conversation (all participants are activePubkey)
|
||||
const isSelfConversation = participants.every(
|
||||
(p) => p === activePubkey,
|
||||
);
|
||||
|
||||
// Get partner pubkey (for self-conversation, use self)
|
||||
const partnerPubkey = isSelfConversation
|
||||
? activePubkey
|
||||
: participants.find((p) => p !== activePubkey);
|
||||
|
||||
// Skip if we can't determine partner (shouldn't happen)
|
||||
if (!partnerPubkey) continue;
|
||||
|
||||
// Create unique participant list for conversation ID
|
||||
const uniqueParticipants = isSelfConversation
|
||||
? [activePubkey]
|
||||
: participants.sort();
|
||||
@@ -585,7 +437,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
protocol: "nip-17",
|
||||
title: isSelfConversation
|
||||
? "Saved Messages"
|
||||
: partnerPubkey.slice(0, 8) + "...", // Will be replaced with display name
|
||||
: partnerPubkey.slice(0, 8) + "...",
|
||||
participants: isSelfConversation
|
||||
? [{ pubkey: activePubkey, role: "member" as const }]
|
||||
: participants.map((p) => ({
|
||||
@@ -604,12 +456,10 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
|
||||
// Sort: Saved Messages at top, then by last message timestamp
|
||||
conversations.sort((a, b) => {
|
||||
// Saved Messages always first
|
||||
if (a.metadata?.isSavedMessages && !b.metadata?.isSavedMessages)
|
||||
return -1;
|
||||
if (!a.metadata?.isSavedMessages && b.metadata?.isSavedMessages)
|
||||
return 1;
|
||||
// Then by timestamp
|
||||
return (
|
||||
(b.lastMessage?.timestamp || 0) - (a.lastMessage?.timestamp || 0)
|
||||
);
|
||||
@@ -620,277 +470,19 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Public Methods for Subscription Management ====================
|
||||
|
||||
/**
|
||||
* Ensure gift wrap subscription is active for the current user
|
||||
* Call this when InboxViewer or ChatViewer mounts
|
||||
* Get inbox relays for a pubkey (delegates to GiftWrapService)
|
||||
*/
|
||||
ensureSubscription(): void {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
console.warn("[NIP-17] Cannot start subscription: no active account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.subscriptionActive) {
|
||||
console.log("[NIP-17] Starting gift wrap subscription");
|
||||
this.subscribeToGiftWraps(activePubkey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription is currently active
|
||||
*/
|
||||
isSubscriptionActive(): boolean {
|
||||
return this.subscriptionActive;
|
||||
async getInboxRelays(pubkey: string): Promise<string[]> {
|
||||
return giftWrapService.getInboxRelays(pubkey);
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
/**
|
||||
* Subscribe to gift wraps for the user from their inbox relays
|
||||
* Also subscribes to eventStore.insert$ to catch locally published gift wraps
|
||||
*/
|
||||
private async subscribeToGiftWraps(pubkey: string): Promise<void> {
|
||||
// Don't create duplicate subscriptions
|
||||
if (this.subscriptionActive) {
|
||||
console.log("[NIP-17] Subscription already active, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscriptionActive = true;
|
||||
|
||||
// First, load any cached gift wraps from EventStore (persisted to Dexie)
|
||||
// This is critical for cold start scenarios
|
||||
await this.loadCachedGiftWraps(pubkey);
|
||||
|
||||
// Subscribe to eventStore.insert$ to catch gift wraps added locally (e.g., after sending)
|
||||
// This is critical for immediate display of sent messages
|
||||
const insertSub = eventStore.insert$.subscribe((event) => {
|
||||
if (
|
||||
event.kind === GIFT_WRAP_KIND &&
|
||||
event.tags.some((t) => t[0] === "p" && t[1] === pubkey)
|
||||
) {
|
||||
console.log(
|
||||
`[NIP-17] Detected gift wrap from eventStore.insert$: ${event.id.slice(0, 8)}...`,
|
||||
);
|
||||
this.handleGiftWrap(event);
|
||||
}
|
||||
});
|
||||
this.subscriptions.set(`nip-17:insert:${pubkey}`, insertSub);
|
||||
|
||||
const conversationId = `nip-17:inbox:${pubkey}`;
|
||||
|
||||
// Get user's private inbox relays (kind 10050)
|
||||
const inboxRelays = await this.fetchInboxRelays(pubkey);
|
||||
if (inboxRelays.length === 0) {
|
||||
console.warn(
|
||||
"[NIP-17] No inbox relays found. Configure kind 10050 to receive DMs.",
|
||||
);
|
||||
// Still keep subscriptionActive true for insert$ subscription
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-17] Subscribing to ${inboxRelays.length} inbox relays:`,
|
||||
inboxRelays,
|
||||
);
|
||||
|
||||
// Subscribe to gift wraps addressed to this user from relays
|
||||
const filter: Filter = {
|
||||
kinds: [GIFT_WRAP_KIND],
|
||||
"#p": [pubkey],
|
||||
};
|
||||
|
||||
const relaySub = pool
|
||||
.subscription(inboxRelays, [filter], { eventStore })
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE
|
||||
console.log("[NIP-17] EOSE received for gift wraps");
|
||||
} else {
|
||||
// New gift wrap received from relay
|
||||
console.log(
|
||||
`[NIP-17] Received gift wrap from relay: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
this.handleGiftWrap(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error("[NIP-17] Relay subscription error:", err);
|
||||
},
|
||||
complete: () => {
|
||||
console.log("[NIP-17] Relay subscription completed");
|
||||
},
|
||||
});
|
||||
|
||||
this.subscriptions.set(conversationId, relaySub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached gift wraps from Dexie (persistent storage)
|
||||
* This is called on cold start to restore previously received gift wraps
|
||||
* We query Dexie directly because EventStore is in-memory and empty on cold start
|
||||
*/
|
||||
private async loadCachedGiftWraps(pubkey: string): Promise<void> {
|
||||
try {
|
||||
// Query Dexie directly for cached gift wraps addressed to this user
|
||||
// EventStore is in-memory only, so on cold start it's empty
|
||||
const cachedGiftWraps = await getEventsForFilters([
|
||||
{ kinds: [GIFT_WRAP_KIND], "#p": [pubkey] },
|
||||
]);
|
||||
|
||||
if (cachedGiftWraps.length > 0) {
|
||||
console.log(
|
||||
`[NIP-17] Loading ${cachedGiftWraps.length} cached gift wrap(s) from Dexie`,
|
||||
);
|
||||
for (const giftWrap of cachedGiftWraps) {
|
||||
// Add to EventStore so other parts of the app can access it
|
||||
eventStore.add(giftWrap);
|
||||
// Handle in adapter state
|
||||
this.handleGiftWrap(giftWrap);
|
||||
}
|
||||
} else {
|
||||
console.log("[NIP-17] No cached gift wraps found in Dexie");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[NIP-17] Failed to load cached gift wraps:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a received or sent gift wrap
|
||||
*/
|
||||
private handleGiftWrap(giftWrap: NostrEvent): void {
|
||||
// Add to gift wraps list if not already present
|
||||
const current = this.giftWraps$.value;
|
||||
if (!current.find((g) => g.id === giftWrap.id)) {
|
||||
this.giftWraps$.next([...current, giftWrap]);
|
||||
}
|
||||
|
||||
// Check if unlocked (cached) or pending (skip if already failed)
|
||||
if (!isGiftWrapUnlocked(giftWrap)) {
|
||||
if (!this.failedGiftWraps$.value.has(giftWrap.id)) {
|
||||
const pending = new Set(this.pendingGiftWraps$.value);
|
||||
pending.add(giftWrap.id);
|
||||
this.pendingGiftWraps$.next(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache for inbox relays */
|
||||
private inboxRelayCache = new Map<string, string[]>();
|
||||
|
||||
/**
|
||||
* Get inbox relays for a user (public API for UI display)
|
||||
* Returns cached value or fetches from network
|
||||
*/
|
||||
async getInboxRelays(pubkey: string): Promise<string[]> {
|
||||
const cached = this.inboxRelayCache.get(pubkey);
|
||||
if (cached) return cached;
|
||||
|
||||
const relays = await this.fetchInboxRelays(pubkey);
|
||||
if (relays.length > 0) {
|
||||
this.inboxRelayCache.set(pubkey, relays);
|
||||
}
|
||||
return relays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch private inbox relays for a user (kind 10050)
|
||||
*/
|
||||
private async fetchInboxRelays(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) {
|
||||
const relays = this.extractRelaysFromEvent(existing);
|
||||
if (relays.length > 0) {
|
||||
console.log(
|
||||
`[NIP-17] Found inbox relays in store for ${pubkey.slice(0, 8)}:`,
|
||||
relays,
|
||||
);
|
||||
return relays;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's outbox relays to search for their kind 10050
|
||||
const outboxRelays = await relayListCache.getOutboxRelays(pubkey);
|
||||
const searchRelays =
|
||||
outboxRelays && outboxRelays.length > 0
|
||||
? outboxRelays
|
||||
: AGGREGATOR_RELAYS;
|
||||
|
||||
if (searchRelays.length === 0) {
|
||||
console.warn(
|
||||
`[NIP-17] No relays to search for kind 10050 for ${pubkey.slice(0, 8)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-17] Searching ${searchRelays.length} relays for kind 10050:`,
|
||||
searchRelays,
|
||||
);
|
||||
|
||||
// Fetch from user's outbox 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(searchRelays, [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) {
|
||||
const relays = this.extractRelaysFromEvent(events[0]);
|
||||
console.log(
|
||||
`[NIP-17] Found inbox relays from network for ${pubkey.slice(0, 8)}:`,
|
||||
relays,
|
||||
);
|
||||
return relays;
|
||||
}
|
||||
|
||||
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]),
|
||||
);
|
||||
@@ -908,11 +500,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
metadata: {
|
||||
encrypted: true,
|
||||
},
|
||||
// Create a pseudo-event for the rumor (unsigned)
|
||||
event: {
|
||||
...rumor,
|
||||
sig: "",
|
||||
} as NostrEvent,
|
||||
event: { ...rumor, sig: "" } as NostrEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -924,21 +512,9 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a gift wrap event directly to local state
|
||||
* Used for optimistic updates after sending
|
||||
*/
|
||||
addGiftWrapLocally(giftWrap: NostrEvent): void {
|
||||
const current = this.giftWraps$.value;
|
||||
if (!current.find((g) => g.id === giftWrap.id)) {
|
||||
this.giftWraps$.next([...current, giftWrap]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for shared state across the app
|
||||
* All components should use this to ensure gift wraps are shared
|
||||
*/
|
||||
export const nip17Adapter = new Nip17Adapter();
|
||||
|
||||
461
src/services/gift-wrap-service.ts
Normal file
461
src/services/gift-wrap-service.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Gift Wrap Service - Global gift wrap handling for NIP-59
|
||||
*
|
||||
* Gift wraps (kind 1059) can contain any kind of event, not just DMs.
|
||||
* This service provides global subscription and decryption management
|
||||
* that is independent of any specific protocol adapter.
|
||||
*
|
||||
* Features:
|
||||
* - Subscribe to gift wraps from user's inbox relays
|
||||
* - Track pending (encrypted), decrypted, and failed gift wraps
|
||||
* - Persist decrypted content via applesauce
|
||||
* - Enable/disable via settings (persisted to localStorage)
|
||||
* - Exposes observables for UI components
|
||||
*/
|
||||
import { BehaviorSubject, Observable, Subscription } from "rxjs";
|
||||
import { map, distinctUntilChanged } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "./event-store";
|
||||
import pool from "./relay-pool";
|
||||
import accountManager from "./accounts";
|
||||
import { relayListCache } from "./relay-list-cache";
|
||||
import { getEventsForFilters } from "./event-cache";
|
||||
import { AGGREGATOR_RELAYS } from "./loaders";
|
||||
import {
|
||||
unlockGiftWrap,
|
||||
isGiftWrapUnlocked,
|
||||
getGiftWrapRumor,
|
||||
} from "applesauce-common/helpers";
|
||||
|
||||
const GIFT_WRAP_KIND = 1059;
|
||||
const DM_RELAY_LIST_KIND = 10050;
|
||||
const STORAGE_KEY = "grimoire:gift-wrap-enabled";
|
||||
|
||||
/**
|
||||
* Gift Wrap Service - Singleton for global gift wrap management
|
||||
*/
|
||||
class GiftWrapService {
|
||||
/** Whether gift wrap subscription is enabled */
|
||||
private enabled$ = new BehaviorSubject<boolean>(this.loadEnabledState());
|
||||
|
||||
/** All gift wraps we've seen */
|
||||
private giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
|
||||
|
||||
/** Gift wrap IDs that are still encrypted (pending decryption) */
|
||||
private pendingIds$ = new BehaviorSubject<Set<string>>(new Set());
|
||||
|
||||
/** Gift wrap IDs that failed to decrypt */
|
||||
private failedIds$ = new BehaviorSubject<Set<string>>(new Set());
|
||||
|
||||
/** Active subscriptions */
|
||||
private subscriptions = new Map<string, Subscription>();
|
||||
|
||||
/** Current user pubkey */
|
||||
private currentPubkey: string | null = null;
|
||||
|
||||
/** Whether subscription is currently active */
|
||||
private subscriptionActive = false;
|
||||
|
||||
constructor() {
|
||||
// React to account changes
|
||||
accountManager.active$.subscribe((account) => {
|
||||
const newPubkey = account?.pubkey || null;
|
||||
if (newPubkey !== this.currentPubkey) {
|
||||
this.handleAccountChange(newPubkey);
|
||||
}
|
||||
});
|
||||
|
||||
// React to enabled state changes
|
||||
this.enabled$.pipe(distinctUntilChanged()).subscribe((enabled) => {
|
||||
this.saveEnabledState(enabled);
|
||||
if (enabled && this.currentPubkey) {
|
||||
this.startSubscription(this.currentPubkey);
|
||||
} else if (!enabled) {
|
||||
this.stopSubscription();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Public API ====================
|
||||
|
||||
/**
|
||||
* Check if gift wrap subscription is enabled
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.enabled$.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable of enabled state
|
||||
*/
|
||||
isEnabled$(): Observable<boolean> {
|
||||
return this.enabled$.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable gift wrap subscription
|
||||
*/
|
||||
enable(): void {
|
||||
this.enabled$.next(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable gift wrap subscription
|
||||
*/
|
||||
disable(): void {
|
||||
this.enabled$.next(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle enabled state
|
||||
*/
|
||||
toggle(): void {
|
||||
this.enabled$.next(!this.enabled$.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all gift wraps (both encrypted and decrypted)
|
||||
*/
|
||||
getGiftWraps$(): Observable<NostrEvent[]> {
|
||||
return this.giftWraps$.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only decrypted gift wraps
|
||||
*/
|
||||
getDecryptedGiftWraps$(): Observable<NostrEvent[]> {
|
||||
return this.giftWraps$.pipe(
|
||||
map((wraps) => wraps.filter((w) => isGiftWrapUnlocked(w))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending (encrypted but not failed) gift wraps
|
||||
*/
|
||||
getPendingCount(): number {
|
||||
const pending = this.pendingIds$.value;
|
||||
const failed = this.failedIds$.value;
|
||||
return Array.from(pending).filter((id) => !failed.has(id)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable of pending count
|
||||
*/
|
||||
getPendingCount$(): Observable<number> {
|
||||
return this.pendingIds$.pipe(
|
||||
map((pending) => {
|
||||
const failed = this.failedIds$.value;
|
||||
return Array.from(pending).filter((id) => !failed.has(id)).length;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of failed gift wraps
|
||||
*/
|
||||
getFailedCount(): number {
|
||||
return this.failedIds$.value.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable of failed count
|
||||
*/
|
||||
getFailedCount$(): Observable<number> {
|
||||
return this.failedIds$.pipe(map((failed) => failed.size));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 failedSet = this.failedIds$.value;
|
||||
const pendingIds = Array.from(this.pendingIds$.value).filter(
|
||||
(id) => !failedSet.has(id),
|
||||
);
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const giftWrapId of pendingIds) {
|
||||
try {
|
||||
const giftWrap = this.giftWraps$.value.find((g) => g.id === giftWrapId);
|
||||
if (!giftWrap) {
|
||||
this.markAsFailed(giftWrapId);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isGiftWrapUnlocked(giftWrap)) {
|
||||
this.removeFromPending(giftWrapId);
|
||||
success++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt using signer - applesauce handles caching
|
||||
await unlockGiftWrap(giftWrap, signer);
|
||||
|
||||
this.removeFromPending(giftWrapId);
|
||||
|
||||
// Trigger update so observers know decryption happened
|
||||
this.giftWraps$.next([...this.giftWraps$.value]);
|
||||
|
||||
success++;
|
||||
} catch (error) {
|
||||
console.error(`[GiftWrap] Failed to decrypt ${giftWrapId}:`, error);
|
||||
this.markAsFailed(giftWrapId);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decrypted rumors of a specific kind
|
||||
*/
|
||||
getRumorsByKind$(kind: number): Observable<NostrEvent[]> {
|
||||
return this.getDecryptedGiftWraps$().pipe(
|
||||
map((wraps) => {
|
||||
const rumors: NostrEvent[] = [];
|
||||
for (const wrap of wraps) {
|
||||
try {
|
||||
const rumor = getGiftWrapRumor(wrap);
|
||||
if (rumor && rumor.kind === kind) {
|
||||
rumors.push({ ...rumor, sig: "" } as NostrEvent);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid
|
||||
}
|
||||
}
|
||||
return rumors;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inbox relays for a pubkey
|
||||
*/
|
||||
async getInboxRelays(pubkey: string): Promise<string[]> {
|
||||
return this.fetchInboxRelays(pubkey);
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
private loadEnabledState(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private saveEnabledState(enabled: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(enabled));
|
||||
} catch (error) {
|
||||
console.warn("[GiftWrap] Failed to save enabled state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAccountChange(newPubkey: string | null): void {
|
||||
// Stop existing subscription
|
||||
this.stopSubscription();
|
||||
|
||||
// Clear state
|
||||
this.giftWraps$.next([]);
|
||||
this.pendingIds$.next(new Set());
|
||||
this.failedIds$.next(new Set());
|
||||
this.currentPubkey = newPubkey;
|
||||
|
||||
// Start new subscription if enabled and logged in
|
||||
if (this.enabled$.value && newPubkey) {
|
||||
this.startSubscription(newPubkey);
|
||||
}
|
||||
}
|
||||
|
||||
private async startSubscription(pubkey: string): Promise<void> {
|
||||
if (this.subscriptionActive) {
|
||||
console.log("[GiftWrap] Subscription already active");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[GiftWrap] Starting subscription for", pubkey.slice(0, 8));
|
||||
this.subscriptionActive = true;
|
||||
|
||||
// Load cached gift wraps from Dexie
|
||||
await this.loadCachedGiftWraps(pubkey);
|
||||
|
||||
// Subscribe to eventStore.insert$ for locally published gift wraps
|
||||
const insertSub = eventStore.insert$.subscribe((event) => {
|
||||
if (
|
||||
event.kind === GIFT_WRAP_KIND &&
|
||||
event.tags.some((t) => t[0] === "p" && t[1] === pubkey)
|
||||
) {
|
||||
console.log(
|
||||
`[GiftWrap] New gift wrap from local: ${event.id.slice(0, 8)}`,
|
||||
);
|
||||
this.handleGiftWrap(event);
|
||||
}
|
||||
});
|
||||
this.subscriptions.set("insert", insertSub);
|
||||
|
||||
// Get inbox relays and subscribe
|
||||
const inboxRelays = await this.fetchInboxRelays(pubkey);
|
||||
if (inboxRelays.length === 0) {
|
||||
console.warn("[GiftWrap] No inbox relays found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[GiftWrap] Subscribing to ${inboxRelays.length} inbox relays`);
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [GIFT_WRAP_KIND],
|
||||
"#p": [pubkey],
|
||||
};
|
||||
|
||||
const relaySub = pool
|
||||
.subscription(inboxRelays, [filter], { eventStore })
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
console.log("[GiftWrap] EOSE received");
|
||||
} else {
|
||||
console.log(
|
||||
`[GiftWrap] New gift wrap from relay: ${response.id.slice(0, 8)}`,
|
||||
);
|
||||
this.handleGiftWrap(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error("[GiftWrap] Subscription error:", err);
|
||||
},
|
||||
});
|
||||
|
||||
this.subscriptions.set("relays", relaySub);
|
||||
}
|
||||
|
||||
private stopSubscription(): void {
|
||||
console.log("[GiftWrap] Stopping subscription");
|
||||
this.subscriptionActive = false;
|
||||
|
||||
for (const sub of this.subscriptions.values()) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
private async loadCachedGiftWraps(pubkey: string): Promise<void> {
|
||||
try {
|
||||
const cached = await getEventsForFilters([
|
||||
{ kinds: [GIFT_WRAP_KIND], "#p": [pubkey] },
|
||||
]);
|
||||
|
||||
if (cached.length > 0) {
|
||||
console.log(`[GiftWrap] Loaded ${cached.length} from cache`);
|
||||
for (const giftWrap of cached) {
|
||||
eventStore.add(giftWrap);
|
||||
this.handleGiftWrap(giftWrap);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[GiftWrap] Failed to load cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleGiftWrap(giftWrap: NostrEvent): void {
|
||||
const current = this.giftWraps$.value;
|
||||
if (!current.find((g) => g.id === giftWrap.id)) {
|
||||
this.giftWraps$.next([...current, giftWrap]);
|
||||
}
|
||||
|
||||
if (!isGiftWrapUnlocked(giftWrap)) {
|
||||
if (!this.failedIds$.value.has(giftWrap.id)) {
|
||||
const pending = new Set(this.pendingIds$.value);
|
||||
pending.add(giftWrap.id);
|
||||
this.pendingIds$.next(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private markAsFailed(id: string): void {
|
||||
const failed = new Set(this.failedIds$.value);
|
||||
failed.add(id);
|
||||
this.failedIds$.next(failed);
|
||||
}
|
||||
|
||||
private removeFromPending(id: string): void {
|
||||
const pending = new Set(this.pendingIds$.value);
|
||||
pending.delete(id);
|
||||
this.pendingIds$.next(pending);
|
||||
}
|
||||
|
||||
private async fetchInboxRelays(pubkey: string): Promise<string[]> {
|
||||
// Check EventStore first
|
||||
const existing = eventStore.getReplaceable(DM_RELAY_LIST_KIND, pubkey);
|
||||
if (existing) {
|
||||
const relays = this.extractRelaysFromEvent(existing);
|
||||
if (relays.length > 0) return relays;
|
||||
}
|
||||
|
||||
// Search on outbox relays
|
||||
const outboxRelays = await relayListCache.getOutboxRelays(pubkey);
|
||||
const searchRelays =
|
||||
outboxRelays && outboxRelays.length > 0
|
||||
? outboxRelays
|
||||
: AGGREGATOR_RELAYS;
|
||||
|
||||
if (searchRelays.length === 0) return [];
|
||||
|
||||
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(searchRelays, [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 [];
|
||||
}
|
||||
|
||||
private extractRelaysFromEvent(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
*/
|
||||
export const giftWrapService = new GiftWrapService();
|
||||
Reference in New Issue
Block a user