diff --git a/nip66-cron/index.mjs b/nip66-cron/index.mjs index e8cadf4e..61dcd636 100644 --- a/nip66-cron/index.mjs +++ b/nip66-cron/index.mjs @@ -51,7 +51,6 @@ const DEFAULT_RELAYS_TO_MONITOR = [ 'wss://nostr.wine', 'wss://nostr21.com', 'wss://aggr.nostr.land', - 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.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. */ const DEFAULT_PUBLISH_RELAYS = [ 'wss://nos.lol', - 'wss://relay.damus.io', 'wss://relay.nostr.watch', 'wss://relay.primal.net', 'wss://relaypag.es', diff --git a/package-lock.json b/package-lock.json index 655e4de2..7330379f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.21.5", + "version": "23.21.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.21.5", + "version": "23.21.6", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 658bbce0..755ba63d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 2bcd7c0f..81e38c15 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -552,9 +552,8 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] { const x = u.toLowerCase() if (x.includes('nos.lol')) return 0 if (x.includes('nostr.land')) return 1 - if (x.includes('relay.damus.io')) return 2 - if (x.includes('relay.primal.net')) return 3 - if (x.includes('nostr.wine')) return 4 + if (x.includes('relay.primal.net')) return 2 + if (x.includes('nostr.wine')) return 3 return 30 } return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b)) diff --git a/src/constants.ts b/src/constants.ts index 6bcbfe00..5ba4893d 100644 --- a/src/constants.ts +++ b/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) export const FAST_WRITE_RELAY_URLS = [ - 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://thecitadel.nostr1.com', 'wss://nos.lol' @@ -585,7 +584,6 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550 export const PROFILE_RELAY_URLS = [ 'wss://profiles.nostr1.com', - 'wss://relay.damus.io', 'wss://thecitadel.nostr1.com', 'wss://indexer.coracle.social/', 'wss://purplepag.es' diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts index 5431ddcb..5fcd92bb 100644 --- a/src/lib/index-relay-http.ts +++ b/src/lib/index-relay-http.ts @@ -30,6 +30,10 @@ function indexRelayPublishUrl(baseUrl: string): string { 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). */ function nostrFilterToIndexRelayBody(f: Filter): Record { const body: Record = {} @@ -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( baseUrl: string, filter: Filter, options?: { signal?: AbortSignal } ): Promise { + 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 { + const q = query.trim() + if (!q) return { events: [], apiRowCount: 0 } + const base = devHttpIndexRelayBaseForFetch(baseUrl) - const endpoint = indexRelayFilterUrl(base) + const endpoint = indexRelayPublicationMetadataSearchUrl(base) if (shouldSkipDevIndexRelayFetch(endpoint)) { return { events: [], apiRowCount: 0 } } - const body = nostrFilterToIndexRelayBody(filter) + const limit = Math.max(1, Math.min(options?.limit ?? 100, 100)) try { const res = await fetchWithTimeout(endpoint, { method: 'POST', @@ -438,11 +460,12 @@ export async function queryIndexRelayPublicationSearch( Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: JSON.stringify({ q, limit }), signal: options?.signal, timeoutMs: 25_000 }) if (!res.ok) { + if (res.status === 404 || res.status === 405) return { events: [], apiRowCount: 0 } if (res.status >= 500) { markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint) throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`)) @@ -472,7 +495,7 @@ export async function queryIndexRelayPublicationSearch( handleFilterTransportFailure(endpoint, e) throw new IndexRelayTransportError(e) } - warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication search request error', { + warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication metadata search request error', { endpoint, 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( baseUrl: string, event: NEvent, diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index e7abf889..da4c1a87 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -3,16 +3,19 @@ import { ExtendedKind } from '@/constants' import { buildEngagementMapsFromEvents, buildLibraryPublicationRelaySearchFilters, + buildLibraryPublicationRelaySearchFiltersForAxis, buildRecentPublicationEntries, clearLibrarySearchSessionCache, computeLibraryFeedRootOrder, filterEngagedPublications, + filterEventsForPublicationRelaySearchAxis, filterLibraryPublicationsBySearch, filterLibraryPublicationsByUser, libraryDefaultFeedSlice, libraryPublicationEntriesForUserFromIndex, LIBRARY_PAGE_SIZE, pickLibraryPublicationEntries, + publicationMetadataTagMatchesQuery, publicationRootBelongsToUser, peekLibrarySearchResults, publicationIndexMatchesSearchQuery, @@ -233,20 +236,53 @@ describe('library-publication-index', () => { 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') - const filters = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' }) - expect(filters.length).toBeGreaterThan(0) - expect(filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === ExtendedKind.PUBLICATION)).toBe( - true - ) + const dTagFilters = buildLibraryPublicationRelaySearchFiltersForAxis('d-tag', { + query: 'Village Life in China' + }) + 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']) - expect(dFilter?.['#d']).toContain('village-life-in-china') + const byAuthor = filterEventsForPublicationRelaySearchAxis([root], 'author', 'charlotte brontë') + expect(byAuthor).toHaveLength(1) - const searchFilter = filters.find((f) => f.search === 'Village Life in China') - expect(searchFilter?.kinds).toEqual([ExtendedKind.PUBLICATION]) + expect(filterEventsForPublicationRelaySearchAxis([root], 'title', 'charlotte')).toHaveLength(0) + expect(publicationMetadataTagMatchesQuery(root, 'title', 'Jane Eyre')).toBe(true) }) it('searchLibraryPublications caches results for repeated queries', async () => { diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index 2fcafacd..5dec3992 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -8,7 +8,7 @@ import { import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser' import logger from '@/lib/logger' 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 { buildIndexByAddress, buildStructuralPublicationIndexMap, @@ -63,6 +63,8 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 export const LIBRARY_RELAY_SEARCH_LIMIT = 100 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). */ const PIN_LIST_KIND = 10001 /** 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, '') } +/** 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. */ export function publicationQueryDTagVariants(query: string): string[] { const raw = query.trim() @@ -1742,14 +1753,99 @@ export function publicationQueryDTagVariants(query: string): string[] { return [...seen] } -/** - * OR-merge REQ filters for kind **30040** publication indexes: `#d` slugs plus NIP-50 `search` - * (title, author, summary/description on index relays). - */ -export function buildLibraryPublicationRelaySearchFilters(opts: { +/** Normalized needles for exact publication metadata tag match (d / title / author). */ +export function publicationQueryNeedles(query: string): string[] { + const raw = normalizeGeneralSearchQuery(query.trim()) + if (!raw) return [] + 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 - limit?: number -}): Filter[] { +): boolean { + 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([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, + 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() if (!searchRaw) return [] @@ -1757,67 +1853,153 @@ export function buildLibraryPublicationRelaySearchFilters(opts: { const kind = ExtendedKind.PUBLICATION const seen = new Set() 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) + if (npub) { + addPublicationKindFilter(out, seen, { kinds: [kind], authors: [npub], limit }) + return out + } + return out } - const npub = tryNpubFromQuery(searchRaw) - if (npub) { - add({ kinds: [kind], authors: [npub], limit }) + if (axis === 'title') { return out } - const dTags = publicationQueryDTagVariants(searchRaw) - if (dTags.length > 0) { - add({ kinds: [kind], '#d': dTags, limit }) + if (axis === 'd-tag') { + const dTags = new Set() + for (const term of publicationRelaySearchTermsForAxis('d-tag', searchRaw)) { + 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 searchNorm = normalizeGeneralSearchQuery(searchRaw) - add({ kinds: [kind], search: searchRaw, limit }) - if (searchNorm !== searchRaw) { - add({ kinds: [kind], search: searchNorm, limit }) + return out +} + +/** + * 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. + */ +export function buildLibraryPublicationRelaySearchFilters(opts: { + query: string + limit?: number +}): Filter[] { + const seen = new Set() + 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 +} + +export function filterEventsForPublicationRelaySearchAxis( + events: Event[], + axis: LibraryPublicationRelaySearchAxis, + query: string +): Event[] { + const terms = publicationRelaySearchTermsForAxis(axis, query) + if (terms.length === 0) return [] + + 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 { + const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_HTTP_PAGE_LIMIT } + const matched: Event[] = [] + const seen = new Set() + + const collect = (batch: Event[]) => { + for (const ev of filterEventsForPublicationRelaySearchAxis(batch, axis, term)) { + if (!seen.has(ev.id)) { + seen.add(ev.id) + matched.push(ev) + } + } } - const adv = parseAdvancedSearch(searchRaw) - const titleValues = adv.title - ? Array.isArray(adv.title) - ? adv.title - : [adv.title] - : [] - for (const title of titleValues) { - const t = title.trim() - if (!t) continue - add({ kinds: [kind], search: t, limit }) - const titleDTags = publicationQueryDTagVariants(t) - if (titleDTags.length > 0) { - add({ kinds: [kind], '#d': titleDTags, limit }) + 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 [] } - const authorValues = adv.author - ? Array.isArray(adv.author) - ? adv.author - : [adv.author] - : [] - for (const author of authorValues) { - const a = author.trim() - if (a) add({ kinds: [kind], search: a, limit }) + collect(firstPage) + if (matched.length >= LIBRARY_RELAY_SEARCH_LIMIT || firstPage.length === 0) { + return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT) } - const descriptionValues = adv.description - ? Array.isArray(adv.description) - ? adv.description - : [adv.description] - : [] - for (const description of descriptionValues) { - const d = description.trim() - if (d) add({ kinds: [kind], search: d, 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 out + return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT) +} + +async function searchHttpIndexRelayPublicationAxis( + httpRelay: string, + axis: LibraryPublicationRelaySearchAxis, + term: string +): Promise { + 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}. */ @@ -1852,43 +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 { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) const batches: Promise[] = [] + let filterCount = 0 - if (wsRelays.length > 0) { - batches.push( - queryService - .fetchEvents(wsRelays, filters, { - globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS, - eoseTimeout: 8_000, - firstRelayResultGraceMs: false - }) - .catch((e) => { - if (import.meta.env.DEV) { - logger.warn('[Library] WS publication search failed', { - message: e instanceof Error ? e.message : String(e) - }) - } - return [] as Event[] - }) - ) - } + for (const axis of LIBRARY_PUBLICATION_RELAY_SEARCH_AXES) { + const npubQuery = tryNpubFromQuery(q) + if (npubQuery && axis !== 'author') continue - for (const httpRelay of httpRelays) { - for (const filter of filters) { + const axisFilters = buildLibraryPublicationRelaySearchFiltersForAxis(axis, { query: q }) + const hasNip01Filters = axisFilters.length > 0 + const hasMetadataSearch = axis === 'title' || (axis === 'author' && !npubQuery) + if (!hasNip01Filters && !hasMetadataSearch) continue + + filterCount += axisFilters.length + + if (wsRelays.length > 0 && hasNip01Filters) { batches.push( - queryIndexRelayPublicationSearch(httpRelay, filter) - .then((page) => page.events as Event[]) + queryService + .fetchEvents(wsRelays, axisFilters, { + globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS, + eoseTimeout: 8_000, + firstRelayResultGraceMs: false + }) + .then((events) => filterEventsForPublicationRelaySearchAxis(events, axis, q)) .catch((e) => { if (import.meta.env.DEV) { - logger.warn('[Library] HTTP publication search failed', { - relay: httpRelay, + logger.warn('[Library] WS publication search failed', { + axis, message: e instanceof Error ? e.message : String(e) }) } @@ -1896,6 +2070,49 @@ export async function searchLibraryPublicationsOnRelays( }) ) } + + for (const httpRelay of httpRelays) { + if (hasNip01Filters) { + for (const filter of axisFilters) { + batches.push( + queryIndexRelayForLibrary(httpRelay, filter) + .then((page) => filterEventsForPublicationRelaySearchAxis(page.events as Event[], axis, q)) + .catch((e) => { + if (import.meta.env.DEV) { + 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, + axis, + message: e instanceof Error ? e.message : String(e) + }) + } + return [] as Event[] + }) + ) + } + } + } + + if (batches.length === 0) { + return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false } } const settled = await Promise.all(batches) @@ -1926,7 +2143,9 @@ export async function searchLibraryPublicationsOnRelays( if (import.meta.env.DEV) { logger.info('[Library] relay search done', { - filters: filters.length, + axes: LIBRARY_PUBLICATION_RELAY_SEARCH_AXES.length, + filters: filterCount, + batches: batches.length, network: networkEvents.length, valid: valid.length, roots: roots.length diff --git a/src/lib/metadata-policy-curated-relays.test.ts b/src/lib/metadata-policy-curated-relays.test.ts index e5022f65..566818f8 100644 --- a/src/lib/metadata-policy-curated-relays.test.ts +++ b/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 { isMetadataPolicyActiveReadGrantRelay, @@ -15,13 +15,14 @@ describe('metadata-policy-curated-relays', () => { it('operation scope excludes FAST_READ widening', () => { expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_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) }) it('active read grant includes search and discovery stacks', () => { 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) }) }) diff --git a/src/lib/new-user-template-broadcast.ts b/src/lib/new-user-template-broadcast.ts index 22c003f0..f434a011 100644 --- a/src/lib/new-user-template-broadcast.ts +++ b/src/lib/new-user-template-broadcast.ts @@ -65,7 +65,6 @@ function prioritizeNewUserTemplateRelays(urls: string[]): string[] { 'wss://profiles.nostr1.com', 'wss://nos.lol', 'wss://relay.primal.net', - 'wss://relay.damus.io', 'wss://thecitadel.nostr1.com' ] const byKey = new Map(urls.map((u) => [templateRelayKey(u), u])) diff --git a/src/lib/new-user-template.ts b/src/lib/new-user-template.ts index 2801d061..283fb1b5 100644 --- a/src/lib/new-user-template.ts +++ b/src/lib/new-user-template.ts @@ -14,6 +14,7 @@ import { createProfileDraftEvent, createRelayListDraftEvent } from '@/lib/draft-event' +import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { TDraftEvent, TMailboxRelay } from '@/types' 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 { return createFavoriteRelaysDraftEvent( - [...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL], + dedupeNormalizeRelayUrlsOrdered([...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL]), [] ) } diff --git a/src/services/nip89.service.ts b/src/services/nip89.service.ts index 3b98149e..93f634e1 100644 --- a/src/services/nip89.service.ts +++ b/src/services/nip89.service.ts @@ -237,7 +237,7 @@ class Nip89Service { desktop: 'imwald://note/bech32' }, relays: [ - 'wss://relay.damus.io', + 'wss://thecitadel.nostr1.com', 'wss://relay.snort.social', 'wss://nos.lol' ]