Browse Source

fix spells

imwald
Silberengel 1 month ago
parent
commit
09dd53411e
  1. 13
      src/i18n/locales/de.ts
  2. 13
      src/i18n/locales/en.ts
  3. 323
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  4. 15
      src/pages/primary/SpellsPage/index.tsx
  5. 19
      src/services/spell.service.ts

13
src/i18n/locales/de.ts

@ -728,6 +728,19 @@ export default {
listImportATagFailed: 'Adress-Tag konnte nicht aufgelöst werden: {{preview}}…', listImportATagFailed: 'Adress-Tag konnte nicht aufgelöst werden: {{preview}}…',
listImportEventNotFound: 'Kein Event zu dieser Referenz gefunden.', listImportEventNotFound: 'Kein Event zu dieser Referenz gefunden.',
'REQ tag filters': 'REQ-Tag-Filter', 'REQ tag filters': 'REQ-Tag-Filter',
spellFormTagFiltersLabel: 'Tag-Filter auf passenden Events',
spellCreateIntro:
'Zaubersprüche sind gespeicherte Relay-Filter (NIP-A7). Der Abschnitt „Abfrage“ ist die eigentliche Definition; der gestrichelte Kasten unten nur für Namen, Beschreibung und Katalog-Labels. Beim Ausführen: $me für deinen Pubkey, $contacts für deine Follow-Liste.',
spellFormSectionQueryTitle: 'Abfrage (Spell-Definition)',
spellFormSectionQueryHint:
'Dieser Block ist die eigentliche Definition: Er wird zum Nostr-REQ-/COUNT-Filter (Kinds, Autoren, Zeitraum, Tag-Filter auf passenden Events, Relays usw.).',
spellFormSectionMetadataTitle: 'Anzeige & Beschriftung (optional)',
spellFormSectionMetadataBadge: 'Gehört nicht zur Abfrage',
spellFormSectionMetadataHint:
'Name, Beschreibung und Themen-Labels dienen nur der Darstellung und in Zauberspruch-Listen. Sie werden beim Abrufen von Events nicht verwendet.',
spellFormCatalogTopicsLabel: 'Themen-Labels auf dem Zauberspruch (t-Tags)',
spellTopicsMetadataHint:
'Ein Thema pro Zeile. Um Notes nach Thema zu filtern, nutze oben im Block „Abfrage“ die REQ-Tag-Filter (Buchstabe „t“).',
spellTagFiltersHint: spellTagFiltersHint:
'Optionale Filter auf abonnierte Events (NIP-01 Ein-Buchstaben-Tags). Beispiel: Buchstabe „t“, Werte „bitcoin“.', 'Optionale Filter auf abonnierte Events (NIP-01 Ein-Buchstaben-Tags). Beispiel: Buchstabe „t“, Werte „bitcoin“.',
spellTagFiltersEmpty: spellTagFiltersEmpty:

13
src/i18n/locales/en.ts

@ -763,6 +763,19 @@ export default {
listImportATagFailed: 'Failed to resolve address tag: {{preview}}…', listImportATagFailed: 'Failed to resolve address tag: {{preview}}…',
listImportEventNotFound: 'No event found for that reference.', listImportEventNotFound: 'No event found for that reference.',
'REQ tag filters': 'REQ tag filters', 'REQ tag filters': 'REQ tag filters',
spellFormTagFiltersLabel: 'Tag filters on matching events',
spellCreateIntro:
'Spells are saved relay filters (NIP-A7). The “Spell query” section is the real definition; the dashed box at the bottom is only for names, descriptions, and catalog labels. Use $me for your pubkey and $contacts for your follow list when executing.',
spellFormSectionQueryTitle: 'Spell query',
spellFormSectionQueryHint:
'This block is the actual spell definition: it becomes the Nostr REQ/COUNT filter (kinds, authors, time range, tag filters on matching events, relays, etc.).',
spellFormSectionMetadataTitle: 'Listing & labels (optional)',
spellFormSectionMetadataBadge: 'Not part of the query',
spellFormSectionMetadataHint:
'Name, description, and topic labels are only for display and spell pickers. They are not used when the spell fetches events.',
spellFormCatalogTopicsLabel: 'Topic labels on this spell (t tags)',
spellTopicsMetadataHint:
'One topic per row. To filter which notes you see, use “REQ tag filters” in the spell query above (letter “t”).',
spellTagFiltersHint: spellTagFiltersHint:
'Optional filters on subscribed events (NIP-01 single-letter tags). Example: letter “t”, values “bitcoin”.', 'Optional filters on subscribed events (NIP-01 single-letter tags). Example: letter “t”, values “bitcoin”.',
spellTagFiltersEmpty: 'No tag filters yet. Add rows below or apply an event reference above.', spellTagFiltersEmpty: 'No tag filters yet. Add rows below or apply an event reference above.',

323
src/pages/primary/SpellsPage/CreateSpellDialog.tsx

@ -23,10 +23,10 @@ import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { getRelaysForSpellCatalogSync } from '@/services/spell.service' import { getRelaysForSpellCatalogSync } from '@/services/spell.service'
import { Minus, Plus, X } from 'lucide-react' import { Info, Minus, Plus, X } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { Event as NostrEvent } from 'nostr-tools' import type { Event as NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
/** Arrow keys should control the control, not the dialog scroll */ /** Arrow keys should control the control, not the dialog scroll */
@ -140,6 +140,44 @@ function DynamicStringListField({
) )
} }
/** Bottom-of-form panel: name, description, catalog topics — not part of NIP-A7 REQ filter. */
function SpellMetadataSection({
title,
badge,
hint,
children
}: {
title: string
badge: string
hint: string
children: ReactNode
}) {
return (
<div
className="rounded-xl border-2 border-dashed border-muted-foreground/35 bg-muted/25"
role="region"
aria-labelledby="spell-form-metadata-title"
>
<div className="space-y-1.5 border-b border-border/80 bg-muted/40 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<Info className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<h3 id="spell-form-metadata-title" className="text-sm font-semibold tracking-tight">
{title}
</h3>
<span
className="rounded-md border border-muted-foreground/45 bg-background/80 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
title={hint}
>
{badge}
</span>
</div>
<p className="ps-6 text-xs leading-relaxed text-muted-foreground">{hint}</p>
</div>
<div className="grid gap-4 p-4">{children}</div>
</div>
)
}
function TagFiltersEditor({ function TagFiltersEditor({
tagFilters, tagFilters,
onChange onChange
@ -156,7 +194,7 @@ function TagFiltersEditor({
} }
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('REQ tag filters')}</Label> <Label>{t('spellFormTagFiltersLabel')}</Label>
<p className="text-xs text-muted-foreground">{t('spellTagFiltersHint')}</p> <p className="text-xs text-muted-foreground">{t('spellTagFiltersHint')}</p>
{tagFilters.length === 0 ? ( {tagFilters.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('spellTagFiltersEmpty')}</p> <p className="text-xs text-muted-foreground">{t('spellTagFiltersEmpty')}</p>
@ -405,9 +443,7 @@ export default function CreateSpellDialog({
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{spellToClone {spellToClone
? t('Clone spell intro') ? t('Clone spell intro')
: t( : t('spellCreateIntro')}
'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> </p>
</div> </div>
@ -458,153 +494,166 @@ export default function CreateSpellDialog({
) : null} ) : null}
</div> </div>
<div className="grid gap-2"> <div className="space-y-4 border-t border-border pt-4">
<Label>{t('Command')}</Label> <div className="space-y-1">
<select <h3 className="text-sm font-semibold text-foreground">{t('spellFormSectionQueryTitle')}</h3>
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" <p className="text-xs text-muted-foreground">{t('spellFormSectionQueryHint')}</p>
value={form.cmd} </div>
onChange={(e) => {
const cmd = e.target.value as 'REQ' | 'COUNT'
setForm((f) =>
cmd === 'COUNT' ? { ...f, cmd, closeOnEose: false } : { ...f, cmd }
)
}}
>
<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"> <div className="grid gap-2">
<Label>{t('Name')}</Label> <Label>{t('Command')}</Label>
<Input <select
value={form.name} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} value={form.cmd}
placeholder={t('Human-readable spell name')} onChange={(e) => {
/> const cmd = e.target.value as 'REQ' | 'COUNT'
</div> setForm((f) =>
cmd === 'COUNT' ? { ...f, cmd, closeOnEose: false } : { ...f, cmd }
)
}}
>
<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"> <DynamicStringListField
<Label>{t('Description (content)')}</Label> label={t('Kinds')}
<Textarea hint={t('One kind number per row (e.g. 1 for notes).')}
value={form.content} placeholder="1"
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))} inputType="number"
placeholder={t('Plain text description of the query')} values={form.kinds}
rows={2} onChange={(kinds) => setForm((f) => ({ ...f, kinds }))}
/> />
</div>
<DynamicStringListField
label={t('Kinds')}
hint={t('One kind number per row (e.g. 1 for notes).')}
placeholder="1"
inputType="number"
values={form.kinds}
onChange={(kinds) => setForm((f) => ({ ...f, kinds }))}
/>
<DynamicStringListField
label={t('Authors')}
hint={t('One author per row: $me, $contacts, or hex pubkey / npub.')}
placeholder="$me"
values={form.authors}
onChange={(authors) => setForm((f) => ({ ...f, authors }))}
/>
<DynamicStringListField <DynamicStringListField
label={t('Event IDs (ids)')} label={t('Authors')}
hint={t('One hex event id per row.')} hint={t('One author per row: $me, $contacts, or hex pubkey / npub.')}
placeholder="hex id…" placeholder="$me"
values={form.ids} values={form.authors}
onChange={(ids) => setForm((f) => ({ ...f, ids }))} onChange={(authors) => setForm((f) => ({ ...f, authors }))}
/>
<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"> <DynamicStringListField
<Label>{t('Since')}</Label> label={t('Event IDs (ids)')}
<Input hint={t('One hex event id per row.')}
value={form.since} placeholder="hex id…"
onChange={(e) => setForm((f) => ({ ...f, since: e.target.value }))} values={form.ids}
placeholder="7d or 1704067200 or now" onChange={(ids) => setForm((f) => ({ ...f, ids }))}
/> />
<p className="text-xs text-muted-foreground">
{t('Relative: 7d, 24h, 1w, 1mo, 1y. Or Unix timestamp.')}
</p>
</div>
<div className="grid gap-2"> <TagFiltersEditor
<Label>{t('Until')}</Label> tagFilters={form.tagFilters}
<Input onChange={(tagFilters) => setForm((f) => ({ ...f, tagFilters }))}
value={form.until}
onChange={(e) => setForm((f) => ({ ...f, until: e.target.value }))}
placeholder={t('Optional')}
/> />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Search (NIP-50)')}</Label> <Label>{t('Limit')}</Label>
<Input <Input
value={form.search} type="number"
onChange={(e) => setForm((f) => ({ ...f, search: e.target.value }))} value={form.limit}
placeholder={t('Full-text search query')} onChange={(e) => setForm((f) => ({ ...f, limit: e.target.value }))}
/> placeholder="50"
</div> />
</div>
<DynamicStringListField <div className="grid gap-2">
label={t('Relays')} <Label>{t('Since')}</Label>
hint={t('One wss:// URL per row. Leave empty to use your write relays.')} <Input
placeholder="wss://…" value={form.since}
values={form.relays} onChange={(e) => setForm((f) => ({ ...f, since: e.target.value }))}
onChange={(relays) => setForm((f) => ({ ...f, relays }))} 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>
<DynamicStringListField <div className="grid gap-2">
label={t('Topics (t tags for categorization)')} <Label>{t('Until')}</Label>
hint={t('One topic per row.')} <Input
placeholder={t('topic')} value={form.until}
values={form.topics} onChange={(e) => setForm((f) => ({ ...f, until: e.target.value }))}
onChange={(topics) => setForm((f) => ({ ...f, topics }))} placeholder={t('Optional')}
/> />
</div>
<TagFiltersEditor <div className="grid gap-2">
tagFilters={form.tagFilters} <Label>{t('Search (NIP-50)')}</Label>
onChange={(tagFilters) => setForm((f) => ({ ...f, tagFilters }))} <Input
/> value={form.search}
onChange={(e) => setForm((f) => ({ ...f, search: e.target.value }))}
placeholder={t('Full-text search query')}
/>
</div>
{form.cmd === 'REQ' ? ( <DynamicStringListField
<div className="flex flex-col gap-1.5"> label={t('Relays')}
<Label>{t('Mode')}</Label> hint={t('One wss:// URL per row. Leave empty to use your write relays.')}
<div className="flex rounded-lg border border-input bg-muted p-0.5"> placeholder="wss://…"
<button values={form.relays}
type="button" onChange={(relays) => setForm((f) => ({ ...f, relays }))}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${!form.closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground'}`} />
onClick={() => setForm((f) => ({ ...f, closeOnEose: false }))}
> {form.cmd === 'REQ' ? (
{t('Feed')} <div className="flex flex-col gap-1.5">
</button> <Label>{t('Mode')}</Label>
<button <div className="flex rounded-lg border border-input bg-muted p-0.5">
type="button" <button
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${form.closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground'}`} type="button"
onClick={() => setForm((f) => ({ ...f, closeOnEose: true }))} className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${!form.closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground'}`}
> onClick={() => setForm((f) => ({ ...f, closeOnEose: false }))}
{t('Fetch')} >
</button> {t('Feed')}
</button>
<button
type="button"
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${form.closeOnEose ? 'bg-background text-foreground shadow' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setForm((f) => ({ ...f, closeOnEose: true }))}
>
{t('Fetch')}
</button>
</div>
<p className="text-xs text-muted-foreground">
{form.closeOnEose ? t('Fetch once, then stop.') : t('Live feed; keeps updating.')}
</p>
</div> </div>
<p className="text-xs text-muted-foreground"> ) : null}
{form.closeOnEose ? t('Fetch once, then stop.') : t('Live feed; keeps updating.')} </div>
</p>
<SpellMetadataSection
title={t('spellFormSectionMetadataTitle')}
badge={t('spellFormSectionMetadataBadge')}
hint={t('spellFormSectionMetadataHint')}
>
<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>
) : null}
<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>
<DynamicStringListField
label={t('spellFormCatalogTopicsLabel')}
hint={t('spellTopicsMetadataHint')}
placeholder={t('topic')}
values={form.topics}
onChange={(topics) => setForm((f) => ({ ...f, topics }))}
/>
</SpellMetadataSection>
</div> </div>
</div> </div>

15
src/pages/primary/SpellsPage/index.tsx

@ -24,11 +24,13 @@ import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
buildSpellCatalogAuthors,
getRelaysForSpell, getRelaysForSpell,
getRelaysForSpellCatalogSync, getRelaysForSpellCatalogSync,
getSpellName, getSpellName,
isSpellEvent, isSpellEvent,
SPELL_CATALOG_SYNC_LIMIT, SPELL_CATALOG_SYNC_LIMIT,
SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS,
spellEventToFilter, spellEventToFilter,
spellHasExplicitRelays, spellHasExplicitRelays,
spellIsCount spellIsCount
@ -100,6 +102,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
loadSpells() loadSpells()
}, [loadSpells]) }, [loadSpells])
/** Stable key so we re-sync when the follow list changes (not only on array identity). */
const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts])
/** After showing the cache, pull kind 777 from merged mailbox (10002 + 10432) read/write + fast read. */ /** After showing the cache, pull kind 777 from merged mailbox (10002 + 10432) read/write + fast read. */
useEffect(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {
@ -110,10 +115,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
spellCatalogCloserRef.current = null spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(true) setSpellsCatalogSyncing(true)
const urls = getRelaysForSpellCatalogSync(relayList ?? undefined) const urls = getRelaysForSpellCatalogSync(relayList ?? undefined)
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)
const authorAllowlist = new Set(catalogAuthors)
const filter = { const filter = {
kinds: [ExtendedKind.SPELL], kinds: [ExtendedKind.SPELL],
authors: [pubkey], authors: catalogAuthors,
limit: SPELL_CATALOG_SYNC_LIMIT limit: contacts.length > 0 ? SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS : SPELL_CATALOG_SYNC_LIMIT
} }
const syncTimeout = window.setTimeout(() => { const syncTimeout = window.setTimeout(() => {
if (cancelled) return if (cancelled) return
@ -133,7 +140,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
window.clearTimeout(syncTimeout) window.clearTimeout(syncTimeout)
for (const ev of events) { for (const ev of events) {
if (cancelled) return if (cancelled) return
if (!verifyEvent(ev) || !isSpellEvent(ev) || ev.pubkey !== pubkey) continue if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue
try { try {
await indexedDb.putSpellEvent(ev) await indexedDb.putSpellEvent(ev)
} catch (e) { } catch (e) {
@ -168,7 +175,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
spellCatalogCloserRef.current = null spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false) setSpellsCatalogSyncing(false)
} }
}, [pubkey, spellCatalogRelayKey, loadSpells]) }, [pubkey, spellCatalogRelayKey, loadSpells, contactsSyncKey])
useEffect(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {

19
src/services/spell.service.ts

@ -53,9 +53,26 @@ function defaultSpellWriteFallbackRelays(): string[] {
return dedupeRelayUrls([...FAST_WRITE_RELAY_URLS]) return dedupeRelayUrls([...FAST_WRITE_RELAY_URLS])
} }
/** Max kind-777 events to pull when syncing the user's spell definitions from relays. */ /** Max kind-777 events to pull when syncing spell definitions from relays (you only). */
export const SPELL_CATALOG_SYNC_LIMIT = 200 export const SPELL_CATALOG_SYNC_LIMIT = 200
/**
* When also syncing spells authored by people you follow, allow a larger merged result so
* follow-authored spells are not squeezed out by your own.
*/
export const SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS = 600
/** Max distinct pubkeys in one catalog REQ (relay compatibility). Your pubkey is always first. */
export const SPELL_CATALOG_MAX_AUTHORS = 400
/** Build author list for spell catalog sync: always include `pubkey`, then follows, deduped. */
export function buildSpellCatalogAuthors(pubkey: string, contacts: string[]): string[] {
const rest = contacts.filter((c) => typeof c === 'string' && c.length > 0 && c !== pubkey)
const uniqueFollows = [...new Set(rest)]
const combined = [pubkey, ...uniqueFollows]
return combined.slice(0, SPELL_CATALOG_MAX_AUTHORS)
}
/** /**
* Relays to fetch the user's kind-777 spells: **read** (inboxes), **write** (outboxes), and * Relays to fetch the user's kind-777 spells: **read** (inboxes), **write** (outboxes), and
* {@link FAST_READ_RELAY_URLS}. * {@link FAST_READ_RELAY_URLS}.

Loading…
Cancel
Save