feat: implement Nostr Wallet Connect (NWC)

- Add applesauce-wallet-connect dependency
- Create WalletService for managing NWC connection
- Create WalletViewer component for UI
- Register 'wallet' command
- Fix build errors in spell/spellbook components due to applesauce-actions upgrade
This commit is contained in:
Alejandro Gómez
2025-12-20 18:46:14 +01:00
parent 5d432300b2
commit 5d9ff3cf56
34 changed files with 1821 additions and 111 deletions

18
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"applesauce-loaders": "^4.2.0",
"applesauce-react": "^4.0.0",
"applesauce-relay": "latest",
"applesauce-wallet-connect": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -4851,6 +4852,23 @@
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-wallet-connect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/applesauce-wallet-connect/-/applesauce-wallet-connect-4.1.0.tgz",
"integrity": "sha512-wr07zQP60wenO+KZ/xmWHtWBtXkmczj2zDgK73sDE0j34ZJWvJJ3RAVMv5KLWub55jUD4vuRVyrN2Nff5ux2lw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.7.1",
"applesauce-core": "^4.1.0",
"applesauce-factory": "^4.0.0",
"nostr-tools": "~2.17",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",

View File

@@ -37,6 +37,7 @@
"applesauce-loaders": "^4.2.0",
"applesauce-react": "^4.0.0",
"applesauce-relay": "latest",
"applesauce-wallet-connect": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View File

@@ -6,14 +6,14 @@ import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import { grimoireStateAtom } from "@/core/state";
import { getDefaultStore } from "jotai";
import { LocalSpell } from "@/services/db";
import { NostrEvent } from "@/types/nostr";
export class DeleteEventAction {
type = "delete-event";
label = "Delete Event";
async execute(spell: LocalSpell, reason: string = ""): Promise<void> {
if (!spell.event) throw new Error("Spell has no event to delete");
async execute(item: { event?: NostrEvent }, reason: string = ""): Promise<void> {
if (!item.event) throw new Error("Item has no event to delete");
const account = accountManager.active;
if (!account) throw new Error("No active account");
@@ -23,7 +23,7 @@ export class DeleteEventAction {
const factory = new EventFactory({ signer });
const draft = await factory.delete([spell.event], reason);
const draft = await factory.delete([item.event], reason);
const event = await factory.sign(draft);
// Get write relays from cache and state

View File

@@ -0,0 +1,63 @@
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { createSpellbook } from "@/lib/spellbook-manager";
import { markSpellbookPublished } from "@/services/spellbook-storage";
import { EventFactory } from "applesauce-factory";
import { SpellbookEvent } from "@/types/spell";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import { GrimoireState } from "@/types/app";
export interface PublishSpellbookOptions {
state: GrimoireState;
title: string;
description?: string;
workspaceIds?: string[];
localId?: string; // If provided, updates this local spellbook
}
export class PublishSpellbookAction {
type = "publish-spellbook";
label = "Publish Spellbook";
async execute(options: PublishSpellbookOptions): Promise<void> {
const { state, title, description, workspaceIds, localId } = options;
const account = accountManager.active;
if (!account) throw new Error("No active account");
const signer = account.signer;
if (!signer) throw new Error("No signer available");
// 1. Create event props from state
const encoded = createSpellbook({
state,
title,
description,
workspaceIds,
});
// 2. Build and sign event
const factory = new EventFactory({ signer });
const draft = await factory.build({
kind: encoded.eventProps.kind,
content: encoded.eventProps.content,
tags: encoded.eventProps.tags,
});
const event = (await factory.sign(draft)) as SpellbookEvent;
// 3. Determine relays
let relays: string[] = [];
const authorWriteRelays = (await relayListCache.getOutboxRelays(account.pubkey)) || [];
relays = mergeRelaySets(authorWriteRelays, AGGREGATOR_RELAYS);
// 4. Publish
await pool.publish(relays, event);
// 5. Mark as published in local DB
if (localId) {
await markSpellbookPublished(localId, event);
}
}
}

View File

@@ -113,6 +113,7 @@ export default function CommandLauncher({
result.props,
activeSpell ? effectiveParsed.fullInput : input.trim(),
result.globalFlags?.windowProps?.title,
activeSpell?.id,
);
}

View File

@@ -6,6 +6,8 @@ import {
Sparkles,
SplitSquareHorizontal,
SplitSquareVertical,
Save,
BookOpen,
} from "lucide-react";
import { Button } from "./ui/button";
import { Slider } from "./ui/slider";
@@ -21,15 +23,17 @@ import {
import { toast } from "sonner";
import type { LayoutConfig } from "@/types/app";
import { useState } from "react";
import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
export function LayoutControls() {
const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
const { state, applyPresetLayout, updateLayoutConfig, addWindow } = useGrimoire();
const { workspaces, activeWorkspaceId, layoutConfig } = state;
// Local state for immediate slider feedback (debounced persistence)
const [localSplitPercentage, setLocalSplitPercentage] = useState<
number | null
>(null);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const activeWorkspace = workspaces[activeWorkspaceId];
const windowCount = activeWorkspace?.windowIds.length || 0;
@@ -105,98 +109,122 @@ export function LayoutControls() {
localSplitPercentage ?? layoutConfig.splitPercentage;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label="Layout settings"
>
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Layouts Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Layouts
</div>
{presets.map((preset) => {
const canApply = windowCount >= preset.minSlots;
<>
<SaveSpellbookDialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label="Layout settings"
>
<SlidersHorizontal className="h-3 w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Spellbooks Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Spellbooks
</div>
<DropdownMenuItem
onClick={() => setSaveDialogOpen(true)}
className="flex items-center gap-3 cursor-pointer"
>
<Save className="h-4 w-4 text-muted-foreground" />
<div className="font-medium text-sm">Save Layout</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className="flex items-center gap-3 cursor-pointer"
>
<BookOpen className="h-4 w-4 text-muted-foreground" />
<div className="font-medium text-sm">Open Spellbooks</div>
</DropdownMenuItem>
return (
<DropdownMenuItem
key={preset.id}
onClick={() => handleApplyPreset(preset.id)}
disabled={!canApply}
className="flex items-center gap-3 cursor-pointer"
>
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{preset.name}</div>
<DropdownMenuSeparator />
{/* Layouts Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Layout Presets
</div>
{presets.map((preset) => {
const canApply = windowCount >= preset.minSlots;
return (
<DropdownMenuItem
key={preset.id}
onClick={() => handleApplyPreset(preset.id)}
disabled={!canApply}
className="flex items-center gap-3 cursor-pointer"
>
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{preset.name}</div>
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{/* Placement Section */}
<div className="px-2 py-1.5 space-y-0.5">
<div className="text-xs font-semibold text-muted-foreground">
Placement
</div>
<div className="text-xs text-muted-foreground">Window insertion</div>
</div>
{insertionModes.map((mode) => {
const Icon = mode.icon;
const isActive = layoutConfig.insertionMode === mode.id;
return (
<DropdownMenuItem
key={mode.id}
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
className="flex items-center gap-2 cursor-pointer"
>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1">{mode.label}</span>
{isActive && (
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{/* Split Ratio Section */}
<div className="px-2 py-2 space-y-2">
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-muted-foreground">
Split Ratio
</span>
<span className="text-foreground">
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
</span>
</div>
<div className="text-xs text-muted-foreground">
Default split for new windows
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{/* Placement Section */}
<div className="px-2 py-1.5 space-y-0.5">
<div className="text-xs font-semibold text-muted-foreground">
Placement
</div>
<div className="text-xs text-muted-foreground">Window insertion</div>
</div>
{insertionModes.map((mode) => {
const Icon = mode.icon;
const isActive = layoutConfig.insertionMode === mode.id;
return (
<DropdownMenuItem
key={mode.id}
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
className="flex items-center gap-2 cursor-pointer"
>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1">{mode.label}</span>
{isActive && (
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{/* Split Ratio Section */}
<div className="px-2 py-2 space-y-2">
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-muted-foreground">
Split Ratio
</span>
<span className="text-foreground">
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
</span>
</div>
<div className="text-xs text-muted-foreground">
Default split for new windows
</div>
<Slider
value={[displayedSplitPercentage]}
onValueChange={([value]) => setLocalSplitPercentage(value)}
onValueCommit={([value]) => {
updateLayoutConfig({ splitPercentage: value });
setLocalSplitPercentage(null); // Clear local state after persist
}}
min={20}
max={80}
step={1}
className="w-full"
/>
</div>
<Slider
value={[displayedSplitPercentage]}
onValueChange={([value]) => setLocalSplitPercentage(value)}
onValueCommit={([value]) => {
updateLayoutConfig({ splitPercentage: value });
setLocalSplitPercentage(null); // Clear local state after persist
}}
min={20}
max={80}
step={1}
className="w-full"
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -15,10 +15,12 @@ export function ExecutableCommand({
commandLine,
className,
children,
spellId,
}: {
commandLine: string;
className?: string;
children: React.ReactNode;
spellId?: string;
}) {
const { addWindow } = useGrimoire();
@@ -34,7 +36,7 @@ export function ExecutableCommand({
? await Promise.resolve(command.argParser(cmdArgs))
: command.defaultProps || {};
addWindow(command.appId, cmdProps);
addWindow(command.appId, cmdProps, undefined, undefined, spellId);
}
};

View File

@@ -0,0 +1,188 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { Checkbox } from "./ui/checkbox";
import { useGrimoire } from "@/core/state";
import { toast } from "sonner";
import { saveSpellbook } from "@/services/spellbook-storage";
import { PublishSpellbookAction } from "@/actions/publish-spellbook";
import { createSpellbook } from "@/lib/spellbook-manager";
import { Loader2, Save, Send } from "lucide-react";
interface SaveSpellbookDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SaveSpellbookDialog({
open,
onOpenChange,
}: SaveSpellbookDialogProps) {
const { state } = useGrimoire();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [selectedWorkspaces, setSelectedWorkspaces] = useState<string[]>(
Object.keys(state.workspaces),
);
const [isPublishing, setIsPublishing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async (shouldPublish: boolean) => {
if (!title.trim()) {
toast.error("Please enter a title for your spellbook");
return;
}
if (selectedWorkspaces.length === 0) {
toast.error("Please select at least one workspace to include");
return;
}
setIsSaving(true);
if (shouldPublish) setIsPublishing(true);
try {
// 1. Create content
const encoded = createSpellbook({
state,
title,
description,
workspaceIds: selectedWorkspaces,
});
// 2. Save locally
const localSpellbook = await saveSpellbook({
slug: title.toLowerCase().trim().replace(/\s+/g, "-"),
title,
description,
content: JSON.parse(encoded.eventProps.content),
isPublished: false,
});
// 3. Optionally publish
if (shouldPublish) {
const action = new PublishSpellbookAction();
await action.execute({
state,
title,
description,
workspaceIds: selectedWorkspaces,
localId: localSpellbook.id,
});
toast.success("Spellbook saved and published to Nostr");
} else {
toast.success("Spellbook saved locally");
}
onOpenChange(false);
// Reset form
setTitle("");
setDescription("");
} catch (error) {
console.error("Failed to save spellbook:", error);
toast.error(error instanceof Error ? error.message : "Failed to save spellbook");
} finally {
setIsSaving(false);
setIsPublishing(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Save Layout as Spellbook</DialogTitle>
<DialogDescription>
Save your current workspaces and window configuration.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Title</Label>
<Input
id="title"
placeholder="e.g. My Daily Dashboard"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>Description (optional)</Label>
<Textarea
id="description"
placeholder="What is this layout for?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label>Workspaces to include</Label>
<div className="grid grid-cols-2 gap-2 mt-1">
{Object.values(state.workspaces)
.sort((a, b) => a.number - b.number)
.map((ws) => (
<div key={ws.id} className="flex items-center space-x-2">
<Checkbox
id={`ws-${ws.id}`}
checked={selectedWorkspaces.includes(ws.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedWorkspaces([...selectedWorkspaces, ws.id]);
} else {
setSelectedWorkspaces(
selectedWorkspaces.filter((id) => id !== ws.id),
);
}
}}
/>
<label
htmlFor={`ws-${ws.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{ws.number}. {ws.label || "Workspace"}
</label>
</div>
))}
</div>
</div>
</div>
<DialogFooter className="flex gap-2 sm:justify-between">
<Button
variant="outline"
onClick={() => handleSave(false)}
disabled={isSaving}
>
{isSaving && !isPublishing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Locally
</Button>
<Button onClick={() => handleSave(true)} disabled={isSaving}>
{isPublishing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Save & Publish
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,397 @@
import { useState, useMemo } from "react";
import {
Search,
Grid3x3,
Trash2,
Send,
Cloud,
Lock,
Loader2,
RefreshCw,
Archive,
Layout,
ExternalLink,
} from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import db from "@/services/db";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Badge } from "./ui/badge";
import { toast } from "sonner";
import { deleteSpellbook } from "@/services/spellbook-storage";
import type { LocalSpellbook } from "@/services/db";
import { PublishSpellbookAction } from "@/actions/publish-spellbook";
import { DeleteEventAction } from "@/actions/delete-event";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { useReqTimeline } from "@/hooks/useReqTimeline";
import { parseSpellbook } from "@/lib/spellbook-manager";
import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
import { SPELLBOOK_KIND } from "@/constants/kinds";
interface SpellbookCardProps {
spellbook: LocalSpellbook;
onDelete: (spellbook: LocalSpellbook) => Promise<void>;
onPublish: (spellbook: LocalSpellbook) => Promise<void>;
onApply: (spellbook: ParsedSpellbook) => void;
}
function SpellbookCard({ spellbook, onDelete, onPublish, onApply }: SpellbookCardProps) {
const [isPublishing, setIsPublishing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const displayName = spellbook.title || "Untitled Spellbook";
const workspaceCount = Object.keys(spellbook.content.workspaces).length;
const windowCount = Object.keys(spellbook.content.windows).length;
const handlePublish = async () => {
setIsPublishing(true);
try {
await onPublish(spellbook);
} finally {
setIsPublishing(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDelete(spellbook);
} finally {
setIsDeleting(false);
}
};
const handleApply = () => {
// Construct a ParsedSpellbook from LocalSpellbook for applying
const parsed: ParsedSpellbook = {
slug: spellbook.slug,
title: spellbook.title,
description: spellbook.description,
content: spellbook.content,
referencedSpells: [], // We don't need this for applying
event: spellbook.event as SpellbookEvent,
};
onApply(parsed);
};
return (
<Card
className={cn(
"group flex flex-col h-full transition-opacity",
spellbook.deletedAt && "opacity-60",
)}
>
<CardHeader className="p-4 pb-2">
<div className="flex items-center flex-wrap justify-between gap-2">
<div className="flex items-center gap-2 flex-1 overflow-hidden">
<Grid3x3 className="size-4 flex-shrink-0 text-muted-foreground mt-0.5" />
<CardTitle className="text-xl truncate" title={displayName}>
{displayName}
</CardTitle>
</div>
{spellbook.deletedAt ? (
<Badge variant="outline" className="text-muted-foreground">
<Archive className="size-3 mr-1" />
</Badge>
) : spellbook.isPublished ? (
<Badge
variant="secondary"
className="bg-green-500/10 text-green-500 hover:bg-green-500/20 border-green-500/20"
>
<Cloud className="size-3 mr-1" />
</Badge>
) : (
<Badge variant="secondary" className="opacity-70">
<Lock className="size-3 mr-1" />
</Badge>
)}
</div>
{spellbook.description && (
<CardDescription className="text-sm line-clamp-2">
{spellbook.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<div className="flex flex-col gap-2">
<div className="flex gap-4 mt-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Layout className="size-3" />
{workspaceCount} {workspaceCount === 1 ? 'workspace' : 'workspaces'}
</div>
<div className="flex items-center gap-1">
<ExternalLink className="size-3" />
{windowCount} {windowCount === 1 ? 'window' : 'windows'}
</div>
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex-wrap gap-2 justify-between">
<div className="flex gap-2">
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={handleDelete}
disabled={isPublishing || isDeleting || !!spellbook.deletedAt}
>
{isDeleting ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<Trash2 className="size-3.5 mr-1" />
)}
{spellbook.deletedAt ? "Deleted" : "Delete"}
</Button>
{!spellbook.deletedAt && (
<Button
size="sm"
variant={spellbook.isPublished ? "outline" : "default"}
className="h-8"
onClick={handlePublish}
disabled={isPublishing || isDeleting}
>
{isPublishing ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : spellbook.isPublished ? (
<RefreshCw className="size-3.5 mr-1" />
) : (
<Send className="size-3.5 mr-1" />
)}
{isPublishing ? "Publishing..." : spellbook.isPublished ? "Rebroadcast" : "Publish"}
</Button>
)}
</div>
{!spellbook.deletedAt && (
<Button size="sm" variant="default" className="h-8" onClick={handleApply}>
Apply Layout
</Button>
)}
</CardFooter>
</Card>
);
}
export function SpellbooksViewer() {
const { state, loadSpellbook } = useGrimoire();
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<"all" | "local" | "published">(
"all",
);
// Load local spellbooks from Dexie
const localSpellbooks = useLiveQuery(() =>
db.spellbooks.orderBy("createdAt").reverse().toArray(),
);
// Fetch from Nostr
const { events: networkEvents, loading: networkLoading } = useReqTimeline(
state.activeAccount ? `user-spellbooks-${state.activeAccount.pubkey}` : "none",
state.activeAccount
? { kinds: [SPELLBOOK_KIND], authors: [state.activeAccount.pubkey] }
: [],
state.activeAccount?.relays?.map((r) => r.url) || [],
{ stream: true },
);
const loading = localSpellbooks === undefined;
// Filter and sort
const { filteredSpellbooks, totalCount } = useMemo(() => {
const allSpellbooksMap = new Map<string, LocalSpellbook>();
for (const s of localSpellbooks || []) {
allSpellbooksMap.set(s.id, s);
}
for (const event of networkEvents) {
// Find d tag for matching with local slug
const slug = event.tags.find(t => t[0] === 'd')?.[1] || '';
// Look for existing by slug + pubkey? For now just ID matching if we have it
// Replaceable events are tricky. Let's match by slug if localId not found.
const existing = Array.from(allSpellbooksMap.values()).find(s => s.slug === slug);
if (existing) {
// Update existing with network event if it's newer
if (event.created_at * 1000 > existing.createdAt) {
existing.isPublished = true;
existing.eventId = event.id;
existing.event = event as SpellbookEvent;
}
continue;
}
try {
const parsed = parseSpellbook(event as SpellbookEvent);
const spellbook: LocalSpellbook = {
id: event.id,
slug: parsed.slug,
title: parsed.title,
description: parsed.description,
content: parsed.content,
createdAt: event.created_at * 1000,
isPublished: true,
eventId: event.id,
event: event as SpellbookEvent,
};
allSpellbooksMap.set(event.id, spellbook);
} catch (e) {
console.warn("Failed to decode network spellbook", event.id, e);
}
}
const allMerged = Array.from(allSpellbooksMap.values());
const total = allMerged.length;
let filtered = [...allMerged];
if (filterType === "local") {
filtered = filtered.filter((s) => !s.isPublished || !!s.deletedAt);
} else if (filterType === "published") {
filtered = filtered.filter((s) => s.isPublished && !s.deletedAt);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(s) =>
s.title?.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query),
);
}
filtered.sort((a, b) => {
if (!!a.deletedAt !== !!b.deletedAt) return a.deletedAt ? 1 : -1;
return b.createdAt - a.createdAt;
});
return { filteredSpellbooks: filtered, totalCount: total };
}, [localSpellbooks, networkEvents, searchQuery, filterType]);
const handleDelete = async (spellbook: LocalSpellbook) => {
if (!confirm(`Delete spellbook "${spellbook.title}"?`)) return;
try {
if (spellbook.isPublished && spellbook.event) {
await new DeleteEventAction().execute({ event: spellbook.event }, "Deleted by user");
}
await deleteSpellbook(spellbook.id);
toast.success("Spellbook deleted");
} catch (error) {
toast.error("Failed to delete spellbook");
}
};
const handlePublish = async (spellbook: LocalSpellbook) => {
try {
const action = new PublishSpellbookAction();
await action.execute({
state,
title: spellbook.title,
description: spellbook.description,
workspaceIds: Object.keys(spellbook.content.workspaces),
localId: spellbook.id,
});
toast.success("Spellbook published");
} catch (error) {
toast.error("Failed to publish");
}
};
const handleApply = (spellbook: ParsedSpellbook) => {
loadSpellbook(spellbook);
toast.success("Layout applied", {
description: `Added ${Object.keys(spellbook.content.workspaces).length} workspaces.`,
});
};
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="border-b border-border px-4 py-3 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Grid3x3 className="size-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">Spellbooks</h2>
<Badge variant="secondary" className="ml-2">
{filteredSpellbooks.length}/{totalCount}
</Badge>
{networkLoading && (
<Loader2 className="size-3 animate-spin text-muted-foreground" />
)}
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search spellbooks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={filterType === "all" ? "default" : "outline"}
onClick={() => setFilterType("all")}
>
All
</Button>
<Button
size="sm"
variant={filterType === "local" ? "default" : "outline"}
onClick={() => setFilterType("local")}
>
Local
</Button>
<Button
size="sm"
variant={filterType === "published" ? "default" : "outline"}
onClick={() => setFilterType("published")}
>
Published
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
) : filteredSpellbooks.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No spellbooks found.
</div>
) : (
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{filteredSpellbooks.map((s) => (
<SpellbookCard
key={s.id}
spellbook={s}
onDelete={handleDelete}
onPublish={handlePublish}
onApply={handleApply}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -124,6 +124,7 @@ function SpellCard({ spell, onDelete, onPublish }: SpellCardProps) {
<ExecutableCommand
commandLine={spell.command}
className="text-xs truncate line-clamp-1 text-primary hover:underline cursor-pointer"
spellId={spell.id}
>
{spell.command}
</ExecutableCommand>
@@ -299,7 +300,7 @@ export function SpellsViewer() {
// 1. If published, send Nostr Kind 5
if (isPublic && spell.event) {
toast.promise(
new DeleteEventAction().execute(spell, "Deleted by user in Grimoire"),
new DeleteEventAction().execute({ event: spell.event }, "Deleted by user in Grimoire"),
{
loading: "Sending Nostr deletion request...",
success: "Deletion request broadcasted",

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import walletService from "@/services/wallet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Loader2, Wallet, AlertCircle, CheckCircle2 } from "lucide-react";
export function WalletViewer() {
const [status, setStatus] = useState(walletService.status$.value);
const [error, setError] = useState<Error | null>(null);
const [uri, setUri] = useState("");
const [invoice, setInvoice] = useState("");
const [paymentResult, setPaymentResult] = useState<string | null>(null);
const [isPaying, setIsPaying] = useState(false);
useEffect(() => {
const subStatus = walletService.status$.subscribe(setStatus);
const subError = walletService.error$.subscribe(setError);
return () => {
subStatus.unsubscribe();
subError.unsubscribe();
};
}, []);
const handleConnect = async () => {
if (!uri) return;
try {
await walletService.connect(uri);
} catch (e) {
// Error is handled by subscription
}
};
const handleDisconnect = () => {
walletService.disconnect();
setUri("");
setPaymentResult(null);
};
const handlePay = async () => {
if (!invoice) return;
setIsPaying(true);
setPaymentResult(null);
try {
const preimage = await walletService.payInvoice(invoice);
setPaymentResult(preimage || "Payment successful (no preimage returned)");
setInvoice("");
} catch (e) {
setError(e instanceof Error ? e : new Error("Payment failed"));
} finally {
setIsPaying(false);
}
};
return (
<div className="p-4 max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-2 mb-6">
<Wallet className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold">Nostr Wallet Connect</h1>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
{status === "disconnected" && (
<Card>
<CardHeader>
<CardTitle>Connect Wallet</CardTitle>
<CardDescription>
Enter your Nostr Wallet Connect (NWC) connection string.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Input
placeholder="nostr+walletconnect://..."
value={uri}
onChange={(e) => setUri(e.target.value)}
type="password"
/>
<p className="text-xs text-muted-foreground">
Your connection string is stored locally in your browser.
</p>
</div>
<Button onClick={handleConnect} disabled={!uri} className="w-full">
Connect
</Button>
</CardContent>
</Card>
)}
{status === "connecting" && (
<Card>
<CardContent className="py-10 flex flex-col items-center justify-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground">Connecting to wallet...</p>
</CardContent>
</Card>
)}
{status === "connected" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
Connected
</CardTitle>
<CardDescription>
Your wallet is connected and ready to make payments.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={handleDisconnect}>
Disconnect
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pay Invoice</CardTitle>
<CardDescription>
Paste a Lightning invoice (bolt11) to pay.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="lnbc..."
value={invoice}
onChange={(e) => setInvoice(e.target.value)}
/>
<Button onClick={handlePay} disabled={!invoice || isPaying}>
{isPaying ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Pay"
)}
</Button>
</div>
{paymentResult && (
<Alert className="bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertTitle className="text-green-800 dark:text-green-200">Payment Successful</AlertTitle>
<AlertDescription className="text-green-700 dark:text-green-300 break-all font-mono text-xs mt-1">
Preimage: {paymentResult}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@@ -30,6 +30,12 @@ const ConnViewer = lazy(() => import("./ConnViewer"));
const SpellsViewer = lazy(() =>
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
);
const SpellbooksViewer = lazy(() =>
import("./SpellbooksViewer").then((m) => ({ default: m.SpellbooksViewer })),
);
const WalletViewer = lazy(() =>
import("./WalletViewer").then((m) => ({ default: m.WalletViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -168,6 +174,12 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "spells":
content = <SpellsViewer />;
break;
case "spellbooks":
content = <SpellbooksViewer />;
break;
case "wallet":
content = <WalletViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { VideoPlayer } from "./VideoPlayer";
import { StatusBadge } from "./StatusBadge";
import { UserName } from "../nostr/UserName";
import { Label } from "../ui/Label";
import { Label } from "../ui/label";
import type { LiveStatus } from "@/types/live-activity";
import { cn } from "@/lib/utils";

View File

@@ -20,7 +20,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
interface Kind1337DetailRendererProps {
event: NostrEvent;

View File

@@ -8,7 +8,7 @@ import {
getCodeName,
getCodeDescription,
} from "@/lib/nip-c0-helpers";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
// Map common language names to Prism-supported languages

View File

@@ -12,7 +12,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
/**
* Detail renderer for Kind 1621 - Issue (NIP-34)

View File

@@ -15,7 +15,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
/**
* Renderer for Kind 1621 - Issue

View File

@@ -8,7 +8,7 @@ import {
import { VideoPlayer } from "@/components/live/VideoPlayer";
import { StatusBadge } from "@/components/live/StatusBadge";
import { UserName } from "../UserName";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
import { Calendar } from "lucide-react";
interface LiveActivityDetailRendererProps {

View File

@@ -6,7 +6,7 @@ import {
getLiveHost,
} from "@/lib/live-activity";
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
import { VideoPlayer } from "@/components/live/VideoPlayer";
import { StatusBadge } from "@/components/live/StatusBadge";
import { Users, Play, Circle, Calendar, Video } from "lucide-react";

View File

@@ -19,7 +19,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
/**
* Detail renderer for Kind 1618 - Pull Request

View File

@@ -16,7 +16,7 @@ import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
import { Label } from "@/components/ui/label";
/**
* Renderer for Kind 1618 - Pull Request

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -74,6 +74,9 @@ export interface EventKind {
icon: LucideIcon;
}
export const SPELL_KIND = 777;
export const SPELLBOOK_KIND = 30777;
export const EVENT_KINDS: Record<number | string, EventKind> = {
// Core protocol kinds
0: {
@@ -681,13 +684,20 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
icon: Zap,
},
9735: { kind: 9735, name: "Zap", description: "Zap", nip: "57", icon: Zap },
777: {
kind: 777,
[SPELL_KIND]: {
kind: SPELL_KIND,
name: "Spell",
description: "REQ Command Spell",
nip: "",
icon: WandSparkles,
},
[SPELLBOOK_KIND]: {
kind: SPELLBOOK_KIND,
name: "Spellbook",
description: "Grimoire Layout Configuration",
nip: "",
icon: Grid3x3,
},
9802: {
kind: 9802,
name: "Highlight",

View File

@@ -68,6 +68,7 @@ export const addWindow = (
props: any;
commandString?: string;
customTitle?: string;
spellId?: string;
},
): GrimoireState => {
const activeId = state.activeWorkspaceId;
@@ -79,6 +80,7 @@ export const addWindow = (
customTitle: payload.customTitle,
props: payload.props,
commandString: payload.commandString,
spellId: payload.spellId,
};
// Insert window using global layout configuration

View File

@@ -10,8 +10,10 @@ import {
} from "@/types/app";
import { useLocale } from "@/hooks/useLocale";
import * as Logic from "./logic";
import * as SpellbookManager from "@/lib/spellbook-manager";
import { CURRENT_VERSION, validateState, migrateState } from "@/lib/migrations";
import { toast } from "sonner";
import { ParsedSpellbook } from "@/types/spell";
// Initial State Definition - Empty canvas on first load
const initialState: GrimoireState = {
@@ -171,13 +173,20 @@ export const useGrimoire = () => {
);
const addWindow = useCallback(
(appId: AppId, props: any, commandString?: string, customTitle?: string) =>
(
appId: AppId,
props: any,
commandString?: string,
customTitle?: string,
spellId?: string,
) =>
setState((prev) =>
Logic.addWindow(prev, {
appId,
props,
commandString,
customTitle,
spellId,
}),
),
[setState],
@@ -291,6 +300,12 @@ export const useGrimoire = () => {
[setState],
);
const loadSpellbook = useCallback(
(spellbook: ParsedSpellbook) =>
setState((prev) => SpellbookManager.loadSpellbook(prev, spellbook)),
[setState],
);
return {
state,
locale: state.locale || browserLocale,
@@ -310,5 +325,6 @@ export const useGrimoire = () => {
updateWorkspaceLabel,
reorderWorkspaces,
setCompactModeKinds,
loadSpellbook,
};
};

View File

@@ -0,0 +1,237 @@
import { describe, it, expect } from "vitest";
import {
createSpellbook,
parseSpellbook,
loadSpellbook,
slugify,
} from "./spellbook-manager";
import { GrimoireState, WindowInstance, Workspace } from "@/types/app";
import { SPELLBOOK_KIND, SpellbookEvent } from "@/types/spell";
// Mock Data
const mockWindow1: WindowInstance = {
id: "win-1",
appId: "profile",
props: { pubkey: "abc" },
spellId: "spell-1",
};
const mockWindow2: WindowInstance = {
id: "win-2",
appId: "kind",
props: { kind: 1 },
};
const mockWorkspace1: Workspace = {
id: "ws-1",
number: 1,
layout: "win-1",
windowIds: ["win-1"],
};
const mockWorkspace2: Workspace = {
id: "ws-2",
number: 2,
layout: {
direction: "row",
first: "win-2",
second: "win-1",
},
windowIds: ["win-1", "win-2"],
};
const mockState: GrimoireState = {
__version: 6,
windows: {
"win-1": mockWindow1,
"win-2": mockWindow2,
},
workspaces: {
"ws-1": mockWorkspace1,
"ws-2": mockWorkspace2,
},
activeWorkspaceId: "ws-1",
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
},
};
describe("Spellbook Manager", () => {
describe("slugify", () => {
it("converts titles to slugs", () => {
expect(slugify("Hello World")).toBe("hello-world");
expect(slugify("My Cool Dashboard!")).toBe("my-cool-dashboard");
expect(slugify(" Trim Me ")).toBe("trim-me");
expect(slugify("Mixed Case Title")).toBe("mixed-case-title");
});
});
describe("createSpellbook", () => {
it("creates a valid spellbook from state", () => {
const result = createSpellbook({
state: mockState,
title: "My Backup",
description: "Test description",
workspaceIds: ["ws-1"],
});
const { eventProps, referencedSpells } = result;
const content = JSON.parse(eventProps.content);
// Check event props
expect(eventProps.kind).toBe(SPELLBOOK_KIND);
expect(eventProps.tags).toContainEqual(["d", "my-backup"]);
expect(eventProps.tags).toContainEqual(["title", "My Backup"]);
expect(eventProps.tags).toContainEqual(["description", "Test description"]);
expect(eventProps.tags).toContainEqual(["client", "grimoire"]);
// Check referenced spells (e tags)
expect(referencedSpells).toContain("spell-1");
expect(eventProps.tags).toContainEqual(["e", "spell-1", "", "mention"]);
// Check content structure
expect(content.version).toBe(1);
expect(Object.keys(content.workspaces)).toHaveLength(1);
expect(content.workspaces["ws-1"]).toBeDefined();
// Should only include windows referenced in the workspace
expect(Object.keys(content.windows)).toHaveLength(1);
expect(content.windows["win-1"]).toBeDefined();
expect(content.windows["win-2"]).toBeUndefined();
});
it("includes all workspaces if no IDs provided", () => {
const result = createSpellbook({
state: mockState,
title: "Full Backup",
});
const content = JSON.parse(result.eventProps.content);
expect(Object.keys(content.workspaces)).toHaveLength(2);
expect(Object.keys(content.windows)).toHaveLength(2);
});
});
describe("parseSpellbook", () => {
it("parses a valid spellbook event", () => {
const content = {
version: 1,
workspaces: { "ws-1": mockWorkspace1 },
windows: { "win-1": mockWindow1 },
};
const event: SpellbookEvent = {
id: "evt-1",
pubkey: "pub-1",
created_at: 123456,
kind: SPELLBOOK_KIND,
tags: [
["d", "my-slug"],
["title", "My Title"],
["description", "Desc"],
["e", "spell-1"],
],
content: JSON.stringify(content),
sig: "sig",
};
const parsed = parseSpellbook(event);
expect(parsed.slug).toBe("my-slug");
expect(parsed.title).toBe("My Title");
expect(parsed.description).toBe("Desc");
expect(parsed.content).toEqual(content);
expect(parsed.referencedSpells).toContain("spell-1");
});
it("handles parsing errors gracefully", () => {
const event = {
kind: SPELLBOOK_KIND,
content: "invalid json",
tags: [],
} as any;
expect(() => parseSpellbook(event)).toThrow("Failed to parse spellbook content");
});
});
describe("loadSpellbook", () => {
it("imports workspaces with new IDs and numbers", () => {
const spellbookContent = {
version: 1,
workspaces: { "ws-1": mockWorkspace1 },
windows: { "win-1": mockWindow1 },
};
const parsed = {
slug: "test",
title: "Test",
content: spellbookContent,
referencedSpells: [],
event: {} as any,
};
// State has workspaces 1 and 2 used. Next available should be 3.
const newState = loadSpellbook(mockState, parsed);
// Should have 3 workspaces now (2 original + 1 imported)
expect(Object.keys(newState.workspaces)).toHaveLength(3);
// Find the new workspace
const newWsEntry = Object.entries(newState.workspaces).find(
([id]) => id !== "ws-1" && id !== "ws-2"
);
expect(newWsEntry).toBeDefined();
const [newId, newWs] = newWsEntry!;
// IDs should be regenerated
expect(newId).not.toBe("ws-1");
expect(newWs.id).not.toBe("ws-1");
// Number should be 3 (lowest available)
expect(newWs.number).toBe(3);
// Window IDs should be regenerated
const newWinId = newWs.windowIds[0];
expect(newWinId).not.toBe("win-1");
expect(newState.windows[newWinId]).toBeDefined();
expect(newState.windows[newWinId].appId).toBe("profile");
// Layout should reference new window ID
expect(newWs.layout).toBe(newWinId);
});
it("updates layout tree with new window IDs", () => {
const spellbookContent = {
version: 1,
workspaces: { "ws-2": mockWorkspace2 },
windows: { "win-1": mockWindow1, "win-2": mockWindow2 },
};
const parsed = {
slug: "test",
title: "Test",
content: spellbookContent,
referencedSpells: [],
event: {} as any,
};
const newState = loadSpellbook(mockState, parsed);
const newWs = Object.values(newState.workspaces).find(w => w.number === 3)!;
expect(typeof newWs.layout).toBe("object");
if (typeof newWs.layout === "object" && newWs.layout !== null) {
// Check that leaf nodes are new UUIDs, not old IDs
expect(newWs.layout.first).not.toBe("win-2");
expect(newWs.layout.second).not.toBe("win-1");
// Check that they match the windowIds list
expect(newWs.windowIds).toContain(newWs.layout.first);
expect(newWs.windowIds).toContain(newWs.layout.second);
}
});
});
});

View File

@@ -0,0 +1,293 @@
import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import type { GrimoireState, WindowInstance, Workspace } from "@/types/app";
import { SPELLBOOK_KIND } from "@/constants/kinds";
import {
type SpellbookContent,
type SpellbookEvent,
type ParsedSpellbook,
} from "@/types/spell";
import { findLowestAvailableWorkspaceNumber } from "@/core/logic";
/**
* Options for creating a spellbook
*/
export interface CreateSpellbookOptions {
state: GrimoireState;
workspaceIds?: string[]; // If omitted, saves all workspaces
title: string;
description?: string;
}
/**
* Result of encoding a spellbook
*/
export interface EncodedSpellbook {
eventProps: {
kind: number;
content: string;
tags: [string, string, ...string[]][];
};
referencedSpells: string[];
}
/**
* Helper to slugify a title for the 'd' tag
*/
export function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, "-") // Replace spaces with -
.replace(/[^\w\-]+/g, "") // Remove all non-word chars
.replace(/\-\-+/g, "-"); // Replace multiple - with single -
}
/**
* Traverses a Mosaic layout tree to collect all window IDs
*/
function collectWindowIds(
layout: MosaicNode<string> | null,
ids: Set<string>,
): void {
if (!layout) return;
if (typeof layout === "string") {
ids.add(layout);
return;
}
collectWindowIds(layout.first, ids);
collectWindowIds(layout.second, ids);
}
/**
* Creates a Spellbook (Kind 30777) from the current state
*/
export function createSpellbook(
options: CreateSpellbookOptions,
): EncodedSpellbook {
const { state, title, description, workspaceIds } = options;
// 1. Determine which workspaces to include
const targetWorkspaceIds =
workspaceIds && workspaceIds.length > 0
? workspaceIds
: Object.keys(state.workspaces);
const selectedWorkspaces: Record<string, Workspace> = {};
const selectedWindows: Record<string, WindowInstance> = {};
const referencedSpells = new Set<string>();
// 2. Collect workspaces and their windows
for (const wsId of targetWorkspaceIds) {
const ws = state.workspaces[wsId];
if (!ws) continue;
selectedWorkspaces[wsId] = ws;
// Collect window IDs from layout to ensure we only save what's actually visible/used
// (though state.workspaces[id].windowIds should match, layout is the source of truth for structure)
const windowIds = new Set<string>();
collectWindowIds(ws.layout, windowIds);
// Also include any loose windows in the workspace definition just in case
ws.windowIds.forEach((id) => windowIds.add(id));
// 3. Extract window instances
for (const winId of windowIds) {
const window = state.windows[winId];
if (window) {
selectedWindows[winId] = window;
if (window.spellId) {
referencedSpells.add(window.spellId);
}
}
}
}
// 4. Construct content payload
const content: SpellbookContent = {
version: 1,
workspaces: selectedWorkspaces,
windows: selectedWindows,
};
// 5. Construct tags
const tags: [string, string, ...string[]][] = [
["d", slugify(title)],
["title", title],
["client", "grimoire"],
];
if (description) {
tags.push(["description", description]);
tags.push(["alt", `Grimoire Spellbook: ${title}`]);
} else {
tags.push(["alt", `Grimoire Spellbook: ${title}`]);
}
// Add referenced spells
for (const spellId of referencedSpells) {
tags.push(["e", spellId, "", "mention"]);
}
return {
eventProps: {
kind: SPELLBOOK_KIND,
content: JSON.stringify(content),
tags,
},
referencedSpells: Array.from(referencedSpells),
};
}
/**
* Parses a Spellbook event
*/
export function parseSpellbook(event: SpellbookEvent): ParsedSpellbook {
let content: SpellbookContent;
try {
content = JSON.parse(event.content);
} catch (e) {
throw new Error("Failed to parse spellbook content: Invalid JSON");
}
// Validate version (basic check)
if (!content.version || content.version < 1) {
console.warn("Spellbook missing version or invalid, attempting to load anyway");
}
// Extract metadata
const dTag = event.tags.find((t) => t[0] === "d");
const titleTag = event.tags.find((t) => t[0] === "title");
const descTag = event.tags.find((t) => t[0] === "description");
const eTags = event.tags.filter((t) => t[0] === "e").map((t) => t[1]);
return {
slug: dTag?.[1] || "",
title: titleTag?.[1] || "Untitled Spellbook",
description: descTag?.[1],
content,
referencedSpells: eTags,
event,
};
}
/**
* Recursively updates window IDs in a Mosaic layout tree
*/
function updateLayoutIds(
layout: MosaicNode<string> | null,
idMap: Map<string, string>,
): MosaicNode<string> | null {
if (!layout) return null;
if (typeof layout === "string") {
// If we have a mapping for this ID, return the new one.
// If not (shouldn't happen in valid spellbooks), return old one.
return idMap.get(layout) || layout;
}
return {
...layout,
first: updateLayoutIds(layout.first, idMap)!,
second: updateLayoutIds(layout.second, idMap)!,
};
}
/**
* Imports a parsed spellbook into the current state.
* Regenerates IDs to avoid collisions.
*/
export function loadSpellbook(
state: GrimoireState,
spellbook: ParsedSpellbook,
): GrimoireState {
const { workspaces, windows } = spellbook.content;
// Maps to track old -> new IDs
const workspaceIdMap = new Map<string, string>();
const windowIdMap = new Map<string, string>();
// 1. Generate new Workspace IDs and Numbers
// We don't want to mess up existing workspace numbers, so we append.
// We'll calculate starting number based on existing.
const newWorkspaces: Record<string, Workspace> = { ...state.workspaces };
const newWindows: Record<string, WindowInstance> = { ...state.windows };
// 2. Process Windows first to build ID map
Object.values(windows).forEach((window) => {
const newId = uuidv4();
windowIdMap.set(window.id, newId);
// Create new window instance with new ID
newWindows[newId] = {
...window,
id: newId,
};
});
// 3. Process Workspaces
Object.values(workspaces).forEach((ws) => {
const newWsId = uuidv4();
workspaceIdMap.set(ws.id, newWsId);
// Update window IDs in the windowIds array
const newWindowIds = ws.windowIds
.map((oldId) => windowIdMap.get(oldId))
.filter((id): id is string => !!id);
// Update layout tree with new window IDs
const newLayout = updateLayoutIds(ws.layout, windowIdMap);
// Create new workspace instance
// Note: We use the lowest available number strategy to avoid conflicts
// but this might separate workspaces that were sequential in the spellbook
// if there are gaps in the current state.
// Ideally, we keep them sequential relative to each other.
// Actually, let's find the max existing number and append after that to keep them grouped
// logic.ts findLowestAvailable fills gaps.
// If we want to group them, we should find the max and start there.
// But findLowestAvailable is the standard convention in this codebase.
// Let's stick to findLowestAvailable for now, but call it sequentially.
// Wait, findLowestAvailable scans the *current* map.
// So if we add one to newWorkspaces, the next call will find the next number.
// Simple approach: just use the logic helper against the ACCUMULATING state.
// However, findLowestAvailable is O(N). Calling it in a loop is O(N^2).
// For small N (workspaces) it's fine.
const targetNumber = (() => {
// We can't easily use the helper because we haven't added the new workspace yet.
// And if we add it, we need the number first. Catch-22.
// Actually, we can just pass the current state of newWorkspaces to the helper
// provided it matches the signature Record<string, {number: number}>.
// It does.
return findLowestAvailableWorkspaceNumber(newWorkspaces);
})();
newWorkspaces[newWsId] = {
...ws,
id: newWsId,
number: targetNumber,
layout: newLayout,
windowIds: newWindowIds,
// If label exists, keep it, maybe prepend "Imported"? No, keep it as is.
};
});
return {
...state,
workspaces: newWorkspaces,
windows: newWindows,
// Optionally switch to the first imported workspace?
// Let's just return the state, let the UI decide navigation.
// But usually importing implies you want to see it.
// We'll leave activeWorkspaceId unchanged for now, the caller can update it if needed.
};
}

View File

@@ -3,7 +3,7 @@ import { Dexie, Table } from "dexie";
import { RelayInformation } from "../types/nip11";
import { normalizeRelayURL } from "../lib/relay-url";
import type { NostrEvent } from "@/types/nostr";
import type { SpellEvent } from "@/types/spell";
import type { SpellEvent, SpellbookContent, SpellbookEvent } from "@/types/spell";
export interface Profile extends ProfileContent {
pubkey: string;
@@ -63,6 +63,19 @@ export interface LocalSpell {
deletedAt?: number; // Timestamp when soft-deleted
}
export interface LocalSpellbook {
id: string; // UUID for local-only, or event ID for published
slug: string; // d-tag for replaceable events
title: string; // Human readable title
description?: string; // Optional description
content: SpellbookContent; // JSON payload
createdAt: number;
isPublished: boolean;
eventId?: string;
event?: SpellbookEvent;
deletedAt?: number;
}
class GrimoireDb extends Dexie {
profiles!: Table<Profile>;
nip05!: Table<Nip05>;
@@ -72,6 +85,7 @@ class GrimoireDb extends Dexie {
relayLists!: Table<CachedRelayList>;
relayLiveness!: Table<RelayLivenessEntry>;
spells!: Table<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
constructor(name: string) {
super(name);
@@ -280,6 +294,19 @@ class GrimoireDb extends Dexie {
relayLiveness: "&url",
spells: "&id, alias, createdAt, isPublished, deletedAt",
});
// Version 14: Add local spellbook storage
this.version(14).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",
});
}
}
@@ -307,4 +334,4 @@ export const relayLivenessStorage = {
},
};
export default db;
export default db;

View File

@@ -0,0 +1,58 @@
import db, { LocalSpellbook } from "./db";
import { SpellbookEvent } from "@/types/spell";
/**
* Save a spellbook to local storage
*/
export async function saveSpellbook(
spellbook: Omit<LocalSpellbook, "id" | "createdAt">,
): Promise<LocalSpellbook> {
const id = spellbook.eventId || crypto.randomUUID();
const createdAt = Date.now();
const localSpellbook: LocalSpellbook = {
id,
createdAt,
...spellbook,
};
await db.spellbooks.put(localSpellbook);
return localSpellbook;
}
/**
* Get a spellbook by ID
*/
export async function getSpellbook(id: string): Promise<LocalSpellbook | undefined> {
return db.spellbooks.get(id);
}
/**
* Get all local spellbooks
*/
export async function getAllSpellbooks(): Promise<LocalSpellbook[]> {
return db.spellbooks.orderBy("createdAt").reverse().toArray();
}
/**
* Soft-delete a spellbook
*/
export async function deleteSpellbook(id: string): Promise<void> {
await db.spellbooks.update(id, {
deletedAt: Date.now(),
});
}
/**
* Mark a spellbook as published
*/
export async function markSpellbookPublished(
localId: string,
event: SpellbookEvent,
): Promise<void> {
await db.spellbooks.update(localId, {
isPublished: true,
eventId: event.id,
event,
});
}

60
src/services/wallet.ts Normal file
View File

@@ -0,0 +1,60 @@
import { WalletConnect } from "applesauce-wallet-connect";
import { BehaviorSubject } from "rxjs";
const WALLET_CONNECT_URI = "nostr-wallet-connect-uri";
class WalletService {
public wallet: WalletConnect | null = null;
public status$ = new BehaviorSubject<"connected" | "disconnected" | "connecting">("disconnected");
public error$ = new BehaviorSubject<Error | null>(null);
constructor() {
this.restoreConnection();
}
private async restoreConnection() {
const uri = localStorage.getItem(WALLET_CONNECT_URI);
if (uri) {
await this.connect(uri);
}
}
public async connect(uri: string) {
try {
this.status$.next("connecting");
this.error$.next(null);
// Create new wallet instance
this.wallet = WalletConnect.fromConnectURI(uri);
// Save URI for auto-reconnect
localStorage.setItem(WALLET_CONNECT_URI, uri);
this.status$.next("connected");
return this.wallet;
} catch (err) {
console.error("Failed to connect wallet:", err);
this.error$.next(err instanceof Error ? err : new Error("Unknown error"));
this.status$.next("disconnected");
this.wallet = null;
throw err;
}
}
public disconnect() {
this.wallet = null;
localStorage.removeItem(WALLET_CONNECT_URI);
this.status$.next("disconnected");
}
public async payInvoice(invoice: string): Promise<string | undefined> {
if (!this.wallet) {
throw new Error("Wallet not connected");
}
const response = await this.wallet.payInvoice(invoice);
return response.preimage;
}
}
const walletService = new WalletService();
export default walletService;

View File

@@ -16,7 +16,9 @@ export type AppId =
| "relay"
| "debug"
| "conn"
| "spells";
| "spells"
| "spellbooks"
| "wallet";
export interface WindowInstance {
id: string;
@@ -25,6 +27,7 @@ export interface WindowInstance {
customTitle?: string; // User-provided custom title via --title flag (overrides dynamic title)
props: any;
commandString?: string; // Original command that created this window (e.g., "profile alice@domain.com")
spellId?: string; // ID of the spell that created this window (if any)
}
/**

View File

@@ -73,6 +73,14 @@ export const manPages: Record<string, ManPageEntry> = {
argParser: (args: string[]) => ({ number: args[0] || "1" }),
defaultProps: { number: "1" },
},
spellbooks: {
name: "spellbooks",
section: "1",
synopsis: "spellbooks",
description: "Browse and manage saved layout spellbooks.",
appId: "spellbooks",
category: "System",
},
help: {
name: "help",
section: "1",
@@ -470,4 +478,16 @@ export const manPages: Record<string, ManPageEntry> = {
category: "Nostr",
defaultProps: {},
},
wallet: {
name: "wallet",
section: "1",
synopsis: "wallet",
description:
"Manage Nostr Wallet Connect (NWC) connection. Allows connecting to a remote Lightning wallet to pay invoices.",
examples: ["wallet Open wallet connection manager"],
seeAlso: ["conn"],
appId: "wallet",
category: "System",
defaultProps: {},
},
};

View File

@@ -1,4 +1,8 @@
import type { NostrEvent, NostrFilter } from "./nostr";
import type { Workspace, WindowInstance } from "./app";
import { SPELL_KIND, SPELLBOOK_KIND } from "@/constants/kinds";
export { SPELL_KIND, SPELLBOOK_KIND };
/**
* Spell event (kind 777 immutable event)
@@ -120,3 +124,49 @@ export interface EncodedSpell {
/** Close on EOSE flag */
closeOnEose: boolean;
}
/**
* Content structure for a Spellbook (Kind 30777)
* Represents a saved layout configuration (one or more workspaces)
*/
export interface SpellbookContent {
/** Schema version for migrations */
version: number;
/** Workspaces included in this spellbook */
workspaces: Record<string, Workspace>;
/** Windows referenced by the workspaces */
windows: Record<string, WindowInstance>;
}
/**
* Spellbook event (kind 30777 parameterized replaceable event)
*
* TAGS:
* - ["d", "slug"] - Unique identifier (slugified title)
* - ["title", "My Dashboard"] - Human readable title
* - ["description", "My cool setup"] - Optional description
* - ["e", "spell-id", "relay", "mention"] - References to spells used in the layout
*/
export interface SpellbookEvent extends NostrEvent {
kind: 30777;
content: string; // JSON encoded SpellbookContent
tags: [string, string, ...string[]][];
}
/**
* Parsed spellbook with extracted metadata and content
*/
export interface ParsedSpellbook {
/** Slug identifier (from d tag) */
slug: string;
/** Title (from title tag) */
title: string;
/** Description (from description tag) */
description?: string;
/** The actual layout data */
content: SpellbookContent;
/** IDs of spells referenced in this book (from e tags) */
referencedSpells: string[];
/** Full event reference */
event: SpellbookEvent;
}