diff --git a/SPELL_SYSTEM_PLAN.md b/SPELL_SYSTEM_PLAN.md new file mode 100644 index 0000000..4a08117 --- /dev/null +++ b/SPELL_SYSTEM_PLAN.md @@ -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 +- `` → 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; +} +``` + +**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([]); +export const publishedSpellsAtom = atom([]); +export const spellDiscoveryAtom = atom([]); +``` + +**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("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 diff --git a/package-lock.json b/package-lock.json index 3dc5f42..fb102a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 72fbeac..8707fdf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/actions/delete-event.ts b/src/actions/delete-event.ts new file mode 100644 index 0000000..2867835 --- /dev/null +++ b/src/actions/delete-event.ts @@ -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 { + 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); + } +} diff --git a/src/actions/publish-spell.test.ts b/src/actions/publish-spell.test.ts new file mode 100644 index 0000000..7e870ee --- /dev/null +++ b/src/actions/publish-spell.test.ts @@ -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"], + ]), + }), + ); + }); +}); diff --git a/src/actions/publish-spell.ts b/src/actions/publish-spell.ts new file mode 100644 index 0000000..abb08c6 --- /dev/null +++ b/src/actions/publish-spell.ts @@ -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 { + 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); + } +} diff --git a/src/components/CommandLauncher.tsx b/src/components/CommandLauncher.tsx index 808ac93..59645b6 100644 --- a/src/components/CommandLauncher.tsx +++ b/src/components/CommandLauncher.tsx @@ -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({ )} -
- {cmd.description.split(".")[0]} -
+ {cmd.description && ( +
+ {cmd.description.split(".")[0]} +
+ )} + {cmd.spellCommand && ( +
+ {cmd.spellCommand} +
+ )} ); diff --git a/src/components/CreateSpellDialog.tsx b/src/components/CreateSpellDialog.tsx new file mode 100644 index 0000000..918d40b --- /dev/null +++ b/src/components/CreateSpellDialog.tsx @@ -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([]); + const [authors, setAuthors] = React.useState([]); + const [mentions, setMentions] = React.useState([]); + const [search, setSearch] = React.useState(""); + const [hashtags, setHashtags] = React.useState([]); + const [since, setSince] = React.useState(""); + const [until, setUntil] = React.useState(""); + const [relays, setRelays] = React.useState([]); + const [closeOnEose, setCloseOnEose] = React.useState(false); + const [limit, setLimit] = React.useState(""); + const [genericTags, setGenericTags] = React.useState< + Record + >({}); + 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 ( + <> + + + + + + Create New Spell + + + Build a custom view of Nostr events by selecting filters below. + + + +
+ {/* Kinds Section */} + } + defaultOpen={true} + > +
+ +
+ {kinds.map((k) => ( + + + + + ))} + {kinds.length === 0 && ( + + All event types (notes, profiles, etc.) + + )} +
+
+
+ + {/* Authors Section */} + } + > +
+ +
+ {authors.map((p) => ( + + {p === "$me" || p === "$contacts" ? ( + + {p} + + ) : ( + + )} + + + ))} + {authors.length === 0 && ( + + Anyone on the network + + )} +
+
+
+ + {/* Mentions Section */} + } + > +
+ +
+ {mentions.map((p) => ( + + {p === "$me" || p === "$contacts" ? ( + + {p} + + ) : ( + + )} + + + ))} +
+
+
+ + {/* Content Section */} + } + > +
+
+ + setSearch(e.target.value)} + /> +
+
+ +
+ { + if (e.key === "Enter") { + handleAddHashtag(e.currentTarget.value); + e.currentTarget.value = ""; + } + }} + /> +
+
+ {hashtags.map((t) => ( + + # + {t} + + + ))} +
+
+
+
+ + {/* Generic Tags Section */} + } + > +
+
+
+ + + setActiveTagLetter(e.target.value.trim().slice(0, 1)) + } + className="w-12 text-center font-mono font-bold" + /> +
+
+ + {activeTagLetter === "k" ? ( + handleAddGenericTag("k", k.toString())} + /> + ) : activeTagLetter === "p" || activeTagLetter === "P" ? ( + + handleAddGenericTag(activeTagLetter, pk) + } + placeholder={`Add ${activeTagLetter} pubkey...`} + /> + ) : ( + { + if (e.key === "Enter") { + handleAddGenericTag( + activeTagLetter, + e.currentTarget.value, + ); + e.currentTarget.value = ""; + } + }} + /> + )} +
+
+ +
+ {Object.entries(genericTags).map(([letter, values]) => ( +
+
+ #{letter} +
+
+ {values.map((val) => ( + + {letter === "k" ? ( + + ) : letter === "p" || letter === "P" ? ( + val.startsWith("$") ? ( + + {val} + + ) : ( + + ) + ) : ( + + {val} + + )} + + + ))} +
+
+ ))} +
+
+
+ + {/* Time Section */} + } + > +
+
+ + setSince(e.target.value)} + /> +
+ {["now", "1h", "24h", "7d", "30d"].map((t) => ( + + ))} +
+
+
+ + setUntil(e.target.value)} + /> +
+ {["now", "1h", "24h", "7d", "30d"].map((t) => ( + + ))} +
+
+
+
+ + {/* Options Section */} + } + > +
+
+ + { + const val = e.target.value; + setLimit(val === "" ? "" : parseInt(val)); + }} + placeholder="No limit" + className="w-24" + /> +
+
+
+ +

+ Don't listen for new events in real-time +

+
+ setCloseOnEose(e.target.checked)} + className="size-4 rounded border-gray-300 text-accent focus:ring-accent" + /> +
+
+ +
+ { + if (e.key === "Enter") { + handleAddRelay(e.currentTarget.value); + e.currentTarget.value = ""; + } + }} + /> +
+
+ {relays.map((r) => ( + + {r} + + + ))} +
+
+
+
+
+ +
+
+ +
+ {generatedCommand} +
+
+ +
+ + + +
+
+
+
+ + {/* Actual saving/publishing dialog */} + {showSaveDialog && ( + { + 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 ( + + +
+ {icon} + {title} +
+ +
+ + {children} + +
+ ); +} + +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 ( +
+ {children} +
+ ); +} diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 8100f1f..2534683 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -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; } diff --git a/src/components/JsonViewer.tsx b/src/components/JsonViewer.tsx index c3d4a54..7e7c820 100644 --- a/src/components/JsonViewer.tsx +++ b/src/components/JsonViewer.tsx @@ -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 ( +
+ + +
+ ); +} + interface JsonViewerProps { data: any; open: boolean; diff --git a/src/components/KindSelector.tsx b/src/components/KindSelector.tsx new file mode 100644 index 0000000..98cace3 --- /dev/null +++ b/src/components/KindSelector.tsx @@ -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 ( + + + + + + +
+ +
+ + + No kind found. + + {isCustomNumber && ( + 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" + > + + Add Kind {search} + + )} + {filteredKinds.map((kind) => ( + 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" + > +
+
+ {kind.label} + + {kind.value} + +
+ + {kind.description} + +
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/ManPage.tsx b/src/components/ManPage.tsx index d17a92d..dca47f7 100644 --- a/src/components/ManPage.tsx +++ b/src/components/ManPage.tsx @@ -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 ( - + ); } diff --git a/src/components/ProfileSelector.tsx b/src/components/ProfileSelector.tsx new file mode 100644 index 0000000..f285550 --- /dev/null +++ b/src/components/ProfileSelector.tsx @@ -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([]); + 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 ( + + + + + + +
+ +
+ + + No profiles found in cache. + + + {showShortcuts && !search && ( + + {accountPubkey && ( + 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" + > + +
+ My Profile + + $me + +
+
+ )} + {contacts.length > 0 && ( + 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" + > + +
+ + My Contacts ({contacts.length}) + + + $contacts + +
+
+ )} +
+ )} + + {results.length > 0 && ( + + {results.map((profile) => ( + 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" + > +
+
+ + {getDisplayName(profile.pubkey, profile)} + +
+ {profile.about && ( + + {profile.about} + + )} + {profile.lud16 && ( + + {profile.lud16} + + )} +
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index 9ff4541..c62a6ba 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -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 ( +
+
+ +

Account Required

+

+ The $me alias + requires an active account. Please log in to view your profile. +

+
+
+ ); + } + + if (!resolvedPubkey) { + return ( +
Invalid profile pubkey.
+ ); + } return (
diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 1e7f99e..1c9d388 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -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>(new Set()); + // Freeze timeline after EOSE to prevent auto-scrolling on new events + const [freezePoint, setFreezePoint] = useState(null); + const [isFrozen, setIsFrozen] = useState(false); + const virtuosoRef = useRef(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) && ( -
+
+ {/* Floating "New Events" Button */} + {isFrozen && newEventCount > 0 && ( +
+ +
+ )} + {/* Loading: Before EOSE received */} {loading && events.length === 0 && !eoseReceived && (
@@ -1167,11 +1192,11 @@ export default function ReqViewer({
)} - {events.length > 0 && ( + {visibleEvents.length > 0 && ( item.id} itemContent={(_index, event) => ( diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx new file mode 100644 index 0000000..7224aa7 --- /dev/null +++ b/src/components/SettingsDialog.tsx @@ -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 ( + + + + Settings + + Manage your workspace preferences. + + + +
+ +
+ + + Appearance + + {/* Future tabs can be added here */} + +
+ +
+ + {/* Section: Compact Events */} +
+
+

Compact Events

+

+ Select event kinds to display in a compact format within + timelines and feeds. +

+
+ +
+ +
+ +
+
+ {compactKinds.length === 0 && ( + + No compact kinds configured. + + )} + {compactKinds.map((kind) => ( + + + {kind} + + {getKindName(kind)} + + + ))} +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/SpellsViewer.tsx b/src/components/SpellsViewer.tsx new file mode 100644 index 0000000..a157184 --- /dev/null +++ b/src/components/SpellsViewer.tsx @@ -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; + onPublish: (spell: LocalSpell) => Promise; +} + +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 ( + + +
+
+ + + {displayName} + +
+ {spell.deletedAt ? ( + + + + ) : spell.isPublished ? ( + + + + ) : ( + + + + )} +
+ {spell.description && ( + + {spell.description} + + )} +
+ + +
+ + {spell.command} + +
+ {kinds.map((kind) => ( + + ))} + {spell.alias && ( +
+ Alias: {spell.alias} +
+ )} +
+
+
+ + + + + {!spell.deletedAt && ( + + )} + +
+ ); +} + +/** + * 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 ( +
+ {/* Header */} +
+
+
+ +

Spells

+ + {filteredSpells.length} + +
+ +
+ + {/* Search and filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + {/* Type filter buttons */} +
+ + + +
+
+
+ + {!loading && searchQuery === "" && filterType !== "local" && ( +
+
+
+ + Network Spells +
+

+ Browse spells published by your contacts. +

+
+ + req -k 777 -a $contacts + +
+ )} + + {/* Spell list */} +
+ {loading ? ( +
+
+ +

Loading spells...

+
+
+ ) : filteredSpells.length === 0 ? ( +
+
+ +

No spells found

+

+ {searchQuery + ? "Try a different search query" + : "Create your first spell from any REQ window"} +

+

+ Open a REQ window and click the "Save as Spell" button to create + a spell +

+
+
+ ) : ( +
+ {filteredSpells.map((spell) => ( + + ))} +
+ )} +
+ + +
+ ); +} diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index eed2a06..48172b4 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -98,7 +98,7 @@ export function TabBar() { return ( <> -
+
{/* Left side: Workspace tabs + new workspace button */} 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 = ; break; + case "spells": + content = ; + break; default: content = (
diff --git a/src/components/WindowToolbar.tsx b/src/components/WindowToolbar.tsx index 438f078..aee30ac 100644 --- a/src/components/WindowToolbar.tsx +++ b/src/components/WindowToolbar.tsx @@ -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 && ( - + <> + {/* Edit button with keyboard shortcut hint */} + + + {/* More actions menu - only for REQ windows for now */} + {isReqWindow && ( + + + + + + + + Save as spell + + + + )} + + {/* Spell Dialog */} + {isReqWindow && ( + { + toast.success("Spell published successfully!"); + }} + /> + )} + )} {onClose && (