15 changed files with 855 additions and 50 deletions
@ -0,0 +1,16 @@ |
|||||||
|
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { Wand2 } from 'lucide-react' |
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||||
|
|
||||||
|
export default function SpellsButton() { |
||||||
|
const { navigate, current, display } = usePrimaryPage() |
||||||
|
|
||||||
|
return ( |
||||||
|
<BottomNavigationBarItem |
||||||
|
active={current === 'spells' && display} |
||||||
|
onClick={() => navigate('spells')} |
||||||
|
> |
||||||
|
<Wand2 /> |
||||||
|
</BottomNavigationBarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { Wand2 } from 'lucide-react' |
||||||
|
import SidebarItem from './SidebarItem' |
||||||
|
|
||||||
|
export default function SpellsButton() { |
||||||
|
const { navigate, current, display } = usePrimaryPage() |
||||||
|
|
||||||
|
const isActive = display && current === 'spells' |
||||||
|
|
||||||
|
return ( |
||||||
|
<SidebarItem title="Spells" onClick={() => navigate('spells')} active={isActive}> |
||||||
|
<Wand2 strokeWidth={3} /> |
||||||
|
</SidebarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,259 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle |
||||||
|
} from '@/components/ui/dialog' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Textarea } from '@/components/ui/textarea' |
||||||
|
import { createSpellDraftEvent, type TSpellDraftParams } from '@/lib/draft-event' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import { X } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { useState } from 'react' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
|
||||||
|
const DEFAULT_PARAMS: TSpellDraftParams = { |
||||||
|
cmd: 'REQ', |
||||||
|
content: '', |
||||||
|
name: '', |
||||||
|
alt: '', |
||||||
|
kinds: ['1'], |
||||||
|
authors: ['$me', '$contacts'], |
||||||
|
ids: [], |
||||||
|
tagFilters: [], |
||||||
|
limit: '50', |
||||||
|
since: '7d', |
||||||
|
until: '', |
||||||
|
search: '', |
||||||
|
relays: [], |
||||||
|
topics: [], |
||||||
|
closeOnEose: false |
||||||
|
} |
||||||
|
|
||||||
|
export default function CreateSpellDialog({ |
||||||
|
open, |
||||||
|
onOpenChange, |
||||||
|
onSaved |
||||||
|
}: { |
||||||
|
open: boolean |
||||||
|
onOpenChange: (open: boolean) => void |
||||||
|
onSaved?: () => void |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, publish, checkLogin } = useNostr() |
||||||
|
const [form, setForm] = useState<TSpellDraftParams>(DEFAULT_PARAMS) |
||||||
|
const [saving, setSaving] = useState(false) |
||||||
|
|
||||||
|
const handleClear = () => setForm({ ...DEFAULT_PARAMS }) |
||||||
|
const handleCancel = () => { |
||||||
|
handleClear() |
||||||
|
onOpenChange(false) |
||||||
|
} |
||||||
|
|
||||||
|
const handleSave = async () => { |
||||||
|
if (!pubkey) { |
||||||
|
checkLogin() |
||||||
|
return |
||||||
|
} |
||||||
|
setSaving(true) |
||||||
|
try { |
||||||
|
const draft = createSpellDraftEvent(form) |
||||||
|
const event = await publish(draft) |
||||||
|
await indexedDb.putSpellEvent(event) |
||||||
|
handleClear() |
||||||
|
onOpenChange(false) |
||||||
|
onSaved?.() |
||||||
|
showSimplePublishSuccess(t('Spell published')) |
||||||
|
} catch (e) { |
||||||
|
logger.error('[CreateSpellDialog] Publish failed', e) |
||||||
|
showPublishingError(e instanceof Error ? e : new Error(String(e))) |
||||||
|
} finally { |
||||||
|
setSaving(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const kindsStr = form.kinds.length ? form.kinds.join(', ') : '' |
||||||
|
const setKindsStr = (s: string) => |
||||||
|
setForm((f) => ({ ...f, kinds: s.split(/[\s,]+/).filter(Boolean) })) |
||||||
|
const authorsStr = form.authors.join(', ') |
||||||
|
const setAuthorsStr = (s: string) => |
||||||
|
setForm((f) => ({ ...f, authors: s.split(/[\s,]+/).filter(Boolean) })) |
||||||
|
const idsStr = form.ids.join(', ') |
||||||
|
const setIdsStr = (s: string) => |
||||||
|
setForm((f) => ({ ...f, ids: s.split(/[\s,]+/).filter(Boolean) })) |
||||||
|
const relaysStr = form.relays.join(', ') |
||||||
|
const setRelaysStr = (s: string) => |
||||||
|
setForm((f) => ({ ...f, relays: s.split(/[\s,]+/).filter(Boolean) })) |
||||||
|
const topicsStr = form.topics.join(', ') |
||||||
|
const setTopicsStr = (s: string) => |
||||||
|
setForm((f) => ({ ...f, topics: s.split(/[\s,]+/).filter(Boolean) })) |
||||||
|
|
||||||
|
return ( |
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto max-w-2xl" withoutClose> |
||||||
|
<DialogHeader className="flex flex-row items-center justify-between gap-2 pr-8"> |
||||||
|
<DialogTitle>{t('Create a Spell')}</DialogTitle> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="icon" |
||||||
|
className="h-8 w-8 shrink-0" |
||||||
|
onClick={() => onOpenChange(false)} |
||||||
|
aria-label={t('Close')} |
||||||
|
> |
||||||
|
<X className="size-4" /> |
||||||
|
</Button> |
||||||
|
</DialogHeader> |
||||||
|
<p className="text-sm text-muted-foreground"> |
||||||
|
{t('Spells are saved relay filters (NIP-A7). Fill in the filter fields below. Use $me for your pubkey and $contacts for your follow list when executing.')} |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="grid gap-4 py-2"> |
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Command')}</Label> |
||||||
|
<select |
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" |
||||||
|
value={form.cmd} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, cmd: e.target.value as 'REQ' | 'COUNT' }))} |
||||||
|
> |
||||||
|
<option value="REQ">REQ (subscribe to events)</option> |
||||||
|
<option value="COUNT">COUNT (count only)</option> |
||||||
|
</select> |
||||||
|
<p className="text-xs text-muted-foreground">{t('REQ returns a feed; COUNT returns a number.')}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Name')}</Label> |
||||||
|
<Input |
||||||
|
value={form.name} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} |
||||||
|
placeholder={t('Human-readable spell name')} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Description (content)')}</Label> |
||||||
|
<Textarea |
||||||
|
value={form.content} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))} |
||||||
|
placeholder={t('Plain text description of the query')} |
||||||
|
rows={2} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Kinds')}</Label> |
||||||
|
<Input |
||||||
|
value={kindsStr} |
||||||
|
onChange={(e) => setKindsStr(e.target.value)} |
||||||
|
placeholder="e.g. 1, 6, 7" |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground">{t('Comma-separated kind numbers (e.g. 1 for notes).')}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Authors')}</Label> |
||||||
|
<Input |
||||||
|
value={authorsStr} |
||||||
|
onChange={(e) => setAuthorsStr(e.target.value)} |
||||||
|
placeholder="$me, $contacts, or npub1..." |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground">{t('$me = your pubkey, $contacts = your follow list. Comma-separated.')}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Event IDs (ids)')}</Label> |
||||||
|
<Input |
||||||
|
value={idsStr} |
||||||
|
onChange={(e) => setIdsStr(e.target.value)} |
||||||
|
placeholder={t('Comma-separated event ids')} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Limit')}</Label> |
||||||
|
<Input |
||||||
|
type="number" |
||||||
|
value={form.limit} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, limit: e.target.value }))} |
||||||
|
placeholder="50" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Since')}</Label> |
||||||
|
<Input |
||||||
|
value={form.since} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, since: e.target.value }))} |
||||||
|
placeholder="7d or 1704067200 or now" |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground">{t('Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.')}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Until')}</Label> |
||||||
|
<Input |
||||||
|
value={form.until} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, until: e.target.value }))} |
||||||
|
placeholder={t('Optional')} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Search (NIP-50)')}</Label> |
||||||
|
<Input |
||||||
|
value={form.search} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, search: e.target.value }))} |
||||||
|
placeholder={t('Full-text search query')} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Relays')}</Label> |
||||||
|
<Input |
||||||
|
value={relaysStr} |
||||||
|
onChange={(e) => setRelaysStr(e.target.value)} |
||||||
|
placeholder="wss://relay.example.com, ..." |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground">{t('Leave empty to use your read relays.')}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid gap-2"> |
||||||
|
<Label>{t('Topics (t tags for categorization)')}</Label> |
||||||
|
<Input |
||||||
|
value={topicsStr} |
||||||
|
onChange={(e) => setTopicsStr(e.target.value)} |
||||||
|
placeholder={t('Comma-separated topics')} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
id="close-on-eose" |
||||||
|
checked={form.closeOnEose} |
||||||
|
onChange={(e) => setForm((f) => ({ ...f, closeOnEose: e.target.checked }))} |
||||||
|
/> |
||||||
|
<Label htmlFor="close-on-eose">{t('Close subscription after EOSE')}</Label> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end pt-2 border-t"> |
||||||
|
<Button variant="outline" onClick={handleClear}> |
||||||
|
{t('Clear')} |
||||||
|
</Button> |
||||||
|
<Button variant="outline" onClick={handleCancel}> |
||||||
|
{t('Cancel')} |
||||||
|
</Button> |
||||||
|
<Button onClick={handleSave} disabled={saving}> |
||||||
|
{saving ? t('Saving…') : t('Save')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,211 @@ |
|||||||
|
import NoteList from '@/components/NoteList' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' |
||||||
|
import { |
||||||
|
getRelaysForSpell, |
||||||
|
getSpellName, |
||||||
|
spellEventToFilter, |
||||||
|
spellIsCount |
||||||
|
} from '@/services/spell.service' |
||||||
|
import { TFeedSubRequest } from '@/types' |
||||||
|
import { ChevronLeft, Plus, Wand2 } from 'lucide-react' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { forwardRef, useCallback, useEffect, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import CreateSpellDialog from './CreateSpellDialog' |
||||||
|
import type { TPageRef } from '@/types' |
||||||
|
|
||||||
|
const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, relayList } = useNostr() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const [spells, setSpells] = useState<Event[]>([]) |
||||||
|
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set()) |
||||||
|
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null) |
||||||
|
const [createOpen, setCreateOpen] = useState(false) |
||||||
|
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([]) |
||||||
|
const [contacts, setContacts] = useState<string[]>([]) |
||||||
|
|
||||||
|
const loadSpells = useCallback(async () => { |
||||||
|
const [events, ids] = await Promise.all([ |
||||||
|
indexedDb.getSpellEvents(), |
||||||
|
indexedDb.getSpellFavoriteIds() |
||||||
|
]) |
||||||
|
setSpells(events) |
||||||
|
setFavoriteIds(new Set(ids)) |
||||||
|
}, []) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
loadSpells() |
||||||
|
}, [loadSpells]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!pubkey) { |
||||||
|
setContacts([]) |
||||||
|
return |
||||||
|
} |
||||||
|
client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([])) |
||||||
|
}, [pubkey]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!selectedSpell) { |
||||||
|
setSubRequests([]) |
||||||
|
return |
||||||
|
} |
||||||
|
if (spellIsCount(selectedSpell)) { |
||||||
|
setSubRequests([]) |
||||||
|
return |
||||||
|
} |
||||||
|
const defaultRelays = [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])] |
||||||
|
const relayListRead = relayList?.read?.length ? relayList.read : defaultRelays |
||||||
|
const ctx = { |
||||||
|
pubkey, |
||||||
|
contacts, |
||||||
|
relayListRead |
||||||
|
} |
||||||
|
const filter = spellEventToFilter(selectedSpell, ctx) |
||||||
|
if (!filter) { |
||||||
|
setSubRequests([]) |
||||||
|
return |
||||||
|
} |
||||||
|
const relays = getRelaysForSpell(selectedSpell, { relayListRead }) |
||||||
|
if (!relays.length) { |
||||||
|
setSubRequests([]) |
||||||
|
return |
||||||
|
} |
||||||
|
setSubRequests([{ urls: relays, filter }]) |
||||||
|
}, [selectedSpell, pubkey, contacts, relayList?.read]) |
||||||
|
|
||||||
|
const toggleFavorite = useCallback(async (spellId: string) => { |
||||||
|
const ids = await indexedDb.getSpellFavoriteIds() |
||||||
|
const set = new Set(ids) |
||||||
|
if (set.has(spellId)) set.delete(spellId) |
||||||
|
else set.add(spellId) |
||||||
|
await indexedDb.setSpellFavoriteIds([...set]) |
||||||
|
setFavoriteIds(set) |
||||||
|
}, []) |
||||||
|
|
||||||
|
const orderedSpells = [...spells].sort((a, b) => { |
||||||
|
const aFav = favoriteIds.has(a.id) |
||||||
|
const bFav = favoriteIds.has(b.id) |
||||||
|
if (aFav && !bFav) return -1 |
||||||
|
if (!aFav && bFav) return 1 |
||||||
|
return (b.created_at ?? 0) - (a.created_at ?? 0) |
||||||
|
}) |
||||||
|
|
||||||
|
return ( |
||||||
|
<PrimaryPageLayout |
||||||
|
ref={ref} |
||||||
|
pageName="spells" |
||||||
|
titlebar={ |
||||||
|
isSmallScreen ? ( |
||||||
|
<div className="flex items-center justify-between w-full gap-2"> |
||||||
|
{selectedSpell ? ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="titlebar-icon" |
||||||
|
onClick={() => setSelectedSpell(null)} |
||||||
|
title={t('Back to spell list')} |
||||||
|
> |
||||||
|
<ChevronLeft className="size-5" /> |
||||||
|
</Button> |
||||||
|
) : ( |
||||||
|
<div className="w-10 shrink-0" /> |
||||||
|
)} |
||||||
|
<div className="font-semibold flex-1 text-center min-w-0 truncate"> |
||||||
|
{selectedSpell ? getSpellName(selectedSpell) : t('Spells')} |
||||||
|
</div> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="titlebar-icon" |
||||||
|
onClick={() => setCreateOpen(true)} |
||||||
|
title={t('Create a Spell')} |
||||||
|
> |
||||||
|
<Plus className="size-5" /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<div className="font-semibold">{t('Spells')}</div> |
||||||
|
) |
||||||
|
} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
<div className="flex flex-col md:flex-row min-h-0 flex-1 gap-4 p-4"> |
||||||
|
{/* Left (desktop) / Top (mobile) pane: spell list */} |
||||||
|
<div className={`flex flex-col gap-2 shrink-0 ${isSmallScreen ? 'order-1 border-b border-border pb-4' : 'w-64 border-r border-border pr-4'}`}> |
||||||
|
<Button |
||||||
|
className="w-full justify-start gap-2" |
||||||
|
variant="outline" |
||||||
|
onClick={() => setCreateOpen(true)} |
||||||
|
> |
||||||
|
<Wand2 className="size-4" /> |
||||||
|
{t('Create a Spell')} |
||||||
|
</Button> |
||||||
|
<div className="text-sm text-muted-foreground mt-1"> |
||||||
|
{t('Select a spell to run its filter and see the feed.')} |
||||||
|
</div> |
||||||
|
<ul className="space-y-1 overflow-y-auto min-h-0"> |
||||||
|
{orderedSpells.length === 0 && ( |
||||||
|
<li className="text-sm text-muted-foreground py-2">{t('No spells yet. Create one above.')}</li> |
||||||
|
)} |
||||||
|
{orderedSpells.map((spell) => ( |
||||||
|
<li key={spell.id} className="flex items-center gap-1"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className={`flex-1 text-left text-sm px-2 py-1.5 rounded truncate ${selectedSpell?.id === spell.id ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted'}`} |
||||||
|
onClick={() => setSelectedSpell(spell)} |
||||||
|
> |
||||||
|
{getSpellName(spell)} |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="shrink-0 p-1 text-muted-foreground hover:text-foreground" |
||||||
|
onClick={() => toggleFavorite(spell.id)} |
||||||
|
title={favoriteIds.has(spell.id) ? t('Remove from favorites') : t('Add to favorites')} |
||||||
|
> |
||||||
|
{favoriteIds.has(spell.id) ? '★' : '☆'} |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Right (desktop) / Bottom (mobile) pane: feed */} |
||||||
|
<div className="flex-1 min-w-0 flex flex-col"> |
||||||
|
{selectedSpell ? ( |
||||||
|
subRequests.length > 0 ? ( |
||||||
|
<NoteList |
||||||
|
subRequests={subRequests} |
||||||
|
showKinds={selectedSpell.tags.filter((t) => t[0] === 'k').map((t) => parseInt(t[1], 10)).filter((n) => !Number.isNaN(n)) || [1]} |
||||||
|
useFilterAsIs |
||||||
|
/> |
||||||
|
) : spellIsCount(selectedSpell) ? ( |
||||||
|
<div className="text-muted-foreground py-8 text-center">{t('COUNT spells show a number, not a feed.')}</div> |
||||||
|
) : !pubkey && (selectedSpell.tags.some((t) => t[0] === 'authors' && (t.includes('$me') || t.includes('$contacts')))) ? ( |
||||||
|
<div className="text-muted-foreground py-8 text-center">{t('Log in to run this spell (it uses $me or $contacts).')}</div> |
||||||
|
) : ( |
||||||
|
<div className="text-muted-foreground py-8 text-center"> |
||||||
|
{t('Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.')} |
||||||
|
</div> |
||||||
|
) |
||||||
|
) : ( |
||||||
|
<div className="text-muted-foreground py-8 text-center">{t('Select a spell from the list to view its feed.')}</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<CreateSpellDialog |
||||||
|
open={createOpen} |
||||||
|
onOpenChange={setCreateOpen} |
||||||
|
onSaved={loadSpells} |
||||||
|
/> |
||||||
|
</PrimaryPageLayout> |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
export default SpellsPage |
||||||
@ -0,0 +1,177 @@ |
|||||||
|
/** |
||||||
|
* NIP-A7 Spells: parse and execute kind 777 events as portable relay query filters. |
||||||
|
*/ |
||||||
|
|
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { tagNameEquals } from '@/lib/tag' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import type { Filter } from 'nostr-tools' |
||||||
|
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' |
||||||
|
|
||||||
|
const RELATIVE_UNIT_SECONDS: Record<string, number> = { |
||||||
|
s: 1, |
||||||
|
m: 60, |
||||||
|
h: 3600, |
||||||
|
d: 86400, |
||||||
|
w: 604800, |
||||||
|
mo: 2592000, |
||||||
|
y: 31536000 |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resolve relative time to Unix timestamp. |
||||||
|
* "now" -> current time; "7d" -> now - 7*86400; "1704067200" -> 1704067200. |
||||||
|
*/ |
||||||
|
export function resolveRelativeTime(value: string): number { |
||||||
|
const trimmed = (value || '').trim() |
||||||
|
if (trimmed === 'now' || trimmed === '') { |
||||||
|
return Math.floor(Date.now() / 1000) |
||||||
|
} |
||||||
|
const num = parseInt(trimmed, 10) |
||||||
|
if (!Number.isNaN(num) && trimmed === String(num)) { |
||||||
|
return num |
||||||
|
} |
||||||
|
const match = trimmed.match(/^(\d+)(s|m|h|d|w|mo|y)$/) |
||||||
|
if (!match) { |
||||||
|
return Math.floor(Date.now() / 1000) |
||||||
|
} |
||||||
|
const n = parseInt(match[1]!, 10) |
||||||
|
const unit = match[2]! |
||||||
|
const sec = RELATIVE_UNIT_SECONDS[unit] ?? 86400 |
||||||
|
return Math.floor(Date.now() / 1000) - n * sec |
||||||
|
} |
||||||
|
|
||||||
|
export type SpellExecutionContext = { |
||||||
|
pubkey: string | null |
||||||
|
contacts: string[] |
||||||
|
relayListRead: string[] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relay URLs for executing a spell: from spell's `relays` tag or fallback to context relay list / fast-read. |
||||||
|
*/ |
||||||
|
export function getRelaysForSpell(spell: Event, context: { relayListRead: string[] }): string[] { |
||||||
|
const relayTag = spell.tags.find(tagNameEquals('relays')) |
||||||
|
if (relayTag && relayTag.length > 1) { |
||||||
|
const urls = relayTag.slice(1).filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://'))) |
||||||
|
if (urls.length) return urls |
||||||
|
} |
||||||
|
if (context.relayListRead.length) return context.relayListRead |
||||||
|
return [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resolve authors: replace $me with pubkey and $contacts with contacts array. |
||||||
|
*/ |
||||||
|
function resolveAuthors(authorsTag: string[] | undefined, ctx: SpellExecutionContext): string[] | undefined { |
||||||
|
const raw = authorsTag?.slice(1) ?? [] |
||||||
|
const out: string[] = [] |
||||||
|
for (const v of raw) { |
||||||
|
if (v === '$me') { |
||||||
|
if (ctx.pubkey) out.push(ctx.pubkey) |
||||||
|
} else if (v === '$contacts') { |
||||||
|
out.push(...ctx.contacts) |
||||||
|
} else { |
||||||
|
out.push(v) |
||||||
|
} |
||||||
|
} |
||||||
|
return out.length ? out : undefined |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resolve tag filter values: replace $me and $contacts in ["tag", "p", "$me", "x"] etc. |
||||||
|
*/ |
||||||
|
function resolveTagFilterValues(values: string[], ctx: SpellExecutionContext): string[] { |
||||||
|
const out: string[] = [] |
||||||
|
for (const v of values) { |
||||||
|
if (v === '$me') { |
||||||
|
if (ctx.pubkey) out.push(ctx.pubkey) |
||||||
|
} else if (v === '$contacts') { |
||||||
|
out.push(...ctx.contacts) |
||||||
|
} else { |
||||||
|
out.push(v) |
||||||
|
} |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a Nostr REQ filter from a spell event, resolving variables and relative times. |
||||||
|
*/ |
||||||
|
export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Filter | null { |
||||||
|
const filter: Filter = {} |
||||||
|
|
||||||
|
const cmd = spell.tags.find(tagNameEquals('cmd'))?.[1] |
||||||
|
if (cmd !== 'REQ' && cmd !== 'COUNT') { |
||||||
|
logger.warn('[Spell] Unsupported cmd', { cmd }) |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
const kTag = spell.tags.filter(tagNameEquals('k')) |
||||||
|
if (kTag.length) { |
||||||
|
const kinds = kTag |
||||||
|
.map((t) => t[1]) |
||||||
|
.filter((x): x is string => x != null && x !== '') |
||||||
|
.map((x) => parseInt(x, 10)) |
||||||
|
.filter((n) => !Number.isNaN(n)) |
||||||
|
if (kinds.length) filter.kinds = kinds |
||||||
|
} |
||||||
|
|
||||||
|
const authorsTag = spell.tags.find(tagNameEquals('authors')) |
||||||
|
const authors = resolveAuthors(authorsTag ? [authorsTag[0]!, ...authorsTag.slice(1)] : undefined, ctx) |
||||||
|
if (authors?.length) filter.authors = authors |
||||||
|
|
||||||
|
const idsTag = spell.tags.find(tagNameEquals('ids')) |
||||||
|
if (idsTag && idsTag.length > 1) { |
||||||
|
filter.ids = idsTag.slice(1).filter((x): x is string => typeof x === 'string' && x.length > 0) |
||||||
|
} |
||||||
|
|
||||||
|
const limitTag = spell.tags.find(tagNameEquals('limit')) |
||||||
|
if (limitTag?.[1]) { |
||||||
|
const n = parseInt(limitTag[1], 10) |
||||||
|
if (!Number.isNaN(n)) filter.limit = n |
||||||
|
} |
||||||
|
|
||||||
|
const sinceTag = spell.tags.find(tagNameEquals('since')) |
||||||
|
if (sinceTag?.[1]) filter.since = resolveRelativeTime(sinceTag[1]) |
||||||
|
|
||||||
|
const untilTag = spell.tags.find(tagNameEquals('until')) |
||||||
|
if (untilTag?.[1]) filter.until = resolveRelativeTime(untilTag[1]) |
||||||
|
|
||||||
|
const searchTag = spell.tags.find(tagNameEquals('search')) |
||||||
|
if (searchTag?.[1]) filter.search = searchTag[1] |
||||||
|
|
||||||
|
for (const tag of spell.tags) { |
||||||
|
if (tag[0] === 'tag' && tag.length >= 2) { |
||||||
|
const letter = tag[1] |
||||||
|
const values = resolveTagFilterValues(tag.slice(2), ctx) |
||||||
|
if (letter && values.length) { |
||||||
|
(filter as any)[`#${letter}`] = values |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return filter |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Whether the spell is COUNT (we only support REQ for feed display). |
||||||
|
*/ |
||||||
|
export function spellIsCount(spell: Event): boolean { |
||||||
|
return spell.tags.find(tagNameEquals('cmd'))?.[1] === 'COUNT' |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get display name for a spell (from "name" tag or content). |
||||||
|
*/ |
||||||
|
export function getSpellName(spell: Event): string { |
||||||
|
const nameTag = spell.tags.find(tagNameEquals('name')) |
||||||
|
if (nameTag?.[1]) return nameTag[1] |
||||||
|
if (spell.content?.trim()) return spell.content.trim().slice(0, 80) |
||||||
|
return `Spell ${spell.id.slice(0, 8)}` |
||||||
|
} |
||||||
|
|
||||||
|
export function isSpellEvent(event: Event): boolean { |
||||||
|
return event.kind === ExtendedKind.SPELL |
||||||
|
} |
||||||
Loading…
Reference in new issue