mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
14
docs/apps.md
14
docs/apps.md
@@ -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
|
||||
|
||||
235
src/apps/spells/NewSpellForm.tsx
Normal file
235
src/apps/spells/NewSpellForm.tsx
Normal 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
380
src/apps/spells/index.tsx
Normal 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 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 && 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
175
src/hooks/useSpells.test.ts
Normal 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
243
src/hooks/useSpells.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user