Browse Source

make publication search more comprehensive

remove damus relays
imwald
Silberengel 7 days ago
parent
commit
8720b203d0
  1. 2
      nip66-cron/index.mjs
  2. 4
      package-lock.json
  3. 2
      package.json
  4. 5
      src/components/Embedded/EmbeddedNote.tsx
  5. 2
      src/constants.ts
  6. 39
      src/lib/index-relay-http.ts
  7. 56
      src/lib/library-publication-index.test.ts
  8. 347
      src/lib/library-publication-index.ts
  9. 7
      src/lib/metadata-policy-curated-relays.test.ts
  10. 1
      src/lib/new-user-template-broadcast.ts
  11. 3
      src/lib/new-user-template.ts
  12. 2
      src/services/nip89.service.ts

2
nip66-cron/index.mjs

@ -51,7 +51,6 @@ const DEFAULT_RELAYS_TO_MONITOR = [
'wss://nostr.wine', 'wss://nostr.wine',
'wss://nostr21.com', 'wss://nostr21.com',
'wss://aggr.nostr.land', 'wss://aggr.nostr.land',
'wss://relay.damus.io',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.gifbuddy.lol', 'wss://relay.gifbuddy.lol',
@ -75,7 +74,6 @@ const DEFAULT_RELAYS_TO_MONITOR = [
/** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */ /** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */
const DEFAULT_PUBLISH_RELAYS = [ const DEFAULT_PUBLISH_RELAYS = [
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.damus.io',
'wss://relay.nostr.watch', 'wss://relay.nostr.watch',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://relaypag.es', 'wss://relaypag.es',

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.21.5", "version": "23.21.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.21.5", "version": "23.21.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.21.5", "version": "23.21.6",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

5
src/components/Embedded/EmbeddedNote.tsx

@ -552,9 +552,8 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] {
const x = u.toLowerCase() const x = u.toLowerCase()
if (x.includes('nos.lol')) return 0 if (x.includes('nos.lol')) return 0
if (x.includes('nostr.land')) return 1 if (x.includes('nostr.land')) return 1
if (x.includes('relay.damus.io')) return 2 if (x.includes('relay.primal.net')) return 2
if (x.includes('relay.primal.net')) return 3 if (x.includes('nostr.wine')) return 3
if (x.includes('nostr.wine')) return 4
return 30 return 30
} }
return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b)) return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b))

2
src/constants.ts

@ -536,7 +536,6 @@ export const FAST_READ_RELAY_URLS = [
// Optimized relay list for write operations (no aggregator since it's read-only) // Optimized relay list for write operations (no aggregator since it's read-only)
export const FAST_WRITE_RELAY_URLS = [ export const FAST_WRITE_RELAY_URLS = [
'wss://relay.damus.io',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://nos.lol' 'wss://nos.lol'
@ -585,7 +584,6 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://relay.damus.io',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://indexer.coracle.social/', 'wss://indexer.coracle.social/',
'wss://purplepag.es' 'wss://purplepag.es'

39
src/lib/index-relay-http.ts

@ -30,6 +30,10 @@ function indexRelayPublishUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events` return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events`
} }
function indexRelayPublicationMetadataSearchUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/publications/search`
}
/** Map a Nostr filter to gc_index_relay POST body (requires `limit` 1–100; strips unsupported keys). */ /** Map a Nostr filter to gc_index_relay POST body (requires `limit` 1–100; strips unsupported keys). */
function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> { function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
const body: Record<string, unknown> = {} const body: Record<string, unknown> = {}
@ -418,19 +422,37 @@ export async function queryIndexRelayForLibrary(
} }
} }
/** Kind-30040 discovery search: keeps NIP-50 `search` (unlike bulk {@link queryIndexRelayForLibrary}). */ /** Kind-30040 filter query via POST /api/events/filter (NIP-01 only — no NIP-50 `search`). */
export async function queryIndexRelayPublicationSearch( export async function queryIndexRelayPublicationSearch(
baseUrl: string, baseUrl: string,
filter: Filter, filter: Filter,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }
): Promise<TIndexRelayLibraryPage> { ): Promise<TIndexRelayLibraryPage> {
return queryIndexRelayForLibrary(baseUrl, filter, options)
}
function filterForIndexRelay(f: Filter): Filter {
const rest = { ...f } as Filter & { search?: unknown }
delete rest.search
return rest as Filter
}
/** Kind-30040 metadata search (d / title / author / source) on Mercury-style index relays. */
export async function queryIndexRelayPublicationMetadataSearch(
baseUrl: string,
query: string,
options?: { limit?: number; signal?: AbortSignal }
): Promise<TIndexRelayLibraryPage> {
const q = query.trim()
if (!q) return { events: [], apiRowCount: 0 }
const base = devHttpIndexRelayBaseForFetch(baseUrl) const base = devHttpIndexRelayBaseForFetch(baseUrl)
const endpoint = indexRelayFilterUrl(base) const endpoint = indexRelayPublicationMetadataSearchUrl(base)
if (shouldSkipDevIndexRelayFetch(endpoint)) { if (shouldSkipDevIndexRelayFetch(endpoint)) {
return { events: [], apiRowCount: 0 } return { events: [], apiRowCount: 0 }
} }
const body = nostrFilterToIndexRelayBody(filter) const limit = Math.max(1, Math.min(options?.limit ?? 100, 100))
try { try {
const res = await fetchWithTimeout(endpoint, { const res = await fetchWithTimeout(endpoint, {
method: 'POST', method: 'POST',
@ -438,11 +460,12 @@ export async function queryIndexRelayPublicationSearch(
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(body), body: JSON.stringify({ q, limit }),
signal: options?.signal, signal: options?.signal,
timeoutMs: 25_000 timeoutMs: 25_000
}) })
if (!res.ok) { if (!res.ok) {
if (res.status === 404 || res.status === 405) return { events: [], apiRowCount: 0 }
if (res.status >= 500) { if (res.status >= 500) {
markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint) markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint)
throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`)) throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`))
@ -472,7 +495,7 @@ export async function queryIndexRelayPublicationSearch(
handleFilterTransportFailure(endpoint, e) handleFilterTransportFailure(endpoint, e)
throw new IndexRelayTransportError(e) throw new IndexRelayTransportError(e)
} }
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication search request error', { warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication metadata search request error', {
endpoint, endpoint,
error: e error: e
}) })
@ -480,12 +503,6 @@ export async function queryIndexRelayPublicationSearch(
} }
} }
function filterForIndexRelay(f: Filter): Filter {
const rest = { ...f } as Filter & { search?: unknown }
delete rest.search
return rest as Filter
}
export async function publishEventToHttpRelay( export async function publishEventToHttpRelay(
baseUrl: string, baseUrl: string,
event: NEvent, event: NEvent,

56
src/lib/library-publication-index.test.ts

@ -3,16 +3,19 @@ import { ExtendedKind } from '@/constants'
import { import {
buildEngagementMapsFromEvents, buildEngagementMapsFromEvents,
buildLibraryPublicationRelaySearchFilters, buildLibraryPublicationRelaySearchFilters,
buildLibraryPublicationRelaySearchFiltersForAxis,
buildRecentPublicationEntries, buildRecentPublicationEntries,
clearLibrarySearchSessionCache, clearLibrarySearchSessionCache,
computeLibraryFeedRootOrder, computeLibraryFeedRootOrder,
filterEngagedPublications, filterEngagedPublications,
filterEventsForPublicationRelaySearchAxis,
filterLibraryPublicationsBySearch, filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser, filterLibraryPublicationsByUser,
libraryDefaultFeedSlice, libraryDefaultFeedSlice,
libraryPublicationEntriesForUserFromIndex, libraryPublicationEntriesForUserFromIndex,
LIBRARY_PAGE_SIZE, LIBRARY_PAGE_SIZE,
pickLibraryPublicationEntries, pickLibraryPublicationEntries,
publicationMetadataTagMatchesQuery,
publicationRootBelongsToUser, publicationRootBelongsToUser,
peekLibrarySearchResults, peekLibrarySearchResults,
publicationIndexMatchesSearchQuery, publicationIndexMatchesSearchQuery,
@ -233,20 +236,53 @@ describe('library-publication-index', () => {
expect(publicationIndexMatchesSearchQuery(root, 'missing')).toBe(false) expect(publicationIndexMatchesSearchQuery(root, 'missing')).toBe(false)
}) })
it('buildLibraryPublicationRelaySearchFilters uses kind 30040 for d-tag and search', () => { it('buildLibraryPublicationRelaySearchFilters splits kind 30040 into d-tag, title, and author without NIP-50', () => {
expect(publicationQueryDTagVariants('Village Life in China')).toContain('village-life-in-china') expect(publicationQueryDTagVariants('Village Life in China')).toContain('village-life-in-china')
const filters = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' }) const dTagFilters = buildLibraryPublicationRelaySearchFiltersForAxis('d-tag', {
expect(filters.length).toBeGreaterThan(0) query: 'Village Life in China'
expect(filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === ExtendedKind.PUBLICATION)).toBe( })
true expect(dTagFilters).toHaveLength(1)
) expect(dTagFilters[0].kinds).toEqual([ExtendedKind.PUBLICATION])
expect(dTagFilters[0]['#d']).toContain('village-life-in-china')
expect(dTagFilters[0].search).toBeUndefined()
const titleFilters = buildLibraryPublicationRelaySearchFiltersForAxis('title', {
query: 'Village Life in China'
})
expect(titleFilters).toHaveLength(0)
const authorFilters = buildLibraryPublicationRelaySearchFiltersForAxis('author', {
query: 'Village Life in China'
})
expect(authorFilters).toHaveLength(0)
const merged = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' })
expect(merged).toHaveLength(1)
expect(merged[0]['#d']).toContain('village-life-in-china')
expect(merged.every((f) => f.search == null)).toBe(true)
})
it('filterEventsForPublicationRelaySearchAxis keeps axis-specific kind-30040 matches', () => {
const root = indexEvent('jane-eyre', [`30041:${PK}:intro`])
root.tags = [
['d', 'jane-eyre'],
['title', 'Jane Eyre'],
['author', 'Charlotte Brontë'],
['a', `30041:${PK}:intro`]
]
const byDTag = filterEventsForPublicationRelaySearchAxis([root], 'd-tag', 'jane-eyre')
expect(byDTag).toHaveLength(1)
const byTitle = filterEventsForPublicationRelaySearchAxis([root], 'title', 'jane eyre')
expect(byTitle).toHaveLength(1)
const dFilter = filters.find((f) => f['#d']) const byAuthor = filterEventsForPublicationRelaySearchAxis([root], 'author', 'charlotte brontë')
expect(dFilter?.['#d']).toContain('village-life-in-china') expect(byAuthor).toHaveLength(1)
const searchFilter = filters.find((f) => f.search === 'Village Life in China') expect(filterEventsForPublicationRelaySearchAxis([root], 'title', 'charlotte')).toHaveLength(0)
expect(searchFilter?.kinds).toEqual([ExtendedKind.PUBLICATION]) expect(publicationMetadataTagMatchesQuery(root, 'title', 'Jane Eyre')).toBe(true)
}) })
it('searchLibraryPublications caches results for repeated queries', async () => { it('searchLibraryPublications caches results for repeated queries', async () => {

347
src/lib/library-publication-index.ts

@ -8,7 +8,7 @@ import {
import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser' import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label' import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label'
import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http' import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationMetadataSearch } from '@/lib/index-relay-http'
import { import {
buildIndexByAddress, buildIndexByAddress,
buildStructuralPublicationIndexMap, buildStructuralPublicationIndexMap,
@ -63,6 +63,8 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE
const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200
export const LIBRARY_RELAY_SEARCH_LIMIT = 100 export const LIBRARY_RELAY_SEARCH_LIMIT = 100
const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000 const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000
/** Max paginated HTTP pages when title/author metadata API is unavailable (Mercury v0.2.0). */
const LIBRARY_RELAY_SEARCH_SCAN_MAX_PAGES = 80
/** NIP-51 pin list (kind 10001). */ /** NIP-51 pin list (kind 10001). */
const PIN_LIST_KIND = 10001 const PIN_LIST_KIND = 10001
/** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */ /** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */
@ -1727,6 +1729,15 @@ function normalizePublicationDTag(term: string): string {
.replace(/^-|-$/g, '') .replace(/^-|-$/g, '')
} }
/** Relay search axis for kind-30040 publication indexes. */
export type LibraryPublicationRelaySearchAxis = 'd-tag' | 'title' | 'author'
export const LIBRARY_PUBLICATION_RELAY_SEARCH_AXES: LibraryPublicationRelaySearchAxis[] = [
'd-tag',
'title',
'author'
]
/** d-tag filter values: hyphenated slug variants for relay `#d` REQ. */ /** d-tag filter values: hyphenated slug variants for relay `#d` REQ. */
export function publicationQueryDTagVariants(query: string): string[] { export function publicationQueryDTagVariants(query: string): string[] {
const raw = query.trim() const raw = query.trim()
@ -1742,14 +1753,99 @@ export function publicationQueryDTagVariants(query: string): string[] {
return [...seen] return [...seen]
} }
/** /** Normalized needles for exact publication metadata tag match (d / title / author). */
* OR-merge REQ filters for kind **30040** publication indexes: `#d` slugs plus NIP-50 `search` export function publicationQueryNeedles(query: string): string[] {
* (title, author, summary/description on index relays). const raw = normalizeGeneralSearchQuery(query.trim())
*/ if (!raw) return []
export function buildLibraryPublicationRelaySearchFilters(opts: { const lower = raw.toLowerCase()
const normalized = lower.replace(/\s+/g, ' ').trim()
const hyphen = lower
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return [...new Set([lower, normalized, hyphen].filter(Boolean))]
}
function publicationTagValueMatchesNeedles(tagValue: string, needles: string[]): boolean {
const val = tagValue.trim().toLowerCase()
const valSpaced = val.replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
for (const needle of needles) {
if (val === needle) return true
const needleSpaced = needle.replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
if (valSpaced === needleSpaced) return true
}
return false
}
export function publicationMetadataTagMatchesQuery(
event: Event,
tagName: 'd' | 'title' | 'author',
query: string query: string
limit?: number ): boolean {
}): Filter[] { const needles = publicationQueryNeedles(query)
if (needles.length === 0) return false
for (const tag of event.tags ?? []) {
if ((tag[0] || '').toLowerCase() !== tagName) continue
const value = tag[1]?.trim()
if (value && publicationTagValueMatchesNeedles(value, needles)) return true
}
return false
}
function publicationRelaySearchSourceTerms(query: string): string[] {
const raw = query.trim()
if (!raw) return []
const terms = new Set<string>([raw])
const adv = parseAdvancedSearch(raw)
if (adv.title) {
for (const title of Array.isArray(adv.title) ? adv.title : [adv.title]) {
const t = title.trim()
if (t) terms.add(t)
}
}
return [...terms]
}
function publicationRelaySearchTermsForAxis(
axis: LibraryPublicationRelaySearchAxis,
query: string
): string[] {
const raw = query.trim()
if (!raw) return []
const adv = parseAdvancedSearch(raw)
if (axis === 'title' && adv.title) {
const titles = Array.isArray(adv.title) ? adv.title : [adv.title]
const trimmed = titles.map((t) => t.trim()).filter(Boolean)
if (trimmed.length > 0) return [...new Set(trimmed)]
}
if (axis === 'author' && adv.author) {
const authors = Array.isArray(adv.author) ? adv.author : [adv.author]
const trimmed = authors.map((a) => a.trim()).filter(Boolean)
if (trimmed.length > 0) return [...new Set(trimmed)]
}
if (axis === 'd-tag') {
return publicationRelaySearchSourceTerms(raw)
}
return [raw]
}
function addPublicationKindFilter(
out: Filter[],
seen: Set<string>,
filter: Filter
) {
const key = JSON.stringify(filter)
if (seen.has(key)) return
seen.add(key)
out.push(filter)
}
/** One axis of kind-30040 relay discovery: `#d`, metadata title/author (HTTP), or `authors` for npub. */
export function buildLibraryPublicationRelaySearchFiltersForAxis(
axis: LibraryPublicationRelaySearchAxis,
opts: { query: string; limit?: number }
): Filter[] {
const searchRaw = opts.query.trim() const searchRaw = opts.query.trim()
if (!searchRaw) return [] if (!searchRaw) return []
@ -1757,67 +1853,153 @@ export function buildLibraryPublicationRelaySearchFilters(opts: {
const kind = ExtendedKind.PUBLICATION const kind = ExtendedKind.PUBLICATION
const seen = new Set<string>() const seen = new Set<string>()
const out: Filter[] = [] const out: Filter[] = []
const add = (filter: Filter) => {
const key = JSON.stringify(filter)
if (seen.has(key)) return
seen.add(key)
out.push(filter)
}
if (axis === 'author') {
const npub = tryNpubFromQuery(searchRaw) const npub = tryNpubFromQuery(searchRaw)
if (npub) { if (npub) {
add({ kinds: [kind], authors: [npub], limit }) addPublicationKindFilter(out, seen, { kinds: [kind], authors: [npub], limit })
return out
}
return out return out
} }
const dTags = publicationQueryDTagVariants(searchRaw) if (axis === 'title') {
if (dTags.length > 0) { return out
add({ kinds: [kind], '#d': dTags, limit })
} }
const searchNorm = normalizeGeneralSearchQuery(searchRaw) if (axis === 'd-tag') {
add({ kinds: [kind], search: searchRaw, limit }) const dTags = new Set<string>()
if (searchNorm !== searchRaw) { for (const term of publicationRelaySearchTermsForAxis('d-tag', searchRaw)) {
add({ kinds: [kind], search: searchNorm, limit }) for (const d of publicationQueryDTagVariants(term)) dTags.add(d)
}
if (dTags.size === 0) return []
addPublicationKindFilter(out, seen, { kinds: [kind], '#d': [...dTags], limit })
return out
} }
const adv = parseAdvancedSearch(searchRaw) return out
const titleValues = adv.title }
? Array.isArray(adv.title)
? adv.title /**
: [adv.title] * REQ filters for kind **30040** publication indexes, split by axis (d-tag, title, author).
: [] * Title and author text use HTTP metadata search (not NIP-50). Only `#d` and pubkey `authors` use NIP-01 filters.
for (const title of titleValues) { */
const t = title.trim() export function buildLibraryPublicationRelaySearchFilters(opts: {
if (!t) continue query: string
add({ kinds: [kind], search: t, limit }) limit?: number
const titleDTags = publicationQueryDTagVariants(t) }): Filter[] {
if (titleDTags.length > 0) { const seen = new Set<string>()
add({ kinds: [kind], '#d': titleDTags, limit }) const out: Filter[] = []
for (const axis of LIBRARY_PUBLICATION_RELAY_SEARCH_AXES) {
for (const filter of buildLibraryPublicationRelaySearchFiltersForAxis(axis, opts)) {
const key = JSON.stringify(filter)
if (seen.has(key)) continue
seen.add(key)
out.push(filter)
} }
} }
return out
}
const authorValues = adv.author export function filterEventsForPublicationRelaySearchAxis(
? Array.isArray(adv.author) events: Event[],
? adv.author axis: LibraryPublicationRelaySearchAxis,
: [adv.author] query: string
: [] ): Event[] {
for (const author of authorValues) { const terms = publicationRelaySearchTermsForAxis(axis, query)
const a = author.trim() if (terms.length === 0) return []
if (a) add({ kinds: [kind], search: a, limit })
return events.filter((event) => {
if (event.kind !== ExtendedKind.PUBLICATION) return false
if (axis === 'author') {
const npub = tryNpubFromQuery(query.trim())
if (npub && event.pubkey.toLowerCase() === npub) return true
} }
const tagName = axis === 'd-tag' ? 'd' : axis
return terms.some((term) => publicationMetadataTagMatchesQuery(event, tagName, term))
})
}
async function scanHttpIndexRelayForPublicationAxis(
httpRelay: string,
axis: LibraryPublicationRelaySearchAxis,
term: string
): Promise<Event[]> {
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_HTTP_PAGE_LIMIT }
const matched: Event[] = []
const seen = new Set<string>()
const descriptionValues = adv.description const collect = (batch: Event[]) => {
? Array.isArray(adv.description) for (const ev of filterEventsForPublicationRelaySearchAxis(batch, axis, term)) {
? adv.description if (!seen.has(ev.id)) {
: [adv.description] seen.add(ev.id)
: [] matched.push(ev)
for (const description of descriptionValues) { }
const d = description.trim() }
if (d) add({ kinds: [kind], search: d, limit })
} }
return out let firstPage: Event[]
try {
firstPage = (await queryIndexRelayForLibrary(httpRelay, filter)).events as Event[]
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication scan first page failed', {
relay: httpRelay,
axis,
message: e instanceof Error ? e.message : String(e)
})
}
return []
}
collect(firstPage)
if (matched.length >= LIBRARY_RELAY_SEARCH_LIMIT || firstPage.length === 0) {
return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT)
}
let until = oldestCreatedAt(firstPage) - 1
for (let page = 1; page < LIBRARY_RELAY_SEARCH_SCAN_MAX_PAGES; page++) {
if (until < 0) break
let batch: Event[] = []
let apiRowCount = 0
try {
const pageResult = await queryIndexRelayForLibrary(httpRelay, { ...filter, until })
batch = pageResult.events as Event[]
apiRowCount = pageResult.apiRowCount
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication scan page failed', {
relay: httpRelay,
axis,
page,
message: e instanceof Error ? e.message : String(e)
})
}
break
}
if (apiRowCount === 0) break
collect(batch)
if (matched.length >= LIBRARY_RELAY_SEARCH_LIMIT) break
if (apiRowCount < INDEX_HTTP_PAGE_LIMIT) break
const oldest = oldestCreatedAt(batch)
if (oldest === Number.MAX_SAFE_INTEGER) break
until = oldest - 1
}
return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT)
}
async function searchHttpIndexRelayPublicationAxis(
httpRelay: string,
axis: LibraryPublicationRelaySearchAxis,
term: string
): Promise<Event[]> {
const meta = await queryIndexRelayPublicationMetadataSearch(httpRelay, term, {
limit: LIBRARY_RELAY_SEARCH_LIMIT
})
const fromApi = filterEventsForPublicationRelaySearchAxis(meta.events as Event[], axis, term)
if (fromApi.length > 0) return fromApi
return scanHttpIndexRelayForPublicationAxis(httpRelay, axis, term)
} }
/** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */ /** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */
@ -1852,26 +2034,35 @@ export async function searchLibraryPublicationsOnRelays(
} }
} }
const filters = buildLibraryPublicationRelaySearchFilters({ query: q })
if (filters.length === 0) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
const indexRelays = libraryIndexRelayUrls(relayUrls) const indexRelays = libraryIndexRelayUrls(relayUrls)
const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
const batches: Promise<Event[]>[] = [] const batches: Promise<Event[]>[] = []
let filterCount = 0
for (const axis of LIBRARY_PUBLICATION_RELAY_SEARCH_AXES) {
const npubQuery = tryNpubFromQuery(q)
if (npubQuery && axis !== 'author') continue
const axisFilters = buildLibraryPublicationRelaySearchFiltersForAxis(axis, { query: q })
const hasNip01Filters = axisFilters.length > 0
const hasMetadataSearch = axis === 'title' || (axis === 'author' && !npubQuery)
if (!hasNip01Filters && !hasMetadataSearch) continue
if (wsRelays.length > 0) { filterCount += axisFilters.length
if (wsRelays.length > 0 && hasNip01Filters) {
batches.push( batches.push(
queryService queryService
.fetchEvents(wsRelays, filters, { .fetchEvents(wsRelays, axisFilters, {
globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS, globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS,
eoseTimeout: 8_000, eoseTimeout: 8_000,
firstRelayResultGraceMs: false firstRelayResultGraceMs: false
}) })
.then((events) => filterEventsForPublicationRelaySearchAxis(events, axis, q))
.catch((e) => { .catch((e) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] WS publication search failed', { logger.warn('[Library] WS publication search failed', {
axis,
message: e instanceof Error ? e.message : String(e) message: e instanceof Error ? e.message : String(e)
}) })
} }
@ -1881,14 +2072,35 @@ export async function searchLibraryPublicationsOnRelays(
} }
for (const httpRelay of httpRelays) { for (const httpRelay of httpRelays) {
for (const filter of filters) { if (hasNip01Filters) {
for (const filter of axisFilters) {
batches.push( batches.push(
queryIndexRelayPublicationSearch(httpRelay, filter) queryIndexRelayForLibrary(httpRelay, filter)
.then((page) => page.events as Event[]) .then((page) => filterEventsForPublicationRelaySearchAxis(page.events as Event[], axis, q))
.catch((e) => { .catch((e) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication search failed', { logger.warn('[Library] HTTP publication filter search failed', {
relay: httpRelay,
axis,
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
}
if (!hasMetadataSearch) continue
for (const term of publicationRelaySearchTermsForAxis(axis, q)) {
filterCount += 1
batches.push(
searchHttpIndexRelayPublicationAxis(httpRelay, axis, term).catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication metadata search failed', {
relay: httpRelay, relay: httpRelay,
axis,
message: e instanceof Error ? e.message : String(e) message: e instanceof Error ? e.message : String(e)
}) })
} }
@ -1897,6 +2109,11 @@ export async function searchLibraryPublicationsOnRelays(
) )
} }
} }
}
if (batches.length === 0) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
const settled = await Promise.all(batches) const settled = await Promise.all(batches)
const networkEvents = dedupeEventsById(settled.flat()) const networkEvents = dedupeEventsById(settled.flat())
@ -1926,7 +2143,9 @@ export async function searchLibraryPublicationsOnRelays(
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] relay search done', { logger.info('[Library] relay search done', {
filters: filters.length, axes: LIBRARY_PUBLICATION_RELAY_SEARCH_AXES.length,
filters: filterCount,
batches: batches.length,
network: networkEvents.length, network: networkEvents.length,
valid: valid.length, valid: valid.length,
roots: roots.length roots: roots.length

7
src/lib/metadata-policy-curated-relays.test.ts

@ -1,4 +1,4 @@
import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { DOCUMENT_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
isMetadataPolicyActiveReadGrantRelay, isMetadataPolicyActiveReadGrantRelay,
@ -15,13 +15,14 @@ describe('metadata-policy-curated-relays', () => {
it('operation scope excludes FAST_READ widening', () => { it('operation scope excludes FAST_READ widening', () => {
expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true) expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) expect(isMetadataPolicyOperationScopedRelay('wss://nostr21.com/')).toBe(false)
expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false) expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false)
}) })
it('active read grant includes search and discovery stacks', () => { it('active read grant includes search and discovery stacks', () => {
expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true) expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true)
expect(isMetadataPolicyActiveReadGrantRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false) expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr21.com/')).toBe(true)
expect(isMetadataPolicyActiveReadGrantRelay('wss://relay.primal.net/')).toBe(false)
expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false) expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false)
}) })
}) })

1
src/lib/new-user-template-broadcast.ts

@ -65,7 +65,6 @@ function prioritizeNewUserTemplateRelays(urls: string[]): string[] {
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://thecitadel.nostr1.com' 'wss://thecitadel.nostr1.com'
] ]
const byKey = new Map(urls.map((u) => [templateRelayKey(u), u])) const byKey = new Map(urls.map((u) => [templateRelayKey(u), u]))

3
src/lib/new-user-template.ts

@ -14,6 +14,7 @@ import {
createProfileDraftEvent, createProfileDraftEvent,
createRelayListDraftEvent createRelayListDraftEvent
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { TDraftEvent, TMailboxRelay } from '@/types' import { TDraftEvent, TMailboxRelay } from '@/types'
export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/' export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/'
@ -75,7 +76,7 @@ export function buildNewUserProfileDraft(pubkey: string): TDraftEvent {
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent { export function buildNewUserFavoriteRelaysDraft(): TDraftEvent {
return createFavoriteRelaysDraftEvent( return createFavoriteRelaysDraftEvent(
[...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL], dedupeNormalizeRelayUrlsOrdered([...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL]),
[] []
) )
} }

2
src/services/nip89.service.ts

@ -237,7 +237,7 @@ class Nip89Service {
desktop: 'imwald://note/bech32' desktop: 'imwald://note/bech32'
}, },
relays: [ relays: [
'wss://relay.damus.io', 'wss://thecitadel.nostr1.com',
'wss://relay.snort.social', 'wss://relay.snort.social',
'wss://nos.lol' 'wss://nos.lol'
] ]

Loading…
Cancel
Save