From 856612e1e0a0772d267757524a7c75fcffdc0091 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 19 Mar 2026 09:50:08 +0100 Subject: [PATCH] refining spells and randomized relay selection --- src/constants.ts | 13 +- src/i18n/locales/de.ts | 24 +- src/i18n/locales/en.ts | 24 +- src/lib/draft-event.ts | 66 ++++- .../primary/SpellsPage/CreateSpellDialog.tsx | 92 ++++-- src/pages/primary/SpellsPage/index.tsx | 276 +++++++++++++++++- .../secondary/GeneralSettingsPage/index.tsx | 11 +- src/services/client.service.ts | 64 +++- src/services/nip66.service.ts | 27 +- src/services/relay-selection.service.ts | 17 +- src/services/spell.service.ts | 41 ++- 11 files changed, 579 insertions(+), 76 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index b5a3c7c7..9eac1c6b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -91,6 +91,12 @@ export const BIG_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', ] +/** + * Random public relays (from NIP-66 lively list; write-tested monitors preferred) merged into the + * publish relay picker. More candidates improve odds some accept open writes. + */ +export const RANDOM_PUBLISH_RELAY_COUNT = 5 + /** Relays to query for NIP-66 relay monitoring events (30166), in addition to BIG_RELAY_URLS. */ export const NIP66_DISCOVERY_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', @@ -107,7 +113,12 @@ export const BOOKSTR_RELAY_URLS = [ export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land'] /** Relays that block kind 1 (microblogging); skip for kind 1 read and write. */ -export const KIND_1_BLOCKED_RELAY_URLS = ['wss://thecitadel.nostr1.com'] +export const KIND_1_BLOCKED_RELAY_URLS = [ + 'wss://thecitadel.nostr1.com', + 'wss://hist.nostr.land', + 'wss://profiles.nostr1.com', + 'wss://purplepag.es' +] // Optimized relay list for read operations (includes aggregator) export const FAST_READ_RELAY_URLS = [ diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 7ca577e1..0541a353 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -332,9 +332,9 @@ export default { Autoplay: 'Automatische Wiedergabe', 'Enable video autoplay on this device': 'Aktiviere die automatische Video-Wiedergabe auf diesem Gerät', - 'Add 3 random relays to every publish': '3 zufällige Relays bei jedem Publish hinzufügen', - 'Add 3 random relays to every publish description': - 'Fügt der Publish-Relay-Liste immer 3 zufällige öffentliche Relays hinzu. Bei AN sind sie standardmäßig ausgewählt; bei AUS erscheinen sie in der Liste, sind aber nicht angehakt, sodass du sie optional einbeziehen kannst.', + 'Add random relays to every publish': 'Zufällige Relays in der Publish-Liste', + 'Add random relays to every publish description': + 'Fügt {{n}} zufällige öffentliche Relays aus der NIP-66-Liveliness-Liste hinzu (bevorzugt solche, deren Monitor eine Write-RTT gemeldet hat). Bei AN standardmäßig ausgewählt; bei AUS in der Liste, aber nicht angehakt.', 'relayType_local': 'Lokal', 'relayType_relay_list': 'Relay-Liste', 'relayType_client_default': 'Client-Standard', @@ -650,13 +650,29 @@ export default { '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.', + '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.': + 'Zählung fehlgeschlagen. Relays prüfen oder erneut versuchen.', 'Spell definition': 'Zauberspruch-Definition', 'Spell published': 'Zauberspruch veröffentlicht', + 'Edit spell': 'Zauberspruch bearbeiten', + 'Spell updated': 'Zauberspruch aktualisiert', + 'Relay URL': 'Relay', + Count: 'Anzahl', + 'Edit spell relays': 'Relays bearbeiten', + 'COUNT spell relay errors hint': + 'Mindestens ein Relay ist fehlgeschlagen oder hat einen Fehler gemeldet. Du kannst die Relay-Liste im Zauber anpassen und erneut speichern.', + 'COUNT spell total distinct explanation': + 'Verschiedene passende Event-IDs über alle erfolgreich antwortenden Relays (Duplikate zwischen Relays entfernt). Jedes Relay liefert höchstens so viele wie im Limit steht.', '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.': 'Zaubersprüche sind gespeicherte Relay-Filter (NIP-A7). Fülle die Felder unten. $me = dein Pubkey, $contacts = deine Follow-Liste bei der Ausführung.', Command: 'Befehl', 'REQ returns a feed; COUNT returns a number.': - 'REQ liefert einen Feed; COUNT nur eine Zahl.', + 'REQ: scrollbarer Feed (unten Feed live oder einmaliger Abruf). COUNT: nur eine Zahl, kein Feed.', Name: 'Name', 'Human-readable spell name': 'Lesbarer Name des Zauberspruchs', 'Description (content)': 'Beschreibung (Inhalt)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b3d75077..c531917c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -394,9 +394,9 @@ export default { General: 'General', Autoplay: 'Autoplay', 'Enable video autoplay on this device': 'Enable video autoplay on this device', - 'Add 3 random relays to every publish': 'Random relays in publish list', - 'Add 3 random relays to every publish description': - 'Always adds 3 random public relays to the publish relay list. When ON, they are selected by default; when OFF, they appear in the list but are unchecked so you can optionally include them.', + 'Add random relays to every publish': 'Random relays in publish list', + 'Add random relays to every publish description': + 'Adds {{n}} random public relays from the NIP-66 lively list (preferring monitors that reported a write RTT) to the publish relay list. When ON, they are selected by default; when OFF, they appear in the list but are unchecked so you can optionally include them.', 'relayType_local': 'Local', 'relayType_relay_list': 'Relay list', 'relayType_client_default': 'Client default', @@ -716,6 +716,24 @@ export default { 'One topic per row.': 'One topic per row.', topic: 'topic', 'Spell form fields': 'Spell form fields', + 'Counting matching events…': 'Counting matching events…', + 'Edit spell': 'Edit spell', + 'Spell updated': 'Spell updated', + 'Relay URL': 'Relay', + Count: 'Count', + 'Edit spell relays': 'Edit relays', + 'COUNT spell relay errors hint': + '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.', + '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.', + '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.': + 'Could not complete the count. Check relays or try again.', + 'REQ returns a feed; COUNT returns a number.': + 'REQ: scrollable feed (choose live Feed or one-shot Fetch below). COUNT: a single number, no feed.', Spells: 'Spells', diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 55cdcaaf..c3994ea4 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -638,7 +638,8 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent { .map((t) => t.trim()) .filter(Boolean) .forEach((t) => tags.push(['t', t])) - if (params.closeOnEose) tags.push(['close-on-eose']) + // Live vs one-shot subscription only applies to REQ, not COUNT + if (params.cmd === 'REQ' && params.closeOnEose) tags.push(['close-on-eose']) return { kind: ExtendedKind.SPELL, content: params.content?.trim() ?? '', @@ -647,6 +648,69 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent { } } +/** Rehydrate the spell form from a stored/published kind 777 event (edit flow). */ +export function spellEventToDraftParams(event: Event): TSpellDraftParams { + if (event.kind !== ExtendedKind.SPELL) { + return { + cmd: 'REQ', + content: '', + name: '', + alt: '', + kinds: ['1'], + authors: ['$me', '$contacts'], + ids: [], + tagFilters: [], + limit: '50', + since: '7d', + until: '', + search: '', + relays: [], + topics: [], + closeOnEose: false + } + } + const gt = (name: string) => event.tags.find((t) => t[0] === name) + const all = (name: string) => event.tags.filter((t) => t[0] === name) + const cmdRaw = gt('cmd')?.[1] + const cmd: 'REQ' | 'COUNT' = cmdRaw === 'COUNT' ? 'COUNT' : 'REQ' + const kinds = all('k') + .map((t) => t[1]) + .filter((x): x is string => !!x?.trim()) + const authorsTag = gt('authors') + const authors = + authorsTag && authorsTag.length > 1 ? authorsTag.slice(1).filter((x): x is string => !!x) : [] + const idsTag = gt('ids') + const ids = idsTag && idsTag.length > 1 ? idsTag.slice(1).filter((x): x is string => !!x) : [] + const relaysTag = gt('relays') + const relays = + relaysTag && relaysTag.length > 1 ? relaysTag.slice(1).filter((x): x is string => !!x) : [] + const tagTagRows = all('tag').filter((t) => t.length >= 2) + const tagFilters = tagTagRows.map((t) => ({ + letter: t[1] ?? '', + values: t.slice(2).filter((x): x is string => !!x) + })) + + return { + cmd, + content: event.content ?? '', + name: gt('name')?.[1] ?? '', + alt: gt('alt')?.[1] ?? '', + kinds: kinds.length ? kinds : ['1'], + authors: authors.length ? authors : ['$me', '$contacts'], + ids, + tagFilters, + limit: gt('limit')?.[1] ?? '50', + since: gt('since')?.[1] ?? '7d', + until: gt('until')?.[1] ?? '', + search: gt('search')?.[1] ?? '', + relays, + topics: all('t') + .map((t) => t[1]) + .filter((x): x is string => !!x?.trim()), + closeOnEose: cmd === 'REQ' && event.tags.some((t) => t[0] === 'close-on-eose') + } +} + export function createRssFeedListDraftEvent(feedUrls: string[]): TDraftEvent { // Validate and sanitize feed URLs const validUrls = feedUrls diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index 7136c6d7..f14513f6 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -8,13 +8,18 @@ import { 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 { + createSpellDraftEvent, + spellEventToDraftParams, + 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 { Minus, Plus, X } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { useCallback, useRef, useState } from 'react' +import type { Event as NostrEvent } from 'nostr-tools' +import { useCallback, useEffect, useRef, useState } from 'react' import logger from '@/lib/logger' /** Arrow keys should control the control, not the dialog scroll */ @@ -128,11 +133,15 @@ function DynamicStringListField({ export default function CreateSpellDialog({ open, onOpenChange, - onSaved + onSaved, + spellToEdit }: { open: boolean onOpenChange: (open: boolean) => void - onSaved?: () => void + /** Called after a successful publish; pass the new event so the parent can refresh selection. */ + onSaved?: (publishedEvent?: NostrEvent) => void + /** When set, form is preloaded and save replaces this spell id in storage/favorites. */ + spellToEdit?: NostrEvent | null }) { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() @@ -140,6 +149,15 @@ export default function CreateSpellDialog({ const [saving, setSaving] = useState(false) const scrollBodyRef = useRef(null) + useEffect(() => { + if (!open) return + if (spellToEdit) { + setForm(spellEventToDraftParams(spellToEdit)) + } else { + setForm({ ...DEFAULT_PARAMS }) + } + }, [open, spellToEdit]) + const handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent) => { const el = scrollBodyRef.current if (!el) return @@ -170,6 +188,8 @@ export default function CreateSpellDialog({ onOpenChange(false) } + const replaceSpellId = spellToEdit?.id + const handleSave = async () => { if (!pubkey) { checkLogin() @@ -179,11 +199,18 @@ export default function CreateSpellDialog({ try { const draft = createSpellDraftEvent(form) const event = await publish(draft) + if (replaceSpellId) { + await indexedDb.deleteSpellEvent(replaceSpellId) + const favs = await indexedDb.getSpellFavoriteIds() + if (favs.length) { + await indexedDb.setSpellFavoriteIds(favs.map((id) => (id === replaceSpellId ? event.id : id))) + } + } await indexedDb.putSpellEvent(event) handleClear() + onSaved?.(event) onOpenChange(false) - onSaved?.() - showSimplePublishSuccess(t('Spell published')) + showSimplePublishSuccess(replaceSpellId ? t('Spell updated') : t('Spell published')) } catch (e) { logger.error('[CreateSpellDialog] Publish failed', e) showPublishingError(e instanceof Error ? e : new Error(String(e))) @@ -211,7 +238,7 @@ export default function CreateSpellDialog({ - {t('Create a Spell')} + {replaceSpellId ? t('Edit spell') : t('Create a Spell')}

{t( @@ -234,7 +261,12 @@ export default function CreateSpellDialog({