Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
4221e159c8
  1. 38
      src/components/SearchBar/index.tsx
  2. 159
      src/components/SearchResult/FullTextSearchByRelay.tsx
  3. 10
      src/constants.ts
  4. 5
      src/i18n/locales/de.ts
  5. 6
      src/i18n/locales/en.ts
  6. 51
      src/lib/nip50-local-text-match.ts
  7. 27
      src/pages/primary/SearchPage/index.tsx
  8. 28
      src/pages/secondary/SearchPage/index.tsx
  9. 9
      src/services/client-events.service.ts
  10. 30
      src/services/client.service.ts
  11. 48
      src/services/indexed-db.service.ts

38
src/components/SearchBar/index.tsx

@ -45,6 +45,7 @@ const SearchBar = forwardRef< @@ -45,6 +45,7 @@ const SearchBar = forwardRef<
const [displayList, setDisplayList] = useState(false)
const [selectableOptions, setSelectableOptions] = useState<TSearchParams[]>([])
const [selectedIndex, setSelectedIndex] = useState(-1)
const prevSelectableCountRef = useRef(0)
const searchInputRef = useRef<HTMLInputElement>(null)
const barContainerRef = useRef<HTMLDivElement>(null)
const [suggestPanelTop, setSuggestPanelTop] = useState(0)
@ -271,9 +272,32 @@ const SearchBar = forwardRef< @@ -271,9 +272,32 @@ const SearchBar = forwardRef<
}, [selectableOptions, selectedIndex, isFetchingProfiles, profiles])
useEffect(() => {
setDisplayList(searching && !!input)
setDisplayList(searching && !!input.trim())
}, [searching, input])
/**
* Prefilled / parent-controlled `input` (e.g. URL sync) can have suggestions while the field never received
* focus, so `searching` stays false and the dropdown never mounts. When options first appear, focus the input
* once so `onFocus` runs and the list opens (mousedown on suggestions still prevents premature blur).
*/
useEffect(() => {
const trimmed = input.trim()
const len = selectableOptions.length
if (!trimmed) {
prevSelectableCountRef.current = 0
return
}
if (len > 0 && prevSelectableCountRef.current === 0) {
const el = searchInputRef.current
if (el && document.activeElement !== el) {
queueMicrotask(() => {
el.focus({ preventScroll: true })
})
}
}
prevSelectableCountRef.current = len
}, [input, selectableOptions])
useEffect(() => {
if (displayList && list) {
modalManager.register(id, () => {
@ -389,12 +413,18 @@ const SearchBar = forwardRef< @@ -389,12 +413,18 @@ const SearchBar = forwardRef<
ref={searchInputRef}
className={cn(
'bg-surface-background shadow-inner h-full border-none',
searching && isSmallScreen && 'relative z-[120]',
searching && !isSmallScreen && 'z-50'
displayList && isSmallScreen && 'relative z-[120]',
displayList && !isSmallScreen && 'z-50'
)}
placeholder={t('People, keywords, or relays')}
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => {
setSearching(true)
setInput(e.target.value)
}}
onPaste={() => {
setSearching(true)
}}
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
onBlur={() => setSearching(false)}

159
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -4,11 +4,13 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -4,11 +4,13 @@ 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 { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
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 indexedDb from '@/services/indexed-db.service'
import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service'
import type { TProfile } from '@/types'
import type { Event, Filter } from 'nostr-tools'
@ -20,6 +22,8 @@ import { useSmartRelayNavigationOptional } from '@/PageManager' @@ -20,6 +22,8 @@ import { useSmartRelayNavigationOptional } from '@/PageManager'
type MergedHit = {
event: Event
relayUrls: string[]
/** Matched publication cache / event archive on this device (not relay NIP-50). */
fromLocalArchive?: boolean
}
/**
@ -278,9 +282,10 @@ export default function FullTextSearchByRelay({ @@ -278,9 +282,10 @@ export default function FullTextSearchByRelay({
return dispose
}
const kindsArr = [...kinds]
const filter: Filter = {
search: q,
kinds: [...kinds],
kinds: kindsArr,
limit: FULL_TEXT_SEARCH_PER_RELAY_LIMIT
}
@ -348,36 +353,96 @@ export default function FullTextSearchByRelay({ @@ -348,36 +353,96 @@ export default function FullTextSearchByRelay({
return normalizedRelays[relayCursor++]!
}
const mergeIntoHits = (relayUrl: string, events: Event[]) => {
const rk = relayKey(relayUrl)
const applyMergedUpdate = (
mutate: (map: Map<string, { event: Event; relays: Set<string>; local: boolean }>) => void
) => {
setMergedHits((prev) => {
const map = new Map<string, { event: Event; relays: Set<string> }>()
const urlByKey = new Map<string, string>()
for (const u of normalizedRelays) {
urlByKey.set(relayKey(u), normalizeUrl(u) || u)
}
const map = new Map<string, { event: Event; relays: Set<string>; local: boolean }>()
for (const hit of prev) {
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))),
local: hit.fromLocalArchive ?? false
})
}
mutate(map)
return [...map.values()]
.map(({ event, relays, local }) => {
const relayUrls = sortRelaysByHost(
[...relays].map((k) => urlByKey.get(k) || k).filter((u) => /^wss?:\/\//i.test(u))
)
const row: MergedHit = { event, relayUrls }
if (local) row.fromLocalArchive = true
return row
})
.filter((h) => h.relayUrls.length > 0 || h.fromLocalArchive)
.sort((a, b) => compareEventsForDTagQuery(q, a.event, b.event))
.slice(0, FULL_TEXT_SEARCH_MAX_MERGED_EVENTS)
})
}
const mergeIntoHits = (relayUrl: string, events: Event[]) => {
const rk = relayKey(relayUrl)
applyMergedUpdate((map) => {
for (const ev of events) {
if (!mergedSearchNoteHasPreviewBody(ev)) continue
const cur = map.get(ev.id)
if (cur) {
cur.relays.add(rk)
} else {
map.set(ev.id, { event: ev, relays: new Set([rk]) })
map.set(ev.id, { event: ev, relays: new Set([rk]), local: false })
}
}
const urlByKey = new Map<string, string>()
for (const u of normalizedRelays) {
urlByKey.set(relayKey(u), normalizeUrl(u) || u)
}
return [...map.values()]
.map(({ event, relays }) => ({
event,
relayUrls: sortRelaysByHost([...relays].map((k) => urlByKey.get(k) || k))
}))
.sort((a, b) => compareEventsForDTagQuery(q, a.event, b.event))
.slice(0, FULL_TEXT_SEARCH_MAX_MERGED_EVENTS)
})
}
void (async () => {
const fromSession = client.getSessionEventsMatchingSearch(q, 220, kindsArr)
let fromIdb: Event[] = []
try {
fromIdb = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(q, 120, kindsArr, {
archiveScanMaxMs: 15_000
})
} catch {
fromIdb = []
}
if (myRun !== runGeneration.current || abort.signal.aborted) return
const seen = new Set<string>()
const mergedLocal: Event[] = []
for (const e of fromSession) {
if (seen.has(e.id)) continue
seen.add(e.id)
mergedLocal.push(e)
}
for (const e of fromIdb) {
if (seen.has(e.id)) continue
seen.add(e.id)
mergedLocal.push(e)
}
const mergedLocalMatching = mergedLocal.filter(
(e) =>
kindsArr.includes(e.kind) &&
eventMatchesNip50LocalFullTextQuery(e, q) &&
mergedSearchNoteHasPreviewBody(e)
)
if (mergedLocalMatching.length === 0) return
applyMergedUpdate((map) => {
for (const ev of mergedLocalMatching) {
const cur = map.get(ev.id)
if (cur) {
cur.local = true
} else {
map.set(ev.id, { event: ev, relays: new Set(), local: true })
}
}
})
void addSearchEventsToSessionCacheBatched(mergedLocalMatching, runGeneration, myRun)
})()
const runOneRelay = async (relayUrl: string) => {
if (myRun !== runGeneration.current || abort.signal.aborted) return
beginWaveIfNeeded()
@ -502,30 +567,44 @@ export default function FullTextSearchByRelay({ @@ -502,30 +567,44 @@ export default function FullTextSearchByRelay({
key={hit.event.id}
className="min-w-0 overflow-hidden rounded-lg border border-border/60 bg-card/30 shadow-none transition-[border-color,box-shadow,background-color] duration-150 hover:border-border hover:bg-muted/15 hover:shadow-sm"
>
<div
className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1"
aria-label={t('Full-text search seen on relays')}
>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90 shrink-0">
{t('Full-text search seen on label')}
</span>
<div className="flex flex-wrap items-center gap-0.5">
{hit.relayUrls.map((url) => (
<button
key={`${hit.event.id}-${relayKey(url)}`}
type="button"
title={relayHostForSubscribeLog(url)}
className="inline-flex shrink-0 rounded-sm opacity-90 ring-offset-background hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(url))
}}
>
<RelayIcon url={url} className="h-5 w-5 rounded-sm" iconSize={12} />
</button>
))}
{(hit.relayUrls.length > 0 || hit.fromLocalArchive) && (
<div
className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1"
aria-label={
hit.relayUrls.length > 0
? t('Full-text search seen on relays')
: t('Full-text search local archive description')
}
>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90 shrink-0">
{t('Full-text search seen on label')}
</span>
<div className="flex flex-wrap items-center gap-0.5">
{hit.relayUrls.map((url) => (
<button
key={`${hit.event.id}-${relayKey(url)}`}
type="button"
title={relayHostForSubscribeLog(url)}
className="inline-flex shrink-0 rounded-sm opacity-90 ring-offset-background hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={(e) => {
e.stopPropagation()
navigateToRelay(toRelay(url))
}}
>
<RelayIcon url={url} className="h-5 w-5 rounded-sm" iconSize={12} />
</button>
))}
{hit.fromLocalArchive && (
<span
className="ml-0.5 inline-flex shrink-0 rounded-sm border border-border/50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90"
title={t('Full-text search local archive description')}
>
{t('Full-text search local archive badge')}
</span>
)}
</div>
</div>
</div>
)}
<NoteCard
event={hit.event}
className="w-full border-0 bg-transparent shadow-none"

10
src/constants.ts

@ -118,7 +118,7 @@ export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 @@ -118,7 +118,7 @@ export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000
* After the first relay accepts a publish, resolve {@link ClientService.publishEvent} after this many ms
* so the UI does not wait for every slow or dead relay (callers typically only need 1 success).
*/
export const EARLY_PUBLISH_SUCCESS_GRACE_MS = 1200
export const EARLY_PUBLISH_SUCCESS_GRACE_MS = 900
/**
* Budget for `fetchRelayLists` / NIP-65 resolution on the publish path. Longer waits block the reply button
@ -152,7 +152,13 @@ export const PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS = PUBLISH_RELAY_LIST_RESO @@ -152,7 +152,13 @@ export const PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS = PUBLISH_RELAY_LIST_RESO
* hung relay (e.g. 90s timeout) does not delay returning until every parallel publish settles. Single-relay
* publishes keep {@link RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS} for extension signers on slow paths.
*/
export const MULTI_RELAY_PUBLISH_ACK_CAP_MS = 24_000
export const MULTI_RELAY_PUBLISH_ACK_CAP_MS = 16_000
/**
* When many relays are targeted, cap the `ensureRelay` handshake race so the reply/post dialog does not sit
* on Publishing while several slow TLS peers each burn the full pool timeout in parallel.
*/
export const PUBLISH_MULTI_RELAY_CONNECTION_CAP_MS = 12_000
/** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */
export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS

5
src/i18n/locales/de.ts

@ -1490,6 +1490,11 @@ export default { @@ -1490,6 +1490,11 @@ export default {
"Font size": "Font size",
"Full Quote/Context": "Full Quote/Context",
"Full-text search query": "Volltextsuchanfrage",
"Full-text search local archive badge": "Dieses Gerät",
"Full-text search local archive description":
"Treffer im Veröffentlichungs-Cache oder Event-Archiv auf diesem Gerät. Index-Relays haben die Notiz ggf. noch nicht.",
"Full-text search empty merged":
"Keine Notizen zu dieser Suche in deinem Archiv oder auf den konfigurierten Index-Relays (langsam oder offline).",
Geohash: "Geohash",
"Geohash (optional)": "Geohash (optional)",
"Global quiet mode": "Global quiet mode",

6
src/i18n/locales/en.ts

@ -1860,7 +1860,11 @@ export default { @@ -1860,7 +1860,11 @@ export default {
"Full-text search progress relays": "{{done}} / {{total}} index relays",
"Full-text search seen on label": "Seen on",
"Full-text search seen on relays": "Relays that returned this note",
"Full-text search empty merged": "No notes matched this search on any index relay.",
"Full-text search local archive badge": "This device",
"Full-text search local archive description":
"Matched in your publication cache or event archive on this device. Index relays may not have ingested the note yet.",
"Full-text search empty merged":
"No notes matched this search in your archive or on the configured index relays (they can be slow or offline).",
"Full-text search relay errors summary": "{{count}} relay(s) could not be queried.",
"Full-text search relay querying": "Querying relay…",
"Full-text search relay error": "Query failed",

51
src/lib/nip50-local-text-match.ts

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
/**
* Whether `query` matches `ev` for local / client-side full text discovery aligned with NIP-50 intent:
* substring match over id, pubkey, stringified kind, raw content, every tag cell, and (for kind 0) parsed
* profile fields. No row is a hit on recency alone the term must appear in one of these fields.
*/
export function eventMatchesNip50LocalFullTextQuery(ev: Event, query: string): boolean {
const raw = query.trim()
const q = raw.toLowerCase()
if (!q) return false
const decodedAuthor = decodeProfileSearchQueryToPubkeyHex(raw)
if (decodedAuthor && ev.pubkey.toLowerCase() === decodedAuthor) return true
if (ev.id.toLowerCase().includes(q)) return true
if (ev.pubkey.toLowerCase().includes(q)) return true
if (String(ev.kind).includes(q)) return true
if ((ev.content ?? '').toLowerCase().includes(q)) return true
for (const tag of ev.tags ?? []) {
if (!Array.isArray(tag)) continue
for (const cell of tag) {
if (String(cell).toLowerCase().includes(q)) return true
}
}
if (ev.kind === kinds.Metadata) {
try {
const o = JSON.parse(ev.content || '{}') as {
name?: unknown
display_name?: unknown
about?: unknown
nip05?: unknown
}
const pick = (v: unknown) => (typeof v === 'string' ? v.toLowerCase() : '')
const nip05 = pick(o.nip05)
const blob = [pick(o.name), pick(o.display_name), pick(o.about), nip05]
.filter(Boolean)
.join(' ')
if (blob.includes(q)) return true
const qNeedle = q.startsWith('@') ? q.slice(1) : q
if (q.startsWith('@') && qNeedle.length > 0 && blob.includes(qNeedle)) return true
} catch {
/* ignore invalid profile JSON */
}
}
return false
}

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

@ -6,7 +6,7 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' @@ -6,7 +6,7 @@ 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, X } from 'lucide-react'
import { BookOpen, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -52,14 +52,6 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => { @@ -52,14 +52,6 @@ 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}
@ -69,21 +61,8 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => { @@ -69,21 +61,8 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
>
<div className="min-w-0 pt-4 px-4 pb-4">
<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
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 className="min-w-0">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
</div>
<Button
variant="ghost"

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

@ -8,7 +8,7 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' @@ -8,7 +8,7 @@ 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, X } from 'lucide-react'
import { BookOpen } from 'lucide-react'
import { TSearchParams } from '@/types'
import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -145,15 +145,6 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -145,15 +145,6 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
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}
@ -168,21 +159,8 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -168,21 +159,8 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
<div className="text-2xl font-bold">Search Nostr</div>
</div>
<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
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 className="min-w-0">
<SearchBar ref={searchBarRef} input={input} setInput={setInput} onSearch={onSearch} />
</div>
<Button
variant="ghost"

9
src/services/client-events.service.ts

@ -36,6 +36,7 @@ import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' @@ -36,6 +36,7 @@ import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config'
import { isCalendarEventKind } from '@/lib/calendar-event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url'
@ -667,11 +668,11 @@ export class EventService { @@ -667,11 +668,11 @@ export class EventService {
/**
* Get events from session cache matching search (newest {@link Event.created_at} first).
* Scans up to {@link SESSION_SEARCH_MAX_SCAN} entries so LRU insertion order does not hide recent matches.
* Scans up to {@link SESSION_SEARCH_MAX_SCAN} entries; only rows where {@link eventMatchesNip50LocalFullTextQuery}
* matches the trimmed query are returned (not recent rows without a text hit).
*/
getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] {
const queryTrim = query.trim()
const queryLower = queryTrim.toLowerCase()
const kindSet = allowedKinds && allowedKinds.length > 0 ? new Set(allowedKinds) : null
const buf: NEvent[] = []
let scanned = 0
@ -686,9 +687,7 @@ export class EventService { @@ -686,9 +687,7 @@ export class EventService {
continue
}
const content = (event.content ?? '').toLowerCase()
const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(queryLower) || tagsStr.includes(queryLower)) {
if (eventMatchesNip50LocalFullTextQuery(event, queryTrim)) {
buf.push(event)
}
}

30
src/services/client.service.ts

@ -20,6 +20,7 @@ import { @@ -20,6 +20,7 @@ import {
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS,
FETCH_RELAY_LIST_UI_TIMEOUT_MS,
MULTI_RELAY_PUBLISH_ACK_CAP_MS,
PUBLISH_MULTI_RELAY_CONNECTION_CAP_MS,
RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS,
RELAY_POOL_CONNECTION_TIMEOUT_MS,
RELAY_READ_ONLY_POOL_CONNECT_TIMEOUT_MS,
@ -1535,6 +1536,18 @@ class ClientService extends EventTarget { @@ -1535,6 +1536,18 @@ class ClientService extends EventTarget {
})
let hasResolved = false
let earlyGraceTimer: ReturnType<typeof setTimeout> | null = null
const finishUnfinishedRelaysWithTimeout = (errorMsg: string) => {
publishTargetUrls.forEach((url) => {
const alreadyFinished = relayStatuses.some((rs) => rs.url === url)
if (!alreadyFinished) {
logger.warn('[PublishEvent] Marking relay as timed out', { url })
relayStatuses.push({ url, success: false, error: errorMsg })
relaySessionStrikes.recordPublishFailure(url)
finishedCount++
}
})
}
/**
* Live timelines listen for {@link emitNewEvent} on the first relay ACK not after N/3 successes.
* Waiting for a third of many relays meant the profile/home feed stayed stale until a subscription
@ -1560,16 +1573,7 @@ class ClientService extends EventTarget { @@ -1560,16 +1573,7 @@ class ClientService extends EventTarget {
relayStatusesCount: relayStatuses.length
})
// Mark any unfinished relays as failed
publishTargetUrls.forEach(url => {
const alreadyFinished = relayStatuses.some(rs => rs.url === url)
if (!alreadyFinished) {
logger.warn('[PublishEvent] Marking relay as timed out', { url })
relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' })
relaySessionStrikes.recordPublishFailure(url)
finishedCount++
}
})
finishUnfinishedRelaysWithTimeout('Timeout: Operation took too long')
// Ensure we resolve even if not all relays finished
if (!hasResolved) {
@ -1604,7 +1608,11 @@ class ClientService extends EventTarget { @@ -1604,7 +1608,11 @@ class ClientService extends EventTarget {
logger.debug(`[PublishEvent] Starting relay ${index + 1}/${publishTargetUrls.length}`, { url })
const isLocal = isLocalNetworkUrl(url)
/** Match pool handshake budget; a shorter outer race used to abort `ensureRelay` at 8s while the pool allowed 20s — slow TLS never won. */
const connectionTimeout = isLocal ? 5_000 : RELAY_POOL_CONNECTION_TIMEOUT_MS
const connectionTimeout = isLocal
? 5_000
: publishTargetUrls.length >= 4
? Math.min(RELAY_POOL_CONNECTION_TIMEOUT_MS, PUBLISH_MULTI_RELAY_CONNECTION_CAP_MS)
: RELAY_POOL_CONNECTION_TIMEOUT_MS
/** ACK wait: pool sets {@link RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS}; outer race uses {@link publishAckBudgetCapMs} for multi-relay. */
const publishAckBudgetMs = isLocal ? 5_000 : publishAckBudgetCapMs
const httpPublishBudgetMs = isLocal ? 5_000 : 8_000

48
src/services/indexed-db.service.ts

@ -23,6 +23,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' @@ -23,6 +23,7 @@ import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import type { Filter } from 'nostr-tools'
@ -107,21 +108,6 @@ function profileMetadataMatchesQuery(ev: Event, qRaw: string): boolean { @@ -107,21 +108,6 @@ function profileMetadataMatchesQuery(ev: Event, qRaw: string): boolean {
}
}
function cachedEventMatchesFullTextQuery(ev: Event, qLower: string): boolean {
if (!qLower) return false
if (ev.id.toLowerCase().includes(qLower)) return true
if (ev.pubkey.toLowerCase().includes(qLower)) return true
if (String(ev.kind).includes(qLower)) return true
if ((ev.content ?? '').toLowerCase().includes(qLower)) return true
for (const tag of ev.tags ?? []) {
if (!Array.isArray(tag)) continue
for (const cell of tag) {
if (String(cell).toLowerCase().includes(qLower)) return true
}
}
return false
}
export const StoreNames = {
PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents',
@ -1361,9 +1347,9 @@ class IndexedDbService { @@ -1361,9 +1347,9 @@ class IndexedDbService {
}
/**
* Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and content or tags
* match the query (case-insensitive). Scans up to `scanBudget` rows and keeps up to `collectCap` matches,
* then returns the newest `limit` by {@link Event.created_at} (cursor order alone is not recency).
* Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and that match the query via
* {@link eventMatchesNip50LocalFullTextQuery} (id, pubkey, kind, content, tags, kind-0 profile fields). Scans up
* to `scanBudget` rows and keeps up to `collectCap` matches, then returns the newest `limit` by {@link Event.created_at}.
*/
async getCachedEventsForSearch(
query: string,
@ -1404,12 +1390,8 @@ class IndexedDbService { @@ -1404,12 +1390,8 @@ class IndexedDbService {
const item = cursor.value as TValue<Event> | undefined
if (item?.value) {
const event = item.value as Event
if (kindSet.has(event.kind)) {
const content = (event.content ?? '').toLowerCase()
const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(q) || tagsStr.includes(q)) {
results.push(event)
}
if (kindSet.has(event.kind) && eventMatchesNip50LocalFullTextQuery(event, query)) {
results.push(event)
}
}
cursor.continue()
@ -1530,8 +1512,8 @@ class IndexedDbService { @@ -1530,8 +1512,8 @@ class IndexedDbService {
}
/**
* Publication store + hot {@link StoreNames.EVENT_ARCHIVE}: events whose kind is allowed and content or any tag
* value matches the query (case-insensitive). Used to show local hits before NIP-50 relay results.
* Publication store + hot {@link StoreNames.EVENT_ARCHIVE}: events whose kind is allowed and that match the
* query via {@link eventMatchesNip50LocalFullTextQuery}. Used for local NIP-50-style hits alongside relay search.
*/
async getCachedAndArchivedEventsMatchingLocalSearch(
query: string,
@ -1584,13 +1566,9 @@ class IndexedDbService { @@ -1584,13 +1566,9 @@ class IndexedDbService {
}
const row = cursor.value as TArchivedEventRow
const ev = row?.value
if (ev && kindSet.has(ev.kind) && !seen.has(ev.id)) {
const content = (ev.content ?? '').toLowerCase()
const tagsStr = (ev.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(q) || tagsStr.includes(q)) {
seen.add(ev.id)
rest.push(ev)
}
if (ev && kindSet.has(ev.kind) && !seen.has(ev.id) && eventMatchesNip50LocalFullTextQuery(ev, query)) {
seen.add(ev.id)
rest.push(ev)
}
cursor.continue()
}
@ -1982,7 +1960,7 @@ class IndexedDbService { @@ -1982,7 +1960,7 @@ class IndexedDbService {
const row = raw as TArchivedEventRow
if (row?.value && isLikelyCachedNostrEvent(row.value)) {
const ev = row.value
if (cachedEventMatchesFullTextQuery(ev, qLower)) {
if (eventMatchesNip50LocalFullTextQuery(ev, query)) {
const dedupeKey = `${storeName}:${row.key}`
if (!seen.has(dedupeKey)) {
seen.add(dedupeKey)
@ -2003,7 +1981,7 @@ class IndexedDbService { @@ -2003,7 +1981,7 @@ class IndexedDbService {
isLikelyCachedNostrEvent(item.value)
) {
const ev = item.value
if (cachedEventMatchesFullTextQuery(ev, qLower)) {
if (eventMatchesNip50LocalFullTextQuery(ev, query)) {
const dedupeKey = `${storeName}:${item.key}`
if (!seen.has(dedupeKey)) {
seen.add(dedupeKey)

Loading…
Cancel
Save