Browse Source

refining spells and randomized relay selection

imwald
Silberengel 1 month ago
parent
commit
856612e1e0
  1. 13
      src/constants.ts
  2. 24
      src/i18n/locales/de.ts
  3. 24
      src/i18n/locales/en.ts
  4. 66
      src/lib/draft-event.ts
  5. 92
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  6. 276
      src/pages/primary/SpellsPage/index.tsx
  7. 11
      src/pages/secondary/GeneralSettingsPage/index.tsx
  8. 64
      src/services/client.service.ts
  9. 27
      src/services/nip66.service.ts
  10. 17
      src/services/relay-selection.service.ts
  11. 41
      src/services/spell.service.ts

13
src/constants.ts

@ -91,6 +91,12 @@ export const BIG_RELAY_URLS = [ @@ -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 = [ @@ -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 = [

24
src/i18n/locales/de.ts

@ -332,9 +332,9 @@ export default { @@ -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 { @@ -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)',

24
src/i18n/locales/en.ts

@ -394,9 +394,9 @@ export default { @@ -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 { @@ -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',

66
src/lib/draft-event.ts

@ -638,7 +638,8 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent { @@ -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 { @@ -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

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

@ -8,13 +8,18 @@ import { @@ -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({ @@ -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({ @@ -140,6 +149,15 @@ export default function CreateSpellDialog({
const [saving, setSaving] = useState(false)
const scrollBodyRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
if (spellToEdit) {
setForm(spellEventToDraftParams(spellToEdit))
} else {
setForm({ ...DEFAULT_PARAMS })
}
}, [open, spellToEdit])
const handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
const el = scrollBodyRef.current
if (!el) return
@ -170,6 +188,8 @@ export default function CreateSpellDialog({ @@ -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({ @@ -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({ @@ -211,7 +238,7 @@ export default function CreateSpellDialog({
<X className="size-4" />
</Button>
<DialogHeader className="space-y-1.5 pr-10 text-left sm:text-left">
<DialogTitle>{t('Create a Spell')}</DialogTitle>
<DialogTitle>{replaceSpellId ? t('Edit spell') : t('Create a Spell')}</DialogTitle>
</DialogHeader>
<p className="mt-2 text-sm text-muted-foreground">
{t(
@ -234,7 +261,12 @@ export default function CreateSpellDialog({ @@ -234,7 +261,12 @@ export default function CreateSpellDialog({
<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) => setForm((f) => ({ ...f, cmd: e.target.value as 'REQ' | 'COUNT' }))}
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>
@ -342,28 +374,30 @@ export default function CreateSpellDialog({ @@ -342,28 +374,30 @@ export default function CreateSpellDialog({
onChange={(topics) => setForm((f) => ({ ...f, topics }))}
/>
<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>
{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>
</div>
) : null}
</div>
</div>

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import NoteList from '@/components/NoteList'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
@ -10,6 +11,7 @@ import { @@ -10,6 +11,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
@ -28,10 +30,11 @@ import { @@ -28,10 +30,11 @@ import {
getRelaysForSpell,
getSpellName,
spellEventToFilter,
spellHasExplicitRelays,
spellIsCount
} from '@/services/spell.service'
import { TFeedSubRequest } from '@/types'
import { FileText, MoreVertical, Plus, Star, Trash2, Wand2 } from 'lucide-react'
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 { useTranslation } from 'react-i18next'
@ -48,9 +51,26 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -48,9 +51,26 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [spellToEdit, setSpellToEdit] = useState<Event | null>(null)
const [definitionSpell, setDefinitionSpell] = useState<Event | null>(null)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [contacts, setContacts] = useState<string[]>([])
/** COUNT spells: per-relay breakdown + distinct total */
const [spellCount, setSpellCount] = useState<{
loading: boolean
rows: { url: string; count: number | null; error?: string }[]
totalDistinct: number | null
error: 'none' | 'login' | 'invalid' | 'failed'
mayHitLimit: boolean
usedExplicitRelays: boolean
}>({
loading: false,
rows: [],
totalDistinct: null,
error: 'none',
mayHitLimit: false,
usedExplicitRelays: false
})
const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([
@ -76,12 +96,28 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -76,12 +96,28 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
useEffect(() => {
if (!selectedSpell) {
setSubRequests([])
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'none',
mayHitLimit: false,
usedExplicitRelays: false
})
return
}
if (spellIsCount(selectedSpell)) {
setSubRequests([])
return
}
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'none',
mayHitLimit: false,
usedExplicitRelays: false
})
const defaultRelays = [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])]
const relayListRead = relayList?.read?.length ? relayList.read : defaultRelays
const ctx = {
@ -102,6 +138,115 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -102,6 +138,115 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
setSubRequests([{ urls: relays, filter }])
}, [selectedSpell, pubkey, contacts, relayList?.read])
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 usedExplicitRelays = spellHasExplicitRelays(selectedSpell)
const needsLogin =
!pubkey &&
selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))
)
if (needsLogin) {
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'login',
mayHitLimit: false,
usedExplicitRelays
})
return
}
const filter = spellEventToFilter(selectedSpell, ctx)
if (!filter) {
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'invalid',
mayHitLimit: false,
usedExplicitRelays
})
return
}
const relays = getRelaysForSpell(selectedSpell, { relayListRead }, { mergeDefaultReadRelays: false })
if (!relays.length) {
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'failed',
mayHitLimit: false,
usedExplicitRelays
})
return
}
setSpellCount({
loading: true,
rows: [],
totalDistinct: null,
error: 'none',
mayHitLimit: false,
usedExplicitRelays
})
;(async () => {
const rows: { url: string; count: number | null; error?: string }[] = []
const allIds = new Set<string>()
try {
for (const url of relays) {
if (cancelled) return
const { events, connectionError } = await client.fetchEventsFromSingleRelay(url, filter, {
globalTimeout: 28_000
})
if (cancelled) return
if (connectionError) {
rows.push({ url, count: null, error: connectionError })
} else {
const c = new Set(events.map((e) => e.id)).size
rows.push({ url, count: c })
events.forEach((e) => allIds.add(e.id))
}
}
if (cancelled) return
const lim = filter.limit
const totalDistinct = allIds.size
const mayHitLimit = typeof lim === 'number' && lim > 0 && totalDistinct >= lim
setSpellCount({
loading: false,
rows,
totalDistinct,
error: 'none',
mayHitLimit,
usedExplicitRelays
})
} catch {
if (!cancelled) {
setSpellCount({
loading: false,
rows,
totalDistinct: null,
error: 'failed',
mayHitLimit: false,
usedExplicitRelays
})
}
}
})()
return () => {
cancelled = true
}
}, [selectedSpell, pubkey, contacts, relayList?.read])
const toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds()
const set = new Set(ids)
@ -140,7 +285,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -140,7 +285,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => setCreateOpen(true)}
onClick={() => {
setSpellToEdit(null)
setCreateOpen(true)
}}
title={t('Create a Spell')}
>
<Plus className="size-5" />
@ -177,7 +325,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -177,7 +325,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<Button
className="justify-start gap-2"
variant="outline"
onClick={() => setCreateOpen(true)}
onClick={() => {
setSpellToEdit(null)
setCreateOpen(true)
}}
>
<Wand2 className="size-4" />
{t('Create a Spell')}
@ -206,12 +357,23 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -206,12 +357,23 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setDefinitionSpell(selectedSpell)}>
<DropdownMenuItem
className="gap-2"
onClick={() => {
setSpellToEdit(selectedSpell)
setCreateOpen(true)
}}
>
<Pencil className="size-4" />
{t('Edit spell')}
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onClick={() => setDefinitionSpell(selectedSpell)}>
<FileText className="size-4" />
{t('View definition')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)}
>
<Trash2 className="size-4" />
@ -231,7 +393,89 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -231,7 +393,89 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
{/* Feed */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedSpell ? (
subRequests.length > 0 ? (
spellIsCount(selectedSpell) ? (
<div className="flex flex-col items-center justify-center gap-3 py-10 px-4">
{spellCount.error === 'login' ? (
<p className="text-center text-muted-foreground">
{t('Log in to run this spell (it uses $me or $contacts).')}
</p>
) : 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.'
)}
</p>
) : spellCount.error === 'failed' ? (
<p className="text-center text-muted-foreground">
{t('Spell count failed. Check relays or try again.')}
</p>
) : spellCount.loading ? (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<Skeleton className="h-12 w-24" />
<Skeleton className="h-32 w-full max-w-lg" />
<p className="text-sm text-muted-foreground">{t('Counting matching events…')}</p>
</div>
) : (
<>
<div className="text-5xl font-semibold tabular-nums tracking-tight text-foreground">
{spellCount.totalDistinct ?? '—'}
</div>
<p className="max-w-md text-center text-sm text-muted-foreground">
{t('COUNT spell total distinct explanation')}
</p>
<div className="w-full max-w-3xl overflow-x-auto rounded-md border border-border">
<table className="w-full min-w-[20rem] border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-muted-foreground">
<th className="px-3 py-2 font-medium">{t('Relay URL')}</th>
<th className="w-28 px-3 py-2 font-medium">{t('Count')}</th>
</tr>
</thead>
<tbody>
{spellCount.rows.map((r) => (
<tr key={r.url} className="border-b border-border/60 last:border-0">
<td className="break-all px-3 py-2 align-top font-mono text-xs">{r.url}</td>
<td className="px-3 py-2 align-top tabular-nums">
{r.error ? (
<span className="text-destructive">{r.error}</span>
) : (
(r.count ?? '—')
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{spellCount.usedExplicitRelays &&
spellCount.rows.some((r) => r.error) &&
!spellCount.loading ? (
<div className="flex max-w-md flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">
{t('COUNT spell relay errors hint')}
</p>
<Button
variant="outline"
className="gap-2"
onClick={() => {
setSpellToEdit(selectedSpell)
setCreateOpen(true)
}}
>
<Wand2 className="size-4" />
{t('Edit spell relays')}
</Button>
</div>
) : null}
{spellCount.mayHitLimit ? (
<p className="max-w-md text-center text-xs text-amber-600 dark:text-amber-500">
{t('COUNT spell may be capped by limit')}
</p>
) : null}
</>
)}
</div>
) : subRequests.length > 0 ? (
<NoteList
subRequests={subRequests}
showKinds={(() => {
@ -239,15 +483,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -239,15 +483,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
.filter((tag) => tag[0] === 'k')
.map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n))
// `[] || [1]` is wrong ([] is truthy); default to kind 1 for notes
return kinds.length ? kinds : [1]
})()}
useFilterAsIs
/>
) : spellIsCount(selectedSpell) ? (
<div className="py-8 text-center text-muted-foreground">
{t('COUNT spells show a number, not a feed.')}
</div>
) : !pubkey &&
selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))
@ -270,7 +509,20 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) { @@ -270,7 +509,20 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
</div>
</div>
<CreateSpellDialog open={createOpen} onOpenChange={setCreateOpen} onSaved={loadSpells} />
<CreateSpellDialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open)
if (!open) setSpellToEdit(null)
}}
spellToEdit={spellToEdit}
onSaved={(ev) => {
void loadSpells()
if (ev && spellToEdit && selectedSpell?.id === spellToEdit.id) {
setSelectedSpell(ev)
}
}}
/>
<Dialog open={!!definitionSpell} onOpenChange={(open) => !open && setDefinitionSpell(null)}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">

11
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -1,7 +1,12 @@ @@ -1,7 +1,12 @@
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { FONT_SIZE, MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE } from '@/constants'
import {
FONT_SIZE,
MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE,
RANDOM_PUBLISH_RELAY_COUNT
} from '@/constants'
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn, isSupportCheckConnectionType } from '@/lib/utils'
@ -149,9 +154,9 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -149,9 +154,9 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
</SettingItem>
<SettingItem>
<Label htmlFor="add-random-relays" className="text-base font-normal">
<div>{t('Add 3 random relays to every publish')}</div>
<div>{t('Add random relays to every publish')}</div>
<div className="text-muted-foreground">
{t('Add 3 random relays to every publish description')}
{t('Add random relays to every publish description', { n: RANDOM_PUBLISH_RELAY_COUNT })}
</div>
</Label>
<Switch

64
src/services/client.service.ts

@ -410,6 +410,29 @@ class ClientService extends EventTarget { @@ -410,6 +410,29 @@ class ClientService extends EventTarget {
}
}
/**
* Relays that returned OK on at least one publish this session merged ahead of NIP-66 lively list
* so they stay in the random-relay pool even if not currently in monitoring data.
*/
getSessionSuccessfulPublishRelayUrlsForRandomPool(): string[] {
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const out: string[] = []
for (const [url, stats] of this.sessionRelayPublishStats.entries()) {
if (stats.successCount < 1) continue
const n = normalizeUrl(url) || url
if (!n || readOnlySet.has(n)) continue
if ((this.publishStrikeCount.get(n) ?? 0) >= ClientService.PUBLISH_STRIKES_THRESHOLD) continue
out.push(n)
}
out.sort((a, b) => {
const sa = this.sessionRelayPublishStats.get(a)!
const sb = this.sessionRelayPublishStats.get(b)!
if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount
return sa.sumLatencyMs / sa.successCount - sb.sumLatencyMs / sb.successCount
})
return out
}
/**
* Session-only debug info for the Session Relays settings tab: working/striked preset relays and scored random relays.
*/
@ -460,6 +483,7 @@ class ClientService extends EventTarget { @@ -460,6 +483,7 @@ class ClientService extends EventTarget {
preferred.sort((a, b) => {
const sa = this.sessionRelayPublishStats.get(a)!
const sb = this.sessionRelayPublishStats.get(b)!
if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount
const avgA = sa.sumLatencyMs / sa.successCount
const avgB = sb.sumLatencyMs / sb.successCount
return avgA - avgB
@ -467,12 +491,13 @@ class ClientService extends EventTarget { @@ -467,12 +491,13 @@ class ClientService extends EventTarget {
const result: string[] = []
let pi = 0
let ri = 0
const shuffledRest = rest.slice().sort(() => Math.random() - 0.5)
while (result.length < count && (pi < preferred.length || ri < shuffledRest.length)) {
// Preserve candidate order (e.g. NIP-66 write-proven relays first); avoid full shuffle so monitoring hints apply.
const orderedRest = rest.slice()
while (result.length < count && (pi < preferred.length || ri < orderedRest.length)) {
if (pi < preferred.length) {
result.push(preferred[pi++])
} else if (ri < shuffledRest.length) {
result.push(shuffledRest[ri++])
} else if (ri < orderedRest.length) {
result.push(orderedRest[ri++])
}
}
return result.slice(0, count)
@ -1501,6 +1526,37 @@ class ClientService extends EventTarget { @@ -1501,6 +1526,37 @@ class ClientService extends EventTarget {
return events
}
/**
* Query one relay only (e.g. spell COUNT per-relay). Connection failures return `connectionError` instead of throwing.
*/
async fetchEventsFromSingleRelay(
url: string,
filter: Filter | Filter[],
options?: { globalTimeout?: number }
): Promise<{ events: NEvent[]; connectionError?: string }> {
const normalized = normalizeUrl(url) || url
if (!normalized) {
return { events: [], connectionError: 'Invalid relay URL' }
}
try {
await this.pool.ensureRelay(normalized, { connectionTimeout: 12_000 })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return { events: [], connectionError: msg }
}
try {
const events = await this.query([normalized], filter, undefined, {
globalTimeout: options?.globalTimeout ?? 25_000
})
return { events, connectionError: undefined }
} catch (e) {
return {
events: [],
connectionError: e instanceof Error ? e.message : String(e)
}
}
}
/**
* Fetch a single event by id (hex, note1, nevent1, naddr1).
* Relay order: (1) session/DataLoader cache (2) buildInitialRelayList (user's FAST_READ + favorite + read) or BIG_RELAY_URLS

27
src/services/nip66.service.ts

@ -150,16 +150,33 @@ class Nip66Service { @@ -150,16 +150,33 @@ class Nip66Service {
/**
* Build list of relay URLs that are public (no auth, no payment) and have been
* reported by NIP-66 monitors (lively). Used for "add 3 random relays" censorship resilience.
* reported by NIP-66 monitors (lively). Used for random publish relays (censorship resilience).
* Relays with a sane monitor `rtt-write` measurement are shuffled first more likely to accept EVENT.
*/
private buildPublicLivelyFromDiscovery(): string[] {
const out: string[] = []
const eligible: TNip66RelayDiscovery[] = []
for (const d of this.discoveryByUrl.values()) {
const authRequired = d.requirements.auth === true
const paymentRequired = d.requirements.payment === true
if (!authRequired && !paymentRequired) out.push(d.url)
if (!authRequired && !paymentRequired) eligible.push(d)
}
return out
const shuffleInPlace = <T>(arr: T[]) => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j]!, arr[i]!]
}
return arr
}
/** Monitor recorded write RTT — indicates write path was exercised recently */
const writeProven = eligible.filter(
(d) => d.rttWriteMs != null && d.rttWriteMs > 0 && d.rttWriteMs < 120_000
)
const rest = eligible.filter(
(d) => !(d.rttWriteMs != null && d.rttWriteMs > 0 && d.rttWriteMs < 120_000)
)
shuffleInPlace(writeProven)
shuffleInPlace(rest)
return [...writeProven, ...rest].map((d) => d.url)
}
/**
@ -189,7 +206,7 @@ class Nip66Service { @@ -189,7 +206,7 @@ class Nip66Service {
/**
* Ingest relay info from our own monitor (after we publish 30166). Adds the relay to
* in-memory discovery and updates the IndexedDB public lively cache so it can be used
* for "add 3 random relays" and relay info page liveliness display.
* for random publish relay selection and relay info page liveliness display.
*/
addDiscoveryFromRelayInfo(relayInfo: TRelayInfo): void {
const lim = relayInfo.limitation

17
src/services/relay-selection.service.ts

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import { Event, kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import { FAST_WRITE_RELAY_URLS } from '@/constants'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants'
import client from '@/services/client.service'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { TRelaySet, TRelayList } from '@/types'
@ -145,12 +144,18 @@ class RelaySelectionService { @@ -145,12 +144,18 @@ class RelaySelectionService {
if (typeof window !== 'undefined') {
try {
const publicLively = await nip66Service.getPublicLivelyRelayUrls()
/** Session OK relays first so they stay candidates even if absent from NIP-66 lively list */
const sessionBoost = client.getSessionSuccessfulPublishRelayUrlsForRandomPool()
const existing = new Set(order.map((o) => o.url))
const candidates = publicLively.filter((u) => {
const seenCand = new Set<string>()
const candidates: string[] = []
for (const u of [...sessionBoost, ...publicLively]) {
const n = normalizeUrl(u) || u
return !existing.has(n)
})
const preferred = client.getPreferredRelaysForRandom(candidates, 3)
if (!n || existing.has(n) || seenCand.has(n)) continue
seenCand.add(n)
candidates.push(n)
}
const preferred = client.getPreferredRelaysForRandom(candidates, RANDOM_PUBLISH_RELAY_COUNT)
preferred.forEach((url) => {
const normalized = normalizeUrl(url) || url
addRelay(normalized, 'randomly_selected')

41
src/services/spell.service.ts

@ -65,15 +65,30 @@ function dedupeRelayUrls(urls: string[]): string[] { @@ -65,15 +65,30 @@ function dedupeRelayUrls(urls: string[]): string[] {
return out
}
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).
*/
mergeDefaultReadRelays?: boolean
}
/**
* Get relay URLs for executing a spell: from spell's `relays` tag or context read list, always merged with
* app default read relays so a single down relay (503, etc.) does not block the feed.
* 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.
*/
export function getRelaysForSpell(spell: Event, context: { relayListRead: string[] }): string[] {
export function getRelaysForSpell(
spell: Event,
context: { relayListRead: string[] },
options?: GetRelaysForSpellOptions
): string[] {
const mergeDefaults = options?.mergeDefaultReadRelays !== false
let primary: string[] = []
const relayTag = spell.tags.find(tagNameEquals('relays'))
if (relayTag && relayTag.length > 1) {
const urls = relayTag.slice(1).filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
const urls = relayTag
.slice(1)
.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) {
@ -82,7 +97,19 @@ export function getRelaysForSpell(spell: Event, context: { relayListRead: string @@ -82,7 +97,19 @@ export function getRelaysForSpell(spell: Event, context: { relayListRead: string
if (!primary.length) {
return defaultSpellReadFallbackRelays()
}
return dedupeRelayUrls([...primary, ...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
if (mergeDefaults) {
return dedupeRelayUrls([...primary, ...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])
}
return dedupeRelayUrls(primary)
}
/** Spell lists at least one relay URL in its `relays` tag. */
export function spellHasExplicitRelays(spell: Event): boolean {
const relayTag = spell.tags.find(tagNameEquals('relays'))
if (!relayTag || relayTag.length < 2) return false
return relayTag
.slice(1)
.some((u) => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
}
/**
@ -179,9 +206,7 @@ export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Fi @@ -179,9 +206,7 @@ export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Fi
return filter
}
/**
* Whether the spell is COUNT (we only support REQ for feed display).
*/
/** Spell uses COUNT: run filter against relays and show a numeric result (not a feed). */
export function spellIsCount(spell: Event): boolean {
return spell.tags.find(tagNameEquals('cmd'))?.[1] === 'COUNT'
}

Loading…
Cancel
Save