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

* 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

* fix: address review feedback on the Spells app

Per review:
- Scope is now derived (forced to "discover" when signed out) rather
  than stored as the requested value directly, matching the Feed
  app's pattern — signing out mid-session can no longer leave "My
  Spells" selected.
- resolveSpellFilter() now validates a spell's tag-filter letter
  (single a-zA-Z char) before using it as a "#<letter>" filter key,
  and clamps limit to [1, 500] instead of trusting a relay-sourced
  spell's number outright — a malformed or hostile spell can no
  longer produce a "#undefined" filter key or an enormous/NaN/zero
  limit. Added regression tests for all of these.
- NewSpellForm's Field now renders a real <label htmlFor> connected
  to each input's id (via useId()), and the Authors button group
  moved to a <fieldset>/<legend> instead of a label sitting over
  unrelated buttons — screen readers can now name every control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: validate spell tag filter/limit at write time, and hide malformed badges

Per review:
- encodeSpellTags() now validates the tag filter's letter and the
  limit before writing them, instead of only resolveSpellFilter()
  catching bad values on Run — a spell authored through this app can
  no longer save a filter it will silently fail to apply later.
  Exported isValidTagLetter() so both sides share one definition of
  "valid."
- The spell detail view's tag-filter badge now hides itself for a
  malformed tag filter (e.g. from a relay-sourced spell this app
  didn't author) instead of rendering "#undefined:" or similar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: reject non-integer/negative spell kinds, de-flake a timing test

Per remaining "previously missed" findings:
- parseSpell() now requires k tags to be non-negative integers
  (Number.isInteger && >= 0), not just finite — a relay-sourced spell
  claiming kind "1.5" or "-1" no longer passes through into a
  malformed filter.
- The resolveTimestamp wall-clock tests asserted toBeCloseTo a single
  captured `now`, which a slow runner or timing skew between the two
  Date.now() calls could flake. Replaced with a before/after range
  assertion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mroxso
2026-09-06 18:55:22 +02:00
committed by GitHub
parent ae7d27d63e
commit adc65f9819
6 changed files with 1057 additions and 2 deletions

View File

@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
## The ten apps
## The eleven apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -100,10 +100,22 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
| Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL |
| Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat |
| 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.
### Live only links out to playback, it doesn't embed a player
NIP-53's `streaming` tag is typically an HLS (`.m3u8`) URL, which no browser plays natively

View File

@@ -0,0 +1,235 @@
import { useId, 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 formId = useId();
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 id={`${formId}-name`} label="Name (optional)">
<Input
id={`${formId}-name`}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Bitcoin from contacts"
/>
</Field>
<Field id={`${formId}-description`} label="Description (optional)">
<Textarea
id={`${formId}-description`}
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={2}
className="min-h-14 resize-none text-[14px]"
/>
</Field>
<Field id={`${formId}-kinds`} label="Kinds (comma separated)">
<Input
id={`${formId}-kinds`}
value={kinds}
onChange={(event) => setKinds(event.target.value)}
placeholder="1, 30023"
/>
</Field>
<fieldset className="space-y-1.5">
<legend className="text-xs font-medium text-muted-foreground">Authors</legend>
<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"
aria-label="Custom author pubkeys, comma separated"
className="mt-2"
/>
)}
</fieldset>
<div className="grid grid-cols-2 gap-3">
<Field id={`${formId}-tag-letter`} label="Tag filter letter">
<Input
id={`${formId}-tag-letter`}
value={tagLetter}
onChange={(event) => setTagLetter(event.target.value)}
placeholder="t"
maxLength={1}
/>
</Field>
<Field id={`${formId}-tag-values`} label="Tag values">
<Input
id={`${formId}-tag-values`}
value={tagValues}
onChange={(event) => setTagValues(event.target.value)}
placeholder="bitcoin, nostr"
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field id={`${formId}-since`} label="Since (e.g. 7d, now, or blank)">
<Input id={`${formId}-since`} value={since} onChange={(event) => setSince(event.target.value)} placeholder="7d" />
</Field>
<Field id={`${formId}-limit`} label="Limit">
<Input
id={`${formId}-limit`}
value={limit}
onChange={(event) => setLimit(event.target.value)}
inputMode="numeric"
/>
</Field>
</div>
<Field id={`${formId}-topics`} label="Topics (comma separated, for discovery)">
<Input
id={`${formId}-topics`}
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({ id, label, children }: { id: string; label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-xs font-medium text-muted-foreground">
{label}
</label>
{children}
</div>
);
}

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

@@ -0,0 +1,380 @@
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 {
isValidTagLetter,
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 [requestedScope, setRequestedScope] = useState<Scope>('mine');
const [formOpen, setFormOpen] = useState(false);
// Signing out mid-session must not leave "My Spells" showing the previous
// user's (still-cached) spells — same reasoning as the Feed app's scope.
const scope: Scope = user ? requestedScope : 'discover';
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={() => setRequestedScope('mine')}
icon={<UserIcon className="size-3.5" aria-hidden />}
label="My Spells"
/>
<ScopeTab
active={scope === 'discover'}
onClick={() => setRequestedScope('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 && isValidTagLetter(spell.tagFilter.letter) && spell.tagFilter.values.length > 0 && (
<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>
);
}

175
src/hooks/useSpells.test.ts Normal file
View File

@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import {
encodeSpellTags,
isValidTagLetter,
parseSpell,
resolveSpellFilter,
resolveTimestamp,
type ParsedSpell,
} from './useSpells';
function spellEvent(tags: string[][], content = ''): NostrEvent {
return { id: 'x', pubkey: 'author', created_at: 0, kind: 777, tags, content, sig: '' };
}
/** A ParsedSpell as if it came straight off a relay — encodeSpellTags always produces well-formed data, so malformed cases are built by hand. */
function parsedSpell(overrides: Partial<ParsedSpell>): ParsedSpell {
return {
description: '',
kinds: [1],
authors: [],
topics: [],
event: spellEvent([]),
...overrides,
};
}
describe('resolveTimestamp', () => {
it('resolves a relative duration against now', () => {
const before = Math.floor(Date.now() / 1000) - 7 * 86400;
const result = resolveTimestamp('7d');
const after = Math.floor(Date.now() / 1000) - 7 * 86400;
// A range, not toBeCloseTo against a single captured `now` — a slow test
// runner or wall-clock skew between the two Date.now() calls could
// otherwise make this flaky.
expect(result).toBeGreaterThanOrEqual(before);
expect(result).toBeLessThanOrEqual(after);
});
it('resolves "now"', () => {
const before = Math.floor(Date.now() / 1000);
const result = resolveTimestamp('now');
const after = Math.floor(Date.now() / 1000);
expect(result).toBeGreaterThanOrEqual(before);
expect(result).toBeLessThanOrEqual(after);
});
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']);
});
it('drops an invalid tag-filter letter instead of publishing a spell that can never run', () => {
const tags = encodeSpellTags({ kinds: [1], tagFilter: { letter: '', values: ['x'] } });
expect(tags.some(([name]) => name === 'tag')).toBe(false);
});
it('drops a non-positive or non-integer limit instead of publishing one resolveSpellFilter will ignore', () => {
expect(encodeSpellTags({ kinds: [1], limit: 0 }).some(([name]) => name === 'limit')).toBe(false);
expect(encodeSpellTags({ kinds: [1], limit: -5 }).some(([name]) => name === 'limit')).toBe(false);
expect(encodeSpellTags({ kinds: [1], limit: 1.5 }).some(([name]) => name === 'limit')).toBe(false);
});
it('keeps a valid limit and tag filter', () => {
const tags = encodeSpellTags({ kinds: [1], limit: 50, tagFilter: { letter: 't', values: ['x'] } });
expect(tags).toContainEqual(['limit', '50']);
expect(tags).toContainEqual(['tag', 't', 'x']);
});
});
describe('parseSpell', () => {
it('drops non-integer and negative k tags — kinds are non-negative integers', () => {
const event = spellEvent([
['cmd', 'REQ'],
['k', '1'],
['k', '1.5'],
['k', '-1'],
['k', 'not-a-number'],
]);
expect(parseSpell(event).kinds).toEqual([1]);
});
});
describe('isValidTagLetter', () => {
it('accepts a single letter', () => {
expect(isValidTagLetter('t')).toBe(true);
expect(isValidTagLetter('P')).toBe(true);
});
it('rejects empty, multi-character, and non-letter values', () => {
expect(isValidTagLetter('')).toBe(false);
expect(isValidTagLetter('tt')).toBe(false);
expect(isValidTagLetter('1')).toBe(false);
});
});
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']);
});
it('ignores a malformed (non-single-letter) tag filter instead of producing "#undefined"', () => {
const spell = parsedSpell({ tagFilter: { letter: '', values: ['x'] } });
const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
expect(Object.keys(filter ?? {}).some((key) => key.startsWith('#'))).toBe(false);
});
it('clamps an excessive limit to the maximum', () => {
const spell = parsedSpell({ limit: 1_000_000 });
const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
expect(filter?.limit).toBe(500);
});
it('drops a zero or NaN limit rather than sending a degenerate query', () => {
expect(resolveSpellFilter(parsedSpell({ limit: 0 }), { me: undefined, contacts: [] })?.limit).toBeUndefined();
expect(resolveSpellFilter(parsedSpell({ limit: NaN }), { me: undefined, contacts: [] })?.limit).toBeUndefined();
});
it('keeps a normal, in-range limit as-is', () => {
const spell = parsedSpell({ limit: 50 });
const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
expect(filter?.limit).toBe(50);
});
});

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

@@ -0,0 +1,243 @@
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,
};
/** A single NIP-01 tag-filter letter, e.g. the `t` in `#t`. */
const TAG_LETTER_RE = /^[a-zA-Z]$/;
/** Caps a relay-sourced spell's `limit` so Run can't be tricked into a huge query. */
const MAX_SPELL_LIMIT = 500;
/** A valid NIP-01 tag-filter letter — the only kind `resolveSpellFilter` will act on. */
export function isValidTagLetter(letter: string): boolean {
return TAG_LETTER_RE.test(letter);
}
/** 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. Validates the tag
* filter's letter and the limit before writing them — an invalid `letter`
* or a non-positive/non-integer `limit` is dropped rather than published,
* since `resolveSpellFilter` would silently ignore it on Run anyway and a
* saved-but-inert filter is worse than one that never made it into the
* event at all.
*/
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 && isValidTagLetter(input.tagFilter.letter)) {
tags.push(['tag', input.tagFilter.letter, ...input.tagFilter.values]);
}
if (input.limit !== undefined && Number.isInteger(input.limit) && input.limit > 0) {
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 {
// Nostr kinds are non-negative integers — a relay-sourced spell claiming
// e.g. "1.5" or "-1" would otherwise pass through into a malformed filter.
const kinds = tagValues(event, 'k')
.map(Number)
.filter((n) => Number.isInteger(n) && n >= 0);
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;
}
// Relay-provided events are untrusted: a malformed spell must not produce
// a filter key like "#undefined", or a limit that is 0/NaN/huge enough to
// lock up the UI on Run.
if (spell.tagFilter && isValidTagLetter(spell.tagFilter.letter) && spell.tagFilter.values.length > 0) {
filter[`#${spell.tagFilter.letter}`] = spell.tagFilter.values;
}
if (spell.limit !== undefined && Number.isFinite(spell.limit) && spell.limit > 0) {
filter.limit = Math.min(spell.limit, MAX_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, Bookmark, BookOpen, FileText, Info, Link2, Radio, Rss, Settings, User } from 'lucide-react';
import { Activity, Bookmark, BookOpen, FileText, Info, Link2, Radio, Rss, Settings, Sparkles, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -79,6 +79,16 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 780, height: 720 },
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',