diff --git a/docs/apps.md b/docs/apps.md index 18205e8..5bf3981 100644 --- a/docs/apps.md +++ b/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 diff --git a/src/apps/spells/NewSpellForm.tsx b/src/apps/spells/NewSpellForm.tsx new file mode 100644 index 0000000..19fba53 --- /dev/null +++ b/src/apps/spells/NewSpellForm.tsx @@ -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('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 ( +
+

Sign in to save a spell.

+
+ ); + } + + return ( +
+
+

New spell

+

+ 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. +

+
+ + + setName(event.target.value)} + placeholder="Bitcoin from contacts" + /> + + + +