From 21335a58491681838bfcf22f35a767df0ce2ac9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 21 Dec 2025 15:37:01 +0100 Subject: [PATCH] WIP --- src/actions/publish-spellbook.test.ts | 152 +++++++----------- src/actions/publish-spellbook.ts | 129 ++++++++------- src/components/ShareSpellbookDialog.tsx | 4 +- .../nostr/kinds/SpellbookRenderer.tsx | 2 - src/lib/spellbook-manager.ts | 1 + src/services/spellbook-storage.ts | 4 +- src/types/app.ts | 1 + 7 files changed, 133 insertions(+), 160 deletions(-) diff --git a/src/actions/publish-spellbook.test.ts b/src/actions/publish-spellbook.test.ts index 2413575..13deafa 100644 --- a/src/actions/publish-spellbook.test.ts +++ b/src/actions/publish-spellbook.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { PublishSpellbook } from "./publish-spellbook"; -import type { ActionHub } from "applesauce-actions"; +import type { ActionContext } from "applesauce-actions"; import type { GrimoireState } from "@/types/app"; import type { NostrEvent } from "nostr-tools/core"; @@ -35,9 +35,11 @@ const mockFactory = { })), }; -const mockHub: ActionHub = { - factory: mockFactory, -} as any; +const mockContext: ActionContext = { + factory: mockFactory as any, + events: {} as any, + self: "test-pubkey", +}; const mockState: GrimoireState = { windows: { @@ -62,6 +64,18 @@ const mockState: GrimoireState = { workspaceOrder: ["ws-1"], } as any; +// Helper to run action with context +async function runAction( + options: Parameters[0], +): Promise { + const events: NostrEvent[] = []; + const action = PublishSpellbook(options); + for await (const event of action(mockContext)) { + events.push(event); + } + return events; +} + describe("PublishSpellbook action", () => { beforeEach(async () => { vi.clearAllMocks(); @@ -73,39 +87,33 @@ describe("PublishSpellbook action", () => { describe("validation", () => { it("should throw error if title is empty", async () => { - await expect(async () => { - for await (const event of PublishSpellbook(mockHub, { + await expect( + runAction({ state: mockState, title: "", - })) { - // Should not reach here - } - }).rejects.toThrow("Title is required"); + }), + ).rejects.toThrow("Title is required"); }); it("should throw error if title is only whitespace", async () => { - await expect(async () => { - for await (const event of PublishSpellbook(mockHub, { + await expect( + runAction({ state: mockState, title: " ", - })) { - // Should not reach here - } - }).rejects.toThrow("Title is required"); + }), + ).rejects.toThrow("Title is required"); }); it("should throw error if no active account", async () => { const accountManager = await import("@/services/accounts"); (accountManager.default as any).active = null; - await expect(async () => { - for await (const event of PublishSpellbook(mockHub, { + await expect( + runAction({ state: mockState, title: "Test Spellbook", - })) { - // Should not reach here - } - }).rejects.toThrow("No active account"); + }), + ).rejects.toThrow("No active account"); // Restore for other tests (accountManager.default as any).active = mockAccount; @@ -116,14 +124,12 @@ describe("PublishSpellbook action", () => { const accountWithoutSigner = { ...mockAccount, signer: null }; (accountManager.default as any).active = accountWithoutSigner; - await expect(async () => { - for await (const event of PublishSpellbook(mockHub, { + await expect( + runAction({ state: mockState, title: "Test Spellbook", - })) { - // Should not reach here - } - }).rejects.toThrow("No signer available"); + }), + ).rejects.toThrow("No signer available"); // Restore for other tests (accountManager.default as any).active = mockAccount; @@ -132,15 +138,11 @@ describe("PublishSpellbook action", () => { describe("event creation", () => { it("should yield properly formatted spellbook event", async () => { - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: mockState, title: "Test Spellbook", description: "Test description", - })) { - events.push(event); - } + }); expect(events).toHaveLength(1); const event = events[0]; @@ -170,14 +172,10 @@ describe("PublishSpellbook action", () => { }); it("should create event from state when no content provided", async () => { - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: mockState, title: "My Dashboard", - })) { - events.push(event); - } + }); expect(events).toHaveLength(1); const event = events[0]; @@ -197,15 +195,11 @@ describe("PublishSpellbook action", () => { windows: { "custom-win": {} as any }, }; - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: mockState, title: "Custom Spellbook", content: explicitContent, - })) { - events.push(event); - } + }); expect(events).toHaveLength(1); const event = events[0]; @@ -215,14 +209,10 @@ describe("PublishSpellbook action", () => { }); it("should not include description tag when description is empty", async () => { - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: mockState, title: "No Description", - })) { - events.push(event); - } + }); const event = events[0]; const tags = event.tags as [string, string, ...string[]][]; @@ -234,42 +224,36 @@ describe("PublishSpellbook action", () => { describe("slug generation", () => { it("should generate slug from title", async () => { - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: mockState, title: "My Awesome Dashboard!", - })) { - events.push(event); - } + }); - const dTag = (events[0].tags as [string, string][]).find((t) => t[0] === "d"); + const dTag = (events[0].tags as [string, string][]).find( + (t) => t[0] === "d", + ); expect(dTag?.[1]).toBe("my-awesome-dashboard"); }); it("should handle special characters in title", async () => { - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: mockState, title: "Test@123#Special$Characters", - })) { - events.push(event); - } + }); - const dTag = (events[0].tags as [string, string][]).find((t) => t[0] === "d"); + const dTag = (events[0].tags as [string, string][]).find( + (t) => t[0] === "d", + ); expect(dTag?.[1]).toMatch(/^test123specialcharacters$/); }); }); describe("factory integration", () => { it("should call factory.build with correct props", async () => { - for await (const event of PublishSpellbook(mockHub, { + await runAction({ state: mockState, title: "Test", - })) { - // Event yielded - } + }); expect(mockFactory.build).toHaveBeenCalledWith( expect.objectContaining({ @@ -279,24 +263,20 @@ describe("PublishSpellbook action", () => { ["d", expect.any(String)], ["title", "Test"], ]), - signer: mockSigner, - }) + }), ); }); - it("should call factory.sign with draft and signer", async () => { - for await (const event of PublishSpellbook(mockHub, { + it("should call factory.sign with draft", async () => { + await runAction({ state: mockState, title: "Test", - })) { - // Event yielded - } + }); expect(mockFactory.sign).toHaveBeenCalledWith( expect.objectContaining({ kind: 30777, }), - mockSigner ); }); }); @@ -317,14 +297,10 @@ describe("PublishSpellbook action", () => { }, }; - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: multiWorkspaceState, title: "Multi Workspace", - })) { - events.push(event); - } + }); const content = JSON.parse(events[0].content); expect(Object.keys(content.workspaces).length).toBe(2); @@ -345,15 +321,11 @@ describe("PublishSpellbook action", () => { }, }; - const events: NostrEvent[] = []; - - for await (const event of PublishSpellbook(mockHub, { + const events = await runAction({ state: multiWorkspaceState, title: "Single Workspace", workspaceIds: ["ws-1"], - })) { - events.push(event); - } + }); const content = JSON.parse(events[0].content); expect(Object.keys(content.workspaces).length).toBe(1); diff --git a/src/actions/publish-spellbook.ts b/src/actions/publish-spellbook.ts index b007873..d1464aa 100644 --- a/src/actions/publish-spellbook.ts +++ b/src/actions/publish-spellbook.ts @@ -4,7 +4,7 @@ import { SpellbookEvent } from "@/types/spell"; import { GrimoireState } from "@/types/app"; import { SpellbookContent } from "@/types/spell"; import accountManager from "@/services/accounts"; -import type { ActionHub } from "applesauce-actions"; +import type { ActionContext } from "applesauce-actions"; import type { NostrEvent } from "nostr-tools/core"; export interface PublishSpellbookOptions { @@ -26,9 +26,8 @@ export interface PublishSpellbookOptions { * 4. Yields the signed event (ActionHub handles publishing) * 5. Marks local spellbook as published if localId provided * - * @param hub - The action hub instance * @param options - Spellbook publishing options - * @yields Signed spellbook event ready for publishing + * @returns Action generator for ActionHub * * @throws Error if title is empty, no active account, or no signer available * @@ -43,73 +42,73 @@ export interface PublishSpellbookOptions { * }); * ``` */ -export async function* PublishSpellbook( - hub: ActionHub, - options: PublishSpellbookOptions -): AsyncGenerator { +export function PublishSpellbook(options: PublishSpellbookOptions) { const { state, title, description, workspaceIds, localId, content } = options; - // 1. Validate inputs - if (!title || !title.trim()) { - throw new Error("Title is required"); - } - - const account = accountManager.active; - if (!account) { - throw new Error("No active account. Please log in first."); - } - - const signer = account.signer; - if (!signer) { - throw new Error("No signer available. Please connect a signer."); - } - - // 2. Create event props from state or use provided content - let eventProps; - if (content) { - // Use provided content directly - eventProps = { - kind: 30777, - content: JSON.stringify(content), - tags: [ - ["d", slugify(title)], - ["title", title], - ["client", "grimoire"], - ] as [string, string, ...string[]][], - }; - if (description) { - eventProps.tags.push(["description", description]); - eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]); - } else { - eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]); + return async function* ({ + factory, + }: ActionContext): AsyncGenerator { + // 1. Validate inputs + if (!title || !title.trim()) { + throw new Error("Title is required"); } - } else { - // Create from state - const encoded = createSpellbook({ - state, - title, - description, - workspaceIds, + + const account = accountManager.active; + if (!account) { + throw new Error("No active account. Please log in first."); + } + + const signer = account.signer; + if (!signer) { + throw new Error("No signer available. Please connect a signer."); + } + + // 2. Create event props from state or use provided content + let eventProps; + if (content) { + // Use provided content directly + eventProps = { + kind: 30777, + content: JSON.stringify(content), + tags: [ + ["d", slugify(title)], + ["title", title], + ["client", "grimoire"], + ] as [string, string, ...string[]][], + }; + if (description) { + eventProps.tags.push(["description", description]); + eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]); + } else { + eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]); + } + } else { + // Create from state + const encoded = createSpellbook({ + state, + title, + description, + workspaceIds, + }); + eventProps = encoded.eventProps; + } + + // 3. Build draft using factory from context + const draft = await factory.build({ + kind: eventProps.kind, + content: eventProps.content, + tags: eventProps.tags, }); - eventProps = encoded.eventProps; - } - // 3. Build draft using hub's factory - const draft = await hub.factory.build({ - kind: eventProps.kind, - content: eventProps.content, - tags: eventProps.tags, - signer, - }); + // 4. Sign the event + const event = (await factory.sign(draft)) as SpellbookEvent; - // 4. Sign the event - const event = (await hub.factory.sign(draft, signer)) as SpellbookEvent; + // 5. Mark as published in local DB (before yielding for better UX) + if (localId) { + await markSpellbookPublished(localId, event); + } - // 5. Mark as published in local DB (before yielding for better UX) - if (localId) { - await markSpellbookPublished(localId, event); - } - - // 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing - yield event; + // 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing + yield event; + }; } diff --git a/src/components/ShareSpellbookDialog.tsx b/src/components/ShareSpellbookDialog.tsx index 1963a27..b479166 100644 --- a/src/components/ShareSpellbookDialog.tsx +++ b/src/components/ShareSpellbookDialog.tsx @@ -48,13 +48,13 @@ export function ShareSpellbookDialog({ id: "web", label: "Web Link", description: "Share as a web URL that anyone can open", - getValue: (e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`, + getValue: (_e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`, }, { id: "naddr", label: "Nostr Address (naddr)", description: "NIP-19 address pointer for Nostr clients", - getValue: (e, s) => { + getValue: (e, _s) => { const dTag = e.tags.find((t) => t[0] === "d")?.[1]; if (!dTag) return ""; return nip19.naddrEncode({ diff --git a/src/components/nostr/kinds/SpellbookRenderer.tsx b/src/components/nostr/kinds/SpellbookRenderer.tsx index 017b3c5..b863922 100644 --- a/src/components/nostr/kinds/SpellbookRenderer.tsx +++ b/src/components/nostr/kinds/SpellbookRenderer.tsx @@ -9,7 +9,6 @@ import { SpellbookEvent, ParsedSpellbook } from "@/types/spell"; import { NostrEvent } from "@/types/nostr"; import { Layout, ExternalLink, Eye, Share2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { toast } from "sonner"; import { useProfile } from "@/hooks/useProfile"; import { nip19 } from "nostr-tools"; import { useNavigate } from "react-router"; @@ -283,7 +282,6 @@ export function SpellbookRenderer({ event }: BaseEventProps) { * Shows detailed workspace information with preview and sharing options */ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) { - const profile = useProfile(event.pubkey); const [shareDialogOpen, setShareDialogOpen] = useState(false); const spellbook = useMemo(() => { diff --git a/src/lib/spellbook-manager.ts b/src/lib/spellbook-manager.ts index 9421964..f095f95 100644 --- a/src/lib/spellbook-manager.ts +++ b/src/lib/spellbook-manager.ts @@ -353,6 +353,7 @@ export function loadSpellbook( id: spellbook.event?.id || uuidv4(), // Fallback to uuid if local slug: spellbook.slug, title: spellbook.title, + description: spellbook.description, pubkey: spellbook.event?.pubkey, }, }; diff --git a/src/services/spellbook-storage.ts b/src/services/spellbook-storage.ts index ae444b6..5fb7152 100644 --- a/src/services/spellbook-storage.ts +++ b/src/services/spellbook-storage.ts @@ -63,7 +63,9 @@ export async function saveSpellbook( /** * Get a spellbook by ID */ -export async function getSpellbook(id: string): Promise { +export async function getSpellbook( + id: string, +): Promise { return db.spellbooks.get(id); } diff --git a/src/types/app.ts b/src/types/app.ts index fc1dc29..cb2ac5b 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -99,6 +99,7 @@ export interface GrimoireState { id: string; // event id or local uuid slug: string; // d-tag title: string; + description?: string; pubkey?: string; // owner }; }