From 49ed4d086c2d99031a3d91711743ed6d9825dd83 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 22:54:42 +0200 Subject: [PATCH] bug-fixes --- src/components/RelayIcon/index.tsx | 14 +- src/components/ReplyNoteList/index.tsx | 59 ++++-- src/components/SearchBar/index.tsx | 7 +- .../SearchResult/FullTextSearchByRelay.tsx | 18 +- src/i18n/locales/cs.ts | 4 + src/i18n/locales/de.ts | 4 + src/i18n/locales/en.ts | 4 + src/i18n/locales/es.ts | 4 + src/i18n/locales/fr.ts | 4 + src/i18n/locales/nl.ts | 4 + src/i18n/locales/pl.ts | 4 + src/i18n/locales/ru.ts | 4 + src/i18n/locales/tr.ts | 4 + src/i18n/locales/zh.ts | 4 + src/lib/merged-search-note-preview.ts | 47 +++++ src/lib/thread-reply-root-match.ts | 62 ++++-- src/pages/primary/SearchPage/index.tsx | 54 +++-- src/pages/secondary/SearchPage/index.tsx | 187 +++++++++++------- src/services/client-query.service.ts | 3 +- src/services/note-stats.service.ts | 142 +++++++++++++ 20 files changed, 504 insertions(+), 129 deletions(-) create mode 100644 src/lib/merged-search-note-preview.ts diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index 6adc54f0..3c19ca58 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -3,7 +3,7 @@ import { useFetchRelayInfo } from '@/hooks' import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' import { cn } from '@/lib/utils' import { Server } from 'lucide-react' -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' /** * Resolve an image URL from NIP-11. Handles: @@ -42,6 +42,10 @@ export default function RelayIcon({ skipRelayInfoFetch?: boolean }) { const { relayInfo } = useFetchRelayInfo(skipRelayInfoFetch ? undefined : url) + const [iconLoadFailed, setIconLoadFailed] = useState(false) + useEffect(() => { + setIconLoadFailed(false) + }, [url, relayInfo?.icon]) const iconUrl = useMemo(() => { if (!url) return undefined @@ -64,7 +68,13 @@ export default function RelayIcon({ return ( - {iconUrl && } + {iconUrl && !iconLoadFailed && ( + setIconLoadFailed(true)} + /> + )} ): boolean { if (rootInfo.type === 'I') { return isRssArticleUrlThreadInteraction(evt, rootInfo.id) @@ -299,7 +301,7 @@ function replyMatchesThreadForList( ) { return true } - if (replyBelongsToNoteThread(evt, opEvent, rootInfo)) return true + if (replyBelongsToNoteThread(evt, opEvent, rootInfo, threadWalkLocal)) return true if ( (rootInfo.type === 'E' || rootInfo.type === 'A') && evt.kind !== kinds.ShortTextNote && @@ -1284,11 +1286,15 @@ function ReplyNoteList({ mergeFetchedKind7ReactionsIntoRootNoteStats(allReplies, rootInfo) + const threadWalkFromBatch = new Map( + allReplies.map((e) => [e.id.toLowerCase(), e] as const) + ) + // Filter and add replies (URL threads include kind 9802 highlights of this page) const regularReplies = allReplies.filter((evt) => { if (isPollVoteKind(evt)) return false if (isZapPollThreadZapReceipt(evt, event)) return false - const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) + const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromBatch) if (!match) return false return !shouldHideThreadResponseEvent( evt, @@ -1303,29 +1309,42 @@ function ReplyNoteList({ // Get the merged cache (which includes all replies we've ever seen, including new ones) const mergedCachedReplies = discussionFeedCache.getCachedReplies(rootInfo) - - // Always add all merged cached replies to UI - // This ensures we keep all previously seen replies and add any new ones - // addReplies will deduplicate, so it's safe to call even if some replies are already displayed - if (mergedCachedReplies) { - const mergedForUi = + + let mergedForUi: NEvent[] + if (mergedCachedReplies === null) { + logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') + mergedForUi = regularReplies + } else { + mergedForUi = event.kind === ExtendedKind.ZAP_POLL ? mergedCachedReplies.filter((e) => !isZapPollThreadZapReceipt(e, event)) : mergedCachedReplies - addReplies(mergedForUi) - } else { - // Fallback: if cache somehow failed, at least add the fetched replies - logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') - addReplies(regularReplies) } + const repliesForStatsPrime = mergedForUi + addReplies(mergedForUi) - const statsBatch = mergedCachedReplies?.length ? mergedCachedReplies : regularReplies + const statsBatch = mergedCachedReplies !== null && mergedCachedReplies.length > 0 ? mergedCachedReplies : regularReplies if (statsBatch.length > 0) { noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, { statsRootEvent: event }) } + if (repliesForStatsPrime.length > 0) { + for (const reply of repliesForStatsPrime) { + const sessionEdge = eventService.getSessionEventsForNoteStatsTarget(reply) + if (sessionEdge.length > 0) { + noteStatsService.updateNoteStatsByEvents(sessionEdge, reply.pubkey) + } + } + void noteStatsService.fetchThreadReplyNoteStatsBatch( + repliesForStatsPrime, + relayUrlsForThreadReq, + userPubkey ?? null, + { foreground: statsForeground } + ) + } + if (!hasCache) { // No cache: stop loading after adding replies setLoading(false) @@ -1409,6 +1428,9 @@ function ReplyNoteList({ ) if (parentIdsNested.length > 0) { const nestedAccum: NEvent[] = [] + const streamWalkById = new Map( + regularReplies.map((e) => [e.id.toLowerCase(), e] as const) + ) for (let off = 0; off < parentIdsNested.length; off += MAX_PARENT_IDS_PER_NESTED_REQ) { const idChunk = parentIdsNested.slice(off, off + MAX_PARENT_IDS_PER_NESTED_REQ) const nestedFilters: Filter[] = [ @@ -1425,18 +1447,21 @@ function ReplyNoteList({ if (isPollVoteKind(evt)) return if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return - if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return + streamWalkById.set(evt.id.toLowerCase(), evt) + if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, streamWalkById)) return addReplies([evt]) } }) if (fetchGeneration !== replyFetchGenRef.current) return nestedAccum.push(...nestedReplies) } + const nestedWalkMerged = new Map(streamWalkById) + for (const e of nestedAccum) nestedWalkMerged.set(e.id.toLowerCase(), e) const validNested = nestedAccum.filter( (evt) => !isPollVoteKind(evt) && !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) && - replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) + replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, nestedWalkMerged) ) if (validNested.length > 0) { discussionFeedCache.setCachedReplies(rootInfo, validNested) diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index 214fa1b5..bad11d88 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -123,7 +123,12 @@ const SearchBar = forwardRef< useEffect(() => { const search = input.trim() - if (!search) return + if (!search) { + setSelectableOptions([]) + setSelectedIndex(-1) + setSearching(false) + return + } const hex64 = /^[0-9a-f]{64}$/i if (hex64.test(search)) { diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index 9f3228dd..5c852899 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -3,10 +3,12 @@ import RelayIcon from '@/components/RelayIcon' import { Skeleton } from '@/components/ui/skeleton' import { toRelay } from '@/lib/link' import { compareEventsForDTagQuery } from '@/lib/dtag-search' +import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import client from '@/services/client.service' +import { NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS } from '@/services/client-query.service' import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' import type { TProfile } from '@/types' import type { Event, Filter } from 'nostr-tools' @@ -20,12 +22,15 @@ type MergedHit = { relayUrls: string[] } -/** Hard cap for the merged search wave, counted from the first relay query start (not from React effect mount). */ -const SEARCH_TOTAL_WALL_MS = 10_000 +/** + * Hard cap for the merged search wave (abort signal), from the first relay query start. + * Must exceed {@link NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS} so at least one slow index relay can EOSE. + */ +const SEARCH_TOTAL_WALL_MS = NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 /** After the first results arrive from any relay, end the wave this many ms later (capped by {@link SEARCH_TOTAL_WALL_MS}). */ -const SEARCH_AFTER_FIRST_RELAY_MS = 3_000 -/** Per-relay {@link QueryService.query} budget from when that relay’s fetch starts (capped by remaining wave wall). */ -const SEARCH_PER_RELAY_QUERY_MS = 10_000 +const SEARCH_AFTER_FIRST_RELAY_MS = 6_000 +/** Per-relay {@link QueryService.query} budget (capped by remaining wave wall). Align with NIP-50 index latency. */ +const SEARCH_PER_RELAY_QUERY_MS = NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS /** Avoid opening every index relay at once (pool + main thread). */ const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3 const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 @@ -351,6 +356,7 @@ export default function FullTextSearchByRelay({ map.set(hit.event.id, { event: hit.event, relays: new Set(hit.relayUrls.map((u) => relayKey(u))) }) } for (const ev of events) { + if (!mergedSearchNoteHasPreviewBody(ev)) continue const cur = map.get(ev.id) if (cur) { cur.relays.add(rk) @@ -515,7 +521,7 @@ export default function FullTextSearchByRelay({ navigateToRelay(toRelay(url)) }} > - + ))} diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index b7e9d806..8bf5ad10 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 773595d5..b13e71cc 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Neuestes von deinen Follows", "Latest from our recommended follows": "Neuestes von unseren empfohlenen Follows", "Search page title": "Nostr durchsuchen", + "Search on Alexandria": "Mit Alexandria suchen", + "Search page clear": "Leeren", + "Search page clear description": + "Suchfeld leeren, Vorschläge schließen und Ergebnisse entfernen, um neu zu suchen.", "Follows latest page title": "Neuestes von Follows", "Follows latest page description": "Aktuelle Notizen von Leuten, denen du folgst (ohne Konto: unsere kuratierte Liste). Wir führen Outbox-Relays aus ihren NIP-65-Listen mit deinen Favoriten zusammen und laden in Stapeln. Zeile aufklappen für Notizen oder Profil antippen.", "Follows latest nav label": "Follows: neueste", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e835742b..e118f964 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1211,6 +1211,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 1e77c401..50897598 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 3e613675..35b67a02 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 1ef5b600..95d79d9c 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 140bc871..b97d0581 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 11bd3fdf..bcd2edeb 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 8559d111..830cb35d 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 0d86aa25..cb106ec5 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -1176,6 +1176,10 @@ export default { "Latest from your follows": "Latest from your follows", "Latest from our recommended follows": "Latest from our recommended follows", "Search page title": "Search Nostr", + "Search on Alexandria": "Search on Alexandria", + "Search page clear": "Clear", + "Search page clear description": + "Clear the search field, close suggestions, and remove results so you can start a new search.", "Follows latest page title": "Latest from follows", "Follows latest page description": "Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.", "Follows latest nav label": "Follows latest", diff --git a/src/lib/merged-search-note-preview.ts b/src/lib/merged-search-note-preview.ts new file mode 100644 index 00000000..2ab42ae8 --- /dev/null +++ b/src/lib/merged-search-note-preview.ts @@ -0,0 +1,47 @@ +import { ExtendedKind } from '@/constants' +import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' +import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' + +const DOC_KINDS = new Set([ + kinds.LongFormArticle, + ExtendedKind.WIKI_ARTICLE, + ExtendedKind.WIKI_ARTICLE_MARKDOWN, + ExtendedKind.PUBLICATION, + ExtendedKind.PUBLICATION_CONTENT +]) + +/** + * True if {@link NoteCard} should show a non-empty body in merged NIP-50 search rows. + * Drops indexer stubs / empty shells that only show the “seen on” strip. + */ +export function mergedSearchNoteHasPreviewBody(ev: Event): boolean { + const k = ev.kind + if (k === kinds.ShortTextNote || k === ExtendedKind.COMMENT) { + if (ev.tags.some((t) => t[0] === 'subject' && String(t[1] ?? '').trim().length > 0)) return true + return Boolean(ev.content?.trim().length) + } + if (k === kinds.Metadata) { + const c = ev.content?.trim() ?? '' + if (c.length < 2) return false + try { + const j = JSON.parse(c) as { + name?: unknown + display_name?: unknown + about?: unknown + nip05?: unknown + } + const pick = (v: unknown) => (typeof v === 'string' ? v.trim() : '') + return Boolean(pick(j.name) || pick(j.display_name) || pick(j.about) || pick(j.nip05)) + } catch { + return c.length >= 2 + } + } + if (DOC_KINDS.has(k)) { + const m = getLongFormArticleMetadataFromEvent(ev) + if (m.title?.trim() || m.summary?.trim() || m.image?.trim() || m.tags.length > 0) return true + return Boolean(cardEventBodyBlurb(ev.content).trim()) + } + return true +} diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index 8dea88c2..fdef5719 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/src/lib/thread-reply-root-match.ts @@ -20,11 +20,22 @@ import { kinds } from 'nostr-tools' const THREAD_PARENT_WALK_MAX = 14 +/** Prefer session LRU; use `localByHex` for events in the current relay batch (not yet indexed for parent walks). */ +function peekThreadEvent(hexLower: string, localByHex?: ReadonlyMap): Event | undefined { + const k = hexLower.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/i.test(k)) return undefined + return localByHex?.get(k) ?? client.peekSessionCachedEvent(k) +} + /** * Whether a note (hex id) sits in the thread under `rootHexLower`: it is the root, declares that root, * or we can reach the root by walking `e` parents in the session cache. */ -function hexNoteParticipatesInThread(noteHexLower: string, rootHexLower: string): boolean { +function hexNoteParticipatesInThread( + noteHexLower: string, + rootHexLower: string, + localByHex?: ReadonlyMap +): boolean { const root = rootHexLower.trim().toLowerCase() const start = noteHexLower.trim().toLowerCase() if (!/^[0-9a-f]{64}$/i.test(start)) return false @@ -39,7 +50,7 @@ function hexNoteParticipatesInThread(noteHexLower: string, rootHexLower: string) seen.add(k) if (k === root) return true - const ev = client.peekSessionCachedEvent(k) + const ev = peekThreadEvent(k, localByHex) if (!ev) return false if (ev.id.toLowerCase() === root) return true @@ -54,16 +65,20 @@ function hexNoteParticipatesInThread(noteHexLower: string, rootHexLower: string) } /** Reply whose direct parent is a zap receipt whose zapped note is in this thread (OP or nested under OP). */ -function replyParentIsZapToThreadHex(reply: Event, rootHexLower: string): boolean { +function replyParentIsZapToThreadHex( + reply: Event, + rootHexLower: string, + localByHex?: ReadonlyMap +): boolean { const parentHex = getParentEventHexId(reply) if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false const pl = parentHex.toLowerCase() if (pl === rootHexLower) return false - const parentEv = client.peekSessionCachedEvent(pl) + const parentEv = peekThreadEvent(pl, localByHex) if (!parentEv || parentEv.kind !== kinds.Zap) return false const zapped = getZapInfoFromEvent(parentEv)?.originalEventId if (!zapped || !/^[0-9a-f]{64}$/i.test(zapped)) return false - return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower) + return hexNoteParticipatesInThread(zapped.toLowerCase(), rootHexLower, localByHex) } function reactionTargetNoteHex(reaction: Event): string | undefined { @@ -75,16 +90,20 @@ function reactionTargetNoteHex(reaction: Event): string | undefined { } /** Reply whose direct parent is a reaction to some note in this thread (OP or a nested reply under OP). */ -function replyParentIsReactionToThreadHex(reply: Event, rootHexLower: string): boolean { +function replyParentIsReactionToThreadHex( + reply: Event, + rootHexLower: string, + localByHex?: ReadonlyMap +): boolean { const parentHex = getParentEventHexId(reply) if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false const pl = parentHex.toLowerCase() if (pl === rootHexLower) return false - const parentEv = client.peekSessionCachedEvent(pl) + const parentEv = peekThreadEvent(pl, localByHex) if (!parentEv || !isNip25ReactionKind(parentEv.kind)) return false const targetHex = reactionTargetNoteHex(parentEv) if (!targetHex) return false - return hexNoteParticipatesInThread(targetHex, rootHexLower) + return hexNoteParticipatesInThread(targetHex, rootHexLower, localByHex) } /** Matches `ReplyNoteList` / discussion thread root shapes. */ @@ -94,7 +113,11 @@ export type TThreadRootRef = | { type: 'I'; id: string } /** Whether a newly published/fetched reply belongs to the thread rooted at `root`. */ -export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): boolean { +export function eventReplyMatchesThreadRoot( + evt: Event, + root: TThreadRootRef, + localByHex?: ReadonlyMap +): boolean { if (root.type === 'I') { const u = getArticleUrlFromCommentITags(evt) if (u && canonicalizeRssArticleUrl(u) === canonicalizeRssArticleUrl(root.id)) return true @@ -106,7 +129,7 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b // cache: if the declared root or direct parent is a URL-thread comment, accept this event. const urlMatchesRoot = (hexId: string | undefined): boolean => { if (!hexId || !/^[0-9a-f]{64}$/i.test(hexId)) return false - const ancestor = client.peekSessionCachedEvent(hexId.toLowerCase()) + const ancestor = peekThreadEvent(hexId.toLowerCase(), localByHex) if (!ancestor) return false const aUrl = getArticleUrlFromCommentITags(ancestor) return !!aUrl && canonicalizeRssArticleUrl(aUrl) === canonicalizeRssArticleUrl(root.id) @@ -125,7 +148,7 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b if ( parentHex && /^[0-9a-f]{64}$/i.test(rootEventHex) && - hexNoteParticipatesInThread(parentHex, rootEventHex) + hexNoteParticipatesInThread(parentHex, rootEventHex, localByHex) ) { return true } @@ -136,9 +159,9 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b if (evtRootHex === rid) return true if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true const parentHex = getParentEventHexId(evt)?.toLowerCase() - if (parentHex && hexNoteParticipatesInThread(parentHex, rid)) return true - if (replyParentIsZapToThreadHex(evt, rid)) return true - if (replyParentIsReactionToThreadHex(evt, rid)) return true + if (parentHex && hexNoteParticipatesInThread(parentHex, rid, localByHex)) return true + if (replyParentIsZapToThreadHex(evt, rid, localByHex)) return true + if (replyParentIsReactionToThreadHex(evt, rid, localByHex)) return true return kind1QuotesThreadRoot(evt, root) } @@ -148,11 +171,16 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b * tag the quoted inner note as `e`+`root` do not show under the quoter's thread). * For quote posts, also drops kind-1 replies whose **parent** is the embedded quoted id but not the OP. */ -export function replyBelongsToNoteThread(evt: Event, opEvent: Event, root: TThreadRootRef): boolean { +export function replyBelongsToNoteThread( + evt: Event, + opEvent: Event, + root: TThreadRootRef, + localByHex?: ReadonlyMap +): boolean { if (root.type === 'I') { - return eventReplyMatchesThreadRoot(evt, root) + return eventReplyMatchesThreadRoot(evt, root, localByHex) } - if (!eventReplyMatchesThreadRoot(evt, root)) return false + if (!eventReplyMatchesThreadRoot(evt, root, localByHex)) return false if (root.type === 'A') return true if (opEvent.kind !== kinds.ShortTextNote) return true diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index 30112c94..003bcdd9 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -6,12 +6,13 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useNostr } from '@/providers/NostrProvider' import { TPageRef, TSearchParams } from '@/types' -import { BookOpen, Search } from 'lucide-react' +import { BookOpen, Search, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' const SearchPage = forwardRef((_props, ref) => { + const { t } = useTranslation() const { current, display } = usePrimaryPage() const { pubkey, relayList } = useNostr() const [input, setInput] = useState('') @@ -51,6 +52,14 @@ const SearchPage = forwardRef((_props, ref) => { layoutRef.current?.scrollToTop('instant') } + const clearSearch = useCallback(() => { + setInput('') + setSearchParams(null) + setResultRefreshKey((k) => k + 1) + searchBarRef.current?.blur() + void Promise.resolve().then(() => searchBarRef.current?.focus()) + }, []) + return ( ((_props, ref) => { displayScrollToTopButton >
-
-
- -
-
+
+
+
+ +
+
diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx index 145bcfb5..5d4bc6b3 100644 --- a/src/pages/secondary/SearchPage/index.tsx +++ b/src/pages/secondary/SearchPage/index.tsx @@ -8,15 +8,18 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { BookOpen } from 'lucide-react' +import { BookOpen, X } from 'lucide-react' import { TSearchParams } from '@/types' import { Button } from '@/components/ui/button' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { + const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { push } = useSecondaryPage() const { pubkey, relayList } = useNostr() + const [locationRevision, setLocationRevision] = useState(0) const [resultRefreshKey, setResultRefreshKey] = useState(0) const bumpResults = useCallback(() => { void (async () => { @@ -35,7 +38,19 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number }, [hideTitlebar, registerPrimaryPanelRefresh, bumpResults]) const [input, setInput] = useState('') const searchBarRef = useRef(null) - const searchParams = useMemo(() => { + + const bumpLocationRevision = useCallback(() => { + setLocationRevision((r) => r + 1) + }, []) + + useEffect(() => { + const onPop = () => bumpLocationRevision() + window.addEventListener('popstate', onPop) + return () => window.removeEventListener('popstate', onPop) + }, [bumpLocationRevision]) + + const searchParams = useMemo((): TSearchParams | null => { + void locationRevision const params = new URLSearchParams(window.location.search) const type = params.get('t') if ( @@ -51,69 +66,94 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number if (!search) { return null } - const input = params.get('i') ?? '' - setInput(input || search) - return { type, search, input } as TSearchParams - }, []) + const inputFromUrl = params.get('i') ?? '' + return { type, search, input: inputFromUrl } as TSearchParams + }, [locationRevision]) useEffect(() => { - if (!window.location.search) { - searchBarRef.current?.focus() + const params = new URLSearchParams(window.location.search) + const q = params.get('q') + if (!q) { + setInput('') + return + } + setInput(params.get('i') ?? q) + }, [locationRevision]) + + useEffect(() => { + const q = new URLSearchParams(window.location.search).get('q') + if (!q) { + void Promise.resolve().then(() => searchBarRef.current?.focus()) } }, []) const onSearch = (params: TSearchParams | null) => { - if (params) { - // Check if this is a 'notes' search that contains advanced search parameters - if (params.type === 'notes' && params.search) { - const searchParams = parseAdvancedSearch(params.search) - - // Check if we have advanced search parameters (not just plain text) - // Exclude unsupported multi-letter tag params (title, subject, description, author, type) - const hasAdvancedParams = Object.keys(searchParams).some(key => - key !== 'dtag' && - key !== 'title' && - key !== 'subject' && - key !== 'description' && - key !== 'author' && + if (!params) { + push(toSearch()) + bumpLocationRevision() + setResultRefreshKey((k) => k + 1) + return + } + // Check if this is a 'notes' search that contains advanced search parameters + if (params.type === 'notes' && params.search) { + const searchParams = parseAdvancedSearch(params.search) + + // Check if we have advanced search parameters (not just plain text) + // Exclude unsupported multi-letter tag params (title, subject, description, author, type) + const hasAdvancedParams = Object.keys(searchParams).some( + (key) => + key !== 'dtag' && + key !== 'title' && + key !== 'subject' && + key !== 'description' && + key !== 'author' && key !== 'type' && searchParams[key as keyof typeof searchParams] - ) - - // Handle hashtag search - route to hashtag page - if (searchParams.hashtag) { - const hashtag = Array.isArray(searchParams.hashtag) ? searchParams.hashtag[0] : searchParams.hashtag - const urlParams = new URLSearchParams() - urlParams.set('t', hashtag) - // Note: Kind filter only available as URL parameter k=, not from search parser - push(`/notes?${urlParams.toString()}`) - return - } - - if (hasAdvancedParams || searchParams.dtag) { - // Route to NoteListPage with advanced search - // Note: Only include parameters that Nostr relays actually support - // (single-letter tag indexes: #d, #t, #p, #e, #a, etc.) - const urlParams = new URLSearchParams() - if (searchParams.dtag) { - urlParams.set('d', searchParams.dtag) - } - // Skip title, subject, description, author, type - these use multi-letter tags - // that Nostr relays don't index - // Note: Bare event IDs are handled as standard search, not as filter params - // Date searches and pubkey filters removed - not supported - // Kind filter only available as URL parameter k=, not from search parser - - push(`/notes?${urlParams.toString()}`) - return + ) + + // Handle hashtag search - route to hashtag page + if (searchParams.hashtag) { + const hashtag = Array.isArray(searchParams.hashtag) ? searchParams.hashtag[0] : searchParams.hashtag + const urlParams = new URLSearchParams() + urlParams.set('t', hashtag) + // Note: Kind filter only available as URL parameter k=, not from search parser + push(`/notes?${urlParams.toString()}`) + return + } + + if (hasAdvancedParams || searchParams.dtag) { + // Route to NoteListPage with advanced search + // Note: Only include parameters that Nostr relays actually support + // (single-letter tag indexes: #d, #t, #p, #e, #a, etc.) + const urlParams = new URLSearchParams() + if (searchParams.dtag) { + urlParams.set('d', searchParams.dtag) } + // Skip title, subject, description, author, type - these use multi-letter tags + // that Nostr relays don't index + // Note: Bare event IDs are handled as standard search, not as filter params + // Date searches and pubkey filters removed - not supported + // Kind filter only available as URL parameter k=, not from search parser + + push(`/notes?${urlParams.toString()}`) + return } - - // Default behavior - route to SearchPage - push(toSearch(params)) } + + // Default behavior - route to SearchPage + push(toSearch(params)) + bumpLocationRevision() } + const clearSearch = useCallback(() => { + push(toSearch()) + setInput('') + setResultRefreshKey((k) => k + 1) + bumpLocationRevision() + searchBarRef.current?.blur() + void Promise.resolve().then(() => searchBarRef.current?.focus()) + }, [push, bumpLocationRevision]) + return (
Search Nostr
-
-
- -
-
+
+
+
+ +
+
diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 9d9c2d25..938468c3 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -54,8 +54,9 @@ const NIP50_RELAY_SUBSCRIPTION_EOSE_TIMEOUT_MS = 38_000 /** * {@link QueryService.query} `globalTimeout` is armed at call start; REQ may start seconds later. Used only for * {@link ClientService.fetchEventsFromSingleRelay} so mention/picker queries keep their own shorter caps. + * Merged search UI must not use a budget below this — index relays often exceed 10–20s before EOSE. */ -const NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS = 42_000 +export const NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS = 42_000 const HEX_EVENT_ID_RE = /^[0-9a-f]{64}$/i diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 562d6dd9..b44d5fa6 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -98,6 +98,8 @@ class NoteStatsService { private readonly BATCH_DELAY = 40 /** Larger slices: feed cards each trigger a stats fetch; tiny slices left the tail of the feed starved. */ private readonly MAX_BATCH_SIZE = 32 + /** Max `#e` values per REQ filter when batching thread reply stats (relays often cap array length). */ + private readonly THREAD_REPLY_STATS_BATCH_HEX_CHUNK = 32 /** Parallel stats REQs per slice (bounded by relay pool pressure). */ private readonly STATS_SLICE_CONCURRENCY = 8 /** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */ @@ -223,6 +225,146 @@ class NoteStatsService { this.maybeFlushStatsBatch(foreground) } + /** + * One relay wave for stats on many thread replies: chunked `#e` / `#q` filters instead of N + * {@link fetchNoteStats} queue entries. Replaceable-address notes still use {@link fetchNoteStats}; + * non-hex ids use {@link fetchNoteStats} as well. + * + * Ingest uses {@link updateNoteStatsByEvents} without per-reply `statsRootEvent` (reactions/reposts/zaps + * route by tags; OP-reference kinds that need `statsRootEvent` are uncommon on reply rows). + */ + async fetchThreadReplyNoteStatsBatch( + replies: Event[], + relayUrls: string[], + _pubkey?: string | null, + opts?: { foreground?: boolean } + ): Promise { + const urls = (relayUrls ?? []).filter(Boolean) + const hexReplies: Event[] = [] + const replaceableReplies: Event[] = [] + const oddIdReplies: Event[] = [] + + for (const r of replies) { + if (!this.hexNoteStatsIdRe.test(r.id)) { + oddIdReplies.push(r) + continue + } + if (isReplaceableEvent(r.kind)) { + replaceableReplies.push(r) + } else { + hexReplies.push(r) + } + } + + const hexIds = [...new Set(hexReplies.map((r) => this.statsKey(r.id)))] + + const markHexTargetsLoaded = () => { + for (const id of hexIds) { + this.touchStatsLoadedMarker(id) + } + } + + try { + if (hexIds.length > 0 && urls.length > 0) { + const { nonSocial, social } = this.buildBatchFilterGroupsForHexNoteTargets(hexIds) + const fetchOpts = { + eoseTimeout: 10_000, + globalTimeout: 28_000, + firstRelayResultGraceMs: false as const + } + const onStatsEvent = (evt: Event) => { + this.updateNoteStatsByEvents([evt], undefined) + } + const { queryService } = await import('@/services/client.service') + await Promise.all([ + nonSocial.length > 0 + ? queryService.fetchEvents(urls, nonSocial, { + ...fetchOpts, + onevent: onStatsEvent + }) + : Promise.resolve([] as Event[]), + social.length > 0 + ? queryService.fetchEvents(urls, social, { + ...fetchOpts, + onevent: onStatsEvent + }) + : Promise.resolve([] as Event[]) + ]) + } + } catch (err) { + logger.warn('[NoteStats] fetchThreadReplyNoteStatsBatch failed', { + hexCount: hexIds.length, + error: err instanceof Error ? err.message : String(err) + }) + } finally { + markHexTargetsLoaded() + for (const r of replaceableReplies) { + void this.fetchNoteStats(r, _pubkey, urls, opts) + } + for (const r of oddIdReplies) { + void this.fetchNoteStats(r, _pubkey, urls, opts) + } + } + } + + private touchStatsLoadedMarker(rawStatsKey: string) { + const statsKey = this.statsKey(rawStatsKey) + this.noteStatsMap.set(statsKey, { + ...(this.noteStatsMap.get(statsKey) ?? {}), + updatedAt: dayjs().unix() + }) + this.notifyNoteStats(statsKey) + } + + /** + * Same shape as {@link buildFilterGroups} for fixed hex roots, but ORs many ids per filter via `#e` arrays. + * Omits `#e` / `#E` filters whose kinds are merged only with `statsRootEvent` (OP-reference branch). + */ + private buildBatchFilterGroupsForHexNoteTargets(hexIds: string[]): { nonSocial: Filter[]; social: Filter[] } { + const reactionLimit = 900 + const interactionLimit = 200 + const nip18RepostKinds = [kinds.Repost, ExtendedKind.GENERIC_REPOST] + const qKindsHex = Array.from( + new Set([ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT + ]) + ).sort((a, b) => a - b) + + const nonSocial: Filter[] = [] + const social: Filter[] = [] + + for (let off = 0; off < hexIds.length; off += this.THREAD_REPLY_STATS_BATCH_HEX_CHUNK) { + const ch = hexIds.slice(off, off + this.THREAD_REPLY_STATS_BATCH_HEX_CHUNK) + nonSocial.push( + { '#e': ch, kinds: [kinds.Reaction], limit: reactionLimit }, + { '#e': ch, kinds: [kinds.Zap], limit: 100 } + ) + social.push( + { + '#e': ch, + kinds: [ + ...nip18RepostKinds, + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + kinds.Highlights + ], + limit: interactionLimit + }, + { + '#q': ch, + kinds: qKindsHex, + limit: 75 + } + ) + } + + return { nonSocial, social } + } + private scheduleStatsBatchContinuation() { if (this.pendingForeground.size === 0 && this.pendingEvents.size === 0) return queueMicrotask(() => {