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. 50
      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. 39
      src/services/spell.service.ts

13
src/constants.ts

@ -91,6 +91,12 @@ export const BIG_RELAY_URLS = [
'wss://thecitadel.nostr1.com', '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. */ /** Relays to query for NIP-66 relay monitoring events (30166), in addition to BIG_RELAY_URLS. */
export const NIP66_DISCOVERY_RELAY_URLS = [ export const NIP66_DISCOVERY_RELAY_URLS = [
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
@ -107,7 +113,12 @@ export const BOOKSTR_RELAY_URLS = [
export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land'] export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land']
/** Relays that block kind 1 (microblogging); skip for kind 1 read and write. */ /** 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) // Optimized relay list for read operations (includes aggregator)
export const FAST_READ_RELAY_URLS = [ export const FAST_READ_RELAY_URLS = [

24
src/i18n/locales/de.ts

@ -332,9 +332,9 @@ export default {
Autoplay: 'Automatische Wiedergabe', Autoplay: 'Automatische Wiedergabe',
'Enable video autoplay on this device': 'Enable video autoplay on this device':
'Aktiviere die automatische Video-Wiedergabe auf diesem Gerät', '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 random relays to every publish': 'Zufällige Relays in der Publish-Liste',
'Add 3 random relays to every publish description': 'Add 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.', '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_local': 'Lokal',
'relayType_relay_list': 'Relay-Liste', 'relayType_relay_list': 'Relay-Liste',
'relayType_client_default': 'Client-Standard', 'relayType_client_default': 'Client-Standard',
@ -650,13 +650,29 @@ export default {
'One topic per row.': 'Ein Thema pro Zeile.', 'One topic per row.': 'Ein Thema pro Zeile.',
topic: 'Thema', topic: 'Thema',
'Spell form fields': 'Zauberspruch-Formularfelder', '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 definition': 'Zauberspruch-Definition',
'Spell published': 'Zauberspruch veröffentlicht', '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.': '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.', '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', Command: 'Befehl',
'REQ returns a feed; COUNT returns a number.': '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', Name: 'Name',
'Human-readable spell name': 'Lesbarer Name des Zauberspruchs', 'Human-readable spell name': 'Lesbarer Name des Zauberspruchs',
'Description (content)': 'Beschreibung (Inhalt)', 'Description (content)': 'Beschreibung (Inhalt)',

24
src/i18n/locales/en.ts

@ -394,9 +394,9 @@ export default {
General: 'General', General: 'General',
Autoplay: 'Autoplay', Autoplay: 'Autoplay',
'Enable video autoplay on this device': 'Enable video autoplay on this device', '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 random relays to every publish': 'Random relays in publish list',
'Add 3 random relays to every publish description': 'Add 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.', '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_local': 'Local',
'relayType_relay_list': 'Relay list', 'relayType_relay_list': 'Relay list',
'relayType_client_default': 'Client default', 'relayType_client_default': 'Client default',
@ -716,6 +716,24 @@ export default {
'One topic per row.': 'One topic per row.', 'One topic per row.': 'One topic per row.',
topic: 'topic', topic: 'topic',
'Spell form fields': 'Spell form fields', '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', Spells: 'Spells',

66
src/lib/draft-event.ts

@ -638,7 +638,8 @@ export function createSpellDraftEvent(params: TSpellDraftParams): TDraftEvent {
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean) .filter(Boolean)
.forEach((t) => tags.push(['t', t])) .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 { return {
kind: ExtendedKind.SPELL, kind: ExtendedKind.SPELL,
content: params.content?.trim() ?? '', 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 { export function createRssFeedListDraftEvent(feedUrls: string[]): TDraftEvent {
// Validate and sanitize feed URLs // Validate and sanitize feed URLs
const validUrls = feedUrls const validUrls = feedUrls

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

@ -8,13 +8,18 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' 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 { useNostr } from '@/providers/NostrProvider'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { Minus, Plus, X } from 'lucide-react' import { Minus, Plus, X } from 'lucide-react'
import { useTranslation } from 'react-i18next' 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' 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 */
@ -128,11 +133,15 @@ function DynamicStringListField({
export default function CreateSpellDialog({ export default function CreateSpellDialog({
open, open,
onOpenChange, onOpenChange,
onSaved onSaved,
spellToEdit
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void 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 { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
@ -140,6 +149,15 @@ export default function CreateSpellDialog({
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const scrollBodyRef = useRef<HTMLDivElement>(null) 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 handleScrollBodyKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
const el = scrollBodyRef.current const el = scrollBodyRef.current
if (!el) return if (!el) return
@ -170,6 +188,8 @@ export default function CreateSpellDialog({
onOpenChange(false) onOpenChange(false)
} }
const replaceSpellId = spellToEdit?.id
const handleSave = async () => { const handleSave = async () => {
if (!pubkey) { if (!pubkey) {
checkLogin() checkLogin()
@ -179,11 +199,18 @@ export default function CreateSpellDialog({
try { try {
const draft = createSpellDraftEvent(form) const draft = createSpellDraftEvent(form)
const event = await publish(draft) 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) await indexedDb.putSpellEvent(event)
handleClear() handleClear()
onSaved?.(event)
onOpenChange(false) onOpenChange(false)
onSaved?.() showSimplePublishSuccess(replaceSpellId ? t('Spell updated') : t('Spell published'))
showSimplePublishSuccess(t('Spell published'))
} catch (e) { } catch (e) {
logger.error('[CreateSpellDialog] Publish failed', e) logger.error('[CreateSpellDialog] Publish failed', e)
showPublishingError(e instanceof Error ? e : new Error(String(e))) showPublishingError(e instanceof Error ? e : new Error(String(e)))
@ -211,7 +238,7 @@ export default function CreateSpellDialog({
<X className="size-4" /> <X className="size-4" />
</Button> </Button>
<DialogHeader className="space-y-1.5 pr-10 text-left sm:text-left"> <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> </DialogHeader>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{t( {t(
@ -234,7 +261,12 @@ export default function CreateSpellDialog({
<select <select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={form.cmd} 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="REQ">REQ (subscribe to events)</option>
<option value="COUNT">COUNT (count only)</option> <option value="COUNT">COUNT (count only)</option>
@ -342,6 +374,7 @@ export default function CreateSpellDialog({
onChange={(topics) => setForm((f) => ({ ...f, topics }))} onChange={(topics) => setForm((f) => ({ ...f, topics }))}
/> />
{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>
<div className="flex rounded-lg border border-input bg-muted p-0.5"> <div className="flex rounded-lg border border-input bg-muted p-0.5">
@ -364,6 +397,7 @@ export default function CreateSpellDialog({
{form.closeOnEose ? t('Fetch once, then stop.') : t('Live feed; keeps updating.')} {form.closeOnEose ? t('Fetch once, then stop.') : t('Live feed; keeps updating.')}
</p> </p>
</div> </div>
) : null}
</div> </div>
</div> </div>

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

@ -1,5 +1,6 @@
import NoteList from '@/components/NoteList' import NoteList from '@/components/NoteList'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -10,6 +11,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { import {
@ -28,10 +30,11 @@ import {
getRelaysForSpell, getRelaysForSpell,
getSpellName, getSpellName,
spellEventToFilter, spellEventToFilter,
spellHasExplicitRelays,
spellIsCount spellIsCount
} from '@/services/spell.service' } from '@/services/spell.service'
import { TFeedSubRequest } from '@/types' 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 type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -48,9 +51,26 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set()) const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null) const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [spellToEdit, setSpellToEdit] = useState<Event | null>(null)
const [definitionSpell, setDefinitionSpell] = useState<Event | null>(null) const [definitionSpell, setDefinitionSpell] = useState<Event | null>(null)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([]) const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [contacts, setContacts] = useState<string[]>([]) 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 loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([ const [events, ids] = await Promise.all([
@ -76,12 +96,28 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
useEffect(() => { useEffect(() => {
if (!selectedSpell) { if (!selectedSpell) {
setSubRequests([]) setSubRequests([])
setSpellCount({
loading: false,
rows: [],
totalDistinct: null,
error: 'none',
mayHitLimit: false,
usedExplicitRelays: false
})
return return
} }
if (spellIsCount(selectedSpell)) { if (spellIsCount(selectedSpell)) {
setSubRequests([]) setSubRequests([])
return 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 defaultRelays = [...new Set([...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS])]
const relayListRead = relayList?.read?.length ? relayList.read : defaultRelays const relayListRead = relayList?.read?.length ? relayList.read : defaultRelays
const ctx = { const ctx = {
@ -102,6 +138,115 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
setSubRequests([{ urls: relays, filter }]) setSubRequests([{ urls: relays, filter }])
}, [selectedSpell, pubkey, contacts, relayList?.read]) }, [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 toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds() const ids = await indexedDb.getSpellFavoriteIds()
const set = new Set(ids) const set = new Set(ids)
@ -140,7 +285,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
onClick={() => setCreateOpen(true)} onClick={() => {
setSpellToEdit(null)
setCreateOpen(true)
}}
title={t('Create a Spell')} title={t('Create a Spell')}
> >
<Plus className="size-5" /> <Plus className="size-5" />
@ -177,7 +325,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
<Button <Button
className="justify-start gap-2" className="justify-start gap-2"
variant="outline" variant="outline"
onClick={() => setCreateOpen(true)} onClick={() => {
setSpellToEdit(null)
setCreateOpen(true)
}}
> >
<Wand2 className="size-4" /> <Wand2 className="size-4" />
{t('Create a Spell')} {t('Create a Spell')}
@ -206,12 +357,23 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <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" /> <FileText className="size-4" />
{t('View definition')} {t('View definition')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDeleteSpell(selectedSpell)} onClick={() => handleDeleteSpell(selectedSpell)}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
@ -231,7 +393,89 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
{/* Feed */} {/* Feed */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
{selectedSpell ? ( {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 <NoteList
subRequests={subRequests} subRequests={subRequests}
showKinds={(() => { showKinds={(() => {
@ -239,15 +483,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
.filter((tag) => tag[0] === 'k') .filter((tag) => tag[0] === 'k')
.map((tag) => parseInt(tag[1], 10)) .map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
// `[] || [1]` is wrong ([] is truthy); default to kind 1 for notes
return kinds.length ? kinds : [1] return kinds.length ? kinds : [1]
})()} })()}
useFilterAsIs useFilterAsIs
/> />
) : spellIsCount(selectedSpell) ? (
<div className="py-8 text-center text-muted-foreground">
{t('COUNT spells show a number, not a feed.')}
</div>
) : !pubkey && ) : !pubkey &&
selectedSpell.tags.some( selectedSpell.tags.some(
(tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts')) (tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts'))
@ -270,7 +509,20 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(_, ref) {
</div> </div>
</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)}> <Dialog open={!!definitionSpell} onOpenChange={(open) => !open && setDefinitionSpell(null)}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto"> <DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">

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

@ -1,7 +1,12 @@
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' 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 { LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { cn, isSupportCheckConnectionType } from '@/lib/utils' import { cn, isSupportCheckConnectionType } from '@/lib/utils'
@ -149,9 +154,9 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
</SettingItem> </SettingItem>
<SettingItem> <SettingItem>
<Label htmlFor="add-random-relays" className="text-base font-normal"> <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"> <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> </div>
</Label> </Label>
<Switch <Switch

64
src/services/client.service.ts

@ -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. * 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 {
preferred.sort((a, b) => { preferred.sort((a, b) => {
const sa = this.sessionRelayPublishStats.get(a)! const sa = this.sessionRelayPublishStats.get(a)!
const sb = this.sessionRelayPublishStats.get(b)! const sb = this.sessionRelayPublishStats.get(b)!
if (sb.successCount !== sa.successCount) return sb.successCount - sa.successCount
const avgA = sa.sumLatencyMs / sa.successCount const avgA = sa.sumLatencyMs / sa.successCount
const avgB = sb.sumLatencyMs / sb.successCount const avgB = sb.sumLatencyMs / sb.successCount
return avgA - avgB return avgA - avgB
@ -467,12 +491,13 @@ class ClientService extends EventTarget {
const result: string[] = [] const result: string[] = []
let pi = 0 let pi = 0
let ri = 0 let ri = 0
const shuffledRest = rest.slice().sort(() => Math.random() - 0.5) // Preserve candidate order (e.g. NIP-66 write-proven relays first); avoid full shuffle so monitoring hints apply.
while (result.length < count && (pi < preferred.length || ri < shuffledRest.length)) { const orderedRest = rest.slice()
while (result.length < count && (pi < preferred.length || ri < orderedRest.length)) {
if (pi < preferred.length) { if (pi < preferred.length) {
result.push(preferred[pi++]) result.push(preferred[pi++])
} else if (ri < shuffledRest.length) { } else if (ri < orderedRest.length) {
result.push(shuffledRest[ri++]) result.push(orderedRest[ri++])
} }
} }
return result.slice(0, count) return result.slice(0, count)
@ -1501,6 +1526,37 @@ class ClientService extends EventTarget {
return events 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). * 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 * 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 {
/** /**
* Build list of relay URLs that are public (no auth, no payment) and have been * 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[] { private buildPublicLivelyFromDiscovery(): string[] {
const out: string[] = [] const eligible: TNip66RelayDiscovery[] = []
for (const d of this.discoveryByUrl.values()) { for (const d of this.discoveryByUrl.values()) {
const authRequired = d.requirements.auth === true const authRequired = d.requirements.auth === true
const paymentRequired = d.requirements.payment === 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 {
/** /**
* Ingest relay info from our own monitor (after we publish 30166). Adds the relay to * 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 * 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 { addDiscoveryFromRelayInfo(relayInfo: TRelayInfo): void {
const lim = relayInfo.limitation const lim = relayInfo.limitation

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

@ -1,6 +1,5 @@
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants' import { ExtendedKind, FAST_WRITE_RELAY_URLS, RANDOM_PUBLISH_RELAY_COUNT } from '@/constants'
import { FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { TRelaySet, TRelayList } from '@/types' import { TRelaySet, TRelayList } from '@/types'
@ -145,12 +144,18 @@ class RelaySelectionService {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
const publicLively = await nip66Service.getPublicLivelyRelayUrls() 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 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 const n = normalizeUrl(u) || u
return !existing.has(n) if (!n || existing.has(n) || seenCand.has(n)) continue
}) seenCand.add(n)
const preferred = client.getPreferredRelaysForRandom(candidates, 3) candidates.push(n)
}
const preferred = client.getPreferredRelaysForRandom(candidates, RANDOM_PUBLISH_RELAY_COUNT)
preferred.forEach((url) => { preferred.forEach((url) => {
const normalized = normalizeUrl(url) || url const normalized = normalizeUrl(url) || url
addRelay(normalized, 'randomly_selected') addRelay(normalized, 'randomly_selected')

39
src/services/spell.service.ts

@ -65,15 +65,30 @@ function dedupeRelayUrls(urls: string[]): string[] {
return out return out
} }
export type GetRelaysForSpellOptions = {
/** /**
* Get relay URLs for executing a spell: from spell's `relays` tag or context read list, always merged with * When true (default): merge FAST_READ + SEARCHABLE after primary list (REQ feeds).
* app default read relays so a single down relay (503, etc.) does not block the feed. * When false: use only spell `relays` tag, or only read list / defaults no extra padding (COUNT, explicit relays).
*/ */
export function getRelaysForSpell(spell: Event, context: { relayListRead: string[] }): string[] { 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.
*/
export function getRelaysForSpell(
spell: Event,
context: { relayListRead: string[] },
options?: GetRelaysForSpellOptions
): string[] {
const mergeDefaults = options?.mergeDefaultReadRelays !== false
let primary: string[] = [] let primary: string[] = []
const relayTag = spell.tags.find(tagNameEquals('relays')) const relayTag = spell.tags.find(tagNameEquals('relays'))
if (relayTag && relayTag.length > 1) { 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 (urls.length) primary = urls
} }
if (!primary.length && context.relayListRead.length) { if (!primary.length && context.relayListRead.length) {
@ -82,8 +97,20 @@ export function getRelaysForSpell(spell: Event, context: { relayListRead: string
if (!primary.length) { if (!primary.length) {
return defaultSpellReadFallbackRelays() return defaultSpellReadFallbackRelays()
} }
if (mergeDefaults) {
return dedupeRelayUrls([...primary, ...FAST_READ_RELAY_URLS, ...SEARCHABLE_RELAY_URLS]) 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://')))
}
/** /**
* Resolve authors: replace $me with pubkey and $contacts with contacts array. * Resolve authors: replace $me with pubkey and $contacts with contacts array.
@ -179,9 +206,7 @@ export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Fi
return filter return filter
} }
/** /** Spell uses COUNT: run filter against relays and show a numeric result (not a feed). */
* Whether the spell is COUNT (we only support REQ for feed display).
*/
export function spellIsCount(spell: Event): boolean { export function spellIsCount(spell: Event): boolean {
return spell.tags.find(tagNameEquals('cmd'))?.[1] === 'COUNT' return spell.tags.find(tagNameEquals('cmd'))?.[1] === 'COUNT'
} }

Loading…
Cancel
Save