mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 14:14:07 +02:00
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:
14
docs/apps.md
14
docs/apps.md
@@ -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
|
||||
|
||||
198
src/apps/spells/NewSpellForm.tsx
Normal file
198
src/apps/spells/NewSpellForm.tsx
Normal 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
375
src/apps/spells/index.tsx
Normal 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 haven’t 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>
|
||||
);
|
||||
}
|
||||
81
src/hooks/useSpells.test.ts
Normal file
81
src/hooks/useSpells.test.ts
Normal 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
213
src/hooks/useSpells.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user