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 { @@ -728,6 +728,19 @@ export default {
listImportATagFailed: 'Adress-Tag konnte nicht aufgelöst werden: {{preview}}…',
listImportEventNotFound: 'Kein Event zu dieser Referenz gefunden.',
'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:
'Optionale Filter auf abonnierte Events (NIP-01 Ein-Buchstaben-Tags). Beispiel: Buchstabe „t“, Werte „bitcoin“.',
spellTagFiltersEmpty:

13
src/i18n/locales/en.ts

@ -763,6 +763,19 @@ export default { @@ -763,6 +763,19 @@ export default {
listImportATagFailed: 'Failed to resolve address tag: {{preview}}…',
listImportEventNotFound: 'No event found for that reference.',
'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:
'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.',

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

@ -23,10 +23,10 @@ import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing- @@ -23,10 +23,10 @@ import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.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 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'
/** Arrow keys should control the control, not the dialog scroll */
@ -140,6 +140,44 @@ function DynamicStringListField({ @@ -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({
tagFilters,
onChange
@ -156,7 +194,7 @@ function TagFiltersEditor({ @@ -156,7 +194,7 @@ function TagFiltersEditor({
}
return (
<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>
{tagFilters.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('spellTagFiltersEmpty')}</p>
@ -405,9 +443,7 @@ export default function CreateSpellDialog({ @@ -405,9 +443,7 @@ export default function CreateSpellDialog({
<p className="mt-2 text-sm text-muted-foreground">
{spellToClone
? t('Clone spell intro')
: 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.'
)}
: t('spellCreateIntro')}
</p>
</div>
@ -458,153 +494,166 @@ export default function CreateSpellDialog({ @@ -458,153 +494,166 @@ export default function CreateSpellDialog({
) : null}
</div>
<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) => {
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="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">
<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('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) => {
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">
<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}
<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 }))}
/>
</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
label={t('Event IDs (ids)')}
hint={t('One hex event id per row.')}
placeholder="hex id…"
values={form.ids}
onChange={(ids) => setForm((f) => ({ ...f, ids }))}
/>
<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"
<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 }))}
/>
</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"
<DynamicStringListField
label={t('Event IDs (ids)')}
hint={t('One hex event id per row.')}
placeholder="hex id…"
values={form.ids}
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">
<Label>{t('Until')}</Label>
<Input
value={form.until}
onChange={(e) => setForm((f) => ({ ...f, until: e.target.value }))}
placeholder={t('Optional')}
<TagFiltersEditor
tagFilters={form.tagFilters}
onChange={(tagFilters) => setForm((f) => ({ ...f, tagFilters }))}
/>
</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('Limit')}</Label>
<Input
type="number"
value={form.limit}
onChange={(e) => setForm((f) => ({ ...f, limit: e.target.value }))}
placeholder="50"
/>
</div>
<DynamicStringListField
label={t('Relays')}
hint={t('One wss:// URL per row. Leave empty to use your write relays.')}
placeholder="wss://…"
values={form.relays}
onChange={(relays) => setForm((f) => ({ ...f, relays }))}
/>
<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>
<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 }))}
/>
<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>
<TagFiltersEditor
tagFilters={form.tagFilters}
onChange={(tagFilters) => setForm((f) => ({ ...f, tagFilters }))}
/>
<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>
{form.cmd === 'REQ' ? (
<div className="flex flex-col gap-1.5">
<Label>{t('Mode')}</Label>
<div className="flex rounded-lg border border-input bg-muted p-0.5">
<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: false }))}
>
{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>
<DynamicStringListField
label={t('Relays')}
hint={t('One wss:// URL per row. Leave empty to use your write relays.')}
placeholder="wss://…"
values={form.relays}
onChange={(relays) => setForm((f) => ({ ...f, relays }))}
/>
{form.cmd === 'REQ' ? (
<div className="flex flex-col gap-1.5">
<Label>{t('Mode')}</Label>
<div className="flex rounded-lg border border-input bg-muted p-0.5">
<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: false }))}
>
{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>
<p className="text-xs text-muted-foreground">
{form.closeOnEose ? t('Fetch once, then stop.') : t('Live feed; keeps updating.')}
</p>
) : null}
</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>
) : 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>

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

@ -24,11 +24,13 @@ import client from '@/services/client.service' @@ -24,11 +24,13 @@ import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { ExtendedKind } from '@/constants'
import {
buildSpellCatalogAuthors,
getRelaysForSpell,
getRelaysForSpellCatalogSync,
getSpellName,
isSpellEvent,
SPELL_CATALOG_SYNC_LIMIT,
SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS,
spellEventToFilter,
spellHasExplicitRelays,
spellIsCount
@ -100,6 +102,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -100,6 +102,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
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. */
useEffect(() => {
if (!pubkey) {
@ -110,10 +115,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -110,10 +115,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(true)
const urls = getRelaysForSpellCatalogSync(relayList ?? undefined)
const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts)
const authorAllowlist = new Set(catalogAuthors)
const filter = {
kinds: [ExtendedKind.SPELL],
authors: [pubkey],
limit: SPELL_CATALOG_SYNC_LIMIT
authors: catalogAuthors,
limit: contacts.length > 0 ? SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS : SPELL_CATALOG_SYNC_LIMIT
}
const syncTimeout = window.setTimeout(() => {
if (cancelled) return
@ -133,7 +140,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -133,7 +140,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
window.clearTimeout(syncTimeout)
for (const ev of events) {
if (cancelled) return
if (!verifyEvent(ev) || !isSpellEvent(ev) || ev.pubkey !== pubkey) continue
if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue
try {
await indexedDb.putSpellEvent(ev)
} catch (e) {
@ -168,7 +175,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -168,7 +175,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false)
}
}, [pubkey, spellCatalogRelayKey, loadSpells])
}, [pubkey, spellCatalogRelayKey, loadSpells, contactsSyncKey])
useEffect(() => {
if (!pubkey) {

19
src/services/spell.service.ts

@ -53,9 +53,26 @@ function defaultSpellWriteFallbackRelays(): string[] { @@ -53,9 +53,26 @@ function defaultSpellWriteFallbackRelays(): string[] {
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
/**
* 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
* {@link FAST_READ_RELAY_URLS}.

Loading…
Cancel
Save