Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
81e23f05bf
  1. 6
      src/components/NotFound/index.tsx
  2. 14
      src/components/Profile/index.tsx
  3. 16
      src/components/ProfileListBySearch/index.tsx
  4. 28
      src/components/Relay/index.tsx
  5. 31
      src/components/ReplyNoteList/index.tsx
  6. 12
      src/components/SearchResult/FullTextSearchByRelay.tsx
  7. 42
      src/components/SearchResult/index.tsx
  8. 23
      src/constants.ts
  9. 67
      src/hooks/useQuoteEvents.tsx
  10. 39
      src/lib/alexandria-events-search-url.test.ts
  11. 37
      src/lib/alexandria-events-search-url.ts
  12. 15
      src/pages/secondary/ProfileListPage/index.tsx
  13. 11
      src/pages/secondary/ProfilePage/index.tsx
  14. 59
      src/services/client-replaceable-events.service.ts
  15. 3
      src/services/client.service.ts

6
src/components/NotFound/index.tsx

@ -1,12 +1,14 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
export default function NotFound() { export default function NotFound({ children }: { children?: ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2"> <div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2 px-4">
<div>{t('Lost in the void')} 🌌</div> <div>{t('Lost in the void')} 🌌</div>
<div>(404)</div> <div>(404)</div>
{children}
</div> </div>
) )
} }

14
src/components/Profile/index.tsx

@ -58,6 +58,7 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import NotFound from '../NotFound' import NotFound from '../NotFound'
import FollowedBy from './FollowedBy' import FollowedBy from './FollowedBy'
import ProfileFeedWithPins from './ProfileFeedWithPins' import ProfileFeedWithPins from './ProfileFeedWithPins'
@ -221,11 +222,14 @@ function mergePaymentMethods(
export default function Profile({ export default function Profile({
id, id,
feedRef feedRef,
alexandriaNotFoundHref = null
}: { }: {
id?: string id?: string
/** When set, exposes {@link ProfileFeedWithPins} `refresh` for titlebars / parent pages. */ /** When set, exposes {@link ProfileFeedWithPins} `refresh` for titlebars / parent pages. */
feedRef?: Ref<{ refresh: () => void }> feedRef?: Ref<{ refresh: () => void }>
/** When profile lookup fails, link to Alexandria with the same identifier (search / deep link). */
alexandriaNotFoundHref?: string | null
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
@ -487,7 +491,13 @@ export default function Profile({
</> </>
) )
} }
if (!profile && !isFetching) return <NotFound /> if (!profile && !isFetching) {
return (
<NotFound>
{alexandriaNotFoundHref ? <AlexandriaEventsSearchEmptyCta href={alexandriaNotFoundHref} /> : null}
</NotFound>
)
}
if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker

16
src/components/ProfileListBySearch/index.tsx

@ -1,7 +1,6 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { PROFILE_RELAY_URLS } from '@/constants' import { PROFILE_RELAY_URLS } from '@/constants'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -18,7 +17,13 @@ const PROFILE_SEARCH_RELAY_URLS = Array.from(
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)) new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean))
) )
export function ProfileListBySearch({ search }: { search: string }) { export function ProfileListBySearch({
search,
alexandriaEmptyHref = null
}: {
search: string
alexandriaEmptyHref?: string | null
}) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const [pubkeys, setPubkeys] = useState<string[]>([]) const [pubkeys, setPubkeys] = useState<string[]>([])
@ -180,12 +185,7 @@ export function ProfileListBySearch({ search }: { search: string }) {
{phase === 'ready' && empty && ( {phase === 'ready' && empty && (
<div className="flex flex-col items-center py-6 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center py-6 text-center text-sm text-muted-foreground">
<p>{t('Profile search no results')}</p> <p>{t('Profile search no results')}</p>
{(() => { {alexandriaEmptyHref ? <AlexandriaEventsSearchEmptyCta href={alexandriaEmptyHref} /> : null}
const trimmed = search.trim()
if (!trimmed) return null
const href = buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profiles', search })
return href ? <AlexandriaEventsSearchEmptyCta href={href} /> : null
})()}
</div> </div>
)} )}
{pubkeys.map((pubkey, index) => ( {pubkeys.map((pubkey, index) => (

28
src/components/Relay/index.tsx

@ -13,12 +13,23 @@ import type { TFeedSubRequest } from '@/types'
import { kinds, type Event } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url'
import NotFound from '../NotFound' import NotFound from '../NotFound'
const Relay = forwardRef< const Relay = forwardRef<
TNoteListRef, TNoteListRef,
{ url?: string; className?: string; hostPrimaryPageName?: TPrimaryPageName } {
>(function Relay({ url, className, hostPrimaryPageName }, ref) { url?: string
className?: string
hostPrimaryPageName?: TPrimaryPageName
alexandriaEmptyUrl?: string | null
alexandriaNotFoundHref?: string | null
}
>(function Relay(
{ url, className, hostPrimaryPageName, alexandriaEmptyUrl = null, alexandriaNotFoundHref = null },
ref
) {
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const { showKinds } = useKindFilterOrDefaults() const { showKinds } = useKindFilterOrDefaults()
@ -113,8 +124,18 @@ const Relay = forwardRef<
[relaySeenMatchKey, normalizedUrl] [relaySeenMatchKey, normalizedUrl]
) )
const alexandriaFeedEmptyUrl = useMemo(() => {
const q = debouncedInput.trim()
if (q) return buildAlexandriaEventsSearchUrlFromNotesQuery(q)
return alexandriaEmptyUrl
}, [debouncedInput, alexandriaEmptyUrl])
if (!normalizedUrl) { if (!normalizedUrl) {
return <NotFound /> return (
<NotFound>
{alexandriaNotFoundHref ? <AlexandriaEventsSearchEmptyCta href={alexandriaNotFoundHref} /> : null}
</NotFound>
)
} }
return ( return (
@ -140,6 +161,7 @@ const Relay = forwardRef<
extraShouldHideEvent={shouldHideEventNotFromThisRelay} extraShouldHideEvent={shouldHideEventNotFromThisRelay}
extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay} extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay}
relayAuthoritativeFeedOnly relayAuthoritativeFeedOnly
alexandriaEmptyUrl={alexandriaFeedEmptyUrl}
/> />
</div> </div>
) )

31
src/components/ReplyNoteList/index.tsx

@ -76,17 +76,8 @@ type TRootInfo =
const LIMIT = 200 const LIMIT = 200
const SHOW_COUNT = 10 const SHOW_COUNT = 10
const MAX_KINDS_PER_THREAD_REQ_FILTER = 4
/** Some relays cap `#e` array length; chunk parent-id batches for nested-thread REQs. */ /** Some relays cap `#e` array length; chunk parent-id batches for nested-thread REQs. */
const MAX_PARENT_IDS_PER_NESTED_REQ = 64 const MAX_PARENT_IDS_PER_NESTED_REQ = 64
function chunkKindsForThreadReq(list: readonly number[], size = MAX_KINDS_PER_THREAD_REQ_FILTER): number[][] {
const out: number[][] = []
for (let i = 0; i < list.length; i += size) {
out.push([...list.slice(i, i + size)])
}
return out
}
/** Short debounce so thread / detail headers populate avatars quickly after events arrive. */ /** Short debounce so thread / detail headers populate avatars quickly after events arrive. */
const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 400 const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 400
const THREAD_PROFILE_CHUNK = 80 const THREAD_PROFILE_CHUNK = 80
@ -1149,7 +1140,7 @@ function ReplyNoteList({
...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT
]) ])
).sort((a, b) => a - b) ).sort((a, b) => a - b)
const opRefChunks = chunkKindsForThreadReq(NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT) const opRefKinds = [...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT]
const kindsNoteCommentVoiceZap: number[] = [ const kindsNoteCommentVoiceZap: number[] = [
kinds.ShortTextNote, kinds.ShortTextNote,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
@ -1169,8 +1160,6 @@ function ReplyNoteList({
: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap] : [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap]
if (rootInfo.type === 'E') { if (rootInfo.type === 'E') {
// Fetch all reply types for event-based replies (keep ≤4 kinds per filter — some relays
// NOTICE "too many kinds N" and drop the whole REQ if kind 7 is bundled with four others).
filters.push({ filters.push({
'#e': [rootInfo.id], '#e': [rootInfo.id],
kinds: kindsPrimaryThread, kinds: kindsPrimaryThread,
@ -1200,10 +1189,8 @@ function ReplyNoteList({
limit: LIMIT limit: LIMIT
}) })
} }
for (const chunk of opRefChunks) { filters.push({ '#e': [rootInfo.id], kinds: opRefKinds, limit: LIMIT })
filters.push({ '#e': [rootInfo.id], kinds: chunk, limit: LIMIT }) filters.push({ '#E': [rootInfo.id], kinds: opRefKinds, limit: LIMIT })
filters.push({ '#E': [rootInfo.id], kinds: chunk, limit: LIMIT })
}
} else if (rootInfo.type === 'A') { } else if (rootInfo.type === 'A') {
// Fetch all reply types for replaceable event-based replies // Fetch all reply types for replaceable event-based replies
filters.push( filters.push(
@ -1237,10 +1224,8 @@ function ReplyNoteList({
kinds: [kinds.Reaction], kinds: [kinds.Reaction],
limit: LIMIT limit: LIMIT
}) })
for (const chunk of opRefChunks) { filters.push({ '#e': [eSnap], kinds: opRefKinds, limit: LIMIT })
filters.push({ '#e': [eSnap], kinds: chunk, limit: LIMIT }) filters.push({ '#E': [eSnap], kinds: opRefKinds, limit: LIMIT })
filters.push({ '#E': [eSnap], kinds: chunk, limit: LIMIT })
}
} }
const qVals = Array.from( const qVals = Array.from(
new Set( new Set(
@ -1259,10 +1244,8 @@ function ReplyNoteList({
if (rootInfo.relay) { if (rootInfo.relay) {
finalRelayUrls.push(rootInfo.relay) finalRelayUrls.push(rootInfo.relay)
} }
for (const chunk of opRefChunks) { filters.push({ '#a': [rootInfo.id], kinds: opRefKinds, limit: LIMIT })
filters.push({ '#a': [rootInfo.id], kinds: chunk, limit: LIMIT }) filters.push({ '#A': [rootInfo.id], kinds: opRefKinds, limit: LIMIT })
filters.push({ '#A': [rootInfo.id], kinds: chunk, limit: LIMIT })
}
} else if (rootInfo.type === 'I') { } else if (rootInfo.type === 'I') {
filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT))
} }

12
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -231,11 +231,13 @@ function sortRelaysByHost(urls: readonly string[]): string[] {
export default function FullTextSearchByRelay({ export default function FullTextSearchByRelay({
searchQuery, searchQuery,
relayUrls, relayUrls,
kinds kinds,
alexandriaEmptyHref: alexandriaEmptyHrefProp = null
}: { }: {
searchQuery: string searchQuery: string
relayUrls: readonly string[] relayUrls: readonly string[]
kinds: readonly number[] kinds: readonly number[]
alexandriaEmptyHref?: string | null
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigationOptional() ?? { const { navigateToRelay } = useSmartRelayNavigationOptional() ?? {
@ -250,10 +252,10 @@ export default function FullTextSearchByRelay({
const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls]) const normalizedRelays = useMemo(() => normalizeRelayList(relayUrls), [relayUrls])
const q = searchQuery.trim() const q = searchQuery.trim()
const alexandriaEmptyHref = useMemo( const alexandriaEmptyHref = useMemo(() => {
() => (q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null), if (alexandriaEmptyHrefProp) return alexandriaEmptyHrefProp
[q] return q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null
) }, [alexandriaEmptyHrefProp, q])
const searchProfileResetKey = useMemo( const searchProfileResetKey = useMemo(
() => `${q}\n${normalizedRelays.join('\n')}`, () => `${q}\n${normalizedRelays.join('\n')}`,
[q, normalizedRelays] [q, normalizedRelays]

42
src/components/SearchResult/index.tsx

@ -9,7 +9,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { buildAlexandriaEventsUrlForHashtagParam } from '@/lib/alexandria-events-search-url' import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { useLayoutEffect, useMemo } from 'react' import { useLayoutEffect, useMemo } from 'react'
function relayDedupeKey(url: string): string { function relayDedupeKey(url: string): string {
@ -20,10 +20,18 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
/** Before child effects (e.g. NIP-50) open REQs, abort background queries and drop pooled relay sockets so search gets the pool. */ /**
* Before NIP-50 / hashtag REQs, yield the pool but do not abort profile lookups (npub / profile search).
*/
useLayoutEffect(() => { useLayoutEffect(() => {
if (!searchParams) return if (!searchParams) return
if (searchParams.type === 'relay') return if (
searchParams.type === 'relay' ||
searchParams.type === 'profile' ||
searchParams.type === 'profiles'
) {
return
}
client.interruptBackgroundQueries({ closePooledRelayConnections: true }) client.interruptBackgroundQueries({ closePooledRelayConnections: true })
}, [searchParams?.type, searchParams?.search, searchParams?.input]) }, [searchParams?.type, searchParams?.search, searchParams?.input])
@ -74,22 +82,23 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
[combinedRelays, searchableKeySet] [combinedRelays, searchableKeySet]
) )
const alexandriaEmptyUrlForHashtag = useMemo( const alexandriaEmptyHref = useMemo(
() => () => (searchParams ? buildAlexandriaEventsSearchUrlForTSearchParams(searchParams) : null),
searchParams?.type === 'hashtag' [searchParams]
? buildAlexandriaEventsUrlForHashtagParam(searchParams.search)
: null,
[searchParams?.type, searchParams?.search]
) )
if (!searchParams) { if (!searchParams) {
return null return null
} }
if (searchParams.type === 'profile') { if (searchParams.type === 'profile') {
return <Profile id={searchParams.search} /> return (
<Profile id={searchParams.search} alexandriaNotFoundHref={alexandriaEmptyHref} />
)
} }
if (searchParams.type === 'profiles') { if (searchParams.type === 'profiles') {
return <ProfileListBySearch search={searchParams.search} /> return (
<ProfileListBySearch search={searchParams.search} alexandriaEmptyHref={alexandriaEmptyHref} />
)
} }
if (searchParams.type === 'notes') { if (searchParams.type === 'notes') {
return ( return (
@ -97,6 +106,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
searchQuery={searchParams.search} searchQuery={searchParams.search}
relayUrls={searchableUrls} relayUrls={searchableUrls}
kinds={NIP_SEARCH_PAGE_KINDS} kinds={NIP_SEARCH_PAGE_KINDS}
alexandriaEmptyHref={alexandriaEmptyHref}
/> />
) )
} }
@ -110,9 +120,15 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
<NormalFeed <NormalFeed
timelinePublicReadFallback timelinePublicReadFallback
subRequests={subRequests} subRequests={subRequests}
alexandriaEmptyUrl={alexandriaEmptyUrlForHashtag} alexandriaEmptyUrl={alexandriaEmptyHref}
/> />
) )
} }
return <Relay url={searchParams.search} /> return (
<Relay
url={searchParams.search}
alexandriaEmptyUrl={alexandriaEmptyHref}
alexandriaNotFoundHref={alexandriaEmptyHref}
/>
)
} }

23
src/constants.ts

@ -176,18 +176,17 @@ export const RELAY_POOL_SOCKET_IDLE_MS = 90_000
export const RELAY_POOL_IDLE_SWEEP_INTERVAL_MS = 45_000 export const RELAY_POOL_IDLE_SWEEP_INTERVAL_MS = 45_000
/** /**
* Maximum `kinds` length in a single NIP-01 filter. Some relays NOTICE "too many kinds" and reject the * Maximum `kinds` length in a single NIP-01 filter. A few strfry-style relays still NOTICE "too many kinds";
* entire REQ (e.g. strfry derivatives, relay.vukihreedia.xyz). QueryService splits larger arrays into * {@link QueryService} splits larger arrays into multiple filters with the same tag scope. Sized to fit
* multiple filters with the same tag scope. * {@link NOTE_STATS_OP_REFERENCE_KINDS} plus thread/quote note kinds in one object without manual chunking.
*/ */
export const RELAY_FILTER_MAX_KINDS_PER_OBJECT = 10 export const RELAY_FILTER_MAX_KINDS_PER_OBJECT = 20
/** /**
* Maximum NIP-01 filters per REQ (`["REQ", subId, …filters]`). Primal, damus.io, and others return * Maximum NIP-01 filters per REQ (`["REQ", subId, …filters]`). Some relays return NOTICE `bad req: arr too big`
* NOTICE `bad req: arr too big` when the filter list is long (e.g. replaceable threads with #a + #e * when the filter list is very long; QueryService issues sequential REQ slices when over this cap.
* snapshot + many kind-chunked op-reference filters).
*/ */
export const RELAY_REQ_MAX_FILTERS_PER_MESSAGE = 10 export const RELAY_REQ_MAX_FILTERS_PER_MESSAGE = 12
/** `SimplePool.ensureRelay` WebSocket handshake timeout (parallel multi-relay + slow TLS). */ /** `SimplePool.ensureRelay` WebSocket handshake timeout (parallel multi-relay + slow TLS). */
export const RELAY_POOL_CONNECTION_TIMEOUT_MS = 20_000 export const RELAY_POOL_CONNECTION_TIMEOUT_MS = 20_000
@ -486,14 +485,16 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://nostr.wine', 'wss://nostr.wine',
'wss://orly-relay.imwald.eu', 'wss://orly-relay.imwald.eu',
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',
'wss://nostr-pub.wellorder.net' 'wss://nostr-pub.wellorder.net',
] ]
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://purplepag.es', 'wss://purplepag.es',
'wss://profiles.nostrver.se/', 'wss://profiles.nostrver.se/',
'wss://indexer.coracle.social/' 'wss://indexer.coracle.social/',
'wss://relay.primal.net',
'wss://relay.damus.io'
] ]
export const FOLLOWS_HISTORY_RELAY_URLS = [ export const FOLLOWS_HISTORY_RELAY_URLS = [
@ -664,7 +665,7 @@ export const NOTE_STATS_OP_REFERENCE_KINDS: readonly number[] = Array.from(
new Set<number>([...THREAD_BACKLINK_STREAM_KINDS, ExtendedKind.PUBLICATION]) new Set<number>([...THREAD_BACKLINK_STREAM_KINDS, ExtendedKind.PUBLICATION])
).sort((a, b) => a - b) ).sort((a, b) => a - b)
/** {@link NOTE_STATS_OP_REFERENCE_KINDS} without kind 9802 — pair with a small highlights-only filter on relays that cap `kinds`. */ /** {@link NOTE_STATS_OP_REFERENCE_KINDS} without kind 9802 — use when highlights are requested on a separate `#q` filter. */
export const NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT: readonly number[] = export const NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT: readonly number[] =
NOTE_STATS_OP_REFERENCE_KINDS.filter((k) => k !== kinds.Highlights) NOTE_STATS_OP_REFERENCE_KINDS.filter((k) => k !== kinds.Highlights)

67
src/hooks/useQuoteEvents.tsx

@ -1,7 +1,7 @@
import { import {
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT, NOTE_STATS_OP_REFERENCE_KINDS,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
@ -17,15 +17,6 @@ import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
const LIMIT = 100 const LIMIT = 100
const MAX_KINDS_PER_RELAY_FILTER = 4
function chunkKinds(list: readonly number[], size = MAX_KINDS_PER_RELAY_FILTER): number[][] {
const out: number[][] = []
for (let i = 0; i < list.length; i += size) {
out.push([...list.slice(i, i + size)])
}
return out
}
const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000
/** Fetches events that quote or reference the given event (#q, #e, #a tags). */ /** Fetches events that quote or reference the given event (#q, #e, #a tags). */
@ -102,16 +93,15 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
? getReplaceableCoordinateFromEvent(ev) ? getReplaceableCoordinateFromEvent(ev)
: `${ev.kind}:${ev.pubkey}:${ev.id}` : `${ev.kind}:${ev.pubkey}:${ev.id}`
const highlightKinds = [kinds.Highlights] as const
const opRefKindChunks = chunkKinds(NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT)
const qKindsBroad = Array.from( const qKindsBroad = Array.from(
new Set<number>([ new Set<number>([
kinds.ShortTextNote, kinds.ShortTextNote,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT ...NOTE_STATS_OP_REFERENCE_KINDS
]) ])
).sort((a, b) => a - b) ).sort((a, b) => a - b)
const opRefKinds = [...NOTE_STATS_OP_REFERENCE_KINDS]
const qValsReplaceable = Array.from( const qValsReplaceable = Array.from(
new Set( new Set(
[ev.id, eventCoordinate] [ev.id, eventCoordinate]
@ -129,37 +119,20 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
limit: LIMIT limit: LIMIT
} }
}, },
{
urls: finalRelayUrls,
filter: { '#q': [qeIdForTagFilter], kinds: [...highlightKinds], limit: LIMIT }
},
{ {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { filter: {
'#a': [eventCoordinate], '#a': [eventCoordinate],
kinds: [...highlightKinds], kinds: opRefKinds,
limit: LIMIT limit: LIMIT
} }
}, }
...opRefKindChunks.map(
(kindsChunk) =>
({
urls: finalRelayUrls,
filter: {
'#a': [eventCoordinate],
kinds: kindsChunk,
limit: LIMIT
}
}) as { urls: string[]; filter: TSubRequestFilter }
)
] ]
if (isReplaceableEvent(ev.kind)) { if (isReplaceableEvent(ev.kind)) {
for (const kindsChunk of opRefKindChunks) { subRequests.push({
subRequests.push({ urls: finalRelayUrls,
urls: finalRelayUrls, filter: { '#A': [eventCoordinate], kinds: opRefKinds, limit: LIMIT }
filter: { '#A': [eventCoordinate], kinds: kindsChunk, limit: LIMIT } })
})
}
} }
// `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only. // `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only.
if (qeIdIsHexEventId) { if (qeIdIsHexEventId) {
@ -168,34 +141,18 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { filter: {
'#e': [qeIdForTagFilter], '#e': [qeIdForTagFilter],
kinds: [...highlightKinds], kinds: opRefKinds,
limit: LIMIT limit: LIMIT
} }
}, },
...opRefKindChunks.map((kindsChunk) => ({
urls: finalRelayUrls,
filter: {
'#e': [qeIdForTagFilter],
kinds: kindsChunk,
limit: LIMIT
}
})),
{ {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { filter: {
'#E': [qeIdForTagFilter], '#E': [qeIdForTagFilter],
kinds: [...highlightKinds], kinds: opRefKinds,
limit: LIMIT
}
},
...opRefKindChunks.map((kindsChunk) => ({
urls: finalRelayUrls,
filter: {
'#E': [qeIdForTagFilter],
kinds: kindsChunk,
limit: LIMIT limit: LIMIT
} }
})) }
) )
} }

39
src/lib/alexandria-events-search-url.test.ts

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
ALEXANDRIA_NEXT_EVENTS_BASE,
buildAlexandriaEventsSearchUrlForTSearchParams
} from './alexandria-events-search-url'
describe('buildAlexandriaEventsSearchUrlForTSearchParams', () => {
it('maps profile search to n= query', () => {
const url = buildAlexandriaEventsSearchUrlForTSearchParams({
type: 'profile',
search: 'npub1test'
})
expect(url).toBe(`${ALEXANDRIA_NEXT_EVENTS_BASE}?n=npub1test`)
})
it('maps hashtag search to t= query', () => {
const url = buildAlexandriaEventsSearchUrlForTSearchParams({
type: 'hashtag',
search: 'nostr'
})
expect(url).toBe(`${ALEXANDRIA_NEXT_EVENTS_BASE}?t=nostr`)
})
it('maps relay search to q= query', () => {
const url = buildAlexandriaEventsSearchUrlForTSearchParams({
type: 'relay',
search: 'wss://relay.example.com'
})
expect(url).toBe(`${ALEXANDRIA_NEXT_EVENTS_BASE}?q=wss%3A%2F%2Frelay.example.com`)
})
it('maps notes search to free-text q= when no special tokens', () => {
const url = buildAlexandriaEventsSearchUrlForTSearchParams({
type: 'notes',
search: 'hello world'
})
expect(url).toBe(`${ALEXANDRIA_NEXT_EVENTS_BASE}?q=hello%20world`)
})
})

37
src/lib/alexandria-events-search-url.ts

@ -84,22 +84,31 @@ export function buildAlexandriaEventsSearchUrlFromNotesQuery(query: string): str
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?q=${encodeURIComponent(q)}` return `${ALEXANDRIA_NEXT_EVENTS_BASE}?q=${encodeURIComponent(q)}`
} }
/** Map any in-app search route to a matching Alexandria `/events` URL (empty-state CTA). */
export function buildAlexandriaEventsSearchUrlForTSearchParams(params: TSearchParams): string | null { export function buildAlexandriaEventsSearchUrlForTSearchParams(params: TSearchParams): string | null {
if (params.type === 'hashtag') { const search = params.search?.trim()
const tag = params.search?.trim().toLowerCase() if (!search) return null
if (!tag) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(tag)}` switch (params.type) {
} case 'hashtag':
if (params.type === 'profiles') { return buildAlexandriaEventsUrlForHashtagParam(search)
let n = params.search.trim() case 'profile':
if (n.toLowerCase().startsWith('n:')) n = n.slice(2).trim() case 'profiles': {
if (!n) return null let n = search
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?n=${encodeURIComponent(n)}` if (n.toLowerCase().startsWith('n:')) n = n.slice(2).trim()
} if (!n) return null
if (params.type === 'notes') { return `${ALEXANDRIA_NEXT_EVENTS_BASE}?n=${encodeURIComponent(n)}`
return buildAlexandriaEventsSearchUrlFromNotesQuery(params.search) }
case 'notes':
case 'note':
return buildAlexandriaEventsSearchUrlFromNotesQuery(search)
case 'dtag':
return buildAlexandriaEventsUrlForDTagParam(search)
case 'relay':
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?q=${encodeURIComponent(search)}`
default:
return buildAlexandriaEventsSearchUrlFromNotesQuery(search)
} }
return null
} }
export function buildAlexandriaEventsUrlForHashtagParam(tag: string): string | null { export function buildAlexandriaEventsUrlForHashtagParam(tag: string): string | null {

15
src/pages/secondary/ProfileListPage/index.tsx

@ -3,8 +3,9 @@ import ProfileList from '@/components/ProfileList'
import { ProfileListBySearch } from '@/components/ProfileListBySearch' import { ProfileListBySearch } from '@/components/ProfileListBySearch'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { fetchPubkeysFromDomain } from '@/lib/nip05' import { fetchPubkeysFromDomain } from '@/lib/nip05'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => { const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
@ -39,9 +40,19 @@ const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
}, []) }, [])
const profileSearchAlexandriaHref = useMemo(
() =>
data?.type === 'search'
? buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profiles', search: data.id })
: null,
[data]
)
let content: React.ReactNode = null let content: React.ReactNode = null
if (data?.type === 'search') { if (data?.type === 'search') {
content = <ProfileListBySearch search={data.id} /> content = (
<ProfileListBySearch search={data.id} alexandriaEmptyHref={profileSearchAlexandriaHref} />
)
} else if (data?.type === 'domain') { } else if (data?.type === 'domain') {
content = <ProfileListByDomain domain={data.id} /> content = <ProfileListByDomain domain={data.id} />
} }

11
src/pages/secondary/ProfilePage/index.tsx

@ -11,7 +11,8 @@ import {
SITE_NAME, SITE_NAME,
updateMetaTag updateMetaTag
} from '@/lib/document-meta' } from '@/lib/document-meta'
import { forwardRef, useCallback, useEffect, useRef } from 'react' import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react'
const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => { const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
@ -29,6 +30,12 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const alexandriaNotFoundHref = useMemo(() => {
const lookup = id?.trim()
if (!lookup) return null
return buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profile', search: lookup })
}, [id])
useEffect(() => { useEffect(() => {
if (!profile) { if (!profile) {
applyDefaultSiteSocialMeta() applyDefaultSiteSocialMeta()
@ -95,7 +102,7 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
displayScrollToTopButton displayScrollToTopButton
ref={ref} ref={ref}
> >
<Profile id={id} feedRef={feedRef} /> <Profile id={id} feedRef={feedRef} alexandriaNotFoundHref={alexandriaNotFoundHref} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

59
src/services/client-replaceable-events.service.ts

@ -829,6 +829,45 @@ export class ReplaceableEventService {
* =========== Profile Methods =========== * =========== Profile Methods ===========
*/ */
/** Direct kind-0 REQ on {@link PROFILE_RELAY_URLS} by `authors` (npub / hex lookup — not NIP-50 text). */
private async fetchKind0FromProfileRelays(pubkey: string): Promise<NEvent | undefined> {
const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return undefined
const relays = prependAggrNostrLandIfViewerEligible(
stripLocalNetworkRelaysForWssReq(
Array.from(
new Set(PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u.trim()).filter(Boolean))
)
)
)
if (relays.length === 0) return undefined
try {
const events = await this.queryService.query(
relays,
{ authors: [pk], kinds: [kinds.Metadata], limit: 1 },
undefined,
{
replaceableRace: false,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS,
foreground: true,
relayOpSource: 'ReplaceableEventService.fetchKind0FromProfileRelays'
}
)
if (events.length === 0) return undefined
const sorted = events.sort((a, b) => b.created_at - a.created_at)
return sorted[0]
} catch (error) {
logger.warn('[ReplaceableEventService] fetchKind0FromProfileRelays failed', {
pubkey: pk.slice(0, 8),
error: error instanceof Error ? error.message : String(error)
})
return undefined
}
}
/** /**
* Fetch profile event by id (hex, npub, nprofile) * Fetch profile event by id (hex, npub, nprofile)
*/ */
@ -880,10 +919,19 @@ export class ReplaceableEventService {
// Relay hints from bech32 (nprofile, etc.) — highest priority in later steps // Relay hints from bech32 (nprofile, etc.) — highest priority in later steps
const relayHints = relays.length > 0 ? [...relays] : [] const relayHints = relays.length > 0 ? [...relays] : []
// CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available // Step 0: {@link PROFILE_RELAY_URLS} by `authors` — reliable for npub/hex; avoids batched DataLoader + abort races.
// Relay hints should have highest priority and always be included const fromProfileRelays = await this.fetchKind0FromProfileRelays(pubkey)
if (fromProfileRelays) {
// Step 1: ALWAYS use DataLoader first (checks IndexedDB, then uses default relays) this.replaceableEventFromBigRelaysDataloader.prime(
{ pubkey, kind: kinds.Metadata },
Promise.resolve(fromProfileRelays)
)
await this.indexProfile(fromProfileRelays)
void indexedDb.putReplaceableEvent(fromProfileRelays).catch(() => {})
return fromProfileRelays
}
// Step 1: DataLoader (IndexedDB + batched profile relay stack)
// CRITICAL: Do NOT pass relay hints here - passing any relays bypasses DataLoader and creates individual subscriptions // CRITICAL: Do NOT pass relay hints here - passing any relays bypasses DataLoader and creates individual subscriptions
// DataLoader already uses default relays internally and batches all profile fetches // DataLoader already uses default relays internally and batches all profile fetches
// We'll use relay hints in Step 2/3 only if Step 1 fails // We'll use relay hints in Step 2/3 only if Step 1 fails
@ -984,7 +1032,8 @@ export class ReplaceableEventService {
{ {
replaceableRace: false, replaceableRace: false,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS,
foreground: true
} }
) )

3
src/services/client.service.ts

@ -3696,6 +3696,7 @@ class ClientService extends EventTarget {
const usesNip50TextSearch = filtersArr.some( const usesNip50TextSearch = filtersArr.some(
(f) => typeof f.search === 'string' && f.search.trim().length > 0 (f) => typeof f.search === 'string' && f.search.trim().length > 0
) )
const usesAuthorsLookup = filtersArr.some((f) => (f.authors?.length ?? 0) > 0)
const events = await this.queryService.query(urls, queryFilter, undefined, { const events = await this.queryService.query(urls, queryFilter, undefined, {
replaceableRace: false, replaceableRace: false,
eoseTimeout: usesNip50TextSearch ? 10_000 : 4500, eoseTimeout: usesNip50TextSearch ? 10_000 : 4500,
@ -3703,7 +3704,7 @@ class ClientService extends EventTarget {
? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000
: 9000, : 9000,
relayOpSource: 'ClientService.searchProfiles', relayOpSource: 'ClientService.searchProfiles',
foreground: usesNip50TextSearch foreground: usesNip50TextSearch || usesAuthorsLookup
}) })
const byPk = new Map<string, NEvent>() const byPk = new Map<string, NEvent>()

Loading…
Cancel
Save