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. 125
      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.',

125
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,6 +494,12 @@ export default function CreateSpellDialog({
) : null} ) : null}
</div> </div>
<div className="space-y-4 border-t border-border pt-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-foreground">{t('spellFormSectionQueryTitle')}</h3>
<p className="text-xs text-muted-foreground">{t('spellFormSectionQueryHint')}</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Command')}</Label> <Label>{t('Command')}</Label>
<select <select
@ -476,25 +518,6 @@ export default function CreateSpellDialog({
<p className="text-xs text-muted-foreground">{t('REQ returns a feed; COUNT returns a number.')}</p> <p className="text-xs text-muted-foreground">{t('REQ returns a feed; COUNT returns a number.')}</p>
</div> </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>
<DynamicStringListField <DynamicStringListField
label={t('Kinds')} label={t('Kinds')}
hint={t('One kind number per row (e.g. 1 for notes).')} hint={t('One kind number per row (e.g. 1 for notes).')}
@ -520,6 +543,11 @@ export default function CreateSpellDialog({
onChange={(ids) => setForm((f) => ({ ...f, ids }))} onChange={(ids) => setForm((f) => ({ ...f, ids }))}
/> />
<TagFiltersEditor
tagFilters={form.tagFilters}
onChange={(tagFilters) => setForm((f) => ({ ...f, tagFilters }))}
/>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t('Limit')}</Label> <Label>{t('Limit')}</Label>
<Input <Input
@ -568,19 +596,6 @@ export default function CreateSpellDialog({
onChange={(relays) => setForm((f) => ({ ...f, relays }))} onChange={(relays) => setForm((f) => ({ ...f, relays }))}
/> />
<DynamicStringListField
label={t('Topics (t tags for categorization)')}
hint={t('One topic per row.')}
placeholder={t('topic')}
values={form.topics}
onChange={(topics) => setForm((f) => ({ ...f, topics }))}
/>
<TagFiltersEditor
tagFilters={form.tagFilters}
onChange={(tagFilters) => setForm((f) => ({ ...f, tagFilters }))}
/>
{form.cmd === 'REQ' ? ( {form.cmd === 'REQ' ? (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>{t('Mode')}</Label> <Label>{t('Mode')}</Label>
@ -606,6 +621,40 @@ export default function CreateSpellDialog({
</div> </div>
) : null} ) : null}
</div> </div>
<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 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 className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-6 py-4"> <div className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-6 py-4">

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