mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-15 01:46:53 +02:00
feat: spells
This commit is contained in:
501
SPELL_SYSTEM_PLAN.md
Normal file
501
SPELL_SYSTEM_PLAN.md
Normal 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
73
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
49
src/actions/delete-event.ts
Normal file
49
src/actions/delete-event.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
98
src/actions/publish-spell.test.ts
Normal file
98
src/actions/publish-spell.test.ts
Normal 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"],
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
76
src/actions/publish-spell.ts
Normal file
76
src/actions/publish-spell.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
705
src/components/CreateSpellDialog.tsx
Normal file
705
src/components/CreateSpellDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
133
src/components/KindSelector.tsx
Normal file
133
src/components/KindSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
238
src/components/ProfileSelector.tsx
Normal file
238
src/components/ProfileSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
113
src/components/SettingsDialog.tsx
Normal file
113
src/components/SettingsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
419
src/components/SpellsViewer.tsx
Normal file
419
src/components/SpellsViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
444
src/components/nostr/SpellDialog.tsx
Normal file
444
src/components/nostr/SpellDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
316
src/components/nostr/kinds/SpellRenderer.tsx
Normal file
316
src/components/nostr/kinds/SpellRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 };
|
||||
85
src/components/ui/card.tsx
Normal file
85
src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal 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 };
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal 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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -489,4 +489,3 @@ export const setCompactModeKinds = (
|
||||
compactModeKinds: kinds,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
768
src/lib/spell-conversion.test.ts
Normal file
768
src/lib/spell-conversion.test.ts
Normal 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
439
src/lib/spell-conversion.ts
Normal 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(" ");
|
||||
}
|
||||
@@ -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
9
src/services/hub.ts
Normal 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());
|
||||
120
src/services/spell-storage.ts
Normal file
120
src/services/spell-storage.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
122
src/types/spell.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user