feat: spells

This commit is contained in:
Alejandro Gómez
2025-12-20 14:25:40 +01:00
parent a39dc658cd
commit 2987a37e65
47 changed files with 5590 additions and 169 deletions

501
SPELL_SYSTEM_PLAN.md Normal file
View File

@@ -0,0 +1,501 @@
# Spell System Implementation Plan
## Executive Summary
Refactor the spell system to support:
1. **Alias** (local-only quick name) + **Name** (published spell name)
2. Non-technical user-friendly spell creator wizard
3. Spell discovery and browsing UI
4. Improved command palette integration
## Data Model Changes
### Current State
```typescript
LocalSpell {
localName?: string; // Local only
description?: string; // Published
command: string;
}
SpellEvent (kind 777) {
tags: [
["cmd", "REQ"],
["client", "grimoire"],
// NO name tag
],
content: description
}
```
### New State
```typescript
LocalSpell {
alias?: string; // NEW: Local-only quick name
name?: string; // NEW: Mirror from published event
description?: string;
command: string;
isPublished: boolean;
eventId?: string;
}
SpellEvent (kind 777) {
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["name", "Bitcoin Feed"], // NEW: Published name
["t", "bitcoin"], // Topic tags
],
content: description // Optional
}
```
### Key Distinction
- **Alias**: Personal shortcut, typed in command palette (e.g., `btc`)
- **Name**: Public spell title, shown in discovery (e.g., "Bitcoin Feed")
- **Description**: Detailed explanation of what the spell does
## Implementation Phases
### Phase 1: Foundation Fixes (Immediate - 2-3 hours)
**Goal:** Fix data model and current UI
**Changes:**
1. Add `name` field to `CreateSpellOptions` and `ParsedSpell` types
2. Add name tag encoding/decoding in `spell-conversion.ts`
3. Rename `localName``alias` in `LocalSpell` interface
4. Add database migration v9→v10
5. Update `SpellDialog`:
- Add alias field (local-only, top)
- Add name field (published)
- Rename "Filter" label to "Command"
- Remove Cancel button
6. Update `spell-storage.ts` for alias field
7. Update all tests
**Files Modified:**
- `src/types/spell.ts`
- `src/lib/spell-conversion.ts`
- `src/services/db.ts`
- `src/services/spell-storage.ts`
- `src/components/nostr/SpellDialog.tsx`
- `src/lib/spell-conversion.test.ts`
**Success Criteria:**
- ✅ Build passes
- ✅ All tests pass
- ✅ Migration preserves existing spells
- ✅ Can create spells with alias + name
- ✅ Published spells include name tag
---
### Phase 2: Spell Browser (2-3 days)
**Goal:** Create spell discovery and management UI
**New Components:**
1. **SpellsViewer** (`src/components/SpellsViewer.tsx`)
- Main window component (appId: "spells")
- Three tabs: My Spells, Discover, Favorites
- Search bar and filters
- "New Spell" button
2. **SpellList** (`src/components/nostr/SpellList.tsx`)
- Virtual scrolling for performance
- Sort by: recent, popular, name
- Filter by: content type, author, tags
3. **SpellCard** (`src/components/nostr/SpellCard.tsx`)
- Compact display with metadata
- Quick actions: Run (▶), Edit (✏), More (⋮)
- Visual distinction: local vs published
4. **SpellDetailModal** (`src/components/nostr/SpellDetailModal.tsx`)
- Expanded spell view
- Friendly metadata display (no technical REQ syntax)
- Stats: reactions, forks, usage
- Actions: Run, Edit, Fork, Share
**Features:**
- Browse local and network spells
- Run spells directly from browser
- Fork published spells
- Search by name, alias, description, tags
- Filter by content type (kinds)
- Sort by popularity or recency
**Command Palette Integration:**
- `spells` → Open spell browser
- `spell create` → Open spell creator
- `<alias>` → Run spell by alias
- Autocomplete shows spell suggestions
**Success Criteria:**
- ✅ Can browse local spells
- ✅ Can discover network spells
- ✅ Can run spells from browser
- ✅ Search and filtering work
- ✅ Command palette integration functional
- ✅ Performance good with 100+ spells
---
### Phase 3: Spell Creator Wizard (3-4 days)
**Goal:** Non-technical friendly spell creation
**Wizard Steps:**
**Step 1: Content Type**
```
What do you want to see?
[📝 Notes & Posts] [📰 Long Articles]
[👤 Profiles] [🎨 Images]
[💬 Replies] [🎵 Audio/Video]
[📚 Custom...]
```
Visual cards with descriptions, most popular types first.
**Step 2: Authors**
```
Who posted this?
○ Everyone
○ People I follow
○ Specific people: [Search...]
```
People picker with:
- Profile pictures and display names
- Search by name, NIP-05, npub
- Multi-select with chips
- Quick "Add from follows" button
**Step 3: Time Range**
```
When?
[⏰ Last Hour] [📅 Today] [🗓️ This Week]
[📆 This Month] [🌐 All Time]
Or custom: From [___] to [___]
```
Visual preset buttons + custom date picker.
**Step 4: Advanced Filters** (collapsible, optional)
```
▼ More Options
Tags: [#bitcoin] [#nostr] [+ Add]
Mentions: [@jack] [+ Add]
Search: [_____________]
Limit: [50 ▼]
```
**Step 5: Preview & Name**
```
Preview
This spell will show:
📝 Notes from @jack, @alice
⏰ From the last 7 days
🏷 Tagged #bitcoin
[Live preview of results...]
---
Quick Name (alias): [btc ]
Spell Name (published): [Bitcoin Feed]
Description (published): [___________]
[< Back] [Save Locally] [Save & Publish]
```
**Templates:**
Provide curated templates for quick start:
- My Network (posts from follows)
- Trending Topics (popular recent posts)
- Bitcoin News (#bitcoin #btc)
- Art Gallery (images from artists)
**Helper Components:**
1. **PeoplePicker** (`src/components/ui/people-picker.tsx`)
- Author/mention selection
- Profile integration
- Multi-select support
2. **TagInput** (`src/components/ui/tag-input.tsx`)
- Hashtag selection
- Autocomplete from popular tags
3. **Wizard Converters** (`src/lib/wizard-converter.ts`)
```typescript
wizardToCommand(state: WizardState): string
commandToWizard(command: string): WizardState
filterToFriendlyDescription(filter: NostrFilter): string
```
**Success Criteria:**
- ✅ Non-technical users can create spells
- ✅ All wizard steps functional
- ✅ Live preview works
- ✅ Templates load correctly
- ✅ Conversion wizard↔command accurate
- ✅ Keyboard navigation works
---
### Phase 4: Additional Features (Future)
**Spell Templates** (`src/lib/spell-templates.ts`)
```typescript
interface SpellTemplate {
id: string;
name: string;
description: string;
icon: string;
category: 'social' | 'media' | 'discovery' | 'monitoring';
requiresAccount: boolean;
wizardDefaults: Partial<WizardState>;
}
```
**Spell Discovery Enhancements:**
- Popularity metrics (reactions, forks)
- Trust indicators (verified creators, from follows)
- Categorization by content type
- Network-wide trending spells
**Command Palette Enhancements:**
- Spell autocomplete with descriptions
- Recent spells quick access
- Fuzzy search for spell names/aliases
**Future Enhancements (Phase 5):**
- Parameterized spells (variables)
- Scheduled spells (hourly, daily)
- Spell playlists/collections
- Spell analytics and stats
- Collaborative spell sharing
- AI-assisted spell creation
## Technical Architecture
### Component Structure
```
src/
├── components/
│ ├── SpellsViewer.tsx # Main spell browser
│ ├── nostr/
│ │ ├── SpellList.tsx # List of spells
│ │ ├── SpellCard.tsx # Spell card
│ │ ├── SpellDetailModal.tsx # Expanded view
│ │ ├── SpellCreatorWizard.tsx # Wizard main
│ │ ├── SpellEditor.tsx # Rename from SpellDialog
│ │ └── wizard/
│ │ ├── ContentTypeStep.tsx
│ │ ├── AuthorStep.tsx
│ │ ├── TimeRangeStep.tsx
│ │ ├── AdvancedStep.tsx
│ │ └── PreviewStep.tsx
│ └── ui/
│ ├── people-picker.tsx
│ └── tag-input.tsx
├── lib/
│ ├── spell-templates.ts # Curated templates
│ ├── spell-metadata.ts # Filter formatting
│ └── wizard-converter.ts # Wizard ↔ command
├── hooks/
│ ├── useSpells.ts # Spell data
│ ├── useSpellDiscovery.ts # Network discovery
│ └── useSpellActions.ts # Actions
└── types/
└── wizard.ts # Wizard state types
```
### State Management
**Option A: Jotai (Current Pattern)**
```typescript
export const localSpellsAtom = atom<LocalSpell[]>([]);
export const publishedSpellsAtom = atom<ParsedSpell[]>([]);
export const spellDiscoveryAtom = atom<ParsedSpell[]>([]);
```
**Option B: React Query (Recommended for Phase 2+)**
```typescript
export function useLocalSpells() {
return useQuery({
queryKey: ['spells', 'local'],
queryFn: () => getAllSpells(),
});
}
export function usePublishedSpells() {
const subscription = useSubscription({
filter: { kinds: [777] },
relays: AGGREGATOR_RELAYS,
});
return useQuery({
queryKey: ['spells', 'published'],
queryFn: () => parsePublishedSpells(subscription.events),
});
}
```
### Discovery Mechanisms
1. **From Follows:** Query kind 777 from contact list
2. **From Aggregators:** Query AGGREGATOR_RELAYS
3. **By Category:** Filter by "k" tags
4. **Search:** Full-text on name, description, tags
5. **Popularity:** Sort by reaction count (kind 7)
### Performance Considerations
- Virtual scrolling for spell lists (react-window)
- Debounced search (300ms)
- Lazy load published spells
- Cache parsed spells in memory
- Background sync when inactive
## Edge Cases & Validation
### Alias Validation
- Alphanumeric + dash + underscore only: `/^[a-zA-Z0-9_-]+$/`
- Max length: 32 characters
- Cannot conflict with built-in commands (req, profile, etc.)
### Name Validation
- Any Unicode characters allowed
- Max length: 64 characters
- Optional (can be empty)
### Description Validation
- Any Unicode characters allowed
- Max length: 500 characters
- Optional (can be empty)
### Empty Spell Handling
- If no name/alias/description: show "(Unnamed Spell)"
- Auto-derive fallback from command: "Kind 1 Notes"
### Conflict Resolution
- Alias conflicts: Show warning, allow override
- Published spell updates: Show "Local changes not published"
- Duplicate aliases: Last one wins, show warning
## Testing Strategy
### Unit Tests
- Spell encoding/decoding with name tag
- Alias validation
- Filter-to-metadata conversion
- Wizard-to-command conversion
- Database migration
### Integration Tests
- Create and save spell
- Publish spell to network
- Fork published spell
- Run spell via alias
- Search and filter spells
### Manual Testing Checklist
- [ ] Create spell from REQ window
- [ ] Create spell via wizard
- [ ] Edit existing spell
- [ ] Delete local spell
- [ ] Publish local spell
- [ ] Fork published spell
- [ ] Run spell via alias
- [ ] Search spells
- [ ] Filter by category
- [ ] Command palette integration
## Migration Strategy
### Database Migration v9 → v10
```typescript
this.version(10)
.stores({
// ... same schema ...
})
.upgrade(async (tx) => {
const spells = await tx.table<any>("spells").toArray();
for (const spell of spells) {
// Rename localName → alias
if (spell.localName) {
spell.alias = spell.localName;
delete spell.localName;
}
// Initialize name field
spell.name = spell.name || undefined;
await tx.table("spells").put(spell);
}
console.log(`[DB Migration v10] Migrated ${spells.length} spells`);
});
```
**Zero Data Loss:** Existing spells preserved with quick names as aliases.
## Implementation Timeline
### Phase 1: Immediate (2-3 hours)
- Foundation fixes
- Data model corrections
- SpellDialog updates
- Tests
### Phase 2: Spell Browser (2-3 days)
- SpellsViewer component
- Discovery and browsing
- Command palette integration
- Basic actions
### Phase 3: Wizard (3-4 days)
- Multi-step wizard
- Visual builders
- Templates
- Live preview
### Total: ~1 week full-time
## Success Metrics
- **User Adoption:** 50%+ of users create at least one spell
- **Non-Technical Success:** 30%+ of spells created via wizard
- **Discovery:** 20%+ of runs are discovered spells (not user-created)
- **Performance:** <100ms to load spell browser
- **Quality:** 0 critical bugs in Phase 1
## Accessibility
- Keyboard navigation for all features
- Screen reader support with ARIA labels
- Focus management in modals
- Clear visual hierarchy
- Empty state guidance
## Conclusion
This plan transforms the spell system from technical CLI-only to user-friendly with visual builders, while maintaining power-user CLI workflows. The phased approach allows incremental delivery and iteration based on feedback.
**Next Steps:**
1. Implement Phase 1 (immediate fixes)
2. Test and validate with users
3. Begin Phase 2 (spell browser)
4. Iterate based on feedback

73
package-lock.json generated
View File

@@ -20,8 +20,10 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"applesauce-accounts": "^4.1.0",
"applesauce-actions": "^4.0.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
"applesauce-loaders": "^4.2.0",
@@ -3043,6 +3045,77 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",

View File

@@ -28,8 +28,10 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"applesauce-accounts": "^4.1.0",
"applesauce-actions": "^4.0.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
"applesauce-loaders": "^4.2.0",

View File

@@ -0,0 +1,49 @@
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { EventFactory } from "applesauce-factory";
import { relayListCache } from "@/services/relay-list-cache";
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";
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");
const account = accountManager.active;
if (!account) throw new Error("No active account");
const signer = account.signer;
if (!signer) throw new Error("No signer available");
const factory = new EventFactory({ signer });
const draft = await factory.delete([spell.event], reason);
const event = await factory.sign(draft);
// Get write relays from cache and state
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];
const store = getDefaultStore();
const state = store.get(grimoireStateAtom);
const stateWriteRelays =
state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) ||
[];
// Combine all relay sources
const writeRelays = mergeRelaySets(
authorWriteRelays,
stateWriteRelays,
AGGREGATOR_RELAYS,
);
// Publish to all target relays
await pool.publish(writeRelays, event);
}
}

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PublishSpellAction } from "./publish-spell";
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import * as spellStorage from "@/services/spell-storage";
import { LocalSpell } from "@/services/db";
// Mock dependencies
vi.mock("@/services/accounts", () => ({
default: {
active: {
signer: {},
},
},
}));
vi.mock("@/services/relay-pool", () => ({
default: {
publish: vi.fn(),
},
}));
vi.mock("@/services/spell-storage", () => ({
markSpellPublished: vi.fn(),
}));
describe("PublishSpellAction", () => {
let action: PublishSpellAction;
beforeEach(() => {
vi.clearAllMocks();
action = new PublishSpellAction();
});
it("should fail if no active account", async () => {
// @ts-expect-error: mocking internal state for test
accountManager.active = null;
const spell: LocalSpell = {
id: "spell-1",
command: "req -k 1",
createdAt: 123,
isPublished: false,
};
await expect(action.execute(spell)).rejects.toThrow("No active account");
});
it("should publish spell and update storage", async () => {
const mockSigner = {
getPublicKey: vi.fn().mockResolvedValue("pubkey"),
signEvent: vi.fn().mockImplementation((draft) =>
Promise.resolve({
...draft,
id: "event-id",
pubkey: "pubkey",
sig: "sig",
}),
),
};
// @ts-expect-error: mocking internal state for test
accountManager.active = {
signer: mockSigner,
};
const spell: LocalSpell = {
id: "local-id",
command: "req -k 1",
name: "My Spell",
description: "Description",
createdAt: 1234567890,
isPublished: false,
};
await action.execute(spell);
// Check if signer was called
expect(mockSigner.signEvent).toHaveBeenCalled();
// Check if published to pool
expect(pool.publish).toHaveBeenCalled();
// Check if storage updated
expect(spellStorage.markSpellPublished).toHaveBeenCalledWith(
"local-id",
expect.objectContaining({
kind: 777,
// We expect tags to contain name and alt (description)
tags: expect.arrayContaining([
["name", "My Spell"],
["alt", expect.stringContaining("Description")],
["cmd", "REQ"],
]),
}),
);
});
});

View File

@@ -0,0 +1,76 @@
import { LocalSpell } from "@/services/db";
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { encodeSpell } from "@/lib/spell-conversion";
import { markSpellPublished } from "@/services/spell-storage";
import { EventFactory } from "applesauce-factory";
import { SpellEvent } from "@/types/spell";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
export class PublishSpellAction {
type = "publish-spell";
label = "Publish Spell";
async execute(spell: LocalSpell, targetRelays?: string[]): Promise<void> {
const account = accountManager.active;
if (!account) throw new Error("No active account");
let event: SpellEvent;
if (spell.isPublished && spell.event) {
// Use existing signed event for rebroadcasting
event = spell.event;
} else {
const signer = account.signer;
if (!signer) throw new Error("No signer available");
const encoded = encodeSpell({
command: spell.command,
name: spell.name,
description: spell.description,
});
const factory = new EventFactory({ signer });
const draft = await factory.build({
kind: 777,
content: encoded.content,
tags: encoded.tags,
});
event = (await factory.sign(draft)) as SpellEvent;
}
// Use provided relays or fallback to author's write relays + aggregators
let relays = targetRelays;
if (!relays || relays.length === 0) {
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];
relays = mergeRelaySets(
event.tags.find((t) => t[0] === "relays")?.slice(1) || [],
authorWriteRelays,
AGGREGATOR_RELAYS,
);
}
// Publish to all target relays
await pool.publish(relays, event);
await markSpellPublished(spell.id, event);
}
}

View File

@@ -1,6 +1,8 @@
import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useAtom } from "jotai";
import { useLiveQuery } from "dexie-react-hooks";
import db from "@/services/db";
import { useGrimoire } from "@/core/state";
import { manPages } from "@/types/man";
import { parseCommandInput, executeCommandParser } from "@/lib/command-parser";
@@ -20,7 +22,17 @@ export default function CommandLauncher({
}: CommandLauncherProps) {
const [input, setInput] = useState("");
const [editMode, setEditMode] = useAtom(commandLauncherEditModeAtom);
const { addWindow, updateWindow } = useGrimoire();
const { state, addWindow, updateWindow } = useGrimoire();
// Fetch spells with aliases
const aliasedSpells =
useLiveQuery(() =>
db.spells
.toArray()
.then((spells) =>
spells.filter((s) => s.alias !== undefined && s.alias !== ""),
),
) || [];
// Prefill input when entering edit mode
useEffect(() => {
@@ -36,19 +48,49 @@ export default function CommandLauncher({
// Parse input into command and arguments
const parsed = parseCommandInput(input);
const { commandName } = parsed;
const recognizedCommand = parsed.command;
// Check if it's a spell alias
const activeSpell = aliasedSpells.find(
(s) => s.alias?.toLowerCase() === commandName.toLowerCase(),
);
// Re-parse if it's a spell
const effectiveParsed = activeSpell
? parseCommandInput(
activeSpell.command +
(input.trim().includes(" ")
? " " + input.trim().split(/\s+/).slice(1).join(" ")
: ""),
)
: parsed;
const recognizedCommand = effectiveParsed.command;
// Filter commands by partial match on command name only
const filteredCommands = Object.entries(manPages).filter(([name]) =>
name.toLowerCase().includes(commandName.toLowerCase()),
);
const filteredCommands = [
...Object.entries(manPages),
...aliasedSpells.map((s) => [
s.alias!,
{
name: s.alias!,
synopsis: s.alias!,
description: s.name || s.description || "",
category: "Spells",
appId: "req",
spellCommand: s.command,
} as any,
]),
].filter(([name]) => name.toLowerCase().includes(commandName.toLowerCase()));
// Execute command (async to support async argParsers)
const executeCommand = async () => {
if (!recognizedCommand) return;
// Execute argParser and get props/title
const result = await executeCommandParser(parsed);
const result = await executeCommandParser(
effectiveParsed,
state.activeAccount?.pubkey,
);
if (result.error || !result.props) {
console.error("Failed to parse command:", result.error);
@@ -59,7 +101,7 @@ export default function CommandLauncher({
if (editMode) {
updateWindow(editMode.windowId, {
props: result.props,
commandString: input.trim(),
commandString: activeSpell ? effectiveParsed.fullInput : input.trim(),
appId: recognizedCommand.appId,
customTitle: result.globalFlags?.windowProps?.title,
});
@@ -69,7 +111,7 @@ export default function CommandLauncher({
addWindow(
recognizedCommand.appId,
result.props,
input.trim(),
activeSpell ? effectiveParsed.fullInput : input.trim(),
result.globalFlags?.windowProps?.title,
);
}
@@ -90,19 +132,21 @@ export default function CommandLauncher({
}
};
// Define category order: Nostr first, then Documentation, then System
const categoryOrder = ["Nostr", "Documentation", "System"];
// Define category order: Nostr first, then Spells, then Documentation, then System
const categoryOrder = ["Nostr", "Spells", "Documentation", "System"];
const categories = Array.from(
new Set(filteredCommands.map(([_, cmd]) => cmd.category)),
).sort((a, b) => {
const indexA = categoryOrder.indexOf(a);
const indexB = categoryOrder.indexOf(b);
const indexA = categoryOrder.indexOf(a as string);
const indexB = categoryOrder.indexOf(b as string);
return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
});
// Dynamic placeholder
const placeholder = recognizedCommand
? recognizedCommand.synopsis
? activeSpell
? activeSpell.command
: recognizedCommand.synopsis
: "Type a command...";
return (
@@ -165,9 +209,16 @@ export default function CommandLauncher({
</span>
)}
</div>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
{cmd.description && (
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
)}
{cmd.spellCommand && (
<div className="text-[10px] opacity-50 font-mono truncate mt-0.5">
{cmd.spellCommand}
</div>
)}
</div>
</Command.Item>
);

View File

@@ -0,0 +1,705 @@
import * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
ChevronDown,
WandSparkles,
Plus,
X,
Clock,
User,
FileText,
Search,
Wifi,
Tag,
AtSign,
} from "lucide-react";
import { KindSelector } from "./KindSelector";
import { ProfileSelector } from "./ProfileSelector";
import { KindBadge } from "./KindBadge";
import { UserName } from "./nostr/UserName";
import { reconstructCommand } from "@/lib/spell-conversion";
import { SpellDialog } from "./nostr/SpellDialog";
interface CreateSpellDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* CreateSpellDialog - A newbie-friendly UI for building Nostr REQ commands
* Allows users to visually construct filters without knowing the CLI syntax
*/
export function CreateSpellDialog({
open,
onOpenChange,
}: CreateSpellDialogProps) {
// Filter state
const [kinds, setKinds] = React.useState<number[]>([]);
const [authors, setAuthors] = React.useState<string[]>([]);
const [mentions, setMentions] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const [hashtags, setHashtags] = React.useState<string[]>([]);
const [since, setSince] = React.useState<string>("");
const [until, setUntil] = React.useState<string>("");
const [relays, setRelays] = React.useState<string[]>([]);
const [closeOnEose, setCloseOnEose] = React.useState(false);
const [limit, setLimit] = React.useState<number | "">("");
const [genericTags, setGenericTags] = React.useState<
Record<string, string[]>
>({});
const [activeTagLetter, setActiveTagLetter] = React.useState("e");
// Sub-dialog state
const [showSaveDialog, setShowSaveDialog] = React.useState(false);
// Reconstruct command string for preview and saving
const generatedCommand = React.useMemo(() => {
const filter: any = {
kinds: kinds.length > 0 ? kinds : undefined,
authors: authors.length > 0 ? authors : undefined,
"#p": mentions.length > 0 ? mentions : undefined,
"#t": hashtags.length > 0 ? hashtags : undefined,
search: search || undefined,
limit: limit !== "" ? limit : undefined,
};
// Add generic tags
for (const [letter, values] of Object.entries(genericTags)) {
if (values.length > 0) {
filter[`#${letter}`] = values;
}
}
return reconstructCommand(
filter,
relays.length > 0 ? relays : undefined,
since || undefined,
until || undefined,
closeOnEose,
);
}, [
kinds,
authors,
mentions,
search,
hashtags,
since,
until,
relays,
closeOnEose,
limit,
genericTags,
]);
const handleAddKind = (kind: number) => {
if (!kinds.includes(kind)) setKinds([...kinds, kind]);
};
const handleRemoveKind = (kind: number) => {
setKinds(kinds.filter((k) => k !== kind));
};
const handleAddAuthor = (pubkey: string) => {
if (!authors.includes(pubkey)) setAuthors([...authors, pubkey]);
};
const handleRemoveAuthor = (pubkey: string) => {
setAuthors(authors.filter((p) => p !== pubkey));
};
const handleAddMention = (pubkey: string) => {
if (!mentions.includes(pubkey)) setMentions([...mentions, pubkey]);
};
const handleRemoveMention = (pubkey: string) => {
setMentions(mentions.filter((p) => p !== pubkey));
};
const handleAddHashtag = (tag: string) => {
const clean = tag.replace(/^#/, "").trim();
if (clean && !hashtags.includes(clean)) setHashtags([...hashtags, clean]);
};
const handleRemoveHashtag = (tag: string) => {
setHashtags(hashtags.filter((t) => t !== tag));
};
const handleAddRelay = (url: string) => {
if (url && !relays.includes(url)) setRelays([...relays, url]);
};
const handleRemoveRelay = (url: string) => {
setRelays(relays.filter((r) => r !== url));
};
const handleAddGenericTag = (letter: string, value: string) => {
if (!letter || !value) return;
const cleanLetter = letter.trim().slice(0, 1);
if (!/[a-zA-Z]/.test(cleanLetter)) return;
setGenericTags((prev) => {
const existing = prev[cleanLetter] || [];
if (existing.includes(value)) return prev;
return {
...prev,
[cleanLetter]: [...existing, value],
};
});
};
const handleRemoveGenericTag = (letter: string, value: string) => {
setGenericTags((prev) => {
const existing = prev[letter] || [];
const filtered = existing.filter((v) => v !== value);
if (filtered.length === 0) {
const { [letter]: _, ...rest } = prev;
return rest;
}
return {
...prev,
[letter]: filtered,
};
});
};
const resetForm = () => {
setKinds([]);
setAuthors([]);
setMentions([]);
setSearch("");
setHashtags([]);
setSince("");
setUntil("");
setRelays([]);
setCloseOnEose(false);
setLimit("");
setGenericTags({});
setActiveTagLetter("e");
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col p-0">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2">
<WandSparkles className="size-5 text-accent" />
Create New Spell
</DialogTitle>
<DialogDescription>
Build a custom view of Nostr events by selecting filters below.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-2 space-y-4">
{/* Kinds Section */}
<CollapsibleSection
title="Event Types"
icon={<FileText className="size-4" />}
defaultOpen={true}
>
<div className="space-y-3">
<KindSelector onSelect={handleAddKind} />
<div className="flex flex-wrap gap-2">
{kinds.map((k) => (
<Badge
key={k}
variant="secondary"
className="flex items-center gap-1.5"
>
<KindBadge
kind={k}
className="border-0 bg-transparent p-0 h-auto"
/>
<button
onClick={() => handleRemoveKind(k)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
{kinds.length === 0 && (
<span className="text-xs text-muted-foreground italic">
All event types (notes, profiles, etc.)
</span>
)}
</div>
</div>
</CollapsibleSection>
{/* Authors Section */}
<CollapsibleSection
title="From People"
icon={<User className="size-4" />}
>
<div className="space-y-3">
<ProfileSelector
onSelect={handleAddAuthor}
placeholder="Add person or $me, $contacts..."
/>
<div className="flex flex-wrap gap-2">
{authors.map((p) => (
<Badge
key={p}
variant="secondary"
className="flex items-center gap-1.5"
>
{p === "$me" || p === "$contacts" ? (
<span className="font-mono font-bold text-accent px-1">
{p}
</span>
) : (
<UserName pubkey={p} className="text-xs" />
)}
<button
onClick={() => handleRemoveAuthor(p)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
{authors.length === 0 && (
<span className="text-xs text-muted-foreground italic">
Anyone on the network
</span>
)}
</div>
</div>
</CollapsibleSection>
{/* Mentions Section */}
<CollapsibleSection
title="Mentioning People"
icon={<AtSign className="size-4" />}
>
<div className="space-y-3">
<ProfileSelector
onSelect={handleAddMention}
placeholder="Add person mentioned..."
/>
<div className="flex flex-wrap gap-2">
{mentions.map((p) => (
<Badge
key={p}
variant="secondary"
className="flex items-center gap-1.5"
>
{p === "$me" || p === "$contacts" ? (
<span className="font-mono font-bold text-accent px-1">
{p}
</span>
) : (
<UserName pubkey={p} className="text-xs" />
)}
<button
onClick={() => handleRemoveMention(p)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
</CollapsibleSection>
{/* Content Section */}
<CollapsibleSection
title="Content & Hashtags"
icon={<Search className="size-4" />}
>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Search Text
</label>
<Input
placeholder="e.g. bitcoin, nostr..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Hashtags
</label>
<div className="flex gap-2">
<Input
placeholder="Add tag (press enter)"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddHashtag(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
</div>
<div className="flex flex-wrap gap-2">
{hashtags.map((t) => (
<Badge
key={t}
variant="secondary"
className="flex items-center gap-1.5"
>
<span className="text-accent">#</span>
{t}
<button
onClick={() => handleRemoveHashtag(t)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
</div>
</CollapsibleSection>
{/* Generic Tags Section */}
<CollapsibleSection
title="Generic Tags"
icon={<Tag className="size-4" />}
>
<div className="space-y-4">
<div className="flex gap-2 items-end">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-muted-foreground">
Letter
</label>
<Input
value={activeTagLetter}
onChange={(e) =>
setActiveTagLetter(e.target.value.trim().slice(0, 1))
}
className="w-12 text-center font-mono font-bold"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] uppercase font-bold text-muted-foreground">
Value
</label>
{activeTagLetter === "k" ? (
<KindSelector
onSelect={(k) => handleAddGenericTag("k", k.toString())}
/>
) : activeTagLetter === "p" || activeTagLetter === "P" ? (
<ProfileSelector
onSelect={(pk) =>
handleAddGenericTag(activeTagLetter, pk)
}
placeholder={`Add ${activeTagLetter} pubkey...`}
/>
) : (
<Input
placeholder="Tag value..."
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddGenericTag(
activeTagLetter,
e.currentTarget.value,
);
e.currentTarget.value = "";
}
}}
/>
)}
</div>
</div>
<div className="space-y-3">
{Object.entries(genericTags).map(([letter, values]) => (
<div key={letter} className="space-y-1">
<div className="text-[10px] uppercase font-bold text-muted-foreground">
#{letter}
</div>
<div className="flex flex-wrap gap-2">
{values.map((val) => (
<Badge
key={val}
variant="secondary"
className="flex items-center gap-1.5"
>
{letter === "k" ? (
<KindBadge
kind={parseInt(val)}
className="border-0 bg-transparent p-0 h-auto"
/>
) : letter === "p" || letter === "P" ? (
val.startsWith("$") ? (
<span className="font-mono font-bold text-accent px-1">
{val}
</span>
) : (
<UserName pubkey={val} className="text-xs" />
)
) : (
<span
className="max-w-[200px] truncate"
title={val}
>
{val}
</span>
)}
<button
onClick={() =>
handleRemoveGenericTag(letter, val)
}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
))}
</div>
</div>
</CollapsibleSection>
{/* Time Section */}
<CollapsibleSection
title="Time Range"
icon={<Clock className="size-4" />}
>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Since
</label>
<Input
placeholder="e.g. 24h, 7d, 1mo"
value={since}
onChange={(e) => setSince(e.target.value)}
/>
<div className="flex flex-wrap gap-1">
{["now", "1h", "24h", "7d", "30d"].map((t) => (
<button
key={t}
onClick={() => setSince(t)}
className="text-[10px] bg-muted px-1.5 py-0.5 rounded hover:bg-accent hover:text-accent-foreground transition-colors"
>
{t}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Until
</label>
<Input
placeholder="e.g. now, 24h"
value={until}
onChange={(e) => setUntil(e.target.value)}
/>
<div className="flex flex-wrap gap-1">
{["now", "1h", "24h", "7d", "30d"].map((t) => (
<button
key={t}
onClick={() => setUntil(t)}
className="text-[10px] bg-muted px-1.5 py-0.5 rounded hover:bg-accent hover:text-accent-foreground transition-colors"
>
{t}
</button>
))}
</div>
</div>
</div>
</CollapsibleSection>
{/* Options Section */}
<CollapsibleSection
title="Advanced Options"
icon={<Wifi className="size-4" />}
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Limit results</label>
<Input
type="number"
value={limit}
onChange={(e) => {
const val = e.target.value;
setLimit(val === "" ? "" : parseInt(val));
}}
placeholder="No limit"
className="w-24"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<label className="text-sm font-medium">
Close after loading
</label>
<p className="text-xs text-muted-foreground">
Don't listen for new events in real-time
</p>
</div>
<input
type="checkbox"
checked={closeOnEose}
onChange={(e) => setCloseOnEose(e.target.checked)}
className="size-4 rounded border-gray-300 text-accent focus:ring-accent"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase text-muted-foreground">
Custom Relays (optional)
</label>
<div className="flex gap-2">
<Input
placeholder="relay.damus.io"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddRelay(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
</div>
<div className="flex flex-wrap gap-2">
{relays.map((r) => (
<Badge
key={r}
variant="outline"
className="flex items-center gap-1.5 font-mono text-[10px]"
>
{r}
<button
onClick={() => handleRemoveRelay(r)}
className="hover:text-accent transition-colors"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
</div>
</div>
</CollapsibleSection>
</div>
<div className="p-6 pt-2 bg-muted/30 border-t space-y-4">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground uppercase">
Generated Command
</label>
<div className="bg-background border rounded-md p-3 font-mono text-xs break-all text-primary">
{generatedCommand}
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={resetForm}>
Reset
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="bg-accent text-accent-foreground hover:bg-accent/90"
onClick={() => setShowSaveDialog(true)}
>
<Plus className="size-4 mr-2" />
Continue to Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Actual saving/publishing dialog */}
{showSaveDialog && (
<SpellDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
mode="create"
initialCommand={generatedCommand}
onSuccess={() => {
setShowSaveDialog(false);
onOpenChange(false);
resetForm();
}}
/>
)}
</>
);
}
function CollapsibleSection({
title,
icon,
children,
defaultOpen = false,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="border rounded-lg overflow-hidden"
>
<CollapsibleTrigger className="flex items-center justify-between w-full px-4 py-3 bg-muted/50 hover:bg-muted transition-colors">
<div className="flex items-center gap-3">
<span className="text-accent">{icon}</span>
<span className="font-semibold text-sm">{title}</span>
</div>
<ChevronDown
className={`size-4 text-muted-foreground transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="p-4 bg-background">
{children}
</CollapsibleContent>
</Collapsible>
);
}
function Badge({
children,
variant = "default",
className = "",
}: {
children: React.ReactNode;
variant?: "default" | "secondary" | "outline";
className?: string;
}) {
const variants = {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
outline: "border border-input bg-background",
};
return (
<div
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${variants[variant]} ${className}`}
>
{children}
</div>
);
}

View File

@@ -237,6 +237,9 @@ function generateRawCommand(appId: string, props: any): string {
case "man":
return props.cmd ? `man ${props.cmd}` : "man";
case "spells":
return "spells";
default:
return appId;
}

View File

@@ -8,6 +8,28 @@ import { useCopy } from "../hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
interface CopyableJsonViewerProps {
json: string;
}
export function CopyableJsonViewer({ json }: CopyableJsonViewerProps) {
const { copy, copied } = useCopy();
const handleCopy = () => {
copy(json);
};
return (
<div className="flex-1 overflow-auto relative">
<SyntaxHighlight
code={json}
language="json"
className="bg-muted p-4 pr-10 overflow-scroll"
/>
<CodeCopyButton onCopy={handleCopy} copied={copied} label="Copy JSON" />
</div>
);
}
interface JsonViewerProps {
data: any;
open: boolean;

View File

@@ -0,0 +1,133 @@
import * as React from "react";
import { ChevronsUpDown, Plus } from "lucide-react";
import { Command } from "cmdk";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { EVENT_KINDS } from "@/constants/kinds";
interface KindSelectorProps {
onSelect: (kind: number) => void;
exclude?: number[];
}
export function KindSelector({ onSelect, exclude = [] }: KindSelectorProps) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [search, setSearch] = React.useState("");
const knownKinds = React.useMemo(() => {
return Object.values(EVENT_KINDS)
.filter((k) => typeof k.kind === "number")
.map((k) => ({
value: k.kind.toString(),
label: k.name,
kind: k.kind as number,
description: k.description,
}))
.filter((k) => !exclude.includes(k.kind))
.sort((a, b) => a.kind - b.kind);
}, [exclude]);
const filteredKinds = React.useMemo(() => {
if (!search) return knownKinds;
const lowerSearch = search.toLowerCase();
return knownKinds.filter(
(k) =>
k.value.includes(lowerSearch) ||
k.label.toLowerCase().includes(lowerSearch) ||
k.description.toLowerCase().includes(lowerSearch),
);
}, [knownKinds, search]);
const isCustomNumber =
search &&
!isNaN(parseInt(search)) &&
!knownKinds.find((k) => k.value === search) &&
!exclude.includes(parseInt(search));
const handleSelect = (currentValue: string) => {
const kind = parseInt(currentValue);
if (!isNaN(kind)) {
onSelect(kind);
setValue("");
setSearch("");
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{value
? knownKinds.find((k) => k.value === value)?.label || value
: "Select kind..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command
className="h-full w-full overflow-hidden rounded-md bg-popover text-popover-foreground"
shouldFilter={false}
loop
>
<div
className="flex items-center border-b px-3"
cmdk-input-wrapper=""
>
<Command.Input
placeholder="Search kind name or number..."
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
value={search}
onValueChange={setSearch}
/>
</div>
<Command.List className="max-h-[300px] overflow-y-auto overflow-x-hidden p-1">
<Command.Empty className="py-6 text-center text-sm">
No kind found.
</Command.Empty>
{isCustomNumber && (
<Command.Item
value={search}
onSelect={() => handleSelect(search)}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30"
>
<Plus className="mr-2 h-4 w-4" />
Add Kind {search}
</Command.Item>
)}
{filteredKinds.map((kind) => (
<Command.Item
key={kind.value}
value={kind.value + " " + kind.label}
onSelect={() => handleSelect(kind.value)}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30"
>
<div className="flex flex-col w-full">
<div className="flex items-center justify-between">
<span className="font-medium">{kind.label}</span>
<span className="text-xs text-muted-foreground font-mono">
{kind.value}
</span>
</div>
<span className="text-xs text-muted-foreground truncate">
{kind.description}
</span>
</div>
</Command.Item>
))}
</Command.List>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,6 +1,8 @@
import { manPages } from "@/types/man";
import { useGrimoire } from "@/core/state";
import { CenteredContent } from "./ui/CenteredContent";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
interface ManPageProps {
cmd: string;
@@ -9,11 +11,13 @@ interface ManPageProps {
/**
* ExecutableCommand - Renders a clickable command that executes when clicked
*/
function ExecutableCommand({
export function ExecutableCommand({
commandLine,
className,
children,
}: {
commandLine: string;
className?: string;
children: React.ReactNode;
}) {
const { addWindow } = useGrimoire();
@@ -35,12 +39,16 @@ function ExecutableCommand({
};
return (
<button
<Button
onClick={handleClick}
className="text-accent font-medium hover:underline cursor-crosshair text-left"
variant="link"
className={cn(
"text-accent font-medium hover:underline cursor-crosshair text-left",
className,
)}
>
{children}
</button>
</Button>
);
}

View File

@@ -0,0 +1,238 @@
import * as React from "react";
import { ChevronsUpDown, User, Users } from "lucide-react";
import { Command } from "cmdk";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import db, { Profile } from "@/services/db";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getTagValues, getDisplayName } from "@/lib/nostr-utils";
interface ProfileSelectorProps {
onSelect: (value: string) => void;
showShortcuts?: boolean;
className?: string;
placeholder?: string;
}
/**
* ProfileSelector - A searchable combobox for Nostr profiles
* Autocompletes from locally cached profiles in IndexedDB
* Supports $me and $contacts shortcuts
*/
export function ProfileSelector({
onSelect,
showShortcuts = true,
className,
placeholder = "Select person...",
}: ProfileSelectorProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [results, setResults] = React.useState<Profile[]>([]);
const { state } = useGrimoire();
const accountPubkey = state.activeAccount?.pubkey;
// Fetch contacts for shortcut count and validation
const contactListEvent = useNostrEvent(
showShortcuts && accountPubkey
? { kind: 3, pubkey: accountPubkey, identifier: "" }
: undefined,
);
const contacts = React.useMemo(
() =>
contactListEvent
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64)
: [],
[contactListEvent],
);
// Search profiles when search changes
React.useEffect(() => {
if (!search || search.length < 1) {
setResults([]);
return;
}
const lowerSearch = search.toLowerCase();
// Query Dexie profiles table
// Note: This is a full scan filter, but acceptable for local cache sizes
db.profiles
.filter((p) => {
const displayName = p.display_name?.toLowerCase() || "";
const name = p.name?.toLowerCase() || "";
const about = p.about?.toLowerCase() || "";
const lud16 = p.lud16?.toLowerCase() || "";
const pubkey = p.pubkey.toLowerCase();
return (
displayName.includes(lowerSearch) ||
name.includes(lowerSearch) ||
about.includes(lowerSearch) ||
lud16.includes(lowerSearch) ||
pubkey.startsWith(lowerSearch)
);
})
.limit(20)
.toArray()
.then((matches) => {
// Sort matches by priority: contacts first, then display_name/name, then about, then lud16
const sorted = matches.sort((a, b) => {
// 0. Contact priority
const aIsContact = contacts.includes(a.pubkey);
const bIsContact = contacts.includes(b.pubkey);
if (aIsContact && !bIsContact) return -1;
if (!aIsContact && bIsContact) return 1;
const aDisplayName = a.display_name?.toLowerCase() || "";
const bDisplayName = b.display_name?.toLowerCase() || "";
const aName = a.name?.toLowerCase() || "";
const bName = b.name?.toLowerCase() || "";
const aAbout = a.about?.toLowerCase() || "";
const bAbout = b.about?.toLowerCase() || "";
const aLud = a.lud16?.toLowerCase() || "";
const bLud = b.lud16?.toLowerCase() || "";
// 1. Display Name / Name priority
const aHasNameMatch =
aDisplayName.includes(lowerSearch) || aName.includes(lowerSearch);
const bHasNameMatch =
bDisplayName.includes(lowerSearch) || bName.includes(lowerSearch);
if (aHasNameMatch && !bHasNameMatch) return -1;
if (!aHasNameMatch && bHasNameMatch) return 1;
// 2. Description (About) priority
const aHasAboutMatch = aAbout.includes(lowerSearch);
const bHasAboutMatch = bAbout.includes(lowerSearch);
if (aHasAboutMatch && !bHasAboutMatch) return -1;
if (!aHasAboutMatch && bHasAboutMatch) return 1;
// 3. Lud16 priority
const aHasLudMatch = aLud.includes(lowerSearch);
const bHasLudMatch = bLud.includes(lowerSearch);
if (aHasLudMatch && !bHasLudMatch) return -1;
if (!aHasLudMatch && bHasLudMatch) return 1;
return 0;
});
setResults(sorted);
});
}, [search, contacts]);
const handleSelect = (value: string) => {
onSelect(value);
setSearch("");
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={`w-full justify-between ${className}`}
>
<span className="truncate">{placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command
className="h-full w-full overflow-hidden rounded-md bg-popover text-popover-foreground"
shouldFilter={false}
loop
>
<div
className="flex items-center border-b px-3"
cmdk-input-wrapper=""
>
<Command.Input
placeholder="Search name, bio, lud16..."
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
value={search}
onValueChange={setSearch}
/>
</div>
<Command.List className="max-h-[300px] overflow-y-auto overflow-x-hidden p-1">
<Command.Empty className="py-6 text-center text-sm">
No profiles found in cache.
</Command.Empty>
{showShortcuts && !search && (
<Command.Group heading="Shortcuts">
{accountPubkey && (
<Command.Item
onSelect={() => handleSelect("$me")}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30"
>
<User className="mr-2 h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium">My Profile</span>
<span className="text-xs text-muted-foreground font-mono">
$me
</span>
</div>
</Command.Item>
)}
{contacts.length > 0 && (
<Command.Item
onSelect={() => handleSelect("$contacts")}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30"
>
<Users className="mr-2 h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium">
My Contacts ({contacts.length})
</span>
<span className="text-xs text-muted-foreground font-mono">
$contacts
</span>
</div>
</Command.Item>
)}
</Command.Group>
)}
{results.length > 0 && (
<Command.Group>
{results.map((profile) => (
<Command.Item
key={profile.pubkey}
onSelect={() => handleSelect(profile.pubkey)}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30"
>
<div className="flex flex-col w-full min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-medium truncate">
{getDisplayName(profile.pubkey, profile)}
</span>
</div>
{profile.about && (
<span className="text-xs text-muted-foreground truncate">
{profile.about}
</span>
)}
{profile.lud16 && (
<span className="text-[10px] text-accent truncate">
{profile.lud16}
</span>
)}
</div>
</Command.Item>
))}
</Command.Group>
)}
</Command.List>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -29,6 +29,7 @@ import { addressLoader } from "@/services/loaders";
import { relayListCache } from "@/services/relay-list-cache";
import { useEffect } from "react";
import type { Subscription } from "rxjs";
import { useGrimoire } from "@/core/state";
export interface ProfileViewerProps {
pubkey: string;
@@ -39,7 +40,13 @@ export interface ProfileViewerProps {
* Shows profile metadata, inbox/outbox relays, and raw JSON
*/
export function ProfileViewer({ pubkey }: ProfileViewerProps) {
const profile = useProfile(pubkey);
const { state } = useGrimoire();
const accountPubkey = state.activeAccount?.pubkey;
// Resolve $me alias
const resolvedPubkey = pubkey === "$me" ? accountPubkey : pubkey;
const profile = useProfile(resolvedPubkey);
const eventStore = useEventStore();
const { copy, copied } = useCopy();
const { relays: relayStates } = useRelayState();
@@ -47,20 +54,21 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
// Fetch fresh relay list from network only if not cached or stale
useEffect(() => {
let subscription: Subscription | null = null;
if (!resolvedPubkey) return;
// Check if we have a valid cached relay list
relayListCache.has(pubkey).then(async (hasCached) => {
relayListCache.has(resolvedPubkey).then(async (hasCached) => {
if (hasCached) {
console.debug(
`[ProfileViewer] Using cached relay list for ${pubkey.slice(0, 8)}`,
`[ProfileViewer] Using cached relay list for ${resolvedPubkey.slice(0, 8)}`,
);
// Load cached event into EventStore so UI can display it
const cached = await relayListCache.get(pubkey);
const cached = await relayListCache.get(resolvedPubkey);
if (cached?.event) {
eventStore.add(cached.event);
console.debug(
`[ProfileViewer] Loaded cached relay list into EventStore for ${pubkey.slice(0, 8)}`,
`[ProfileViewer] Loaded cached relay list into EventStore for ${resolvedPubkey.slice(0, 8)}`,
);
}
return;
@@ -68,16 +76,16 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
// No cached or stale - fetch fresh from network
console.debug(
`[ProfileViewer] Fetching fresh relay list for ${pubkey.slice(0, 8)}`,
`[ProfileViewer] Fetching fresh relay list for ${resolvedPubkey.slice(0, 8)}`,
);
subscription = addressLoader({
kind: kinds.RelayList,
pubkey,
pubkey: resolvedPubkey,
identifier: "",
}).subscribe({
error: (err) => {
console.debug(
`[ProfileViewer] Failed to fetch relay list for ${pubkey.slice(0, 8)}:`,
`[ProfileViewer] Failed to fetch relay list for ${resolvedPubkey.slice(0, 8)}:`,
err,
);
},
@@ -89,12 +97,15 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
subscription.unsubscribe();
}
};
}, [pubkey]);
}, [resolvedPubkey, eventStore]);
// Get mailbox relays (kind 10002) - will update when fresh data arrives
const mailboxEvent = useObservableMemo(
() => eventStore.replaceable(kinds.RelayList, pubkey, ""),
[eventStore, pubkey],
() =>
resolvedPubkey
? eventStore.replaceable(kinds.RelayList, resolvedPubkey, "")
: undefined,
[eventStore, resolvedPubkey],
);
const inboxRelays =
mailboxEvent && mailboxEvent.tags ? getInboxes(mailboxEvent) : [];
@@ -103,8 +114,11 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
// Get profile metadata event (kind 0)
const profileEvent = useObservableMemo(
() => eventStore.replaceable(0, pubkey, ""),
[eventStore, pubkey],
() =>
resolvedPubkey
? eventStore.replaceable(0, resolvedPubkey, "")
: undefined,
[eventStore, resolvedPubkey],
);
// Combine all relays (inbox + outbox) for nprofile
@@ -117,12 +131,35 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
// Generate npub or nprofile depending on relay availability
const identifier =
allRelays.length > 0
resolvedPubkey && allRelays.length > 0
? nip19.nprofileEncode({
pubkey,
pubkey: resolvedPubkey,
relays: allRelays,
})
: nip19.npubEncode(pubkey);
: resolvedPubkey
? nip19.npubEncode(resolvedPubkey)
: "";
if (pubkey === "$me" && !accountPubkey) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
<div className="text-muted-foreground">
<UserIcon className="size-12 mx-auto mb-3" />
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
<p className="text-sm max-w-md">
The <code className="bg-muted px-1.5 py-0.5">$me</code> alias
requires an active account. Please log in to view your profile.
</p>
</div>
</div>
);
}
if (!resolvedPubkey) {
return (
<div className="p-4 text-muted-foreground">Invalid profile pubkey.</div>
);
}
return (
<div className="flex flex-col h-full overflow-hidden">

View File

@@ -2,6 +2,7 @@ import { useState, memo, useCallback, useMemo, useRef, useEffect } from "react";
import {
ChevronDown,
ChevronRight,
ChevronUp,
Radio,
FileText,
Wifi,
@@ -678,9 +679,8 @@ export default function ReqViewer({
// Memoize fallbackRelays to prevent re-creation on every render
const fallbackRelays = useMemo(
() =>
state.activeAccount?.relays
?.filter((r) => r.read)
.map((r) => r.url) || AGGREGATOR_RELAYS,
state.activeAccount?.relays?.filter((r) => r.read).map((r) => r.url) ||
AGGREGATOR_RELAYS,
[state.activeAccount?.relays],
);
@@ -748,42 +748,53 @@ export default function ReqViewer({
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
// Virtuoso scroll position preservation for prepending events
const STARTING_INDEX = 100000;
const [firstItemIndex, setFirstItemIndex] = useState(STARTING_INDEX);
const seenEventIdsRef = useRef<Set<string>>(new Set());
// Freeze timeline after EOSE to prevent auto-scrolling on new events
const [freezePoint, setFreezePoint] = useState<string | null>(null);
const [isFrozen, setIsFrozen] = useState(false);
const virtuosoRef = useRef<any>(null);
// Adjust firstItemIndex when new events are prepended to preserve scroll position
// Uses Set-based tracking to handle rapid batches correctly
// Freeze timeline after EOSE in streaming mode
useEffect(() => {
// Reset on query change (events cleared)
// Freeze after EOSE in streaming mode
if (eoseReceived && stream && !isFrozen && events.length > 0) {
setFreezePoint(events[0].id);
setIsFrozen(true);
}
// Reset freeze on query change (events cleared)
if (events.length === 0) {
seenEventIdsRef.current = new Set();
setFirstItemIndex(STARTING_INDEX);
return;
setFreezePoint(null);
setIsFrozen(false);
}
}, [eoseReceived, stream, isFrozen, events]);
// Filter events based on freeze point
const { visibleEvents, newEventCount } = useMemo(() => {
if (!isFrozen || !freezePoint) {
return { visibleEvents: events, newEventCount: 0 };
}
// Find new events at the start of the array (prepended)
// This approach is immune to rapid updates because we track ALL seen IDs cumulatively
let prependCount = 0;
for (let i = 0; i < events.length; i++) {
const event = events[i];
if (!seenEventIdsRef.current.has(event.id)) {
// New event found at position i
prependCount++;
seenEventIdsRef.current.add(event.id);
} else {
// Found first existing event, stop counting
// All events after this are old (already seen)
break;
}
}
const freezeIndex = events.findIndex((e) => e.id === freezePoint);
return freezeIndex === -1
? { visibleEvents: events, newEventCount: 0 }
: {
visibleEvents: events.slice(freezeIndex),
newEventCount: freezeIndex,
};
}, [events, isFrozen, freezePoint]);
// Adjust index only in streaming mode after EOSE
if (prependCount > 0 && stream && eoseReceived) {
setFirstItemIndex((prev) => prev - prependCount);
}
}, [events, stream, eoseReceived]);
// Unfreeze handler - show new events and scroll to top
const handleUnfreeze = useCallback(() => {
setIsFrozen(false);
setFreezePoint(null);
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: 0,
align: "start",
behavior: "smooth",
});
});
}, []);
/**
* Export events to JSONL format with chunked processing for large datasets
@@ -1145,7 +1156,21 @@ export default function ReqViewer({
{/* Results */}
{(!needsAccount || accountPubkey) && (
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto relative">
{/* Floating "New Events" Button */}
{isFrozen && newEventCount > 0 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10">
<Button
onClick={handleUnfreeze}
className="shadow-lg bg-accent text-accent-foreground opacity-100 hover:bg-accent"
size="sm"
>
<ChevronUp className="size-4 mr-2" />
{newEventCount} new event{newEventCount !== 1 ? "s" : ""}
</Button>
</div>
)}
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
<div className="p-4">
@@ -1167,11 +1192,11 @@ export default function ReqViewer({
</div>
)}
{events.length > 0 && (
{visibleEvents.length > 0 && (
<Virtuoso
ref={virtuosoRef}
style={{ height: "100%" }}
data={events}
firstItemIndex={firstItemIndex}
data={visibleEvents}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, event) => (
<MemoizedFeedEvent event={event} />

View File

@@ -0,0 +1,113 @@
import { useGrimoire } from "@/core/state";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { X } from "lucide-react";
import { KindSelector } from "./KindSelector";
import { getKindName } from "@/constants/kinds";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function SettingsDialog({
open,
onOpenChange,
}: SettingsDialogProps) {
const { state, setCompactModeKinds } = useGrimoire();
const compactKinds = state.compactModeKinds || [];
const removeKind = (kindToRemove: number) => {
setCompactModeKinds(compactKinds.filter((k) => k !== kindToRemove));
};
const addKind = (kind: number) => {
if (!compactKinds.includes(kind)) {
setCompactModeKinds([...compactKinds, kind].sort((a, b) => a - b));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Manage your workspace preferences.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="appearance" className="flex flex-col h-full">
<div className="px-6 py-2 border-b">
<TabsList className="w-full justify-start">
<TabsTrigger value="appearance" className="flex-1">
Appearance
</TabsTrigger>
{/* Future tabs can be added here */}
</TabsList>
</div>
<div className="flex-1 overflow-y-auto p-6">
<TabsContent value="appearance" className="space-y-6 m-0">
{/* Section: Compact Events */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium">Compact Events</h3>
<p className="text-sm text-muted-foreground">
Select event kinds to display in a compact format within
timelines and feeds.
</p>
</div>
<div className="max-w-sm">
<KindSelector onSelect={addKind} exclude={compactKinds} />
</div>
<div className="border rounded-lg p-4 bg-muted/30">
<div className="flex flex-wrap gap-2">
{compactKinds.length === 0 && (
<span className="text-sm text-muted-foreground italic">
No compact kinds configured.
</span>
)}
{compactKinds.map((kind) => (
<Badge
key={kind}
variant="secondary"
className="pl-2 pr-1 py-1 flex items-center gap-1 hover:bg-background border transition-colors"
>
<span className="text-muted-foreground font-mono text-xs">
{kind}
</span>
<span>{getKindName(kind)}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 ml-1 -mr-0.5 hover:bg-destructive/10 hover:text-destructive rounded-full"
onClick={() => removeKind(kind)}
>
<X className="h-3 w-3" />
<span className="sr-only">Remove Kind {kind}</span>
</Button>
</Badge>
))}
</div>
</div>
</div>
</TabsContent>
</div>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,419 @@
import { useState, useMemo } from "react";
import {
Search,
WandSparkles,
Trash2,
Send,
Cloud,
Lock,
Loader2,
RefreshCw,
Archive,
Users,
WandSparkles as Wand,
} 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 { deleteSpell } from "@/services/spell-storage";
import type { LocalSpell } from "@/services/db";
import { ExecutableCommand } from "./ManPage";
import { PublishSpellAction } from "@/actions/publish-spell";
import { DeleteEventAction } from "@/actions/delete-event";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { KindBadge } from "@/components/KindBadge";
import { parseReqCommand } from "@/lib/req-parser";
import { CreateSpellDialog } from "./CreateSpellDialog";
interface SpellCardProps {
spell: LocalSpell;
onDelete: (spell: LocalSpell) => Promise<void>;
onPublish: (spell: LocalSpell) => Promise<void>;
}
function SpellCard({ spell, onDelete, onPublish }: SpellCardProps) {
const [isPublishing, setIsPublishing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const displayName = spell.name || spell.alias || "Untitled Spell";
const kinds = useMemo(() => {
try {
const commandWithoutReq = spell.command.replace(/^\s*req\s+/, "");
const tokens = commandWithoutReq.split(/\s+/);
const parsed = parseReqCommand(tokens);
return parsed.filter.kinds || [];
} catch {
return [];
}
}, [spell.command]);
const handlePublish = async () => {
setIsPublishing(true);
try {
await onPublish(spell);
} finally {
setIsPublishing(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDelete(spell);
} finally {
setIsDeleting(false);
}
};
return (
<Card
className={cn(
"group flex flex-col h-full transition-opacity",
spell.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">
<WandSparkles className="size-4 flex-shrink-0 text-muted-foreground mt-0.5" />
<CardTitle className="text-xl truncate" title={displayName}>
{displayName}
</CardTitle>
</div>
{spell.deletedAt ? (
<Badge variant="outline" className="text-muted-foreground">
<Archive className="size-3 mr-1" />
</Badge>
) : spell.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>
{spell.description && (
<CardDescription className="text-sm line-clamp-2">
{spell.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<div className="flex flex-col gap-2">
<ExecutableCommand
commandLine={spell.command}
className="text-xs truncate line-clamp-1 text-primary hover:underline cursor-pointer"
>
{spell.command}
</ExecutableCommand>
<div className="flex flex-wrap gap-1.5 mt-1">
{kinds.map((kind) => (
<KindBadge
key={kind}
kind={kind}
variant="compact"
className="text-[10px]"
clickable
/>
))}
{spell.alias && (
<div className="text-[10px] font-mono opacity-50 ml-auto">
Alias: {spell.alias}
</div>
)}
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex-wrap gap-2 justify-between">
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={handleDelete}
disabled={isPublishing || isDeleting || !!spell.deletedAt}
>
{isDeleting ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<Trash2 className="size-3.5 mr-1" />
)}
{spell.deletedAt ? "Deleted" : "Delete"}
</Button>
{!spell.deletedAt && (
<Button
size="sm"
variant={spell.isPublished ? "outline" : "default"}
className="h-8"
onClick={handlePublish}
disabled={isPublishing || isDeleting}
>
{isPublishing ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : spell.isPublished ? (
<RefreshCw className="size-3.5 mr-1" />
) : (
<Send className="size-3.5 mr-1" />
)}
{isPublishing
? spell.isPublished
? "Rebroadcasting..."
: "Publishing..."
: spell.isPublished
? "Rebroadcast"
: "Publish"}
</Button>
)}
</CardFooter>
</Card>
);
}
/**
* SpellsViewer - Browse and manage saved spells
* Shows both local and published spells with search/filter capabilities
*/
export function SpellsViewer() {
const { state } = useGrimoire();
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<"all" | "local" | "published">(
"all",
);
const [isCreateOpen, setIsCreateOpen] = useState(false);
// Load spells from storage with live query
const spells =
useLiveQuery(() => db.spells.orderBy("createdAt").reverse().toArray()) ||
[];
const loading = spells === undefined;
// Filter and sort spells
const filteredSpells = useMemo(() => {
let filtered = [...spells];
// Filter by type
if (filterType === "local") {
filtered = filtered.filter((s) => !s.isPublished && !s.deletedAt);
} else if (filterType === "published") {
filtered = filtered.filter((s) => s.isPublished && !s.deletedAt);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(s) =>
s.name?.toLowerCase().includes(query) ||
s.alias?.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query) ||
s.command.toLowerCase().includes(query),
);
}
// Sort: non-deleted first, then by createdAt descending
return filtered.sort((a, b) => {
if (!!a.deletedAt !== !!b.deletedAt) {
return a.deletedAt ? 1 : -1;
}
return b.createdAt - a.createdAt;
});
}, [spells, searchQuery, filterType]);
// Handle deleting a spell
const handleDeleteSpell = async (spell: LocalSpell) => {
const isPublic = spell.isPublished && spell.eventId;
const confirmMsg = isPublic
? `Are you sure you want to delete "${spell.name || spell.alias || "this spell"}"? This will also send a deletion request to Nostr relays.`
: `Are you sure you want to delete "${spell.name || spell.alias || "this spell"}"?`;
if (!confirm(confirmMsg)) {
return;
}
try {
// 1. If published, send Nostr Kind 5
if (isPublic && spell.event) {
toast.promise(
new DeleteEventAction().execute(spell, "Deleted by user in Grimoire"),
{
loading: "Sending Nostr deletion request...",
success: "Deletion request broadcasted",
error: "Failed to broadcast deletion request",
},
);
}
// 2. Mark as deleted in local DB
await deleteSpell(spell.id);
toast.success(`"${spell.name || spell.alias || "spell"}" archived`);
} catch (error) {
console.error("Failed to delete spell:", error);
toast.error("Failed to delete spell");
}
};
const handlePublishSpell = async (spell: LocalSpell) => {
try {
const action = new PublishSpellAction();
const writeRelays =
state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) ||
[];
await action.execute(spell, writeRelays);
toast.success(
spell.isPublished
? `Rebroadcasted "${spell.name || spell.alias || "spell"}"`
: `Published "${spell.name || spell.alias || "spell"}"`,
);
} catch (error) {
console.error("Failed to publish spell:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish spell",
);
throw error; // Re-throw to let the card know it failed
}
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<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">
<WandSparkles className="size-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">Spells</h2>
<Badge variant="secondary" className="ml-2">
{filteredSpells.length}
</Badge>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setIsCreateOpen(true)}
>
<Wand className="size-4 mr-1.5" />
Create Spell
</Button>
</div>
{/* Search and filters */}
<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 spells..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Type filter buttons */}
<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>
{!loading && searchQuery === "" && filterType !== "local" && (
<div className="px-4 py-3 border-b border-border bg-accent/5 flex flex-col md:flex-row md:items-center justify-between gap-4 shrink-0">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-sm font-semibold text-accent">
<Users className="size-4" />
Network Spells
</div>
<p className="text-xs text-muted-foreground">
Browse spells published by your contacts.
</p>
</div>
<ExecutableCommand
commandLine="req -k 777 -a $contacts"
className="text-xs font-mono px-3 py-2 bg-background border border-border rounded-md text-primary hover:underline cursor-pointer transition-colors hover:border-accent/50 h-auto"
>
req -k 777 -a $contacts
</ExecutableCommand>
</div>
)}
{/* Spell list */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<WandSparkles className="size-8 mx-auto mb-2 animate-pulse" />
<p>Loading spells...</p>
</div>
</div>
) : filteredSpells.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center max-w-md">
<WandSparkles className="size-12 mx-auto mb-3 opacity-50" />
<h3 className="text-lg font-semibold mb-2">No spells found</h3>
<p className="text-sm mb-4">
{searchQuery
? "Try a different search query"
: "Create your first spell from any REQ window"}
</p>
<p className="text-xs">
Open a REQ window and click the "Save as Spell" button to create
a spell
</p>
</div>
</div>
) : (
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{filteredSpells.map((spell) => (
<SpellCard
key={spell.id}
spell={spell}
onDelete={handleDeleteSpell}
onPublish={handlePublishSpell}
/>
))}
</div>
)}
</div>
<CreateSpellDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
</div>
);
}

View File

@@ -98,7 +98,7 @@ export function TabBar() {
return (
<>
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto no-scrollbar">
{/* Left side: Workspace tabs + new workspace button */}
<Reorder.Group
axis="x"

View File

@@ -27,6 +27,9 @@ const DebugViewer = lazy(() =>
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
);
const ConnViewer = lazy(() => import("./ConnViewer"));
const SpellsViewer = lazy(() =>
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -162,6 +165,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "conn":
content = <ConnViewer />;
break;
case "spells":
content = <SpellsViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -1,8 +1,18 @@
import { X, Pencil } from "lucide-react";
import { X, Pencil, MoreVertical, WandSparkles } from "lucide-react";
import { useSetAtom } from "jotai";
import { useState } from "react";
import { WindowInstance } from "@/types/app";
import { commandLauncherEditModeAtom } from "@/core/command-launcher-state";
import { reconstructCommand } from "@/lib/command-reconstructor";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SpellDialog } from "@/components/nostr/SpellDialog";
import { reconstructCommand as reconstructReqCommand } from "@/lib/spell-conversion";
import { toast } from "sonner";
interface WindowToolbarProps {
window?: WindowInstance;
@@ -16,6 +26,7 @@ export function WindowToolbar({
onEditCommand,
}: WindowToolbarProps) {
const setEditMode = useSetAtom(commandLauncherEditModeAtom);
const [showSpellDialog, setShowSpellDialog] = useState(false);
const handleEdit = () => {
if (!window) return;
@@ -35,23 +46,88 @@ export function WindowToolbar({
}
};
const handleTurnIntoSpell = () => {
if (!window) return;
// Only available for REQ windows
if (window.appId !== "req") {
toast.error("Only REQ windows can be turned into spells");
return;
}
setShowSpellDialog(true);
};
// Check if this is a REQ window for spell creation
const isReqWindow = window?.appId === "req";
// Get REQ command for spell dialog
const reqCommand =
isReqWindow && window
? window.commandString ||
reconstructReqCommand(
window.props?.filter || {},
window.props?.relays,
undefined,
undefined,
window.props?.closeOnEose,
)
: "";
return (
<>
{window && (
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={handleEdit}
title="Edit command"
aria-label="Edit command"
>
<Pencil className="size-4" />
</button>
<>
{/* Edit button with keyboard shortcut hint */}
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={handleEdit}
title="Edit command (Cmd+E)"
aria-label="Edit command"
>
<Pencil className="size-4" />
</button>
{/* More actions menu - only for REQ windows for now */}
{isReqWindow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="More actions"
aria-label="More actions"
>
<MoreVertical className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleTurnIntoSpell}>
<WandSparkles className="size-4 mr-2" />
Save as spell
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Spell Dialog */}
{isReqWindow && (
<SpellDialog
open={showSpellDialog}
onOpenChange={setShowSpellDialog}
mode="create"
initialCommand={reqCommand}
onSuccess={() => {
toast.success("Spell published successfully!");
}}
/>
)}
</>
)}
{onClose && (
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={onClose}
title="Close window"
title="Close window (Cmd+W)"
aria-label="Close window"
>
<X className="size-4" />

View File

@@ -0,0 +1,444 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useObservableMemo } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import pool from "@/services/relay-pool";
import eventStore from "@/services/event-store";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { encodeSpell } from "@/lib/spell-conversion";
import { parseReqCommand } from "@/lib/req-parser";
import { reconstructCommand } from "@/lib/spell-conversion";
import type { ParsedSpell, SpellEvent } from "@/types/spell";
import type { NostrEvent } from "@/types/nostr";
import { useGrimoire } from "@/core/state";
import { Loader2 } from "lucide-react";
import { saveSpell } from "@/services/spell-storage";
import { LocalSpell } from "@/services/db";
import { relayListCache } from "@/services/relay-list-cache";
import { mergeRelaySets } from "applesauce-core/helpers";
/**
* Filter command to show only spell-relevant parts
* Removes global flags like --title that don't affect the filter
*/
function filterSpellCommand(command: string): string {
if (!command) return "";
try {
// Parse the command
const commandWithoutReq = command.replace(/^\s*req\s+/, "");
const tokens = commandWithoutReq.split(/\s+/);
// Parse to get filter and relays
const parsed = parseReqCommand(tokens);
// Reconstruct with only filter-relevant parts
return reconstructCommand(
parsed.filter,
parsed.relays,
undefined,
undefined,
parsed.closeOnEose,
);
} catch {
// If parsing fails, return original
return command;
}
}
interface SpellDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "create" | "edit";
initialCommand?: string;
existingSpell?: ParsedSpell | LocalSpell;
onSuccess?: (event: SpellEvent | null) => void;
}
type PublishingState =
| "idle"
| "validating"
| "signing"
| "publishing"
| "saving"
| "error";
export function SpellDialog({
open,
onOpenChange,
mode,
initialCommand = "",
existingSpell,
onSuccess,
}: SpellDialogProps) {
const { state } = useGrimoire();
const activeAccount = useObservableMemo(() => accounts.active$, []);
// Form state
const [alias, setAlias] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// Publishing/saving state
const [publishingState, setPublishingState] =
useState<PublishingState>("idle");
const [errorMessage, setErrorMessage] = useState<string>("");
// Initialize form from existing spell in edit mode
useEffect(() => {
if (mode === "edit" && existingSpell) {
setAlias("alias" in existingSpell ? existingSpell.alias || "" : "");
setName(existingSpell.name || "");
setDescription(existingSpell.description || "");
} else if (mode === "create") {
// Reset form for create mode
setAlias("");
setName("");
setDescription("");
}
}, [mode, existingSpell, open]);
// Form is always valid (all fields optional)
const isFormValid = true;
// Reset form and close dialog
const handleClose = () => {
if (
publishingState === "signing" ||
publishingState === "publishing" ||
publishingState === "saving"
) {
// Prevent closing during critical operations
return;
}
setAlias("");
setName("");
setDescription("");
setPublishingState("idle");
setErrorMessage("");
onOpenChange(false);
};
// Handle local save (no publishing)
const handleSaveLocally = async () => {
try {
setPublishingState("saving");
setErrorMessage("");
// Get command (from initialCommand or existing spell)
const command =
mode === "edit" && existingSpell
? existingSpell.command
: initialCommand;
if (!command) {
throw new Error("No command provided");
}
// Save to local storage
await saveSpell({
alias: alias.trim() || undefined,
name: name.trim() || undefined,
command,
description: description.trim() || undefined,
isPublished: false,
});
// Success!
setPublishingState("idle");
// Call success callback
if (onSuccess) {
onSuccess(null);
}
const spellLabel = alias.trim() || name.trim() || "Spell";
toast.success(`${spellLabel} saved locally!`, {
description: "Your spell has been saved to local storage.",
});
// Close dialog
handleClose();
} catch (error) {
console.error("Failed to save spell locally:", error);
setPublishingState("error");
if (error instanceof Error) {
setErrorMessage(error.message);
} else {
setErrorMessage("Failed to save spell. Please try again.");
}
toast.error("Failed to save spell", {
description: errorMessage || "An unexpected error occurred.",
});
}
};
// Handle form submission (publish to Nostr)
const handlePublish = async () => {
if (!isFormValid) return;
// Check for active account
if (!activeAccount) {
setErrorMessage("No active account. Please sign in first.");
setPublishingState("error");
return;
}
try {
setPublishingState("validating");
setErrorMessage("");
// Get command (from initialCommand or existing spell)
const command =
mode === "edit" && existingSpell
? existingSpell.command
: initialCommand;
if (!command) {
throw new Error("No command provided");
}
// Encode spell (name and description optional, published to Nostr)
const encoded = encodeSpell({
command,
name: name.trim() || undefined,
description: description.trim() || undefined,
});
// Create unsigned event
const unsignedEvent: Omit<NostrEvent, "id" | "sig"> = {
kind: 777,
pubkey: activeAccount.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: encoded.tags,
content: encoded.content,
};
// Sign event
setPublishingState("signing");
const signedEvent = await activeAccount.signer.sign(
unsignedEvent as NostrEvent,
);
// Get write relays
const authorWriteRelays =
(await relayListCache.getOutboxRelays(activeAccount.pubkey)) || [];
const stateWriteRelays =
state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) ||
[];
// Combine all relay sources
const writeRelays = mergeRelaySets(
encoded.relays || [],
authorWriteRelays,
stateWriteRelays,
AGGREGATOR_RELAYS,
);
// Publish event
setPublishingState("publishing");
await pool.publish(writeRelays, signedEvent);
// Add to event store for immediate availability
eventStore.add(signedEvent);
// Save locally with alias and event ID
await saveSpell({
alias: alias.trim() || undefined,
name: name.trim() || undefined,
command,
description: description.trim() || undefined,
isPublished: true,
eventId: signedEvent.id,
});
// Success!
setPublishingState("idle");
const spellLabel = alias.trim() || name.trim() || "Spell";
toast.success(`${spellLabel} published!`, {
description: `Your spell has been saved and published to ${writeRelays.length} relay${writeRelays.length > 1 ? "s" : ""}.`,
});
// Call success callback
if (onSuccess) {
onSuccess(signedEvent as SpellEvent);
}
// Close dialog
handleClose();
} catch (error) {
console.error("Failed to publish spell:", error);
setPublishingState("error");
// Handle specific errors
if (error instanceof Error) {
if (error.message.includes("User rejected")) {
setErrorMessage("Signing was rejected. Please try again.");
} else if (error.message.includes("No command provided")) {
setErrorMessage(
"No command to save. Please try again from a REQ window.",
);
} else {
setErrorMessage(error.message);
}
} else {
setErrorMessage("Failed to publish spell. Please try again.");
}
toast.error("Failed to publish spell", {
description: errorMessage || "An unexpected error occurred.",
});
}
};
const isBusy =
publishingState === "signing" ||
publishingState === "publishing" ||
publishingState === "saving";
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Save as Spell" : "Edit Spell"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Save this REQ command as a spell. You can save it locally or publish it to Nostr relays."
: "Edit your spell and republish it to relays."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Alias field - local only */}
<div className="grid gap-2">
<label htmlFor="alias" className="text-sm font-medium">
Alias{" "}
<span className="text-muted-foreground text-xs">
(optional, local-only)
</span>
</label>
<Input
id="alias"
placeholder="btc"
value={alias}
onChange={(e) => setAlias(e.target.value)}
disabled={isBusy}
/>
<p className="text-muted-foreground text-xs">
Quick name for running this spell (not published)
</p>
</div>
{/* Name field - published */}
<div className="grid gap-2">
<label htmlFor="name" className="text-sm font-medium">
Name{" "}
<span className="text-muted-foreground text-xs">
(optional, published)
</span>
</label>
<Input
id="name"
placeholder="Bitcoin Feed"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isBusy}
/>
<p className="text-muted-foreground text-xs">
Public spell name (shown to others if published)
</p>
</div>
{/* Description field */}
<div className="grid gap-2">
<label htmlFor="description" className="text-sm font-medium">
Description{" "}
<span className="text-muted-foreground text-xs">
(optional, published)
</span>
</label>
<Textarea
id="description"
placeholder="Notes from the last 7 days about Bitcoin"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isBusy}
rows={3}
/>
</div>
{/* Command display (read-only, filtered to show only spell parts) */}
<div className="grid gap-2">
<label htmlFor="command" className="text-sm font-medium">
Command
</label>
<div className="rounded-md border border-input bg-muted px-3 py-2 text-sm font-mono">
{filterSpellCommand(
mode === "edit" && existingSpell
? existingSpell.command
: initialCommand || "",
) || "(no filter)"}
</div>
</div>
{/* Error message */}
{publishingState === "error" && errorMessage && (
<div className="rounded-md border border-red-500 bg-red-50 dark:bg-red-950/20 px-3 py-2 text-sm text-red-600 dark:text-red-400">
{errorMessage}
</div>
)}
{/* No account warning */}
{!activeAccount && (
<div className="rounded-md border border-yellow-500 bg-yellow-50 dark:bg-yellow-950/20 px-3 py-2 text-sm text-yellow-600 dark:text-yellow-400">
You need to sign in to publish spells.
</div>
)}
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={handleSaveLocally}
disabled={!isFormValid || isBusy}
>
{publishingState === "saving" && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{publishingState === "saving" ? "Saving..." : "Save Locally"}
</Button>
<Button
onClick={handlePublish}
disabled={!isFormValid || !activeAccount || isBusy}
>
{(publishingState === "signing" ||
publishingState === "publishing") && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{publishingState === "signing" && "Signing..."}
{publishingState === "publishing" && "Publishing..."}
{publishingState !== "signing" &&
publishingState !== "publishing" &&
(mode === "create" ? "Save & Publish" : "Update Spell")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { NostrEvent } from "@/types/nostr";
import { UserName } from "../UserName";
import { KindBadge } from "@/components/KindBadge";
// import { kinds } from "nostr-tools";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,6 +20,11 @@ import { nip19 } from "nostr-tools";
import { getTagValue } from "applesauce-core/helpers";
import { EventFooter } from "@/components/EventFooter";
import { cn } from "@/lib/utils";
// import { RichText } from "../RichText";
// import { getEventReply } from "@/lib/nostr-utils";
// import { useNostrEvent } from "@/hooks/useNostrEvent";
// import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
// import { Skeleton } from "@/components/ui/skeleton";
// NIP-01 Kind ranges
const REPLACEABLE_START = 10000;
@@ -51,13 +57,56 @@ export interface BaseEventProps {
export function EventAuthor({
pubkey,
label: _label,
className,
}: {
pubkey: string;
label?: string;
className?: string;
}) {
return <UserName pubkey={pubkey} className="text-md" />;
return <UserName pubkey={pubkey} className={cn("text-md", className)} />;
}
/**
* Preview component for a replied-to event in compact mode
*/
/*
function ReplyPreview({
pointer,
onClick,
}: {
pointer: EventPointer | AddressPointer;
onClick: (e: React.MouseEvent) => void;
}) {
const event = useNostrEvent(pointer);
if (!event) {
return (
<div className="flex items-center gap-1.5">
<Skeleton className="h-3.5 w-3.5 rounded-sm opacity-50" />
<Skeleton className="h-3 w-16 opacity-50" />
</div>
);
}
return (
<div
className="flex items-center gap-1.5 text-inherit flex-1 cursor-crosshair hover:underline hover:decoration-dotted line-clamp-1 truncate text-sm"
onClick={onClick}
>
<UserName pubkey={event.pubkey} className="font-medium" />
<RichText
className="truncate line-clamp-1"
event={event}
options={{
showEventEmbeds: false,
showMedia: false,
}}
/>
</div>
);
}
*/
/**
* Event menu - universal actions for any event
*/
@@ -252,8 +301,12 @@ export function BaseEventContainer({
label?: string;
};
}) {
// Format relative time for display
// const { addWindow } = useGrimoire();
const { locale } = useGrimoire();
// const compactModeKinds = state.compactModeKinds || [];
// const isCompact = compactModeKinds.includes(event.kind);
// Format relative time for display
const relativeTime = formatTimestamp(
event.created_at,
"relative",
@@ -270,6 +323,59 @@ export function BaseEventContainer({
// Use author override if provided, otherwise use event author
const displayPubkey = authorOverride?.pubkey || event.pubkey;
/*
if (isCompact) {
const reply = getEventReply(event);
const handleReplyClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!reply) return;
// Type guard to check if it's an AddressPointer (has 'kind' property)
const pointer = reply.pointer;
if ("kind" in pointer) {
addWindow("open", { pointer: pointer });
} else {
addWindow("open", { pointer: { id: pointer.id } });
}
};
return (
<div className="flex flex-row items-center gap-2 p-3 py-0">
<EventAuthor pubkey={displayPubkey} className="" />
{event.kind === kinds.Zap ? (
<div className="flex items-center gap-1">
<Zap className="size-4 text-amber-300" />
<span>{(getZapAmount(event) || 0) / 1000}</span>
</div>
) : [kinds.Repost, kinds.GenericRepost].includes(event.kind) ? (
<KindBadge kind={event.kind} variant="compact" />
) : event.content ? (
<RichText
event={event}
className="truncate line-clamp-1 text-sm"
options={{
showMedia: false,
showEventEmbeds: false,
}}
/>
) : (
<KindBadge kind={event.kind} variant="compact" />
)}
{reply && (
<ReplyPreview pointer={reply.pointer} onClick={handleReplyClick} />
)}
<span
className="text-xs text-muted-foreground/70 whitespace-nowrap ml-auto"
title={absoluteTime}
>
{relativeTime}
</span>
</div>
);
}
*/
return (
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
<div className="flex flex-row justify-between items-center">

View File

@@ -0,0 +1,316 @@
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { decodeSpell } from "@/lib/spell-conversion";
import { ExecutableCommand } from "../../ManPage";
import { Badge } from "@/components/ui/badge";
import { KindBadge } from "@/components/KindBadge";
import { SpellEvent } from "@/types/spell";
import { CopyableJsonViewer } from "@/components/JsonViewer";
import { User, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
import { useProfile } from "@/hooks/useProfile";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getDisplayName } from "@/lib/nostr-utils";
/**
* Visual placeholder for $me
*/
export function MePlaceholder({
size = "sm",
className,
pubkey,
}: {
size?: "sm" | "md" | "lg";
className?: string;
pubkey?: string;
}) {
const { addWindow } = useGrimoire();
const profile = useProfile(pubkey);
const displayName = pubkey ? getDisplayName(pubkey, profile) : "$me";
const handleClick = (e: React.MouseEvent) => {
if (!pubkey) return;
e.stopPropagation();
addWindow("profile", { pubkey });
};
return (
<span
className={cn(
"inline-flex items-center gap-1.5 font-bold text-orange-400 select-none",
pubkey && "cursor-crosshair hover:underline decoration-dotted",
size === "sm" ? "text-xs" : size === "md" ? "text-sm" : "text-lg",
className,
)}
onClick={handleClick}
>
<User className={cn(size === "sm" ? "size-3" : "size-4")} />
{displayName}
</span>
);
}
/**
* Visual placeholder for $contacts
*/
export function ContactsPlaceholder({
size = "sm",
className,
pubkey,
}: {
size?: "sm" | "md" | "lg";
className?: string;
pubkey?: string;
}) {
const { addWindow } = useGrimoire();
const contactList = useNostrEvent(
pubkey
? {
kind: 3,
pubkey,
identifier: "",
}
: undefined,
);
const count = contactList?.tags.filter((t) => t[0] === "p").length;
const label = count !== undefined ? `${count} contacts` : "$contacts";
const handleClick = (e: React.MouseEvent) => {
if (!pubkey) return;
e.stopPropagation();
addWindow("open", {
pointer: {
kind: 3,
pubkey,
identifier: "",
},
});
};
return (
<span
className={cn(
"inline-flex items-center gap-1.5 font-bold text-accent select-none",
pubkey && "cursor-crosshair hover:underline decoration-dotted",
size === "sm" ? "text-xs" : size === "md" ? "text-sm" : "text-lg",
className,
)}
onClick={handleClick}
>
<Users className={cn(size === "sm" ? "size-3" : "size-4")} />
{label}
</span>
);
}
/**
* Renderer for a list of identifiers (pubkeys or placeholders)
*/
function IdentifierList({
values,
size = "md",
activePubkey,
}: {
values: string[];
size?: "sm" | "md" | "lg";
activePubkey?: string;
}) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-2">
{values.map((val) => {
if (val === "$me")
return <MePlaceholder key={val} size={size} pubkey={activePubkey} />;
if (val === "$contacts")
return (
<ContactsPlaceholder key={val} size={size} pubkey={activePubkey} />
);
return (
<UserName
key={val}
pubkey={val}
className={cn(
size === "sm" ? "text-xs" : size === "md" ? "text-sm" : "text-lg",
)}
/>
);
})}
</div>
);
}
/**
* Renderer for Kind 777 - Spell (REQ Command)
* Displays spell name, description, and the reconstructed command
*/
export function SpellRenderer({ event }: BaseEventProps) {
try {
const spell = decodeSpell(event as SpellEvent);
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
{spell.name && (
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{spell.name}
</ClickableEventTitle>
)}
{/* Description */}
{spell.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{spell.description}
</p>
)}
{/* Command Preview */}
<ExecutableCommand
commandLine={spell.command}
className="text-xs font-mono bg-muted/30 p-2 border border-border truncate line-clamp-1 text-primary hover:underline cursor-pointer"
>
{spell.command}
</ExecutableCommand>
{/* Kind Badges */}
{spell.filter.kinds && spell.filter.kinds.length > 0 && (
<div className="flex flex-wrap gap-2 mt-1">
{spell.filter.kinds.map((kind) => (
<KindBadge
key={kind}
kind={kind}
className="text-[10px]"
showName
clickable
/>
))}
</div>
)}
</div>
</BaseEventContainer>
);
} catch (error) {
return (
<BaseEventContainer event={event}>
<div className="text-destructive text-sm p-2 border border-destructive/20 bg-destructive/10">
Failed to decode spell:{" "}
{error instanceof Error ? error.message : "Unknown error"}
</div>
</BaseEventContainer>
);
}
}
/**
* Detail renderer for Kind 777 - Spell
* Shows more information about the spell and its filter
*/
export function SpellDetailRenderer({ event }: BaseEventProps) {
const { state } = useGrimoire();
const activePubkey = state.activeAccount?.pubkey;
try {
const spell = decodeSpell(event as SpellEvent);
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{spell.name && <h2 className="text-2xl font-bold">{spell.name}</h2>}
{spell.description && (
<p className="text-muted-foreground">{spell.description}</p>
)}
</div>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Command
</h3>
<ExecutableCommand
commandLine={spell.command}
className="text-sm font-mono p-4 bg-muted/30 border border-border text-primary hover:underline hover:decoration-dotted cursor-crosshair break-all"
>
{spell.command}
</ExecutableCommand>
</div>
{spell.filter.kinds && spell.filter.kinds.length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Kinds
</h3>
<div className="flex flex-wrap gap-4">
{spell.filter.kinds.map((kind) => (
<KindBadge key={kind} kind={kind} clickable />
))}
</div>
</div>
)}
{spell.filter.authors && spell.filter.authors.length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Authors
</h3>
<IdentifierList
values={spell.filter.authors}
size="md"
activePubkey={activePubkey}
/>
</div>
)}
{spell.filter["#p"] && spell.filter["#p"].length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Mentions
</h3>
<IdentifierList
values={spell.filter["#p"]}
size="md"
activePubkey={activePubkey}
/>
</div>
)}
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Filter
</h3>
<CopyableJsonViewer json={JSON.stringify(spell.filter, null, 2)} />
</div>
{spell.relays && spell.relays.length > 0 && (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Target Relays
</h3>
<div className="flex flex-wrap gap-2">
{spell.relays.map((relay) => (
<Badge key={relay} variant="secondary" className="font-mono">
{relay}
</Badge>
))}
</div>
</div>
)}
</div>
);
} catch (error) {
return (
<div className="p-4">
<div className="text-destructive p-4 border border-destructive/20 bg-destructive/10 rounded">
Failed to decode spell:{" "}
{error instanceof Error ? error.message : "Unknown error"}
</div>
</div>
);
}
}

View File

@@ -34,6 +34,7 @@ import { Kind39701Renderer } from "./BookmarkRenderer";
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
import { LiveActivityRenderer } from "./LiveActivityRenderer";
import { LiveActivityDetailRenderer } from "./LiveActivityDetailRenderer";
import { SpellRenderer, SpellDetailRenderer } from "./SpellRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
@@ -62,6 +63,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
1621: IssueRenderer, // Issue (NIP-34)
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
777: SpellRenderer, // Spell (Grimoire)
10002: Kind10002Renderer, // Relay List Metadata (NIP-65)
10006: GenericRelayListRenderer, // Blocked Relays (NIP-51)
10007: GenericRelayListRenderer, // Search Relays (NIP-51)
@@ -122,6 +124,7 @@ const detailRenderers: Record<
1621: IssueDetailRenderer, // Issue Detail (NIP-34)
9802: Kind9802DetailRenderer, // Highlight Detail
10002: Kind10002DetailRenderer, // Relay List Detail (NIP-65)
777: SpellDetailRenderer, // Spell Detail
30023: Kind30023DetailRenderer, // Long-form Article Detail
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)

View File

@@ -19,6 +19,8 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Nip05 from "./nip05";
import { RelayLink } from "./RelayLink";
import SettingsDialog from "@/components/SettingsDialog";
import { useState } from "react";
function UserAvatar({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
@@ -53,6 +55,7 @@ export default function UserMenu() {
const account = useObservableMemo(() => accounts.active$, []);
const { state, addWindow } = useGrimoire();
const relays = state.activeAccount?.relays;
const [showSettings, setShowSettings] = useState(false);
function openProfile() {
if (!account?.pubkey) return;
@@ -81,63 +84,74 @@ export default function UserMenu() {
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="link"
aria-label={account ? "User menu" : "Log in"}
>
{account ? (
<UserAvatar pubkey={account.pubkey} />
) : (
<User onClick={login} className="size-4 text-muted-foreground" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80" align="start">
{account ? (
<>
<DropdownMenuGroup>
<DropdownMenuLabel
className="cursor-crosshair hover:bg-muted/50"
onClick={openProfile}
>
<UserLabel pubkey={account.pubkey} />
</DropdownMenuLabel>
</DropdownMenuGroup>
{relays && relays.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Relays
</DropdownMenuLabel>
{relays.map((relay) => (
<RelayLink
className="px-2 py-1"
urlClassname="text-sm"
iconClassname="size-4"
key={relay.url}
url={relay.url}
read={relay.read}
write={relay.write}
/>
))}
</DropdownMenuGroup>
</>
<>
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="link"
aria-label={account ? "User menu" : "Log in"}
>
{account ? (
<UserAvatar pubkey={account.pubkey} />
) : (
<User onClick={login} className="size-4 text-muted-foreground" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80" align="start">
{account ? (
<>
<DropdownMenuGroup>
<DropdownMenuLabel
className="cursor-crosshair hover:bg-muted/50"
onClick={openProfile}
>
<UserLabel pubkey={account.pubkey} />
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="cursor-crosshair">
Log out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={login}>Log in</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{relays && relays.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
Relays
</DropdownMenuLabel>
{relays.map((relay) => (
<RelayLink
className="px-2 py-1"
urlClassname="text-sm"
iconClassname="size-4"
key={relay.url}
url={relay.url}
read={relay.read}
write={relay.write}
/>
))}
</DropdownMenuGroup>
</>
)}
<DropdownMenuSeparator />
{/* <DropdownMenuItem
onClick={() => setShowSettings(true)}
className="cursor-pointer"
>
<Settings className="mr-2 size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator /> */}
<DropdownMenuItem onClick={logout} className="cursor-crosshair">
Log out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={login}>Log in</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,85 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -60,6 +60,7 @@ import {
UserX,
Video,
Wallet,
WandSparkles,
XCircle,
Zap,
type LucideIcon,
@@ -680,6 +681,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
icon: Zap,
},
9735: { kind: 9735, name: "Zap", description: "Zap", nip: "57", icon: Zap },
777: {
kind: 777,
name: "Spell",
description: "REQ Command Spell",
nip: "",
icon: WandSparkles,
},
9802: {
kind: 9802,
name: "Highlight",

View File

@@ -489,4 +489,3 @@ export const setCompactModeKinds = (
compactModeKinds: kinds,
};
};

View File

@@ -95,6 +95,14 @@
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.text-grimoire-gradient {
background: linear-gradient(
to bottom,

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseCommandInput } from "./command-parser";
import { parseCommandInput, executeCommandParser } from "./command-parser";
/**
* Regression tests for parseCommandInput
@@ -254,3 +254,27 @@ describe("parseCommandInput - regression tests", () => {
});
});
});
describe("executeCommandParser - alias resolution", () => {
it("should resolve $me in profile command when activeAccountPubkey is provided", async () => {
const input = "profile $me";
const parsed = parseCommandInput(input);
const activeAccountPubkey =
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
const result = await executeCommandParser(parsed, activeAccountPubkey);
expect(result.error).toBeUndefined();
expect(result.props.pubkey).toBe(activeAccountPubkey);
});
it("should return $me literal in profile command when activeAccountPubkey is NOT provided", async () => {
const input = "profile $me";
const parsed = parseCommandInput(input);
const result = await executeCommandParser(parsed);
expect(result.error).toBeUndefined();
expect(result.props.pubkey).toBe("$me");
});
});

View File

@@ -97,6 +97,7 @@ export function parseCommandInput(input: string): ParsedCommand {
*/
export async function executeCommandParser(
parsed: ParsedCommand,
activeAccountPubkey?: string,
): Promise<ParsedCommand> {
if (!parsed.command) {
return parsed; // Already has error, return as-is
@@ -105,7 +106,9 @@ export async function executeCommandParser(
try {
// Use argParser if available, otherwise use defaultProps
const props = parsed.command.argParser
? await Promise.resolve(parsed.command.argParser(parsed.args))
? await Promise.resolve(
parsed.command.argParser(parsed.args, activeAccountPubkey),
)
: parsed.command.defaultProps || {};
return {
@@ -129,10 +132,11 @@ export async function executeCommandParser(
*/
export async function parseAndExecuteCommand(
input: string,
activeAccountPubkey?: string,
): Promise<ParsedCommand> {
const parsed = parseCommandInput(input);
if (parsed.error || !parsed.command) {
return parsed;
}
return executeCommandParser(parsed);
return executeCommandParser(parsed, activeAccountPubkey);
}

View File

@@ -24,7 +24,8 @@ const RESERVED_GLOBAL_FLAGS = ["--title"] as const;
*/
function sanitizeTitle(title: string): string | undefined {
const sanitized = title
.replace(/[\x00-\x1F\x7F]/g, "") // Strip control chars (newlines, tabs, null bytes)
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001F\u007F]/g, "") // Strip control chars (newlines, tabs, null bytes)
.trim();
if (!sanitized) {

View File

@@ -8,7 +8,7 @@
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
export const CURRENT_VERSION = 9;
export const CURRENT_VERSION = 10;
/**
* Migration function type
@@ -105,6 +105,14 @@ const migrations: Record<number, MigrationFn> = {
__version: 9,
};
},
// Migration from v9 to v10 - adds compactModeKinds
9: (state: any) => {
return {
...state,
__version: 10,
compactModeKinds: [6, 7, 16, 9735],
};
},
};
/**
@@ -134,6 +142,11 @@ export function validateState(state: any): state is GrimoireState {
return false;
}
// compactModeKinds must be an array if present
if (state.compactModeKinds && !Array.isArray(state.compactModeKinds)) {
return false;
}
// Windows must be an object
if (typeof state.windows !== "object") {
return false;

View File

@@ -1,11 +1,61 @@
import type { ProfileContent } from "applesauce-core/helpers";
import type { NostrEvent } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
import { getNip10References } from "applesauce-core/helpers/threading";
import { getCommentReplyPointer } from "applesauce-core/helpers/comment";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
export function derivePlaceholderName(pubkey: string): string {
return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`;
}
/**
* Get a reply pointer for an event, abstracting the differences between NIP-10 and NIP-22 (comments).
*/
export function getEventReply(
event: NostrEvent,
):
| { type: "root"; pointer: EventPointer | AddressPointer }
| { type: "reply"; pointer: EventPointer | AddressPointer }
| { type: "comment"; pointer: any }
| null {
// Handle Kind 1 (Text Note) - NIP-10
if (event.kind === 1) {
const references = getNip10References(event);
if (references.reply) {
const pointer = references.reply.e || references.reply.a;
if (pointer) return { type: "reply", pointer };
}
if (references.root) {
const pointer = references.root.e || references.root.a;
if (pointer) return { type: "root", pointer };
}
}
// Handle Kind 1111 (Comment) - NIP-22
if (event.kind === 1111) {
const pointer = getCommentReplyPointer(event);
if (pointer) {
return { type: "comment", pointer };
}
}
// Fallback for generic replies (using NIP-10 logic for other kinds usually works)
if (event.kind !== 1111) {
const references = getNip10References(event);
if (references.reply) {
const pointer = references.reply.e || references.reply.a;
if (pointer) return { type: "reply", pointer };
}
if (references.root) {
const pointer = references.root.e || references.root.a;
if (pointer) return { type: "root", pointer };
}
}
return null;
}
export function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags
.filter((tag) => tag[0] === tagName && tag[1])

View File

@@ -14,9 +14,11 @@ export interface ParsedProfileCommand {
* - abc123... (64-char hex pubkey)
* - user@domain.com (NIP-05 identifier)
* - domain.com (bare domain, resolved as _@domain.com)
* - $me (active account alias)
*/
export async function parseProfileCommand(
args: string[],
activeAccountPubkey?: string,
): Promise<ParsedProfileCommand> {
const identifier = args[0];
@@ -24,6 +26,13 @@ export async function parseProfileCommand(
throw new Error("User identifier required");
}
// Handle $me alias
if (identifier.toLowerCase() === "$me") {
return {
pubkey: activeAccountPubkey || "$me",
};
}
// Try bech32 decode first (npub, nprofile)
if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
try {

View File

@@ -179,7 +179,8 @@ describe("parseReqCommand", () => {
describe("event ID flag (-e) with nevent/naddr support", () => {
describe("nevent support", () => {
it("should parse nevent and populate filter.ids", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
});
@@ -192,7 +193,8 @@ describe("parseReqCommand", () => {
});
it("should extract relay hints from nevent", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
@@ -204,7 +206,8 @@ describe("parseReqCommand", () => {
});
it("should normalize relay URLs from nevent", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
@@ -218,7 +221,8 @@ describe("parseReqCommand", () => {
});
it("should handle nevent without relay hints", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
id: eventId,
});
@@ -231,7 +235,8 @@ describe("parseReqCommand", () => {
describe("naddr support", () => {
it("should parse naddr and populate filter['#a']", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -245,7 +250,8 @@ describe("parseReqCommand", () => {
});
it("should extract relay hints from naddr", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -261,7 +267,8 @@ describe("parseReqCommand", () => {
});
it("should format coordinate correctly (kind:pubkey:identifier)", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -280,7 +287,8 @@ describe("parseReqCommand", () => {
});
it("should handle naddr without relay hints", () => {
const pubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: pubkey,
@@ -295,7 +303,8 @@ describe("parseReqCommand", () => {
describe("note/hex support (existing behavior)", () => {
it("should parse note and populate filter['#e']", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const note = nip19.noteEncode(eventId);
const result = parseReqCommand(["-e", note]);
@@ -318,7 +327,8 @@ describe("parseReqCommand", () => {
describe("mixed format support", () => {
it("should handle comma-separated mix of all formats", () => {
const hex = "a".repeat(64);
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey = "b".repeat(64);
const note = nip19.noteEncode(eventId);
@@ -349,9 +359,13 @@ describe("parseReqCommand", () => {
});
it("should deduplicate within each filter field", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent1 = nip19.neventEncode({ id: eventId });
const nevent2 = nip19.neventEncode({ id: eventId, relays: ["wss://relay.damus.io"] });
const nevent2 = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
});
const result = parseReqCommand(["-e", `${nevent1},${nevent2}`]);
@@ -360,7 +374,8 @@ describe("parseReqCommand", () => {
});
it("should collect relay hints from mixed formats", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const pubkey = "b".repeat(64);
const nevent = nip19.neventEncode({
@@ -383,7 +398,8 @@ describe("parseReqCommand", () => {
});
it("should handle multiple nevents with different relay hints", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent1 = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.damus.io"],
@@ -438,7 +454,8 @@ describe("parseReqCommand", () => {
describe("integration with other flags", () => {
it("should work with kind filter", () => {
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({ id: eventId });
const result = parseReqCommand(["-k", "1", "-e", nevent]);
@@ -465,7 +482,8 @@ describe("parseReqCommand", () => {
it("should work with author and time filters", () => {
const hex = "c".repeat(64);
const eventId = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({ id: eventId });
const result = parseReqCommand([
"-k",

View File

@@ -0,0 +1,768 @@
import { describe, it, expect } from "vitest";
import { encodeSpell, decodeSpell } from "./spell-conversion";
import type { SpellEvent } from "@/types/spell";
describe("Spell Conversion", () => {
describe("encodeSpell", () => {
it("should encode a simple REQ command with kinds", () => {
const result = encodeSpell({
command: "req -k 1,3,7",
description: "Test spell",
});
expect(result.tags).toContainEqual(["cmd", "REQ"]);
expect(result.tags).toContainEqual(["client", "grimoire"]);
expect(result.tags).toContainEqual(["k", "1"]);
expect(result.tags).toContainEqual(["k", "3"]);
expect(result.tags).toContainEqual(["k", "7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
expect(result.content).toBe("Test spell");
});
it("should encode with optional description", () => {
const result = encodeSpell({
command: "req -k 1",
description: "Test spell",
});
expect(result.tags).toContainEqual(["cmd", "REQ"]);
expect(result.content).toBe("Test spell");
});
it("should encode with empty description", () => {
const result = encodeSpell({
command: "req -k 1",
});
expect(result.tags).toContainEqual(["cmd", "REQ"]);
expect(result.content).toBe("");
});
it("should encode optional name tag", () => {
const result = encodeSpell({
command: "req -k 1",
name: "Bitcoin Feed",
});
expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]);
expect(result.tags).toContainEqual(["cmd", "REQ"]);
});
it("should skip name tag if not provided", () => {
const result = encodeSpell({
command: "req -k 1",
});
const nameTag = result.tags.find((t) => t[0] === "name");
expect(nameTag).toBeUndefined();
});
it("should trim and skip empty name", () => {
const result = encodeSpell({
command: "req -k 1",
name: " ",
});
const nameTag = result.tags.find((t) => t[0] === "name");
expect(nameTag).toBeUndefined();
});
it("should encode both name and description", () => {
const result = encodeSpell({
command: "req -k 1",
name: "Bitcoin Feed",
description: "Notes about Bitcoin",
});
expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]);
expect(result.content).toBe("Notes about Bitcoin");
});
it("should encode authors as array tag", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = encodeSpell({
command: `req -k 1 -a ${hex1},${hex2}`,
description: "Author spell",
});
const authorsTag = result.tags.find((t) => t[0] === "authors");
expect(authorsTag).toEqual(["authors", hex1, hex2]);
expect(result.filter.authors).toEqual([hex1, hex2]);
});
it("should encode limit, since, until", () => {
const result = encodeSpell({
command: "req -k 1 -l 50 --since 7d --until now",
description: "Time spell",
});
expect(result.tags).toContainEqual(["limit", "50"]);
expect(result.tags).toContainEqual(["since", "7d"]);
expect(result.tags).toContainEqual(["until", "now"]);
expect(result.filter.limit).toBe(50);
});
it("should encode tag filters with new format", () => {
const hex = "c".repeat(64);
const result = encodeSpell({
command: `req -k 1 -t bitcoin,nostr -p ${hex} -d article1`,
description: "Tag spell",
});
expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]);
expect(result.tags).toContainEqual(["tag", "p", hex]);
expect(result.tags).toContainEqual(["tag", "d", "article1"]);
expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(result.filter["#p"]).toEqual([hex]);
expect(result.filter["#d"]).toEqual(["article1"]);
});
it("should encode search query", () => {
const result = encodeSpell({
command: 'req -k 1 --search "bitcoin price"',
description: "Search spell",
});
expect(result.tags).toContainEqual(["search", "bitcoin price"]);
expect(result.filter.search).toBe("bitcoin price");
});
it("should encode relays", () => {
const result = encodeSpell({
command: "req -k 1 wss://relay1.com wss://relay2.com",
description: "Relay spell",
});
const relaysTag = result.tags.find((t) => t[0] === "relays");
expect(relaysTag).toEqual([
"relays",
"wss://relay1.com/",
"wss://relay2.com/",
]);
expect(result.relays).toEqual(["wss://relay1.com/", "wss://relay2.com/"]);
});
it("should encode close-on-eose flag", () => {
const result = encodeSpell({
command: "req -k 1 --close-on-eose",
description: "Close spell",
});
const closeTag = result.tags.find((t) => t[0] === "close-on-eose");
expect(closeTag).toBeDefined();
expect(result.closeOnEose).toBe(true);
});
it("should add topic tags", () => {
const result = encodeSpell({
command: "req -k 1",
description: "A test spell",
topics: ["bitcoin", "news"],
});
expect(result.tags).toContainEqual([
"alt",
"Grimoire REQ spell: A test spell",
]);
expect(result.tags).toContainEqual(["t", "bitcoin"]);
expect(result.tags).toContainEqual(["t", "news"]);
expect(result.content).toBe("A test spell");
});
it("should add fork provenance", () => {
const result = encodeSpell({
command: "req -k 1",
description: "Forked spell",
forkedFrom: "abc123def456",
});
expect(result.tags).toContainEqual(["e", "abc123def456"]);
});
it("should handle special aliases $me and $contacts", () => {
const result = encodeSpell({
command: "req -k 1 -a $me,$contacts",
description: "Alias spell",
});
const authorsTag = result.tags.find((t) => t[0] === "authors");
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
expect(result.filter.authors).toEqual(["$me", "$contacts"]);
});
it("should handle uppercase P tag separately from lowercase p", () => {
const hex1 = "d".repeat(64);
const hex2 = "e".repeat(64);
const result = encodeSpell({
command: `req -k 9735 -p ${hex1} -P ${hex2}`,
description: "P tag spell",
});
expect(result.tags).toContainEqual(["tag", "p", hex1]);
expect(result.tags).toContainEqual(["tag", "P", hex2]);
expect(result.filter["#p"]).toEqual([hex1]);
expect(result.filter["#P"]).toEqual([hex2]);
});
it("should handle generic tags with -T flag", () => {
const result = encodeSpell({
command: "req -k 1 -T x value1,value2",
description: "Generic tag spell",
});
expect(result.tags).toContainEqual(["tag", "x", "value1", "value2"]);
expect(result.filter["#x"]).toEqual(["value1", "value2"]);
});
it("should handle complex command with multiple filters", () => {
const hex1 = "f".repeat(64);
const hex2 = "0".repeat(64);
const result = encodeSpell({
command: `req -k 1,3,30023 -a ${hex1},${hex2} -l 100 -t bitcoin,nostr --since 7d --search crypto wss://relay.com --close-on-eose`,
description: "Multi-filter spell",
topics: ["test"],
});
// Verify all components are present
expect(result.tags).toContainEqual(["k", "1"]);
expect(result.tags).toContainEqual(["k", "3"]);
expect(result.tags).toContainEqual(["k", "30023"]);
expect(result.tags).toContainEqual(["authors", hex1, hex2]);
expect(result.tags).toContainEqual(["limit", "100"]);
expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]);
expect(result.tags).toContainEqual(["since", "7d"]);
expect(result.tags).toContainEqual(["search", "crypto"]);
expect(result.tags).toContainEqual(["relays", "wss://relay.com/"]);
expect(result.tags).toContainEqual(["close-on-eose", ""]);
expect(result.tags).toContainEqual(["t", "test"]);
// Verify filter
expect(result.filter.kinds).toEqual([1, 3, 30023]);
expect(result.filter.authors).toEqual([hex1, hex2]);
expect(result.filter.limit).toBe(100);
expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(result.filter.search).toBe("crypto");
});
});
describe("decodeSpell", () => {
it("should decode a simple spell back to command", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["k", "3"],
],
content: "Test spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBe("Test spell");
expect(parsed.filter.kinds).toEqual([1, 3]);
expect(parsed.command).toContain("-k 1,3");
});
it("should decode description from content", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["k", "1"],
],
content: "Test spell description",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBe("Test spell description");
expect(parsed.filter.kinds).toEqual([1]);
});
it("should handle empty content", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["k", "1"],
],
content: "",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBeUndefined();
expect(parsed.filter.kinds).toEqual([1]);
});
it("should decode name from tags", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["name", "Bitcoin Feed"],
["k", "1"],
],
content: "Notes about Bitcoin",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.name).toBe("Bitcoin Feed");
expect(parsed.description).toBe("Notes about Bitcoin");
expect(parsed.filter.kinds).toEqual([1]);
});
it("should handle missing name tag", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["k", "1"],
],
content: "Test",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.name).toBeUndefined();
expect(parsed.description).toBe("Test");
});
it("should decode authors", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["authors", "abc123", "def456"],
],
content: "Author spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.filter.authors).toEqual(["abc123", "def456"]);
expect(parsed.command).toContain("-a abc123,def456");
});
it("should decode tag filters with new format", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["tag", "t", "bitcoin", "nostr"],
["tag", "p", "abc123"],
["tag", "P", "def456"],
],
content: "Tag spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(parsed.filter["#p"]).toEqual(["abc123"]);
expect(parsed.filter["#P"]).toEqual(["def456"]);
expect(parsed.command).toContain("-t bitcoin,nostr");
expect(parsed.command).toContain("-p abc123");
expect(parsed.command).toContain("-P def456");
});
it("should decode time bounds with relative format", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["since", "7d"],
["until", "now"],
],
content: "Time spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.command).toContain("--since 7d");
expect(parsed.command).toContain("--until now");
});
it("should decode topics", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["t", "bitcoin"],
["t", "news"],
],
content: "A test spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.description).toBe("A test spell");
expect(parsed.topics).toEqual(["bitcoin", "news"]);
});
it("should decode fork provenance", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["k", "1"],
["e", "abc123def456"],
],
content: "Forked spell",
sig: "test-sig",
};
const parsed = decodeSpell(event);
expect(parsed.forkedFrom).toBe("abc123def456");
});
it("should throw error if cmd is not REQ", () => {
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: [["cmd", "INVALID"]],
content: "Test",
sig: "test-sig",
};
expect(() => decodeSpell(event)).toThrow(
"Invalid spell command type: INVALID",
);
});
});
describe("Round-trip conversion", () => {
it("should preserve filter semantics through encode → decode", () => {
const hex1 = "1".repeat(64);
const hex2 = "2".repeat(64);
const original = {
command: `req -k 1,3,7 -a ${hex1},${hex2} -l 50 -t bitcoin,nostr --since 7d --search crypto`,
description: "Testing round-trip conversion",
topics: ["test"],
};
// Encode
const encoded = encodeSpell(original);
// Create event
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
// Decode
const decoded = decodeSpell(event);
// Verify filter semantics are preserved
expect(decoded.filter.kinds).toEqual([1, 3, 7]);
expect(decoded.filter.authors).toEqual([hex1, hex2]);
expect(decoded.filter.limit).toBe(50);
expect(decoded.filter["#t"]).toEqual(["bitcoin", "nostr"]);
expect(decoded.filter.search).toBe("crypto");
// Verify metadata
expect(decoded.description).toBe("Testing round-trip conversion");
expect(decoded.topics).toEqual(["test"]);
// Verify command contains key components (order may differ)
expect(decoded.command).toContain("-k 1,3,7");
expect(decoded.command).toContain(`-a ${hex1},${hex2}`);
expect(decoded.command).toContain("-l 50");
expect(decoded.command).toContain("-t bitcoin,nostr");
expect(decoded.command).toContain("--since 7d");
expect(decoded.command).toContain("--search");
});
it("should handle minimal spell without description", () => {
const original = {
command: "req -k 1",
};
const encoded = encodeSpell(original);
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
const decoded = decodeSpell(event);
expect(decoded.description).toBeUndefined();
expect(decoded.filter.kinds).toEqual([1]);
expect(decoded.command).toBe("req -k 1");
});
it("should round-trip with name", () => {
const original = {
command: "req -k 1",
name: "Bitcoin Feed",
description: "Notes about Bitcoin",
};
const encoded = encodeSpell(original);
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
const decoded = decodeSpell(event);
expect(decoded.name).toBe("Bitcoin Feed");
expect(decoded.description).toBe("Notes about Bitcoin");
expect(decoded.filter.kinds).toEqual([1]);
});
it("should preserve special aliases through round-trip", () => {
const original = {
command: "req -k 1 -a $me,$contacts -p $me",
description: "Alias spell",
};
const encoded = encodeSpell(original);
// Debug: Check what was encoded
const authorsTag = encoded.tags.find((t) => t[0] === "authors");
const pTag = encoded.tags.find((t) => t[0] === "tag" && t[1] === "p");
// Verify encoding worked
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
expect(pTag).toEqual(["tag", "p", "$me"]);
const event: SpellEvent = {
id: "test-id",
pubkey: "test-pubkey",
created_at: 1234567890,
kind: 777,
tags: encoded.tags,
content: encoded.content,
sig: "test-sig",
};
const decoded = decodeSpell(event);
expect(decoded.filter.authors).toEqual(["$me", "$contacts"]);
expect(decoded.filter["#p"]).toEqual(["$me"]);
expect(decoded.command).toContain("-a $me,$contacts");
expect(decoded.command).toContain("-p $me");
});
});
describe("Validation and edge cases", () => {
it("should throw error for empty command", () => {
expect(() =>
encodeSpell({
command: "",
}),
).toThrow("Spell command is required");
});
it("should throw error for whitespace-only command", () => {
expect(() =>
encodeSpell({
command: " ",
}),
).toThrow("Spell command is required");
});
it("should throw error for 'req' with no filters", () => {
expect(() =>
encodeSpell({
command: "req",
}),
).toThrow(); // Will throw either empty tokens or no constraints error
});
it("should throw error for command with no valid filters", () => {
expect(() =>
encodeSpell({
command: "req --invalid-flag",
}),
).toThrow(
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
);
});
it("should throw error for command with only invalid values", () => {
expect(() =>
encodeSpell({
command: "req -k invalid",
}),
).toThrow(
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
);
});
it("should handle malformed author values gracefully", () => {
// Invalid hex should be ignored, not cause errors
const result = encodeSpell({
command: "req -k 1 -a invalid",
});
// Should have kinds but no authors
expect(result.filter.kinds).toEqual([1]);
expect(result.filter.authors).toBeUndefined();
});
it("should handle mixed valid and invalid values", () => {
const hex = "a".repeat(64);
const result = encodeSpell({
command: `req -k 1,invalid,3 -a ${hex},invalid`,
});
// Should keep only valid values
expect(result.filter.kinds).toEqual([1, 3]);
expect(result.filter.authors).toEqual([hex]);
});
it("should handle quotes in search query", () => {
const result = encodeSpell({
command: 'req -k 1 --search "quoted text"',
});
expect(result.filter.search).toBe("quoted text");
});
it("should handle single quotes in search query", () => {
const result = encodeSpell({
command: "req -k 1 --search 'single quoted'",
});
expect(result.filter.search).toBe("single quoted");
});
it("should handle special characters in search", () => {
const result = encodeSpell({
command: "req -k 1 --search 'text with #hashtag @mention'",
});
expect(result.filter.search).toBe("text with #hashtag @mention");
});
it("should accept commands with only limit", () => {
const result = encodeSpell({
command: "req -l 50",
});
expect(result.filter.limit).toBe(50);
});
it("should accept commands with only time bounds", () => {
const result = encodeSpell({
command: "req --since 7d",
});
expect(result.filter.since).toBeDefined();
});
it("should accept commands with only search", () => {
const result = encodeSpell({
command: "req --search bitcoin",
});
expect(result.filter.search).toBe("bitcoin");
});
it("should handle very long commands", () => {
// Generate 10 different hex values to test long author lists
const hexValues = Array(10)
.fill(0)
.map((_, i) => i.toString(16).repeat(64).slice(0, 64))
.join(",");
const result = encodeSpell({
command: `req -k 1 -a ${hexValues}`,
});
expect(result.filter.authors).toBeDefined();
expect(result.filter.authors!.length).toBe(10);
});
it("should handle Unicode in descriptions", () => {
const result = encodeSpell({
command: "req -k 1",
description: "Testing with emoji 🎨 and unicode 你好",
});
expect(result.content).toBe("Testing with emoji 🎨 and unicode 你好");
});
it("should preserve exact capitalization in $me/$contacts", () => {
const result = encodeSpell({
command: "req -k 1 -a $Me,$CONTACTS",
});
// Parser normalizes to lowercase
const authorsTag = result.tags.find((t) => t[0] === "authors");
expect(authorsTag).toEqual(["authors", "$me", "$contacts"]);
});
});
});

439
src/lib/spell-conversion.ts Normal file
View File

@@ -0,0 +1,439 @@
import { parseReqCommand } from "./req-parser";
import type {
CreateSpellOptions,
EncodedSpell,
ParsedSpell,
SpellEvent,
} from "@/types/spell";
import type { NostrFilter } from "@/types/nostr";
/**
* Simple tokenization that doesn't expand shell variables
* Splits on whitespace while respecting quoted strings
*/
function tokenizeCommand(command: string): string[] {
const tokens: string[] = [];
let current = "";
let inQuotes = false;
let quoteChar = "";
for (let i = 0; i < command.length; i++) {
const char = command[i];
if ((char === '"' || char === "'") && !inQuotes) {
// Start quoted string
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar && inQuotes) {
// End quoted string
inQuotes = false;
quoteChar = "";
} else if (char === " " && !inQuotes) {
// Whitespace outside quotes - end token
if (current) {
tokens.push(current);
current = "";
}
} else {
// Regular character
current += char;
}
}
// Add final token
if (current) {
tokens.push(current);
}
return tokens;
}
/**
* Encode a REQ command as spell event tags
*
* Parses the command and extracts filter parameters into Nostr tags.
* Preserves relative timestamps (7d, now) for dynamic spell behavior.
*
* @param options - Spell creation options with command string
* @returns Encoded spell with tags, content, and parsed filter
* @throws Error if command is invalid or produces empty filter
*/
export function encodeSpell(options: CreateSpellOptions): EncodedSpell {
const { command, name, description, topics, forkedFrom } = options;
// Validate command
if (!command || command.trim().length === 0) {
throw new Error("Spell command is required");
}
// Parse the command to extract filter components
// Remove "req" prefix if present and tokenize
const commandWithoutReq = command.replace(/^\s*req\s+/, "");
const tokens = tokenizeCommand(commandWithoutReq);
// Validate we have tokens to parse
if (tokens.length === 0) {
throw new Error("Spell command must contain filters or parameters");
}
const parsed = parseReqCommand(tokens);
// Validate that parsing produced a useful filter
// A filter must have at least one constraint
const hasConstraints =
(parsed.filter.kinds && parsed.filter.kinds.length > 0) ||
(parsed.filter.authors && parsed.filter.authors.length > 0) ||
(parsed.filter.ids && parsed.filter.ids.length > 0) ||
parsed.filter.limit !== undefined ||
parsed.filter.since !== undefined ||
parsed.filter.until !== undefined ||
parsed.filter.search !== undefined ||
Object.keys(parsed.filter).some((k) => k.startsWith("#"));
if (!hasConstraints) {
throw new Error(
"Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)",
);
}
// Start with required tags
const tags: [string, string, ...string[]][] = [
["cmd", "REQ"],
["client", "grimoire"],
];
// Add name tag if provided
if (name && name.trim().length > 0) {
tags.push(["name", name.trim()]);
}
// Add alt tag for NIP-31 compatibility
const altText = description
? `Grimoire REQ spell: ${description.substring(0, 100)}`
: "Grimoire REQ spell";
tags.push(["alt", altText]);
// Add provenance if forked
if (forkedFrom) {
tags.push(["e", forkedFrom]);
}
// Encode filter.kinds as multiple k tags for queryability
if (parsed.filter.kinds) {
for (const kind of parsed.filter.kinds) {
tags.push(["k", kind.toString()]);
}
}
// Encode filter.authors as single array tag
if (parsed.filter.authors && parsed.filter.authors.length > 0) {
tags.push(["authors", ...parsed.filter.authors] as [
string,
string,
...string[],
]);
}
// Encode filter.ids as single array tag
if (parsed.filter.ids && parsed.filter.ids.length > 0) {
tags.push(["ids", ...parsed.filter.ids] as [string, string, ...string[]]);
}
// Encode tag filters (#e, #p, #P, #t, #d, #a, and any generic tags)
// New format: ["tag", "letter", ...values]
const tagFilters: Record<string, string[]> = {};
// Collect all # tags from filter
for (const [key, value] of Object.entries(parsed.filter)) {
if (key.startsWith("#") && Array.isArray(value)) {
tagFilters[key] = value as string[];
}
}
// Add tag filter tags with new format
for (const [tagName, values] of Object.entries(tagFilters)) {
if (values.length > 0) {
// Extract the letter from #letter format
const letter = tagName.substring(1); // Remove the # prefix
tags.push(["tag", letter, ...values] as [string, string, ...string[]]);
}
}
// Encode scalars
if (parsed.filter.limit !== undefined) {
tags.push(["limit", parsed.filter.limit.toString()]);
}
// For timestamps, we need to preserve the original format if it was relative
// The parser converts everything to unix timestamps, losing this info
// We'll need to detect relative times in the original command
// This is a limitation - for MVP, we'll store the resolved timestamps
// TODO: Enhance parser to preserve original time format
if (parsed.filter.since !== undefined) {
// Try to extract original since value from command
const sinceMatch = command.match(/--since\s+(\S+)/);
if (sinceMatch && sinceMatch[1]) {
tags.push(["since", sinceMatch[1]]);
} else {
tags.push(["since", parsed.filter.since.toString()]);
}
}
if (parsed.filter.until !== undefined) {
// Try to extract original until value from command
const untilMatch = command.match(/--until\s+(\S+)/);
if (untilMatch && untilMatch[1]) {
tags.push(["until", untilMatch[1]]);
} else {
tags.push(["until", parsed.filter.until.toString()]);
}
}
if (parsed.filter.search) {
tags.push(["search", parsed.filter.search]);
}
// Add relays if specified
if (parsed.relays && parsed.relays.length > 0) {
tags.push(["relays", ...parsed.relays] as [string, string, ...string[]]);
}
// Add close-on-eose flag if set
if (parsed.closeOnEose) {
tags.push(["close-on-eose", ""] as [string, string, ...string[]]);
}
// Add topic tags for categorization
if (topics && topics.length > 0) {
for (const topic of topics) {
tags.push(["t", topic]);
}
}
// Content is the description (or empty if not provided)
const content = description || "";
return {
tags,
content,
filter: parsed.filter,
relays: parsed.relays,
closeOnEose: parsed.closeOnEose || false,
};
}
/**
* Decode a spell event back to a REQ command string
*
* Reconstructs a canonical REQ command from the spell's tags.
* The reconstructed command may differ in formatting from the original
* but produces an equivalent Nostr filter.
*
* @param event - Spell event (kind 777)
* @returns Parsed spell with reconstructed command
*/
export function decodeSpell(event: SpellEvent): ParsedSpell {
// Extract tags into a map for easier access
const tagMap = new Map<string, string[]>();
for (const tag of event.tags) {
const [name, ...values] = tag;
if (!tagMap.has(name)) {
tagMap.set(name, []);
}
tagMap.get(name)!.push(...values);
}
// Validate cmd tag
const cmd = tagMap.get("cmd")?.[0];
if (cmd !== "REQ") {
throw new Error(`Invalid spell command type: ${cmd}`);
}
// Extract metadata
const name = tagMap.get("name")?.[0];
const description = event.content || undefined;
const topics = tagMap.get("t") || [];
const forkedFrom = tagMap.get("e")?.[0];
// Reconstruct filter from tags
const filter: NostrFilter = {};
// Kinds
const kinds = tagMap.get("k");
if (kinds && kinds.length > 0) {
filter.kinds = kinds.map((k) => parseInt(k, 10)).filter((k) => !isNaN(k));
}
// Authors
const authors = tagMap.get("authors");
if (authors && authors.length > 0) {
filter.authors = authors;
}
// IDs
const ids = tagMap.get("ids");
if (ids && ids.length > 0) {
filter.ids = ids;
}
// Tag filters - new format: ["tag", "letter", ...values]
// Parse all "tag" tags and convert to filter[#letter] format
const tagFilterTags = event.tags.filter((t) => t[0] === "tag");
for (const tag of tagFilterTags) {
const [, letter, ...values] = tag;
if (letter && values.length > 0) {
(filter as any)[`#${letter}`] = values;
}
}
// Scalars
const limit = tagMap.get("limit")?.[0];
if (limit) {
filter.limit = parseInt(limit, 10);
}
const since = tagMap.get("since")?.[0];
if (since) {
// Check if it's a relative time or unix timestamp
if (/^\d{10}$/.test(since)) {
filter.since = parseInt(since, 10);
} else {
// It's a relative time format - preserve it as a comment
// For actual filtering, we'd need to resolve it at runtime
// For now, skip adding to filter (will be resolved at execution)
}
}
const until = tagMap.get("until")?.[0];
if (until) {
// Check if it's a relative time or unix timestamp
if (/^\d{10}$/.test(until)) {
filter.until = parseInt(until, 10);
} else {
// It's a relative time format - preserve it as a comment
// For now, skip adding to filter (will be resolved at execution)
}
}
const search = tagMap.get("search")?.[0];
if (search) {
filter.search = search;
}
// Options
const relays = tagMap.get("relays");
const closeOnEose = tagMap.has("close-on-eose");
// Reconstruct command string
const command = reconstructCommand(filter, relays, since, until, closeOnEose);
return {
name,
description,
command,
filter,
relays,
closeOnEose,
topics,
forkedFrom,
event,
};
}
/**
* Reconstruct a canonical REQ command string from filter components
*/
export function reconstructCommand(
filter: NostrFilter,
relays?: string[],
since?: string,
until?: string,
closeOnEose?: boolean,
): string {
const parts: string[] = ["req"];
// Kinds
if (filter.kinds && filter.kinds.length > 0) {
parts.push(`-k ${filter.kinds.join(",")}`);
}
// Authors
if (filter.authors && filter.authors.length > 0) {
parts.push(`-a ${filter.authors.join(",")}`);
}
// Limit
if (filter.limit !== undefined) {
parts.push(`-l ${filter.limit}`);
}
// IDs (use -e flag, though semantics differ slightly)
if (filter.ids && filter.ids.length > 0) {
parts.push(`-e ${filter.ids.join(",")}`);
}
// Tag filters
if (filter["#e"] && filter["#e"].length > 0) {
parts.push(`-e ${filter["#e"].join(",")}`);
}
if (filter["#p"] && filter["#p"].length > 0) {
parts.push(`-p ${filter["#p"].join(",")}`);
}
if (filter["#P"] && filter["#P"].length > 0) {
parts.push(`-P ${filter["#P"].join(",")}`);
}
if (filter["#t"] && filter["#t"].length > 0) {
parts.push(`-t ${filter["#t"].join(",")}`);
}
if (filter["#d"] && filter["#d"].length > 0) {
parts.push(`-d ${filter["#d"].join(",")}`);
}
if (filter["#a"] && filter["#a"].length > 0) {
// Note: #a filters came from naddr, but we reconstruct as comma-separated
parts.push(`-e ${filter["#a"].join(",")}`);
}
// Generic single-letter tags
for (const [key, value] of Object.entries(filter)) {
if (key.startsWith("#") && key.length === 2 && Array.isArray(value)) {
const letter = key[1];
// Skip already handled tags
if (!["e", "p", "P", "t", "d", "a"].includes(letter)) {
parts.push(`-T ${letter} ${(value as string[]).join(",")}`);
}
}
}
// Time bounds (preserve relative format if available)
if (since) {
parts.push(`--since ${since}`);
}
if (until) {
parts.push(`--until ${until}`);
}
// Search
if (filter.search) {
parts.push(`--search "${filter.search}"`);
}
// Relays
if (relays && relays.length > 0) {
parts.push(...relays);
}
// Close on EOSE
if (closeOnEose) {
parts.push("--close-on-eose");
}
return parts.join(" ");
}

View File

@@ -3,6 +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";
export interface Profile extends ProfileContent {
pubkey: string;
@@ -49,6 +50,19 @@ export interface RelayLivenessEntry {
backoffUntil?: number;
}
export interface LocalSpell {
id: string; // UUID for local-only spells, or event ID for published spells
alias?: string; // Optional local-only quick name (e.g., "btc")
name?: string; // Optional spell name (published to Nostr or mirrored from event)
command: string; // REQ command
description?: string; // Optional description
createdAt: number; // Timestamp
isPublished: boolean; // Whether it's been published to Nostr
eventId?: string; // Nostr event ID if published
event?: SpellEvent; // Full signed event for rebroadcasting
deletedAt?: number; // Timestamp when soft-deleted
}
class GrimoireDb extends Dexie {
profiles!: Table<Profile>;
nip05!: Table<Nip05>;
@@ -57,6 +71,7 @@ class GrimoireDb extends Dexie {
relayAuthPreferences!: Table<RelayAuthPreference>;
relayLists!: Table<CachedRelayList>;
relayLiveness!: Table<RelayLivenessEntry>;
spells!: Table<LocalSpell>;
constructor(name: string) {
super(name);
@@ -180,6 +195,91 @@ class GrimoireDb extends Dexie {
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
});
// Version 9: Add local spell storage
this.version(9).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
spells: "&id, createdAt, isPublished",
});
// Version 10: Rename localName → alias, add name field
this.version(10)
.stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
spells: "&id, createdAt, isPublished",
})
.upgrade(async (tx) => {
console.log(
"[DB Migration v10] Migrating spell schema (localName → alias)...",
);
const spells = await tx.table<any>("spells").toArray();
for (const spell of spells) {
// Rename localName → alias
if (spell.localName) {
spell.alias = spell.localName;
delete spell.localName;
}
// Initialize name field (will be populated from published events)
if (!spell.name) {
spell.name = undefined;
}
await tx.table("spells").put(spell);
}
console.log(`[DB Migration v10] Migrated ${spells.length} spells`);
});
// Version 11: Add index for spell alias
this.version(11).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
spells: "&id, alias, createdAt, isPublished",
});
// Version 12: Add full event storage for spells
this.version(12).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
spells: "&id, alias, createdAt, isPublished",
});
// Version 13: Add index for deletedAt
this.version(13).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
spells: "&id, alias, createdAt, isPublished, deletedAt",
});
}
}

9
src/services/hub.ts Normal file
View File

@@ -0,0 +1,9 @@
import { ActionHub } from "applesauce-actions";
import eventStore from "./event-store";
import { EventFactory } from "applesauce-factory";
/**
* Global action hub for Grimoire
* Used to register and execute actions throughout the application
*/
export const hub = new ActionHub(eventStore, new EventFactory());

View File

@@ -0,0 +1,120 @@
import db, { LocalSpell } from "./db";
import { SpellEvent } from "@/types/spell";
/**
* Save a spell to local storage
* @param spell - Spell data to save
* @returns The saved spell
* @throws Error if alias is already in use by another spell
*/
export async function saveSpell(
spell: Omit<LocalSpell, "id" | "createdAt">,
): Promise<LocalSpell> {
const id = spell.eventId || crypto.randomUUID();
const createdAt = Date.now();
// Validate alias uniqueness if provided
if (spell.alias) {
const existingWithAlias = await getSpellByAlias(spell.alias);
if (existingWithAlias && existingWithAlias.id !== id) {
throw new Error(
`Alias "${spell.alias}" is already in use by another spell`,
);
}
}
const localSpell: LocalSpell = {
id,
createdAt,
...spell,
};
await db.spells.put(localSpell);
return localSpell;
}
/**
* Get a spell by ID
* @param id - Spell ID
* @returns The spell or undefined if not found
*/
export async function getSpell(id: string): Promise<LocalSpell | undefined> {
return db.spells.get(id);
}
/**
* Get all spells, sorted by creation date (newest first)
* @returns Array of spells
*/
export async function getAllSpells(): Promise<LocalSpell[]> {
return db.spells.orderBy("createdAt").reverse().toArray();
}
/**
* Update an existing spell
* @param id - Spell ID
* @param updates - Fields to update
*/
export async function updateSpell(
id: string,
updates: Partial<Omit<LocalSpell, "id" | "createdAt">>,
): Promise<void> {
await db.spells.update(id, updates);
}
/**
* Soft-delete a spell (mark as deleted)
* @param id - Spell ID
*/
export async function deleteSpell(id: string): Promise<void> {
await db.spells.update(id, {
deletedAt: Date.now(),
});
}
/**
* Hard-delete a spell (permanently remove from DB)
* @param id - Spell ID
*/
export async function hardDeleteSpell(id: string): Promise<void> {
await db.spells.delete(id);
}
/**
* Mark a spell as published and associate with event ID
* @param localId - Local spell ID
* @param event - Published spell event
*/
export async function markSpellPublished(
localId: string,
event: SpellEvent,
): Promise<void> {
await db.spells.update(localId, {
isPublished: true,
eventId: event.id,
event,
});
}
/**
* Get spell alias by event ID
* @param eventId - Nostr event ID
* @returns Local alias or undefined
*/
export async function getSpellAliasByEventId(
eventId: string,
): Promise<string | undefined> {
const spell = await db.spells.where("eventId").equals(eventId).first();
return spell?.alias;
}
/**
* Get spell by alias
* @param alias - Spell alias
* @returns The spell or undefined if not found
*/
export async function getSpellByAlias(
alias: string,
): Promise<LocalSpell | undefined> {
return db.spells.where("alias").equals(alias).first();
}

View File

@@ -15,7 +15,8 @@ export type AppId =
| "decode"
| "relay"
| "debug"
| "conn";
| "conn"
| "spells";
export interface WindowInstance {
id: string;
@@ -82,6 +83,7 @@ export interface GrimoireState {
pubkey: string;
relays?: RelayInfo[];
};
compactModeKinds?: number[];
locale?: {
locale: string;
language: string;

View File

@@ -17,7 +17,7 @@ export interface ManPageEntry {
// Command execution metadata
appId: AppId;
category: "Documentation" | "System" | "Nostr";
argParser?: (args: string[]) => any;
argParser?: (args: string[], activeAccountPubkey?: string) => any;
defaultProps?: any;
}
@@ -335,7 +335,7 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "profile <identifier>",
description:
"Open a detailed view of a Nostr user profile. Accepts multiple identifier formats including npub, nprofile, hex pubkeys, and NIP-05 identifiers (including bare domains). Displays profile metadata, inbox/outbox relays, and raw JSON.",
"Open a detailed view of a Nostr user profile. Accepts multiple identifier formats including npub, nprofile, hex pubkeys, NIP-05 identifiers (including bare domains), and the $me alias. Displays profile metadata, inbox/outbox relays, and raw JSON.",
options: [
{
flag: "<identifier>",
@@ -344,6 +344,7 @@ export const manPages: Record<string, ManPageEntry> = {
],
examples: [
"profile fiatjaf.com Open profile by NIP-05 identifier",
"profile $me Open your own profile",
"profile nprofile1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309a6xsetxdaex2um59ehx7um5wgcjucm0d5hsz9mhwden5te0veex2mnn9ehx7um5wgcjucm0d5hszxrhwden5te0ve5kcar9wghxummnw3ezuamfdejj7qpq07jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2q0al9p4 Open profile with relay hints",
"profile 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d Open profile by hex pubkey (64 chars)",
"profile dergigi.com Open profile by domain (resolves to _@dergigi.com)",
@@ -352,8 +353,8 @@ export const manPages: Record<string, ManPageEntry> = {
seeAlso: ["open", "req"],
appId: "profile",
category: "Nostr",
argParser: async (args: string[]) => {
const parsed = await parseProfileCommand(args);
argParser: async (args: string[], activeAccountPubkey?: string) => {
const parsed = await parseProfileCommand(args, activeAccountPubkey);
return parsed;
},
},
@@ -457,4 +458,16 @@ export const manPages: Record<string, ManPageEntry> = {
category: "System",
defaultProps: {},
},
spells: {
name: "spells",
section: "1",
synopsis: "spells",
description:
"Browse and manage your REQ command spells. Spells are saved queries that can be run instantly. You can save spells locally or publish them as Nostr events (kind 777) to relays, making them portable and shareable. Use the 'Save as Spell' button in any REQ window to create new spells.",
examples: ["spells Browse your saved spells"],
seeAlso: ["req"],
appId: "spells",
category: "Nostr",
defaultProps: {},
},
};

122
src/types/spell.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { NostrEvent, NostrFilter } from "./nostr";
/**
* Spell event (kind 777 immutable event)
*
* REQ command parameters encoded as Nostr tags:
*
* REQUIRED:
* - ["cmd", "REQ"] - Command type
*
* METADATA:
* - ["client", "grimoire"] - Client identifier
* - ["alt", "description"] - NIP-31 human-readable description
* - ["name", "My Spell"] - Optional spell name (metadata only, not unique identifier)
* - ["t", "bitcoin"], ["t", "news"] - Topic tags for categorization
*
* FILTER - Queryable (multiple tags):
* - ["k", "1"], ["k", "3"] - Kinds filters
*
* FILTER - Arrays (single tag with multiple values):
* - ["authors", "hex1", "hex2", "$me", "$contacts"] - Author filters
* - ["ids", "id1", "id2"] - Direct event IDs (filter.ids)
* - ["tag", "e", "id1", "id2"] - Event tag filters (filter["#e"])
* - ["tag", "p", "pub1", "pub2"] - #p tag filters (can contain $me, $contacts)
* - ["tag", "P", "pub1", "pub2"] - #P tag filters (uppercase, can contain $me, $contacts)
* - ["tag", "t", "tag1", "tag2"] - #t tag filters (hashtags in filter)
* - ["tag", "d", "tag1", "tag2"] - #d tag filters
* - ["tag", "a", "kind:pubkey:d-tag"] - #a tag filters
* - ["tag", "X", "val1", "val2"] - Any single-letter tag filter
* - ["relays", "wss://relay1.com", "wss://relay2.com"] - Relay URLs
*
* FILTER - Scalars:
* - ["limit", "50"] - Result limit
* - ["since", "7d"] - Since timestamp (PRESERVE relative format for dynamic spells!)
* - ["until", "now"] - Until timestamp (PRESERVE relative format!)
* - ["search", "query text"] - Search query
*
* OPTIONS:
* - ["close-on-eose"] - Close subscription on EOSE (boolean flag)
*
* PROVENANCE:
* - ["e", "event-id"] - Fork source (references another spell event)
*
* CONTENT: Human-readable description (required)
*/
export interface SpellEvent extends NostrEvent {
kind: 777;
content: string;
tags: [string, string, ...string[]][];
}
/**
* Parsed spell with extracted metadata and reconstructed command
*/
export interface ParsedSpell {
/** Spell name (from name tag, published) */
name?: string;
/** Description (from content field) */
description?: string;
/** Reconstructed REQ command string (canonical form) */
command: string;
/** Parsed Nostr filter components */
filter: NostrFilter;
/** Relay URLs */
relays?: string[];
/** Close on EOSE flag */
closeOnEose: boolean;
/** Topic tags for categorization */
topics: string[];
/** Fork provenance (event ID of source spell) */
forkedFrom?: string;
/** Full event for reference */
event: SpellEvent;
}
/**
* Options for creating a spell from a REQ command
*/
export interface CreateSpellOptions {
/** Full REQ command string to parse (e.g., "req -k 1,3 -a npub... -l 50") */
command: string;
/** Optional spell name (published to Nostr) */
name?: string;
/** Optional description (goes to content field) */
description?: string;
/** Optional topic tags for categorization (stored as regular t tags) */
topics?: string[];
/** If forking, provide source event ID */
forkedFrom?: string;
}
/**
* Result of encoding a REQ command as a spell event
*/
export interface EncodedSpell {
/** Event tags encoding the REQ command parameters */
tags: [string, string, ...string[]][];
/** Human-readable content (optional) */
content: string;
/** Parsed filter for verification */
filter: NostrFilter;
/** Relay URLs extracted from command */
relays?: string[];
/** Close on EOSE flag */
closeOnEose: boolean;
}