Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
49ed4d086c
  1. 14
      src/components/RelayIcon/index.tsx
  2. 59
      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. 54
      src/pages/primary/SearchPage/index.tsx
  18. 187
      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' @@ -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({ @@ -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({ @@ -64,7 +68,13 @@ export default function RelayIcon({
return (
<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
className="bg-transparent"
style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }}

59
src/components/ReplyNoteList/index.tsx

@ -287,7 +287,9 @@ function replyMatchesThreadForList( @@ -287,7 +287,9 @@ function replyMatchesThreadForList(
evt: NEvent,
opEvent: NEvent,
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 {
if (rootInfo.type === 'I') {
return isRssArticleUrlThreadInteraction(evt, rootInfo.id)
@ -299,7 +301,7 @@ function replyMatchesThreadForList( @@ -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({ @@ -1284,11 +1286,15 @@ function ReplyNoteList({
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)
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({ @@ -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({ @@ -1409,6 +1428,9 @@ function ReplyNoteList({
)
if (parentIdsNested.length > 0) {
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) {
const idChunk = parentIdsNested.slice(off, off + MAX_PARENT_IDS_PER_NESTED_REQ)
const nestedFilters: Filter[] = [
@ -1425,18 +1447,21 @@ function ReplyNoteList({ @@ -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<string, NEvent>(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)

7
src/components/SearchBar/index.tsx

@ -123,7 +123,12 @@ const SearchBar = forwardRef< @@ -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)) {

18
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -3,10 +3,12 @@ import RelayIcon from '@/components/RelayIcon' @@ -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 = { @@ -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({ @@ -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({ @@ -515,7 +521,7 @@ export default function FullTextSearchByRelay({
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>
))}
</div>

4
src/i18n/locales/cs.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/de.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/en.ts

@ -1211,6 +1211,10 @@ export default { @@ -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",

4
src/i18n/locales/es.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/fr.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/nl.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/pl.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/ru.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/tr.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

4
src/i18n/locales/zh.ts

@ -1176,6 +1176,10 @@ export default { @@ -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",

47
src/lib/merged-search-note-preview.ts

@ -0,0 +1,47 @@ @@ -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' @@ -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<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,
* 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 start = noteHexLower.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(start)) return false
@ -39,7 +50,7 @@ function hexNoteParticipatesInThread(noteHexLower: string, rootHexLower: string) @@ -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) @@ -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<string, Event>
): 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 { @@ -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<string, Event>
): 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 = @@ -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<string, Event>
): 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 @@ -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 @@ -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 @@ -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 @@ -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<string, Event>
): 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

54
src/pages/primary/SearchPage/index.tsx

@ -6,12 +6,13 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' @@ -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<TPageRef>((_props, ref) => {
const { t } = useTranslation()
const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr()
const [input, setInput] = useState('')
@ -51,6 +52,14 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => { @@ -51,6 +52,14 @@ const SearchPage = forwardRef<TPageRef>((_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 (
<PrimaryPageLayout
ref={layoutRef}
@ -59,26 +68,37 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => { @@ -59,26 +68,37 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
displayScrollToTopButton
>
<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="flex-1 relative order-2 sm:order-1">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
</div>
<div className="flex-shrink-0 relative z-50 w-full sm:w-auto order-1 sm:order-2">
<div className="mb-4 space-y-2 relative z-40">
<div className="flex items-stretch gap-2">
<div className="min-w-0 flex-1">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
</div>
<Button
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"
asChild
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')}
>
<a
href="https://next-alexandria.gitcitadel.eu/events"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4" />
<span className="text-sm">Search on Alexandria</span>
</a>
<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
variant="ghost"
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
>
<a
href="https://next-alexandria.gitcitadel.eu/events"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="text-sm">{t('Search on Alexandria')}</span>
</a>
</Button>
</div>
<div className="h-4"></div>
<div key={resultRefreshKey} className="min-w-0">

187
src/pages/secondary/SearchPage/index.tsx

@ -8,15 +8,18 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' @@ -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 @@ -35,7 +38,19 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpResults])
const [input, setInput] = useState('')
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 type = params.get('t')
if (
@ -51,69 +66,94 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -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 (
<SecondaryPageLayout
ref={ref}
@ -127,26 +167,37 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -127,26 +167,37 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
<div className="mb-4">
<div className="text-2xl font-bold">Search Nostr</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative order-2 sm:order-1">
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
</div>
<div className="flex-shrink-0 relative z-50 w-full sm:w-auto order-1 sm:order-2">
<div className="mb-4 space-y-2 relative z-40">
<div className="flex items-stretch gap-2">
<div className="min-w-0 flex-1">
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
</div>
<Button
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"
asChild
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')}
>
<a
href="https://next-alexandria.gitcitadel.eu/events"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4" />
<span className="text-sm">Search on Alexandria</span>
</a>
<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
variant="ghost"
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
>
<a
href="https://next-alexandria.gitcitadel.eu/events"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="text-sm">{t('Search on Alexandria')}</span>
</a>
</Button>
</div>
<div className="h-4"></div>
<div key={resultRefreshKey} className="min-w-0">

3
src/services/client-query.service.ts

@ -54,8 +54,9 @@ const NIP50_RELAY_SUBSCRIPTION_EOSE_TIMEOUT_MS = 38_000 @@ -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 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

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

@ -98,6 +98,8 @@ class NoteStatsService { @@ -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 { @@ -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<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() {
if (this.pendingForeground.size === 0 && this.pendingEvents.size === 0) return
queueMicrotask(() => {

Loading…
Cancel
Save