Browse Source

fix spells search

share spells
imwald
Silberengel 1 month ago
parent
commit
70201f91c8
  1. 15
      src/i18n/locales/de.ts
  2. 12
      src/i18n/locales/en.ts
  3. 2
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  4. 124
      src/pages/primary/SpellsPage/index.tsx
  5. 36
      src/services/client.service.ts
  6. 47
      src/services/spell.service.ts

15
src/i18n/locales/de.ts

@ -627,6 +627,7 @@ export default { @@ -627,6 +627,7 @@ export default {
'Create a Spell': 'Zauberspruch anlegen',
'No spells yet. Create one with the button above.':
'Noch keine Zaubersprüche. Lege mit dem Button oben einen an.',
'Loading spells from your relays…': 'Zaubersprüche werden von deinen Relays geladen…',
'Select a spell…': 'Zauberspruch wählen…',
'View definition': 'Definition anzeigen',
'Add to favorites': 'Zu Favoriten hinzufügen',
@ -635,8 +636,8 @@ export default { @@ -635,8 +636,8 @@ export default {
'COUNT-Zaubersprüche zeigen eine Zahl, keinen Feed.',
'Log in to run this spell (it uses $me or $contacts).':
'Zum Ausführen anmelden (verwendet $me oder $contacts).',
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.':
'Zauberspruch konnte nicht ausgeführt werden. Prüfe REQ/COUNT oder füge Lese-Relays in den Einstellungen hinzu.',
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.':
'Zauberspruch konnte nicht ausgeführt werden. Prüfe REQ/COUNT oder füge Schreib-Relays (Outbox) in den Einstellungen hinzu.',
'Select a spell to view its feed.': 'Wähle einen Zauberspruch, um den Feed zu sehen.',
'Add another row': 'Weitere Zeile hinzufügen',
'Remove this row': 'Diese Zeile entfernen',
@ -645,14 +646,14 @@ export default { @@ -645,14 +646,14 @@ export default {
'One author per row: $me, $contacts, or hex pubkey / npub.':
'Ein Autor pro Zeile: $me, $contacts oder Hex-Pubkey / npub.',
'One hex event id per row.': 'Eine Hex-Event-ID pro Zeile.',
'One wss:// URL per row. Leave empty to use your read relays.':
'Eine wss://-URL pro Zeile. Alle leer lassen für deine Lese-Relays.',
'One wss:// URL per row. Leave empty to use your write relays.':
'Eine wss://-URL pro Zeile. Alle leer lassen für deine Schreib-Relays (Outbox).',
'One topic per row.': 'Ein Thema pro Zeile.',
topic: 'Thema',
'Spell form fields': 'Zauberspruch-Formularfelder',
'Counting matching events…': 'Zähle passende Events…',
'COUNT spell result explanation':
'Verschiedene Events, die zum Filter passen (von den Relays des Zaubers und Fallbacks zusammengeführt, Duplikate entfernt). Relays liefern höchstens so viele wie im Limit steht.',
'Verschiedene Events, die zum Filter passen (von den Relays des Zaubers und Standard-Schreib-Relays zusammengeführt, Duplikate entfernt). Relays liefern höchstens so viele wie im Limit steht.',
'COUNT spell may be capped by limit':
'Die Zahl kann deinem Limit entsprechen — es könnte noch mehr passende Events geben.',
'Spell count failed. Check relays or try again.':
@ -693,8 +694,8 @@ export default { @@ -693,8 +694,8 @@ export default {
Optional: 'Optional',
'Search (NIP-50)': 'Suche (NIP-50)',
'Full-text search query': 'Volltextsuchanfrage',
'Leave empty to use your read relays.':
'Leer lassen, um deine Lese-Relays zu verwenden.',
'Leave empty to use your write relays.':
'Leer lassen, um deine Schreib-Relays (Outbox) zu verwenden.',
'Topics (t tags for categorization)': 'Themen (t-Tags zur Kategorisierung)',
'Comma-separated topics': 'Themen, komma-getrennt',
Mode: 'Modus',

12
src/i18n/locales/en.ts

@ -703,6 +703,9 @@ export default { @@ -703,6 +703,9 @@ export default {
'No spells yet. Create one with the button above.':
'No spells yet. Create one with the button above.',
'Loading spells from your relays…': 'Loading spells from your relays…',
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.':
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.',
'Select a spell…': 'Select a spell…',
'Select a spell to view its feed.': 'Select a spell to view its feed.',
'Add another row': 'Add another row',
@ -711,8 +714,8 @@ export default { @@ -711,8 +714,8 @@ export default {
'One author per row: $me, $contacts, or hex pubkey / npub.':
'One author per row: $me, $contacts, or hex pubkey / npub.',
'One hex event id per row.': 'One hex event id per row.',
'One wss:// URL per row. Leave empty to use your read relays.':
'One wss:// URL per row. Leave empty to use your read relays.',
'One wss:// URL per row. Leave empty to use your write relays.':
'One wss:// URL per row. Leave empty to use your write relays.',
'One topic per row.': 'One topic per row.',
topic: 'topic',
'Spell form fields': 'Spell form fields',
@ -726,8 +729,11 @@ export default { @@ -726,8 +729,11 @@ export default {
'One or more relays failed or returned an error. You can change the relay list in the spell and save again.',
'COUNT spell total distinct explanation':
'Distinct matching event IDs across all relays that responded successfully (duplicates across relays removed). Each relay only returns up to the filter limit.',
'Leave empty to use your write relays.':
'Leave empty to use your write relays.',
'COUNT spell result explanation':
'Distinct events returned for this filter (merged from your spell relays and fallbacks, duplicates removed). Relays only return up to the filter limit.',
'Distinct events returned for this filter (merged from your spell relays and default write relays, duplicates removed). Relays only return up to the filter limit.',
'COUNT spell may be capped by limit':
'This count may equal your spell limit — there could be more matching events on the network.',
'Spell count failed. Check relays or try again.':

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

@ -360,7 +360,7 @@ export default function CreateSpellDialog({ @@ -360,7 +360,7 @@ export default function CreateSpellDialog({
<DynamicStringListField
label={t('Relays')}
hint={t('One wss:// URL per row. Leave empty to use your read 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 }))}

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

@ -22,13 +22,17 @@ import { @@ -22,13 +22,17 @@ import {
SelectValue
} from '@/components/ui/select'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import logger from '@/lib/logger'
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 { ExtendedKind } from '@/constants'
import {
getRelaysForSpell,
getRelaysForSpellCatalogSync,
getSpellName,
isSpellEvent,
SPELL_CATALOG_SYNC_LIMIT,
spellEventToFilter,
spellHasExplicitRelays,
spellIsCount
@ -36,7 +40,8 @@ import { @@ -36,7 +40,8 @@ import {
import { TFeedSubRequest } from '@/types'
import { FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { verifyEvent } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog'
import type { TPageRef } from '@/types'
@ -55,6 +60,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -55,6 +60,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const [definitionSpell, setDefinitionSpell] = useState<Event | null>(null)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [contacts, setContacts] = useState<string[]>([])
/** True while fetching kind 777 authored by the user from write relays into IndexedDB */
const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false)
const spellCatalogCloserRef = useRef<(() => void) | null>(null)
/** COUNT spells: per-relay breakdown + distinct total */
const [spellCount, setSpellCount] = useState<{
loading: boolean
@ -81,10 +89,93 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -81,10 +89,93 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
setFavoriteIds(new Set(ids))
}, [])
/** Re-sync catalog when inbox / outbox / mailbox entries change (not only `write`). */
const spellCatalogRelayKey = useMemo(
() =>
relayList
? JSON.stringify({
r: relayList.read,
w: relayList.write,
o: relayList.originalRelays.map((x) => [x.url, x.scope])
})
: '',
[relayList]
)
useEffect(() => {
loadSpells()
}, [loadSpells])
/** After showing the cache, pull kind 777 from merged mailbox (10002 + 10432) read/write + fast read. */
useEffect(() => {
if (!pubkey) {
setSpellsCatalogSyncing(false)
return
}
let cancelled = false
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(true)
const urls = getRelaysForSpellCatalogSync(relayList ?? undefined)
const filter = {
kinds: [ExtendedKind.SPELL],
authors: [pubkey],
limit: SPELL_CATALOG_SYNC_LIMIT
}
const syncTimeout = window.setTimeout(() => {
if (cancelled) return
logger.warn('[SpellsPage] Spell catalog sync timed out')
spellCatalogCloserRef.current?.()
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false)
}, 40_000)
void (async () => {
try {
const { closer } = await client.subscribeTimeline(
[{ urls, filter }],
{
onEvents: async (events, eosed) => {
if (!eosed || cancelled) return
window.clearTimeout(syncTimeout)
for (const ev of events) {
if (cancelled) return
if (!verifyEvent(ev) || !isSpellEvent(ev) || ev.pubkey !== pubkey) continue
try {
await indexedDb.putSpellEvent(ev)
} catch (e) {
logger.warn('[SpellsPage] Failed to cache spell from relay', e)
}
}
if (!cancelled) await loadSpells()
if (!cancelled) setSpellsCatalogSyncing(false)
closer()
spellCatalogCloserRef.current = null
},
onNew: () => {}
},
{ needSort: true }
)
if (cancelled) {
closer()
return
}
spellCatalogCloserRef.current = closer
} catch (e) {
window.clearTimeout(syncTimeout)
logger.warn('[SpellsPage] Spell catalog subscribe failed', e)
if (!cancelled) setSpellsCatalogSyncing(false)
}
})()
return () => {
cancelled = true
window.clearTimeout(syncTimeout)
spellCatalogCloserRef.current?.()
spellCatalogCloserRef.current = null
setSpellsCatalogSyncing(false)
}
}, [pubkey, spellCatalogRelayKey, loadSpells])
useEffect(() => {
if (!pubkey) {
setContacts([])
@ -118,34 +209,31 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -118,34 +209,31 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
mayHitLimit: false,
usedExplicitRelays: false
})
const defaultRelays = [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])]
const relayListRead = relayList?.read?.length ? relayList.read : defaultRelays
const relayListWrite = relayList?.write ?? []
const ctx = {
pubkey,
contacts,
relayListRead
contacts
}
const filter = spellEventToFilter(selectedSpell, ctx)
if (!filter) {
setSubRequests([])
return
}
const relays = getRelaysForSpell(selectedSpell, { relayListRead })
const relays = getRelaysForSpell(selectedSpell, { relayListWrite })
if (!relays.length) {
setSubRequests([])
return
}
setSubRequests([{ urls: relays, filter }])
}, [selectedSpell, pubkey, contacts, relayList?.read])
}, [selectedSpell, pubkey, contacts, relayList?.write])
useEffect(() => {
if (!selectedSpell || !spellIsCount(selectedSpell)) {
return
}
let cancelled = false
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 relayListWrite = relayList?.write ?? []
const ctx = { pubkey, contacts }
const usedExplicitRelays = spellHasExplicitRelays(selectedSpell)
const needsLogin =
@ -177,7 +265,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -177,7 +265,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
})
return
}
const relays = getRelaysForSpell(selectedSpell, { relayListRead }, { mergeDefaultReadRelays: false })
const relays = getRelaysForSpell(selectedSpell, { relayListWrite }, { mergeDefaultReadRelays: false })
if (!relays.length) {
setSpellCount({
loading: false,
@ -245,7 +333,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -245,7 +333,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
return () => {
cancelled = true
}
}, [selectedSpell, pubkey, contacts, relayList?.read])
}, [selectedSpell, pubkey, contacts, relayList?.write])
const toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds()
@ -386,7 +474,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -386,7 +474,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
</div>
</div>
{orderedSpells.length === 0 && (
{spellsCatalogSyncing ? (
<p className="text-xs text-muted-foreground">{t('Loading spells from your relays…')}</p>
) : null}
{orderedSpells.length === 0 && !spellsCatalogSyncing && (
<p className="text-sm text-muted-foreground">{t('No spells yet. Create one with the button above.')}</p>
)}
@ -402,7 +494,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -402,7 +494,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
) : spellCount.error === 'invalid' ? (
<p className="text-center text-muted-foreground">
{t(
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.'
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.'
)}
</p>
) : spellCount.error === 'failed' ? (
@ -497,7 +589,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -497,7 +589,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
) : (
<div className="py-8 text-center text-muted-foreground">
{t(
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add read relays in settings.'
'Could not run this spell. Check that it has a valid REQ/COUNT command, or add write relays in settings.'
)}
</div>
)

36
src/services/client.service.ts

@ -279,6 +279,42 @@ class ClientService extends EventTarget { @@ -279,6 +279,42 @@ class ClientService extends EventTarget {
if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls
} else {
// Kind 777 spells: merged write list (kind 10002 outbox + kind 10432 CACHE_RELAYS) + fast write.
if (event.kind === ExtendedKind.SPELL) {
let spellRelayList: TRelayList | undefined
try {
spellRelayList = await this.fetchRelayList(event.pubkey)
} catch (err) {
logger.warn('[DetermineTargetRelays] fetchRelayList failed for spell', {
pubkey: event.pubkey?.substring(0, 8),
error: err instanceof Error ? err.message : String(err)
})
spellRelayList = { write: [], read: [], originalRelays: [] }
}
const normalizedWrite = (spellRelayList?.write ?? [])
.map((url) => normalizeUrl(url))
.filter((url): url is string => !!url)
const cappedWrite = normalizedWrite.slice(0, 10)
const merged = [...cappedWrite, ...FAST_WRITE_RELAY_URLS]
const seen = new Set<string>()
let spellRelays: string[] = []
for (const u of merged) {
const n = normalizeUrl(u) || u
if (!n || seen.has(n)) continue
seen.add(n)
spellRelays.push(n)
}
if (!spellRelays.length) {
spellRelays = [...FAST_WRITE_RELAY_URLS]
}
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
spellRelays = spellRelays.filter((url) => {
const n = normalizeUrl(url) || url
return !readOnlySet.has(n)
})
return spellRelays.length > 0 ? spellRelays : [...FAST_WRITE_RELAY_URLS]
}
const _additionalRelayUrls: string[] = additionalRelayUrls ?? []
if (!specifiedRelayUrls?.length && ![kinds.Contacts, kinds.Mutelist].includes(event.kind)) {
const mentions: string[] = []

47
src/services/spell.service.ts

@ -2,9 +2,10 @@ @@ -2,9 +2,10 @@
* NIP-A7 Spells: parse and execute kind 777 events as portable relay query filters.
*/
import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import logger from '@/lib/logger'
import type { TRelayList } from '@/types'
import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools'
import type { Filter } from 'nostr-tools'
@ -45,12 +46,30 @@ export function resolveRelativeTime(value: string): number { @@ -45,12 +46,30 @@ export function resolveRelativeTime(value: string): number {
export type SpellExecutionContext = {
pubkey: string | null
contacts: string[]
relayListRead: string[]
}
/** Default read relays for spells (deduped); merged after spell/user lists so outages on one relay still leave alternatives. */
function defaultSpellReadFallbackRelays(): string[] {
return dedupeRelayUrls([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
/** When the spell has no `relays` tag and NIP-65 write list is empty: known-good write relays. */
function defaultSpellWriteFallbackRelays(): string[] {
return dedupeRelayUrls([...FAST_WRITE_RELAY_URLS])
}
/** Max kind-777 events to pull when syncing the user's spell definitions from relays. */
export const SPELL_CATALOG_SYNC_LIMIT = 200
/**
* Relays to fetch the user's kind-777 spells: **read** (inboxes), **write** (outboxes), and
* {@link FAST_READ_RELAY_URLS}.
*
* Pass `relayList` from {@link ClientService.fetchRelayList} / NostrProvider it already merges
* kind **10002** and kind **10432** (CACHE_RELAYS / local relays in the app). Do not infer local
* relays from hostnames.
*/
export function getRelaysForSpellCatalogSync(relayList: TRelayList | null | undefined): string[] {
return dedupeRelayUrls([
...(relayList?.read ?? []),
...(relayList?.write ?? []),
...FAST_READ_RELAY_URLS
])
}
function dedupeRelayUrls(urls: string[]): string[] {
@ -67,19 +86,19 @@ function dedupeRelayUrls(urls: string[]): string[] { @@ -67,19 +86,19 @@ function dedupeRelayUrls(urls: string[]): string[] {
export type GetRelaysForSpellOptions = {
/**
* When true (default): merge FAST_READ + SEARCHABLE after primary list (REQ feeds).
* When false: use only spell `relays` tag, or only read list / defaults no extra padding (COUNT, explicit relays).
* When true (default): merge FAST_WRITE after the primary list (REQ feeds) for resilience.
* When false: use only spell `relays` tag, NIP-65 write relays, or write fallback no extra padding (COUNT).
*/
mergeDefaultReadRelays?: boolean
}
/**
* Get relay URLs for executing a spell: from spell's `relays` tag or context read list.
* REQ feeds default to merging default read relays; pass `mergeDefaultReadRelays: false` for COUNT-only lists.
* Get relay URLs for executing a spell: spell `relays` tag, else the user's NIP-65 **write** (outbox) relays.
* Publishing and running spells use outboxes only (plus optional FAST_WRITE padding when mergeDefaults is true).
*/
export function getRelaysForSpell(
spell: Event,
context: { relayListRead: string[] },
context: { relayListWrite: string[] },
options?: GetRelaysForSpellOptions
): string[] {
const mergeDefaults = options?.mergeDefaultReadRelays !== false
@ -91,14 +110,14 @@ export function getRelaysForSpell( @@ -91,14 +110,14 @@ export function getRelaysForSpell(
.filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
if (urls.length) primary = urls
}
if (!primary.length && context.relayListRead.length) {
primary = [...context.relayListRead]
if (!primary.length && context.relayListWrite.length) {
primary = [...context.relayListWrite]
}
if (!primary.length) {
return defaultSpellReadFallbackRelays()
return defaultSpellWriteFallbackRelays()
}
if (mergeDefaults) {
return dedupeRelayUrls([...primary, ...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
return dedupeRelayUrls([...primary, ...FAST_WRITE_RELAY_URLS])
}
return dedupeRelayUrls(primary)
}

Loading…
Cancel
Save