15 changed files with 855 additions and 50 deletions
@ -0,0 +1,16 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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