feat: Spells app for saved, shareable Nostr queries (grimoire kind 777)

The original feedback mentioned "grimoire spells" — traced to
github.com/purrgrammer/grimoire, a third-party Nostr client with its
own draft NIP for kind 777 "Spell" events: a REQ filter (kinds,
authors, one tag filter, limit, time window) encoded as portable,
shareable tags, with $me/$contacts runtime variables and relative
timestamps ("7d", "now").

- src/hooks/useSpells.ts implements that draft NIP as-is (same tags,
  same variables, same relative-time grammar) rather than a
  reinterpretation, so a spell saved here round-trips with Grimoire.
- src/apps/spells: browse "My Spells" / "Discover", build one with
  NewSpellForm, and Run it on demand against the resolved filter,
  rendering kind-1 results with NoteCard.
- Only the "Spell" half is implemented; "Spellbook" (kind 30777,
  saved window layouts) is left as a documented follow-up.

Closes #24

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 16:58:34 +02:00
parent 809077d054
commit 89692ad382
6 changed files with 891 additions and 2 deletions

View File

@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
## The seven apps
## The eight apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -97,10 +97,22 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow |
| Note | `notes` | `id`, `relays?` | One note and its replies. **Not** a singleton |
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` |
| Spells | `spells` | `id?` | Saved/shareable REQ filters — kind 777, a third-party draft NIP |
| Relays | `relays` | — | Connection state, subscription count, measured latency |
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
| About | `about` | — | What this is, the app list, the shortcuts |
### Spells are a third-party kind, adopted for interop
Kind `777` ("Spell") isn't in the official nostr-protocol/nips registry — it comes from
[Grimoire](https://github.com/purrgrammer/grimoire), a third-party Nostr client that also
happens to be a tiling-window-manager OS like this one. `src/hooks/useSpells.ts` implements
its draft NIP as-is (same tag names, same `$me`/`$contacts` runtime variables, same relative
timestamp grammar) rather than inventing an incompatible shape, so a spell saved here is
readable by Grimoire and vice versa. Only the "Spell" half (kind `777`, a saved query) is
implemented; "Spellbook" (kind `30777`, a saved window layout) is not — see the app's
tracking issue for that as a possible follow-up.
### Follow lists are a whole-list replacement
kind 3 replaces the entire contact list. The follow button therefore reads the current

View File

@@ -0,0 +1,198 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCreateSpell, type SpellInput } from '@/hooks/useSpells';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
type AuthorsMode = 'anyone' | 'me' | 'contacts' | 'custom';
export function NewSpellForm({ onDone }: { onDone: () => void }) {
const { user } = useCurrentUser();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [kinds, setKinds] = useState('1');
const [authorsMode, setAuthorsMode] = useState<AuthorsMode>('anyone');
const [customAuthors, setCustomAuthors] = useState('');
const [tagLetter, setTagLetter] = useState('');
const [tagValues, setTagValues] = useState('');
const [since, setSince] = useState('');
const [limit, setLimit] = useState('50');
const [topics, setTopics] = useState('');
const create = useCreateSpell();
const { toast } = useToast();
const parsedKinds = kinds
.split(',')
.map((value) => Number(value.trim()))
.filter((value) => Number.isInteger(value) && value >= 0);
const isValid = parsedKinds.length > 0;
const submit = async () => {
if (!isValid) return;
const authors: string[] | undefined =
authorsMode === 'me'
? ['$me']
: authorsMode === 'contacts'
? ['$contacts']
: authorsMode === 'custom'
? customAuthors
.split(',')
.map((value) => value.trim())
.filter(Boolean)
: undefined;
const input: SpellInput = {
name: name.trim() || undefined,
description: description.trim() || undefined,
kinds: parsedKinds,
authors,
tagFilter:
tagLetter.trim() && tagValues.trim()
? {
letter: tagLetter.trim().slice(0, 1),
values: tagValues
.split(',')
.map((value) => value.trim())
.filter(Boolean),
}
: undefined,
limit: limit.trim() ? Number(limit.trim()) : undefined,
since: since.trim() || undefined,
topics: topics
.split(',')
.map((value) => value.trim())
.filter(Boolean),
};
try {
await create.mutateAsync(input);
toast({ title: 'Spell saved' });
onDone();
} catch (error) {
toast({
title: 'Could not save spell',
description: error instanceof Error ? error.message : 'No relay accepted it.',
variant: 'destructive',
});
}
};
if (!user) {
return (
<div className="p-5">
<p className="text-sm text-muted-foreground">Sign in to save a spell.</p>
</div>
);
}
return (
<div className="space-y-4 p-5">
<div>
<h2 className="text-lg font-semibold">New spell</h2>
<p className="mt-1 text-sm text-muted-foreground">
A spell is a saved Nostr query kinds, authors, a tag filter and a time window
that you can re-run, share, or come back to later.
</p>
</div>
<Field label="Name (optional)">
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Bitcoin from contacts" />
</Field>
<Field label="Description (optional)">
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={2}
className="min-h-14 resize-none text-[14px]"
/>
</Field>
<Field label="Kinds (comma separated)">
<Input value={kinds} onChange={(event) => setKinds(event.target.value)} placeholder="1, 30023" />
</Field>
<Field label="Authors">
<div className="flex flex-wrap gap-1.5">
{(
[
['anyone', 'Anyone'],
['me', 'Me'],
['contacts', 'My contacts'],
['custom', 'Custom'],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setAuthorsMode(mode)}
aria-pressed={authorsMode === mode}
className={cn(
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
authorsMode === mode ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70',
)}
>
{label}
</button>
))}
</div>
{authorsMode === 'custom' && (
<Input
value={customAuthors}
onChange={(event) => setCustomAuthors(event.target.value)}
placeholder="hex pubkeys, comma separated"
className="mt-2"
/>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Tag filter letter">
<Input value={tagLetter} onChange={(event) => setTagLetter(event.target.value)} placeholder="t" maxLength={1} />
</Field>
<Field label="Tag values">
<Input value={tagValues} onChange={(event) => setTagValues(event.target.value)} placeholder="bitcoin, nostr" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Since (e.g. 7d, now, or blank)">
<Input value={since} onChange={(event) => setSince(event.target.value)} placeholder="7d" />
</Field>
<Field label="Limit">
<Input value={limit} onChange={(event) => setLimit(event.target.value)} inputMode="numeric" />
</Field>
</div>
<Field label="Topics (comma separated, for discovery)">
<Input value={topics} onChange={(event) => setTopics(event.target.value)} placeholder="bitcoin, social" />
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onDone}>
Cancel
</Button>
<Button size="sm" onClick={submit} disabled={!isValid || create.isPending} className="gap-1.5">
{create.isPending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Save spell
</Button>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<span className="block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</div>
);
}

375
src/apps/spells/index.tsx Normal file
View File

@@ -0,0 +1,375 @@
import { useEffect, useMemo, useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { ChevronLeft, Globe, Loader2, Plus, Sparkles, User as UserIcon } from 'lucide-react';
import {
AppBody,
AppLayout,
AppSectionTitle,
AppSidebar,
AppSplit,
AppToolbar,
EmptyState,
} from '@/components/os/AppChrome';
import { AuthorLine } from '@/components/nostr/AuthorLine';
import { NoteCard } from '@/components/nostr/NoteCard';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useIsMobile } from '@/hooks/useIsMobile';
import {
parseSpell,
resolveSpellFilter,
useDiscoverSpells,
useMySpells,
useRunSpell,
useSpellContext,
} from '@/hooks/useSpells';
import { relativeTime } from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
import { NewSpellForm } from './NewSpellForm';
import type { AppProps } from '@/os/types';
type Scope = 'mine' | 'discover';
export default function SpellsApp({ params, setTitle, setParams }: AppProps) {
const { user } = useCurrentUser();
const isMobile = useIsMobile();
const [scope, setScope] = useState<Scope>(user ? 'mine' : 'discover');
const [formOpen, setFormOpen] = useState(false);
const mine = useMySpells();
const discover = useDiscoverSpells();
const query = scope === 'mine' ? mine : discover;
const selectedId = params.id;
const selected = query.data?.find((event) => event.id === selectedId);
useEffect(() => setTitle('Spells'), [setTitle]);
const select = (id: string | null) => setParams(id ? { id } : {});
const listPane = (
<SpellList
query={query}
scope={scope}
selectedId={selectedId}
onSelect={(event) => select(event.id)}
/>
);
const detailPane = formOpen ? (
<NewSpellForm onDone={() => setFormOpen(false)} />
) : selected ? (
<SpellDetail event={selected} />
) : (
<EmptyState
title="Pick a spell"
hint="Choose a saved query from the list, or cast a new one."
action={
<Button size="sm" onClick={() => setFormOpen(true)} className="gap-1.5">
<Sparkles className="size-3.5" aria-hidden />
New spell
</Button>
}
/>
);
const toolbarTabs = (
<>
<ScopeTab
active={scope === 'mine'}
disabled={!user}
onClick={() => setScope('mine')}
icon={<UserIcon className="size-3.5" aria-hidden />}
label="My Spells"
/>
<ScopeTab
active={scope === 'discover'}
onClick={() => setScope('discover')}
icon={<Globe className="size-3.5" aria-hidden />}
label="Discover"
/>
</>
);
if (isMobile) {
const showingDetail = formOpen || Boolean(selected);
return (
<AppLayout>
<AppToolbar className="gap-1">
{showingDetail ? (
<button
type="button"
onClick={() => {
setFormOpen(false);
select(null);
}}
className="-ml-1 flex items-center gap-1 rounded px-1 py-0.5 text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronLeft className="size-4" aria-hidden />
Spells
</button>
) : (
toolbarTabs
)}
{!showingDetail && (
<Button
variant="ghost"
size="sm"
className="ml-auto h-7 gap-1.5 px-2 text-xs"
onClick={() => setFormOpen(true)}
>
<Plus className="size-3.5" aria-hidden />
New
</Button>
)}
</AppToolbar>
<AppBody>{showingDetail ? detailPane : listPane}</AppBody>
</AppLayout>
);
}
return (
<AppLayout>
<AppToolbar className="gap-1">
{toolbarTabs}
<Button
variant="ghost"
size="sm"
className="ml-auto h-7 gap-1.5 px-2 text-xs"
onClick={() => setFormOpen(true)}
>
<Plus className="size-3.5" aria-hidden />
New spell
</Button>
</AppToolbar>
<AppSplit>
<AppSidebar className="p-0">{listPane}</AppSidebar>
<AppBody>{detailPane}</AppBody>
</AppSplit>
</AppLayout>
);
}
function ScopeTab({
active,
disabled,
onClick,
icon,
label,
}: {
active: boolean;
disabled?: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[13px] font-medium transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
'disabled:cursor-not-allowed disabled:opacity-40',
active ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-muted',
)}
>
{icon}
{label}
</button>
);
}
function SpellList({
query,
scope,
selectedId,
onSelect,
}: {
query: { isLoading: boolean; data: NostrEvent[] | undefined };
scope: Scope;
selectedId: string | undefined;
onSelect: (event: NostrEvent) => void;
}) {
if (query.isLoading) {
return (
<div className="space-y-3 p-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-full" />
))}
</div>
);
}
if (!query.data || query.data.length === 0) {
return (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{scope === 'mine' ? 'You havent saved any spells yet.' : 'No spells found on your relays.'}
</p>
);
}
return (
<>
<AppSectionTitle>{scope === 'mine' ? 'My Spells' : 'Discover'}</AppSectionTitle>
<ul className="pb-2">
{query.data.map((event) => (
<li key={event.id}>
<SpellListItem event={event} active={event.id === selectedId} onSelect={() => onSelect(event)} />
</li>
))}
</ul>
</>
);
}
function SpellListItem({
event,
active,
onSelect,
}: {
event: NostrEvent;
active: boolean;
onSelect: () => void;
}) {
const spell = useMemo(() => parseSpell(event), [event]);
const label = spell.name || spell.description || `${spell.kinds.join(', ') || 'empty'} query`;
return (
<button
type="button"
onClick={onSelect}
className={cn(
'w-full px-3 py-2 text-left transition-colors',
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
active ? 'bg-accent text-accent-foreground' : 'hover:bg-muted',
)}
>
<span className="line-clamp-1 block text-[13px] font-medium leading-snug">{label}</span>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
kinds {spell.kinds.join(', ') || '—'} · {relativeTime(event.created_at)}
</span>
</button>
);
}
function SpellDetail({ event }: { event: NostrEvent }) {
const spell = useMemo(() => parseSpell(event), [event]);
const context = useSpellContext();
const filter = useMemo(() => resolveSpellFilter(spell, context), [spell, context]);
const run = useRunSpell(filter);
return (
<div className="p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold leading-tight">
{spell.name || spell.description || 'Untitled spell'}
</h1>
{spell.name && spell.description && (
<p className="mt-1 text-sm text-muted-foreground">{spell.description}</p>
)}
</div>
<Button size="sm" onClick={() => run.mutate()} disabled={!filter || run.isPending} className="shrink-0 gap-1.5">
{run.isPending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Run
</Button>
</div>
<AuthorLine pubkey={event.pubkey} createdAt={event.created_at} size="sm" />
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{spell.kinds.map((kind) => (
<Badge key={kind} variant="secondary" className="text-[11px]">
kind {kind}
</Badge>
))}
{spell.authors.length > 0 && (
<Badge variant="outline" className="text-[11px]">
authors: {spell.authors.join(', ')}
</Badge>
)}
{spell.tagFilter && (
<Badge variant="outline" className="text-[11px]">
#{spell.tagFilter.letter}: {spell.tagFilter.values.join(', ')}
</Badge>
)}
{spell.since && (
<Badge variant="outline" className="text-[11px]">
since {spell.since}
</Badge>
)}
{spell.topics.map((topic) => (
<Badge key={topic} variant="outline" className="text-[11px]">
{topic}
</Badge>
))}
</div>
{!filter && (
<p className="mt-4 text-xs text-muted-foreground">
{spell.authors.includes('$me') && !context.me
? 'This spell needs $me — sign in to run it.'
: 'This spell has no runnable filter.'}
</p>
)}
<div className="mt-6">
{run.isPending ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : run.isError ? (
<p className="text-sm text-destructive">
{run.error instanceof Error ? run.error.message : 'The query failed.'}
</p>
) : run.isSuccess ? (
<SpellResults events={run.data} />
) : null}
</div>
</div>
);
}
function SpellResults({ events }: { events: NostrEvent[] }) {
if (events.length === 0) {
return <p className="text-sm text-muted-foreground">No events matched.</p>;
}
return (
<div className="-mx-5 border-t border-border">
<p className="px-5 py-2 text-xs text-muted-foreground">
{events.length} {events.length === 1 ? 'result' : 'results'}
</p>
{events.map((event) =>
event.kind === 1 ? (
<NoteCard key={event.id} event={event} compact />
) : (
<GenericResultRow key={event.id} event={event} />
),
)}
</div>
);
}
function GenericResultRow({ event }: { event: NostrEvent }) {
return (
<div className="border-b border-border px-5 py-3 last:border-b-0">
<div className="flex items-center justify-between gap-2">
<AuthorLine pubkey={event.pubkey} createdAt={event.created_at} size="sm" />
<Badge variant="secondary" className="shrink-0 text-[11px]">
kind {event.kind}
</Badge>
</div>
{event.content && (
<p className="mt-1.5 line-clamp-3 text-[13px] text-muted-foreground">{event.content}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { encodeSpellTags, parseSpell, resolveSpellFilter, resolveTimestamp } from './useSpells';
function spellEvent(tags: string[][], content = ''): NostrEvent {
return { id: 'x', pubkey: 'author', created_at: 0, kind: 777, tags, content, sig: '' };
}
describe('resolveTimestamp', () => {
it('resolves a relative duration against now', () => {
const now = Math.floor(Date.now() / 1000);
expect(resolveTimestamp('7d')).toBeCloseTo(now - 7 * 86400, -1);
});
it('resolves "now"', () => {
const now = Math.floor(Date.now() / 1000);
expect(resolveTimestamp('now')).toBeCloseTo(now, -1);
});
it('resolves an absolute unix timestamp', () => {
expect(resolveTimestamp('1700000000')).toBe(1700000000);
});
it('is undefined for blank input', () => {
expect(resolveTimestamp('')).toBeUndefined();
expect(resolveTimestamp(' ')).toBeUndefined();
});
});
describe('encodeSpellTags / parseSpell round-trip', () => {
it('round-trips a spell with authors, a tag filter, since and topics', () => {
const tags = encodeSpellTags({
name: 'Bitcoin from contacts',
description: 'Notes about Bitcoin from my contacts',
kinds: [1],
authors: ['$contacts'],
tagFilter: { letter: 't', values: ['bitcoin'] },
since: '7d',
limit: 50,
topics: ['bitcoin', 'social'],
});
const event = spellEvent(tags, 'Notes about Bitcoin from my contacts');
const parsed = parseSpell(event);
expect(parsed.name).toBe('Bitcoin from contacts');
expect(parsed.kinds).toEqual([1]);
expect(parsed.authors).toEqual(['$contacts']);
expect(parsed.tagFilter).toEqual({ letter: 't', values: ['bitcoin'] });
expect(parsed.since).toBe('7d');
expect(parsed.limit).toBe(50);
expect(parsed.topics).toEqual(['bitcoin', 'social']);
});
});
describe('resolveSpellFilter', () => {
it('resolves $me and $contacts into real pubkeys', () => {
const event = spellEvent(encodeSpellTags({ kinds: [1], authors: ['$me', '$contacts'] }));
const filter = resolveSpellFilter(parseSpell(event), { me: 'abc', contacts: ['def', 'ghi'] });
expect(filter?.authors).toEqual(['abc', 'def', 'ghi']);
});
it('returns null when $me is required but nobody is signed in', () => {
const event = spellEvent(encodeSpellTags({ kinds: [1], authors: ['$me'] }));
const filter = resolveSpellFilter(parseSpell(event), { me: undefined, contacts: [] });
expect(filter).toBeNull();
});
it('returns null when there are no kinds', () => {
const event = spellEvent(encodeSpellTags({ kinds: [] }));
const filter = resolveSpellFilter(parseSpell(event), { me: undefined, contacts: [] });
expect(filter).toBeNull();
});
it('turns a tag filter into a #<letter> filter field', () => {
const event = spellEvent(
encodeSpellTags({ kinds: [1], tagFilter: { letter: 't', values: ['bitcoin', 'nostr'] } }),
);
const filter = resolveSpellFilter(parseSpell(event), { me: undefined, contacts: [] });
expect(filter?.['#t']).toEqual(['bitcoin', 'nostr']);
});
});

213
src/hooks/useSpells.ts Normal file
View File

@@ -0,0 +1,213 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useMyFollows } from './useFollows';
import { useNostrPublish } from './useNostrPublish';
import { tagValue, tagValues } from '@/lib/nostrUtils';
/**
* A third-party draft NIP (not in the official nostr-protocol/nips registry)
* from the Grimoire client (github.com/purrgrammer/grimoire): kind 777
* "Spell" events encode a REQ filter as portable, shareable tags, with
* `$me`/`$contacts` runtime variables and relative timestamps ("7d", "now").
* Kind numbers are adopted as-is for interop with that client.
*/
export const SPELL_KIND = 777;
const RELATIVE_TIME_RE = /^(\d+)(s|m|h|d|w|mo|y)$/;
const UNIT_SECONDS: Record<string, number> = {
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
mo: 2_592_000,
y: 31_536_000,
};
/** Resolves `now`, `<n><unit>` (e.g. `7d`) or a literal unix timestamp string. */
export function resolveTimestamp(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed === 'now') return Math.floor(Date.now() / 1000);
const relative = RELATIVE_TIME_RE.exec(trimmed);
if (relative) {
const [, amount, unit] = relative;
return Math.floor(Date.now() / 1000) - Number(amount) * UNIT_SECONDS[unit];
}
const absolute = Number(trimmed);
return Number.isFinite(absolute) && absolute > 0 ? Math.floor(absolute) : undefined;
}
export interface SpellInput {
name?: string;
description?: string;
kinds: number[];
/** `$me`, `$contacts`, or a literal list of pubkeys (hex). */
authors?: string[];
/** A single `#<letter>` tag filter, e.g. `{ letter: 't', values: ['bitcoin'] }`. */
tagFilter?: { letter: string; values: string[] };
limit?: number;
since?: string;
until?: string;
search?: string;
topics?: string[];
}
export interface ParsedSpell {
name?: string;
description: string;
kinds: number[];
authors: string[];
tagFilter?: { letter: string; values: string[] };
limit?: number;
since?: string;
until?: string;
search?: string;
topics: string[];
event: NostrEvent;
}
/** Builds the tag set for a spell event per the draft NIP. */
export function encodeSpellTags(input: SpellInput): string[][] {
const tags: string[][] = [['cmd', 'REQ']];
for (const kind of input.kinds) tags.push(['k', String(kind)]);
if (input.authors?.length) tags.push(['authors', ...input.authors]);
if (input.tagFilter?.values.length) tags.push(['tag', input.tagFilter.letter, ...input.tagFilter.values]);
if (input.limit) tags.push(['limit', String(input.limit)]);
if (input.since?.trim()) tags.push(['since', input.since.trim()]);
if (input.until?.trim()) tags.push(['until', input.until.trim()]);
if (input.search?.trim()) tags.push(['search', input.search.trim()]);
if (input.name?.trim()) tags.push(['name', input.name.trim()]);
tags.push(['alt', `Spell: ${input.description || input.name || 'a saved Nostr query'}`]);
for (const topic of input.topics ?? []) if (topic.trim()) tags.push(['t', topic.trim()]);
return tags;
}
export function parseSpell(event: NostrEvent): ParsedSpell {
const kinds = tagValues(event, 'k').map(Number).filter((n) => Number.isFinite(n));
const authorsTag = event.tags.find(([name]) => name === 'authors');
const tagFilterTag = event.tags.find(([name]) => name === 'tag');
const limitValue = tagValue(event, 'limit');
return {
name: tagValue(event, 'name'),
description: event.content,
kinds,
authors: authorsTag ? authorsTag.slice(1) : [],
tagFilter: tagFilterTag ? { letter: tagFilterTag[1], values: tagFilterTag.slice(2) } : undefined,
limit: limitValue ? Number(limitValue) : undefined,
since: tagValue(event, 'since'),
until: tagValue(event, 'until'),
search: tagValue(event, 'search'),
topics: tagValues(event, 't'),
event,
};
}
/** Resolves a parsed spell's `$me`/`$contacts` and relative timestamps into a real filter. */
export function resolveSpellFilter(
spell: ParsedSpell,
context: { me: string | undefined; contacts: string[] },
): NostrFilter | null {
if (spell.kinds.length === 0) return null;
const filter: NostrFilter = { kinds: spell.kinds };
if (spell.authors.length > 0) {
const resolved = spell.authors.flatMap((author) => {
if (author === '$me') return context.me ? [context.me] : [];
if (author === '$contacts') return context.contacts;
return [author];
});
if (resolved.length === 0) return null;
filter.authors = resolved;
}
if (spell.tagFilter) {
filter[`#${spell.tagFilter.letter}`] = spell.tagFilter.values;
}
if (spell.limit) filter.limit = spell.limit;
if (spell.since) {
const since = resolveTimestamp(spell.since);
if (since !== undefined) filter.since = since;
}
if (spell.until) {
const until = resolveTimestamp(spell.until);
if (until !== undefined) filter.until = until;
}
if (spell.search) filter.search = spell.search;
return filter;
}
/** `$me`/`$contacts` for the signed-in user, ready to hand to `resolveSpellFilter`. */
export function useSpellContext() {
const { user } = useCurrentUser();
const { data: contacts } = useMyFollows();
return { me: user?.pubkey, contacts: contacts ?? [] };
}
export function useMySpells() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'spells', 'mine', user?.pubkey ?? ''],
enabled: Boolean(user),
queryFn: async ({ signal }) => {
const events = await nostr.query(
[{ kinds: [SPELL_KIND], authors: [user!.pubkey], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
);
return events.sort((a, b) => b.created_at - a.created_at);
},
staleTime: 30_000,
});
}
export function useDiscoverSpells() {
const { nostr } = useNostr();
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'spells', 'discover'],
queryFn: async ({ signal }) => {
const events = await nostr.query([{ kinds: [SPELL_KIND], limit: 100 }], {
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
});
return events.sort((a, b) => b.created_at - a.created_at);
},
staleTime: 60_000,
});
}
export function useCreateSpell() {
const publish = useNostrPublish();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
return useMutation({
mutationFn: async (input: SpellInput) => {
if (!user) throw new Error('Sign in to save a spell');
return publish.mutateAsync({ kind: SPELL_KIND, content: input.description ?? '', tags: encodeSpellTags(input) });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nostr', 'spells', 'mine', user?.pubkey ?? ''] });
queryClient.invalidateQueries({ queryKey: ['nostr', 'spells', 'discover'] });
},
});
}
/** Runs a spell's resolved filter on demand — a spell is a saved query, not a subscription. */
export function useRunSpell(filter: NostrFilter | null) {
const { nostr } = useNostr();
return useMutation({
mutationFn: async () => {
if (!filter) throw new Error('This spell has no runnable filter');
return nostr.query([filter], { signal: AbortSignal.timeout(8000) });
},
});
}

View File

@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { Activity, BookOpen, FileText, Info, Rss, Settings, User } from 'lucide-react';
import { Activity, BookOpen, FileText, Info, Rss, Settings, Sparkles, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -49,6 +49,16 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 780, height: 760 },
minSize: { width: 360, height: 320 },
},
{
id: 'spells',
title: 'Spells',
description: 'Saved, shareable Nostr queries you can re-run any time',
icon: Sparkles,
category: 'tools',
component: lazy(() => import('@/apps/spells')),
defaultSize: { width: 760, height: 700 },
minSize: { width: 380, height: 320 },
},
{
id: 'relays',
title: 'Relays',