fix: Complete NIP-59 gift wrap implementation with API fixes

This commit resolves all build errors in the NIP-59 gift wrap feature:

- Created Switch UI component using simple toggle button pattern
- Fixed InboxViewer to use <label> elements instead of custom Label component
- Corrected createTimelineLoader API usage (4 args + call loader() to get observable)
- Fixed ISigner nip44 decryption API (use signer.nip44.decrypt instead of signer.nip44Decrypt)
- Updated mock signer in tests to match ISigner interface
- Added event handling via eventStore timeline subscription

All tests passing, build successful.
This commit is contained in:
Claude
2026-01-15 21:41:31 +00:00
parent 69ed479697
commit 4f0ec4a86e
5 changed files with 92 additions and 25 deletions

View File

@@ -12,7 +12,6 @@ import {
import { useGrimoire } from "@/core/state";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import giftWrapLoader from "@/services/gift-wrap-loader";
import { toast } from "sonner";
@@ -172,9 +171,12 @@ export function InboxViewer() {
{/* Enable Private Messages */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<Label htmlFor="enable-private-messages">
<label
htmlFor="enable-private-messages"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable Private Messages
</Label>
</label>
<p className="text-sm text-muted-foreground">
Fetch and store encrypted gift wraps from DM relays
</p>
@@ -190,7 +192,12 @@ export function InboxViewer() {
{privateMessagesEnabled && (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<Label htmlFor="auto-decrypt">Auto-Decrypt Messages</Label>
<label
htmlFor="auto-decrypt"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Auto-Decrypt Messages
</label>
<p className="text-sm text-muted-foreground">
Automatically decrypt gift wraps as they arrive
</p>

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SwitchProps {
id?: string;
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
/**
* Simple toggle switch component
* Styled as a sliding toggle for boolean states
*/
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
(
{ id, checked = false, onCheckedChange, disabled = false, className },
ref,
) => {
return (
<button
id={id}
ref={ref}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onCheckedChange?.(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-input",
className,
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
);
},
);
Switch.displayName = "Switch";

View File

@@ -163,17 +163,12 @@ class GiftWrapLoader {
};
// Create timeline loader for gift wraps
const timeline = createTimelineLoader(pool, {
const loader = createTimelineLoader(pool, inboxRelays, [filter], {
eventStore,
relays: inboxRelays,
filters: [filter],
});
// Subscribe to timeline
this.subscription = timeline.subscribe({
next: (event: NostrEvent) => {
void this.handleGiftWrap(event);
},
this.subscription = loader().subscribe({
error: (error: Error) => {
console.error("[GiftWrapLoader] Timeline error:", error);
this.state$.next({
@@ -192,6 +187,23 @@ class GiftWrapLoader {
},
});
// Handle events from timeline via eventStore subscription
// Timeline loader automatically adds events to eventStore
const eventSub = eventStore.timeline([filter]).subscribe((events) => {
events.forEach((event) => {
void this.handleGiftWrap(event);
});
});
// Store both subscriptions for cleanup
const originalUnsub = this.subscription.unsubscribe.bind(
this.subscription,
);
this.subscription.unsubscribe = () => {
originalUnsub();
eventSub.unsubscribe();
};
// Process any pending gift wraps from database
await this.processPendingGiftWraps();
} catch (error) {

View File

@@ -8,19 +8,20 @@ import type { NostrEvent } from "@/types/nostr";
import type { ISigner } from "applesauce-signers";
// Mock signer for testing
function createMockSigner(decryptResponses: Map<string, string>): ISigner & {
nip44Decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
} {
function createMockSigner(decryptResponses: Map<string, string>): ISigner {
return {
getPublicKey: vi.fn().mockResolvedValue("mock-pubkey"),
signEvent: vi.fn(),
nip44Decrypt: vi.fn(async (pubkey: string, ciphertext: string) => {
const response = decryptResponses.get(`${pubkey}:${ciphertext}`);
if (!response) {
throw new Error("Mock decryption failed: no response configured");
}
return response;
}),
nip44: {
encrypt: vi.fn(),
decrypt: vi.fn(async (pubkey: string, ciphertext: string) => {
const response = decryptResponses.get(`${pubkey}:${ciphertext}`);
if (!response) {
throw new Error("Mock decryption failed: no response configured");
}
return response;
}),
},
};
}

View File

@@ -121,7 +121,7 @@ async function unwrapGiftWrap(
): Promise<NostrEvent> {
validateGiftWrap(giftWrap);
if (!signer.nip44Decrypt) {
if (!signer.nip44?.decrypt) {
throw new GiftWrapError(
"Signer does not support NIP-44 decryption",
"NO_SIGNER",
@@ -130,7 +130,7 @@ async function unwrapGiftWrap(
try {
// Decrypt using the gift wrap author's pubkey (ephemeral key)
const decryptedContent = await signer.nip44Decrypt(
const decryptedContent = await signer.nip44.decrypt(
giftWrap.pubkey,
giftWrap.content,
);
@@ -165,7 +165,7 @@ async function unsealSeal(
): Promise<NostrEvent> {
validateSeal(seal);
if (!signer.nip44Decrypt) {
if (!signer.nip44?.decrypt) {
throw new GiftWrapError(
"Signer does not support NIP-44 decryption",
"NO_SIGNER",
@@ -174,7 +174,7 @@ async function unsealSeal(
try {
// Decrypt using the seal author's pubkey (sender's real key)
const decryptedContent = await signer.nip44Decrypt(
const decryptedContent = await signer.nip44.decrypt(
seal.pubkey,
seal.content,
);