Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
49ed4d086c
  1. 14
      src/components/RelayIcon/index.tsx
  2. 57
      src/components/ReplyNoteList/index.tsx
  3. 7
      src/components/SearchBar/index.tsx
  4. 18
      src/components/SearchResult/FullTextSearchByRelay.tsx
  5. 4
      src/i18n/locales/cs.ts
  6. 4
      src/i18n/locales/de.ts
  7. 4
      src/i18n/locales/en.ts
  8. 4
      src/i18n/locales/es.ts
  9. 4
      src/i18n/locales/fr.ts
  10. 4
      src/i18n/locales/nl.ts
  11. 4
      src/i18n/locales/pl.ts
  12. 4
      src/i18n/locales/ru.ts
  13. 4
      src/i18n/locales/tr.ts
  14. 4
      src/i18n/locales/zh.ts
  15. 47
      src/lib/merged-search-note-preview.ts
  16. 62
      src/lib/thread-reply-root-match.ts
  17. 36
      src/pages/primary/SearchPage/index.tsx
  18. 87
      src/pages/secondary/SearchPage/index.tsx
  19. 3
      src/services/client-query.service.ts
  20. 142
      src/services/note-stats.service.ts

14
src/components/RelayIcon/index.tsx

@ -3,7 +3,7 @@ import { useFetchRelayInfo } from '@/hooks'
import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
import { useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
/** /**
* Resolve an image URL from NIP-11. Handles: * Resolve an image URL from NIP-11. Handles:
@ -42,6 +42,10 @@ export default function RelayIcon({
skipRelayInfoFetch?: boolean skipRelayInfoFetch?: boolean
}) { }) {
const { relayInfo } = useFetchRelayInfo(skipRelayInfoFetch ? undefined : url) const { relayInfo } = useFetchRelayInfo(skipRelayInfoFetch ? undefined : url)
const [iconLoadFailed, setIconLoadFailed] = useState(false)
useEffect(() => {
setIconLoadFailed(false)
}, [url, relayInfo?.icon])
const iconUrl = useMemo(() => { const iconUrl = useMemo(() => {
if (!url) return undefined if (!url) return undefined
@ -64,7 +68,13 @@ export default function RelayIcon({
return ( return (
<Avatar className={cn('w-6 h-6', className)}> <Avatar className={cn('w-6 h-6', className)}>
{iconUrl && <AvatarImage src={iconUrl} className="object-cover object-center" />} {iconUrl && !iconLoadFailed && (
<AvatarImage
src={iconUrl}
className="object-cover object-center"
onError={() => setIconLoadFailed(true)}
/>
)}
<AvatarFallback <AvatarFallback
className="bg-transparent" className="bg-transparent"
style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }} style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }}

57
src/components/ReplyNoteList/index.tsx

@ -287,7 +287,9 @@ function replyMatchesThreadForList(
evt: NEvent, evt: NEvent,
opEvent: NEvent, opEvent: NEvent,
rootInfo: TRootInfo, rootInfo: TRootInfo,
isDiscussionRoot: boolean isDiscussionRoot: boolean,
/** Events from the current relay batch (parent walk may not be in session LRU yet). */
threadWalkLocal?: ReadonlyMap<string, NEvent>
): boolean { ): boolean {
if (rootInfo.type === 'I') { if (rootInfo.type === 'I') {
return isRssArticleUrlThreadInteraction(evt, rootInfo.id) return isRssArticleUrlThreadInteraction(evt, rootInfo.id)
@ -299,7 +301,7 @@ function replyMatchesThreadForList(
) { ) {
return true return true
} }
if (replyBelongsToNoteThread(evt, opEvent, rootInfo)) return true if (replyBelongsToNoteThread(evt, opEvent, rootInfo, threadWalkLocal)) return true
if ( if (
(rootInfo.type === 'E' || rootInfo.type === 'A') && (rootInfo.type === 'E' || rootInfo.type === 'A') &&
evt.kind !== kinds.ShortTextNote && evt.kind !== kinds.ShortTextNote &&
@ -1284,11 +1286,15 @@ function ReplyNoteList({
mergeFetchedKind7ReactionsIntoRootNoteStats(allReplies, rootInfo) mergeFetchedKind7ReactionsIntoRootNoteStats(allReplies, rootInfo)
const threadWalkFromBatch = new Map<string, NEvent>(
allReplies.map((e) => [e.id.toLowerCase(), e] as const)
)
// Filter and add replies (URL threads include kind 9802 highlights of this page) // Filter and add replies (URL threads include kind 9802 highlights of this page)
const regularReplies = allReplies.filter((evt) => { const regularReplies = allReplies.filter((evt) => {
if (isPollVoteKind(evt)) return false if (isPollVoteKind(evt)) return false
if (isZapPollThreadZapReceipt(evt, event)) 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 if (!match) return false
return !shouldHideThreadResponseEvent( return !shouldHideThreadResponseEvent(
evt, evt,
@ -1304,28 +1310,41 @@ function ReplyNoteList({
// Get the merged cache (which includes all replies we've ever seen, including new ones) // Get the merged cache (which includes all replies we've ever seen, including new ones)
const mergedCachedReplies = discussionFeedCache.getCachedReplies(rootInfo) const mergedCachedReplies = discussionFeedCache.getCachedReplies(rootInfo)
// Always add all merged cached replies to UI let mergedForUi: NEvent[]
// This ensures we keep all previously seen replies and add any new ones if (mergedCachedReplies === null) {
// addReplies will deduplicate, so it's safe to call even if some replies are already displayed logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only')
if (mergedCachedReplies) { mergedForUi = regularReplies
const mergedForUi = } else {
mergedForUi =
event.kind === ExtendedKind.ZAP_POLL event.kind === ExtendedKind.ZAP_POLL
? mergedCachedReplies.filter((e) => !isZapPollThreadZapReceipt(e, event)) ? mergedCachedReplies.filter((e) => !isZapPollThreadZapReceipt(e, event))
: mergedCachedReplies : 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) { if (statsBatch.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, { noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, {
statsRootEvent: event 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) { if (!hasCache) {
// No cache: stop loading after adding replies // No cache: stop loading after adding replies
setLoading(false) setLoading(false)
@ -1409,6 +1428,9 @@ function ReplyNoteList({
) )
if (parentIdsNested.length > 0) { if (parentIdsNested.length > 0) {
const nestedAccum: NEvent[] = [] const nestedAccum: NEvent[] = []
const streamWalkById = new Map<string, NEvent>(
regularReplies.map((e) => [e.id.toLowerCase(), e] as const)
)
for (let off = 0; off < parentIdsNested.length; off += MAX_PARENT_IDS_PER_NESTED_REQ) { 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 idChunk = parentIdsNested.slice(off, off + MAX_PARENT_IDS_PER_NESTED_REQ)
const nestedFilters: Filter[] = [ const nestedFilters: Filter[] = [
@ -1425,18 +1447,21 @@ function ReplyNoteList({
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))
return return
if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return streamWalkById.set(evt.id.toLowerCase(), evt)
if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, streamWalkById)) return
addReplies([evt]) addReplies([evt])
} }
}) })
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
nestedAccum.push(...nestedReplies) nestedAccum.push(...nestedReplies)
} }
const nestedWalkMerged = new Map<string, NEvent>(streamWalkById)
for (const e of nestedAccum) nestedWalkMerged.set(e.id.toLowerCase(), e)
const validNested = nestedAccum.filter( const validNested = nestedAccum.filter(
(evt) => (evt) =>
!isPollVoteKind(evt) && !isPollVoteKind(evt) &&
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) && !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) &&
replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, nestedWalkMerged)
) )
if (validNested.length > 0) { if (validNested.length > 0) {
discussionFeedCache.setCachedReplies(rootInfo, validNested) discussionFeedCache.setCachedReplies(rootInfo, validNested)

7
src/components/SearchBar/index.tsx

@ -123,7 +123,12 @@ const SearchBar = forwardRef<
useEffect(() => { useEffect(() => {
const search = input.trim() const search = input.trim()
if (!search) return if (!search) {
setSelectableOptions([])
setSelectedIndex(-1)
setSearching(false)
return
}
const hex64 = /^[0-9a-f]{64}$/i const hex64 = /^[0-9a-f]{64}$/i
if (hex64.test(search)) { if (hex64.test(search)) {

18
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -3,10 +3,12 @@ import RelayIcon from '@/components/RelayIcon'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { compareEventsForDTagQuery } from '@/lib/dtag-search' import { compareEventsForDTagQuery } from '@/lib/dtag-search'
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import client from '@/services/client.service' 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 { relayHostForSubscribeLog } from '@/services/relay-operation-log.service'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import type { Event, Filter } from 'nostr-tools' import type { Event, Filter } from 'nostr-tools'
@ -20,12 +22,15 @@ type MergedHit = {
relayUrls: string[] 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}). */ /** 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 const SEARCH_AFTER_FIRST_RELAY_MS = 6_000
/** Per-relay {@link QueryService.query} budget from when that relay’s fetch starts (capped by remaining wave wall). */ /** Per-relay {@link QueryService.query} budget (capped by remaining wave wall). Align with NIP-50 index latency. */
const SEARCH_PER_RELAY_QUERY_MS = 10_000 const SEARCH_PER_RELAY_QUERY_MS = NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS
/** Avoid opening every index relay at once (pool + main thread). */ /** Avoid opening every index relay at once (pool + main thread). */
const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3 const FULL_TEXT_SEARCH_RELAY_CONCURRENCY = 3
const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 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))) }) map.set(hit.event.id, { event: hit.event, relays: new Set(hit.relayUrls.map((u) => relayKey(u))) })
} }
for (const ev of events) { for (const ev of events) {
if (!mergedSearchNoteHasPreviewBody(ev)) continue
const cur = map.get(ev.id) const cur = map.get(ev.id)
if (cur) { if (cur) {
cur.relays.add(rk) cur.relays.add(rk)
@ -515,7 +521,7 @@ export default function FullTextSearchByRelay({
navigateToRelay(toRelay(url)) navigateToRelay(toRelay(url))
}} }}
> >
<RelayIcon url={url} skipRelayInfoFetch className="h-5 w-5 rounded-sm" iconSize={12} /> <RelayIcon url={url} className="h-5 w-5 rounded-sm" iconSize={12} />
</button> </button>
))} ))}
</div> </div>

4
src/i18n/locales/cs.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/de.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Neuestes von deinen Follows", "Latest from your follows": "Neuestes von deinen Follows",
"Latest from our recommended follows": "Neuestes von unseren empfohlenen Follows", "Latest from our recommended follows": "Neuestes von unseren empfohlenen Follows",
"Search page title": "Nostr durchsuchen", "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 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 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", "Follows latest nav label": "Follows: neueste",

4
src/i18n/locales/en.ts

@ -1211,6 +1211,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/es.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/fr.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/nl.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/pl.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/ru.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/tr.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

4
src/i18n/locales/zh.ts

@ -1176,6 +1176,10 @@ export default {
"Latest from your follows": "Latest from your follows", "Latest from your follows": "Latest from your follows",
"Latest from our recommended follows": "Latest from our recommended follows", "Latest from our recommended follows": "Latest from our recommended follows",
"Search page title": "Search Nostr", "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 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 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", "Follows latest nav label": "Follows latest",

47
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<number>([
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
}

62
src/lib/thread-reply-root-match.ts

@ -20,11 +20,22 @@ import { kinds } from 'nostr-tools'
const THREAD_PARENT_WALK_MAX = 14 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<string, Event>): 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, * 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. * 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<string, Event>
): boolean {
const root = rootHexLower.trim().toLowerCase() const root = rootHexLower.trim().toLowerCase()
const start = noteHexLower.trim().toLowerCase() const start = noteHexLower.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(start)) return false if (!/^[0-9a-f]{64}$/i.test(start)) return false
@ -39,7 +50,7 @@ function hexNoteParticipatesInThread(noteHexLower: string, rootHexLower: string)
seen.add(k) seen.add(k)
if (k === root) return true if (k === root) return true
const ev = client.peekSessionCachedEvent(k) const ev = peekThreadEvent(k, localByHex)
if (!ev) return false if (!ev) return false
if (ev.id.toLowerCase() === root) return true 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). */ /** 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<string, Event>
): boolean {
const parentHex = getParentEventHexId(reply) const parentHex = getParentEventHexId(reply)
if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false
const pl = parentHex.toLowerCase() const pl = parentHex.toLowerCase()
if (pl === rootHexLower) return false if (pl === rootHexLower) return false
const parentEv = client.peekSessionCachedEvent(pl) const parentEv = peekThreadEvent(pl, localByHex)
if (!parentEv || parentEv.kind !== kinds.Zap) return false if (!parentEv || parentEv.kind !== kinds.Zap) return false
const zapped = getZapInfoFromEvent(parentEv)?.originalEventId const zapped = getZapInfoFromEvent(parentEv)?.originalEventId
if (!zapped || !/^[0-9a-f]{64}$/i.test(zapped)) return false 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 { 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). */ /** 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<string, Event>
): boolean {
const parentHex = getParentEventHexId(reply) const parentHex = getParentEventHexId(reply)
if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false if (!parentHex || !/^[0-9a-f]{64}$/i.test(parentHex)) return false
const pl = parentHex.toLowerCase() const pl = parentHex.toLowerCase()
if (pl === rootHexLower) return false if (pl === rootHexLower) return false
const parentEv = client.peekSessionCachedEvent(pl) const parentEv = peekThreadEvent(pl, localByHex)
if (!parentEv || !isNip25ReactionKind(parentEv.kind)) return false if (!parentEv || !isNip25ReactionKind(parentEv.kind)) return false
const targetHex = reactionTargetNoteHex(parentEv) const targetHex = reactionTargetNoteHex(parentEv)
if (!targetHex) return false if (!targetHex) return false
return hexNoteParticipatesInThread(targetHex, rootHexLower) return hexNoteParticipatesInThread(targetHex, rootHexLower, localByHex)
} }
/** Matches `ReplyNoteList` / discussion thread root shapes. */ /** Matches `ReplyNoteList` / discussion thread root shapes. */
@ -94,7 +113,11 @@ export type TThreadRootRef =
| { type: 'I'; id: string } | { type: 'I'; id: string }
/** Whether a newly published/fetched reply belongs to the thread rooted at `root`. */ /** 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<string, Event>
): boolean {
if (root.type === 'I') { if (root.type === 'I') {
const u = getArticleUrlFromCommentITags(evt) const u = getArticleUrlFromCommentITags(evt)
if (u && canonicalizeRssArticleUrl(u) === canonicalizeRssArticleUrl(root.id)) return true 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. // cache: if the declared root or direct parent is a URL-thread comment, accept this event.
const urlMatchesRoot = (hexId: string | undefined): boolean => { const urlMatchesRoot = (hexId: string | undefined): boolean => {
if (!hexId || !/^[0-9a-f]{64}$/i.test(hexId)) return false 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 if (!ancestor) return false
const aUrl = getArticleUrlFromCommentITags(ancestor) const aUrl = getArticleUrlFromCommentITags(ancestor)
return !!aUrl && canonicalizeRssArticleUrl(aUrl) === canonicalizeRssArticleUrl(root.id) return !!aUrl && canonicalizeRssArticleUrl(aUrl) === canonicalizeRssArticleUrl(root.id)
@ -125,7 +148,7 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if ( if (
parentHex && parentHex &&
/^[0-9a-f]{64}$/i.test(rootEventHex) && /^[0-9a-f]{64}$/i.test(rootEventHex) &&
hexNoteParticipatesInThread(parentHex, rootEventHex) hexNoteParticipatesInThread(parentHex, rootEventHex, localByHex)
) { ) {
return true return true
} }
@ -136,9 +159,9 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if (evtRootHex === rid) return true if (evtRootHex === rid) return true
if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true
const parentHex = getParentEventHexId(evt)?.toLowerCase() const parentHex = getParentEventHexId(evt)?.toLowerCase()
if (parentHex && hexNoteParticipatesInThread(parentHex, rid)) return true if (parentHex && hexNoteParticipatesInThread(parentHex, rid, localByHex)) return true
if (replyParentIsZapToThreadHex(evt, rid)) return true if (replyParentIsZapToThreadHex(evt, rid, localByHex)) return true
if (replyParentIsReactionToThreadHex(evt, rid)) return true if (replyParentIsReactionToThreadHex(evt, rid, localByHex)) return true
return kind1QuotesThreadRoot(evt, root) 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). * 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. * 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<string, Event>
): boolean {
if (root.type === 'I') { 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 (root.type === 'A') return true
if (opEvent.kind !== kinds.ShortTextNote) return true if (opEvent.kind !== kinds.ShortTextNote) return true

36
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 { usePrimaryPage } from '@/contexts/primary-page-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TPageRef, TSearchParams } from '@/types' 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 { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SearchPage = forwardRef<TPageRef>((_props, ref) => { const SearchPage = forwardRef<TPageRef>((_props, ref) => {
const { t } = useTranslation()
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const [input, setInput] = useState('') const [input, setInput] = useState('')
@ -51,6 +52,14 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
layoutRef.current?.scrollToTop('instant') 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 ( return (
<PrimaryPageLayout <PrimaryPageLayout
ref={layoutRef} ref={layoutRef}
@ -59,14 +68,26 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
displayScrollToTopButton displayScrollToTopButton
> >
<div className="min-w-0 pt-4 px-4 pb-4"> <div className="min-w-0 pt-4 px-4 pb-4">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40"> <div className="mb-4 space-y-2 relative z-40">
<div className="flex-1 relative order-2 sm:order-1"> <div className="flex items-stretch gap-2">
<div className="min-w-0 flex-1">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} /> <SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
</div> </div>
<div className="flex-shrink-0 relative z-50 w-full sm:w-auto order-1 sm:order-2"> <Button
type="button"
variant="outline"
className="h-auto min-h-9 shrink-0 px-3 text-muted-foreground hover:text-foreground"
onClick={clearSearch}
title={t('Search page clear description')}
aria-label={t('Search page clear description')}
>
<X className="h-4 w-4 sm:mr-1.5" aria-hidden />
<span className="hidden sm:inline">{t('Search page clear')}</span>
</Button>
</div>
<Button <Button
variant="ghost" variant="ghost"
className="h-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2 w-full sm:w-auto" className="h-9 w-full justify-start text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2 sm:w-auto"
asChild asChild
> >
<a <a
@ -74,12 +95,11 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<BookOpen className="h-4 w-4" /> <BookOpen className="h-4 w-4 shrink-0" />
<span className="text-sm">Search on Alexandria</span> <span className="text-sm">{t('Search on Alexandria')}</span>
</a> </a>
</Button> </Button>
</div> </div>
</div>
<div className="h-4"></div> <div className="h-4"></div>
<div key={resultRefreshKey} className="min-w-0"> <div key={resultRefreshKey} className="min-w-0">
<SearchResult searchParams={searchParams} /> <SearchResult searchParams={searchParams} />

87
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 { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { BookOpen } from 'lucide-react' import { BookOpen, X } from 'lucide-react'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const [locationRevision, setLocationRevision] = useState(0)
const [resultRefreshKey, setResultRefreshKey] = useState(0) const [resultRefreshKey, setResultRefreshKey] = useState(0)
const bumpResults = useCallback(() => { const bumpResults = useCallback(() => {
void (async () => { void (async () => {
@ -35,7 +38,19 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpResults]) }, [hideTitlebar, registerPrimaryPanelRefresh, bumpResults])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const searchBarRef = useRef<TSearchBarRef>(null) const searchBarRef = useRef<TSearchBarRef>(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 params = new URLSearchParams(window.location.search)
const type = params.get('t') const type = params.get('t')
if ( if (
@ -51,26 +66,42 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
if (!search) { if (!search) {
return null return null
} }
const input = params.get('i') ?? '' const inputFromUrl = params.get('i') ?? ''
setInput(input || search) return { type, search, input: inputFromUrl } as TSearchParams
return { type, search, input } as TSearchParams }, [locationRevision])
}, [])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const q = params.get('q')
if (!q) {
setInput('')
return
}
setInput(params.get('i') ?? q)
}, [locationRevision])
useEffect(() => { useEffect(() => {
if (!window.location.search) { const q = new URLSearchParams(window.location.search).get('q')
searchBarRef.current?.focus() if (!q) {
void Promise.resolve().then(() => searchBarRef.current?.focus())
} }
}, []) }, [])
const onSearch = (params: TSearchParams | null) => { const onSearch = (params: TSearchParams | null) => {
if (params) { if (!params) {
push(toSearch())
bumpLocationRevision()
setResultRefreshKey((k) => k + 1)
return
}
// Check if this is a 'notes' search that contains advanced search parameters // Check if this is a 'notes' search that contains advanced search parameters
if (params.type === 'notes' && params.search) { if (params.type === 'notes' && params.search) {
const searchParams = parseAdvancedSearch(params.search) const searchParams = parseAdvancedSearch(params.search)
// Check if we have advanced search parameters (not just plain text) // Check if we have advanced search parameters (not just plain text)
// Exclude unsupported multi-letter tag params (title, subject, description, author, type) // Exclude unsupported multi-letter tag params (title, subject, description, author, type)
const hasAdvancedParams = Object.keys(searchParams).some(key => const hasAdvancedParams = Object.keys(searchParams).some(
(key) =>
key !== 'dtag' && key !== 'dtag' &&
key !== 'title' && key !== 'title' &&
key !== 'subject' && key !== 'subject' &&
@ -111,8 +142,17 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
// Default behavior - route to SearchPage // Default behavior - route to SearchPage
push(toSearch(params)) 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 ( return (
<SecondaryPageLayout <SecondaryPageLayout
@ -127,14 +167,26 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
<div className="mb-4"> <div className="mb-4">
<div className="text-2xl font-bold">Search Nostr</div> <div className="text-2xl font-bold">Search Nostr</div>
</div> </div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40"> <div className="mb-4 space-y-2 relative z-40">
<div className="flex-1 relative order-2 sm:order-1"> <div className="flex items-stretch gap-2">
<div className="min-w-0 flex-1">
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} /> <SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
</div> </div>
<div className="flex-shrink-0 relative z-50 w-full sm:w-auto order-1 sm:order-2"> <Button
type="button"
variant="outline"
className="h-auto min-h-9 shrink-0 px-3 text-muted-foreground hover:text-foreground"
onClick={clearSearch}
title={t('Search page clear description')}
aria-label={t('Search page clear description')}
>
<X className="h-4 w-4 sm:mr-1.5" aria-hidden />
<span className="hidden sm:inline">{t('Search page clear')}</span>
</Button>
</div>
<Button <Button
variant="ghost" variant="ghost"
className="h-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2 w-full sm:w-auto" className="h-9 w-full justify-start text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md px-3 gap-2 sm:w-auto"
asChild asChild
> >
<a <a
@ -142,12 +194,11 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<BookOpen className="h-4 w-4" /> <BookOpen className="h-4 w-4 shrink-0" />
<span className="text-sm">Search on Alexandria</span> <span className="text-sm">{t('Search on Alexandria')}</span>
</a> </a>
</Button> </Button>
</div> </div>
</div>
<div className="h-4"></div> <div className="h-4"></div>
<div key={resultRefreshKey} className="min-w-0"> <div key={resultRefreshKey} className="min-w-0">
<SearchResult searchParams={searchParams} /> <SearchResult searchParams={searchParams} />

3
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 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. * {@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 1020s 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 const HEX_EVENT_ID_RE = /^[0-9a-f]{64}$/i

142
src/services/note-stats.service.ts

@ -98,6 +98,8 @@ class NoteStatsService {
private readonly BATCH_DELAY = 40 private readonly BATCH_DELAY = 40
/** Larger slices: feed cards each trigger a stats fetch; tiny slices left the tail of the feed starved. */ /** Larger slices: feed cards each trigger a stats fetch; tiny slices left the tail of the feed starved. */
private readonly MAX_BATCH_SIZE = 32 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). */ /** Parallel stats REQs per slice (bounded by relay pool pressure). */
private readonly STATS_SLICE_CONCURRENCY = 8 private readonly STATS_SLICE_CONCURRENCY = 8
/** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */ /** 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) 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<void> {
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<number>([
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() { private scheduleStatsBatchContinuation() {
if (this.pendingForeground.size === 0 && this.pendingEvents.size === 0) return if (this.pendingForeground.size === 0 && this.pendingEvents.size === 0) return
queueMicrotask(() => { queueMicrotask(() => {

Loading…
Cancel
Save