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. 57
      src/services/client-replaceable-events.service.ts
  15. 3
      src/services/client.service.ts

6
src/components/NotFound/index.tsx

@ -1,12 +1,14 @@ @@ -1,12 +1,14 @@
import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
export default function NotFound() {
export default function NotFound({ children }: { children?: ReactNode }) {
const { t } = useTranslation()
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>(404)</div>
{children}
</div>
)
}

14
src/components/Profile/index.tsx

@ -58,6 +58,7 @@ import { @@ -58,6 +58,7 @@ import {
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import logger from '@/lib/logger'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import NotFound from '../NotFound'
import FollowedBy from './FollowedBy'
import ProfileFeedWithPins from './ProfileFeedWithPins'
@ -221,11 +222,14 @@ function mergePaymentMethods( @@ -221,11 +222,14 @@ function mergePaymentMethods(
export default function Profile({
id,
feedRef
feedRef,
alexandriaNotFoundHref = null
}: {
id?: string
/** When set, exposes {@link ProfileFeedWithPins} `refresh` for titlebars / parent pages. */
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 { push } = useSecondaryPage()
@ -487,7 +491,13 @@ export default function Profile({ @@ -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

16
src/components/ProfileListBySearch/index.tsx

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

28
src/components/Relay/index.tsx

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

31
src/components/ReplyNoteList/index.tsx

@ -76,17 +76,8 @@ type TRootInfo = @@ -76,17 +76,8 @@ type TRootInfo =
const LIMIT = 200
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. */
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. */
const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 400
const THREAD_PROFILE_CHUNK = 80
@ -1149,7 +1140,7 @@ function ReplyNoteList({ @@ -1149,7 +1140,7 @@ function ReplyNoteList({
...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT
])
).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[] = [
kinds.ShortTextNote,
ExtendedKind.COMMENT,
@ -1169,8 +1160,6 @@ function ReplyNoteList({ @@ -1169,8 +1160,6 @@ function ReplyNoteList({
: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap]
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({
'#e': [rootInfo.id],
kinds: kindsPrimaryThread,
@ -1200,10 +1189,8 @@ function ReplyNoteList({ @@ -1200,10 +1189,8 @@ function ReplyNoteList({
limit: LIMIT
})
}
for (const chunk of opRefChunks) {
filters.push({ '#e': [rootInfo.id], kinds: chunk, 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: opRefKinds, limit: LIMIT })
} else if (rootInfo.type === 'A') {
// Fetch all reply types for replaceable event-based replies
filters.push(
@ -1237,10 +1224,8 @@ function ReplyNoteList({ @@ -1237,10 +1224,8 @@ function ReplyNoteList({
kinds: [kinds.Reaction],
limit: LIMIT
})
for (const chunk of opRefChunks) {
filters.push({ '#e': [eSnap], kinds: chunk, limit: LIMIT })
filters.push({ '#E': [eSnap], kinds: chunk, limit: LIMIT })
}
filters.push({ '#e': [eSnap], kinds: opRefKinds, limit: LIMIT })
filters.push({ '#E': [eSnap], kinds: opRefKinds, limit: LIMIT })
}
const qVals = Array.from(
new Set(
@ -1259,10 +1244,8 @@ function ReplyNoteList({ @@ -1259,10 +1244,8 @@ function ReplyNoteList({
if (rootInfo.relay) {
finalRelayUrls.push(rootInfo.relay)
}
for (const chunk of opRefChunks) {
filters.push({ '#a': [rootInfo.id], kinds: chunk, 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: opRefKinds, limit: LIMIT })
} else if (rootInfo.type === 'I') {
filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT))
}

12
src/components/SearchResult/FullTextSearchByRelay.tsx

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

42
src/components/SearchResult/index.tsx

@ -9,7 +9,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -9,7 +9,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
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'
function relayDedupeKey(url: string): string {
@ -20,10 +20,18 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -20,10 +20,18 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
const { pubkey, relayList } = useNostr()
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(() => {
if (!searchParams) return
if (searchParams.type === 'relay') return
if (
searchParams.type === 'relay' ||
searchParams.type === 'profile' ||
searchParams.type === 'profiles'
) {
return
}
client.interruptBackgroundQueries({ closePooledRelayConnections: true })
}, [searchParams?.type, searchParams?.search, searchParams?.input])
@ -74,22 +82,23 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -74,22 +82,23 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
[combinedRelays, searchableKeySet]
)
const alexandriaEmptyUrlForHashtag = useMemo(
() =>
searchParams?.type === 'hashtag'
? buildAlexandriaEventsUrlForHashtagParam(searchParams.search)
: null,
[searchParams?.type, searchParams?.search]
const alexandriaEmptyHref = useMemo(
() => (searchParams ? buildAlexandriaEventsSearchUrlForTSearchParams(searchParams) : null),
[searchParams]
)
if (!searchParams) {
return null
}
if (searchParams.type === 'profile') {
return <Profile id={searchParams.search} />
return (
<Profile id={searchParams.search} alexandriaNotFoundHref={alexandriaEmptyHref} />
)
}
if (searchParams.type === 'profiles') {
return <ProfileListBySearch search={searchParams.search} />
return (
<ProfileListBySearch search={searchParams.search} alexandriaEmptyHref={alexandriaEmptyHref} />
)
}
if (searchParams.type === 'notes') {
return (
@ -97,6 +106,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -97,6 +106,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
searchQuery={searchParams.search}
relayUrls={searchableUrls}
kinds={NIP_SEARCH_PAGE_KINDS}
alexandriaEmptyHref={alexandriaEmptyHref}
/>
)
}
@ -110,9 +120,15 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -110,9 +120,15 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
<NormalFeed
timelinePublicReadFallback
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 @@ -176,18 +176,17 @@ export const RELAY_POOL_SOCKET_IDLE_MS = 90_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
* entire REQ (e.g. strfry derivatives, relay.vukihreedia.xyz). QueryService splits larger arrays into
* multiple filters with the same tag scope.
* Maximum `kinds` length in a single NIP-01 filter. A few strfry-style relays still NOTICE "too many kinds";
* {@link QueryService} splits larger arrays into multiple filters with the same tag scope. Sized to fit
* {@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
* NOTICE `bad req: arr too big` when the filter list is long (e.g. replaceable threads with #a + #e
* snapshot + many kind-chunked op-reference filters).
* Maximum NIP-01 filters per REQ (`["REQ", subId, …filters]`). Some relays return NOTICE `bad req: arr too big`
* when the filter list is very long; QueryService issues sequential REQ slices when over this cap.
*/
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). */
export const RELAY_POOL_CONNECTION_TIMEOUT_MS = 20_000
@ -486,14 +485,16 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -486,14 +485,16 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://nostr.wine',
'wss://orly-relay.imwald.eu',
'wss://relay.noswhere.com',
'wss://nostr-pub.wellorder.net'
'wss://nostr-pub.wellorder.net',
]
export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com',
'wss://purplepag.es',
'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 = [
@ -664,7 +665,7 @@ export const NOTE_STATS_OP_REFERENCE_KINDS: readonly number[] = Array.from( @@ -664,7 +665,7 @@ export const NOTE_STATS_OP_REFERENCE_KINDS: readonly number[] = Array.from(
new Set<number>([...THREAD_BACKLINK_STREAM_KINDS, ExtendedKind.PUBLICATION])
).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[] =
NOTE_STATS_OP_REFERENCE_KINDS.filter((k) => k !== kinds.Highlights)

67
src/hooks/useQuoteEvents.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import {
ExtendedKind,
FAST_READ_RELAY_URLS,
NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT,
NOTE_STATS_OP_REFERENCE_KINDS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
@ -17,15 +17,6 @@ import { Event, kinds } from 'nostr-tools' @@ -17,15 +17,6 @@ import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
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
/** 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) { @@ -102,16 +93,15 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
? getReplaceableCoordinateFromEvent(ev)
: `${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(
new Set<number>([
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT
...NOTE_STATS_OP_REFERENCE_KINDS
])
).sort((a, b) => a - b)
const opRefKinds = [...NOTE_STATS_OP_REFERENCE_KINDS]
const qValsReplaceable = Array.from(
new Set(
[ev.id, eventCoordinate]
@ -129,37 +119,20 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { @@ -129,37 +119,20 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
limit: LIMIT
}
},
{
urls: finalRelayUrls,
filter: { '#q': [qeIdForTagFilter], kinds: [...highlightKinds], limit: LIMIT }
},
{
urls: finalRelayUrls,
filter: {
'#a': [eventCoordinate],
kinds: [...highlightKinds],
kinds: opRefKinds,
limit: LIMIT
}
},
...opRefKindChunks.map(
(kindsChunk) =>
({
urls: finalRelayUrls,
filter: {
'#a': [eventCoordinate],
kinds: kindsChunk,
limit: LIMIT
}
}) as { urls: string[]; filter: TSubRequestFilter }
)
}
]
if (isReplaceableEvent(ev.kind)) {
for (const kindsChunk of opRefKindChunks) {
subRequests.push({
urls: finalRelayUrls,
filter: { '#A': [eventCoordinate], kinds: kindsChunk, limit: LIMIT }
})
}
subRequests.push({
urls: finalRelayUrls,
filter: { '#A': [eventCoordinate], kinds: opRefKinds, limit: LIMIT }
})
}
// `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only.
if (qeIdIsHexEventId) {
@ -168,34 +141,18 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { @@ -168,34 +141,18 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
urls: finalRelayUrls,
filter: {
'#e': [qeIdForTagFilter],
kinds: [...highlightKinds],
kinds: opRefKinds,
limit: LIMIT
}
},
...opRefKindChunks.map((kindsChunk) => ({
urls: finalRelayUrls,
filter: {
'#e': [qeIdForTagFilter],
kinds: kindsChunk,
limit: LIMIT
}
})),
{
urls: finalRelayUrls,
filter: {
'#E': [qeIdForTagFilter],
kinds: [...highlightKinds],
limit: LIMIT
}
},
...opRefKindChunks.map((kindsChunk) => ({
urls: finalRelayUrls,
filter: {
'#E': [qeIdForTagFilter],
kinds: kindsChunk,
kinds: opRefKinds,
limit: LIMIT
}
}))
}
)
}

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

@ -0,0 +1,39 @@ @@ -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 @@ -84,22 +84,31 @@ export function buildAlexandriaEventsSearchUrlFromNotesQuery(query: string): str
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 {
if (params.type === 'hashtag') {
const tag = params.search?.trim().toLowerCase()
if (!tag) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(tag)}`
}
if (params.type === 'profiles') {
let n = params.search.trim()
if (n.toLowerCase().startsWith('n:')) n = n.slice(2).trim()
if (!n) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?n=${encodeURIComponent(n)}`
}
if (params.type === 'notes') {
return buildAlexandriaEventsSearchUrlFromNotesQuery(params.search)
const search = params.search?.trim()
if (!search) return null
switch (params.type) {
case 'hashtag':
return buildAlexandriaEventsUrlForHashtagParam(search)
case 'profile':
case 'profiles': {
let n = search
if (n.toLowerCase().startsWith('n:')) n = n.slice(2).trim()
if (!n) return null
return `${ALEXANDRIA_NEXT_EVENTS_BASE}?n=${encodeURIComponent(n)}`
}
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 {

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

@ -3,8 +3,9 @@ import ProfileList from '@/components/ProfileList' @@ -3,8 +3,9 @@ import ProfileList from '@/components/ProfileList'
import { ProfileListBySearch } from '@/components/ProfileListBySearch'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { buildAlexandriaEventsSearchUrlForTSearchParams } from '@/lib/alexandria-events-search-url'
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'
const ProfileListPage = forwardRef(({ index }: { index?: number }, ref) => {
@ -39,9 +40,19 @@ 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
if (data?.type === 'search') {
content = <ProfileListBySearch search={data.id} />
content = (
<ProfileListBySearch search={data.id} alexandriaEmptyHref={profileSearchAlexandriaHref} />
)
} else if (data?.type === 'domain') {
content = <ProfileListByDomain domain={data.id} />
}

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

@ -11,7 +11,8 @@ import { @@ -11,7 +11,8 @@ import {
SITE_NAME,
updateMetaTag
} 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 { registerPrimaryPanelRefresh } = usePrimaryNoteView()
@ -29,6 +30,12 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri @@ -29,6 +30,12 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
const { profile } = useFetchProfile(id)
const alexandriaNotFoundHref = useMemo(() => {
const lookup = id?.trim()
if (!lookup) return null
return buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profile', search: lookup })
}, [id])
useEffect(() => {
if (!profile) {
applyDefaultSiteSocialMeta()
@ -95,7 +102,7 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri @@ -95,7 +102,7 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
displayScrollToTopButton
ref={ref}
>
<Profile id={id} feedRef={feedRef} />
<Profile id={id} feedRef={feedRef} alexandriaNotFoundHref={alexandriaNotFoundHref} />
</SecondaryPageLayout>
)
})

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

@ -829,6 +829,45 @@ export class ReplaceableEventService { @@ -829,6 +829,45 @@ export class ReplaceableEventService {
* =========== 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)
*/
@ -880,10 +919,19 @@ export class ReplaceableEventService { @@ -880,10 +919,19 @@ export class ReplaceableEventService {
// Relay hints from bech32 (nprofile, etc.) — highest priority in later steps
const relayHints = relays.length > 0 ? [...relays] : []
// CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available
// Relay hints should have highest priority and always be included
// Step 0: {@link PROFILE_RELAY_URLS} by `authors` — reliable for npub/hex; avoids batched DataLoader + abort races.
const fromProfileRelays = await this.fetchKind0FromProfileRelays(pubkey)
if (fromProfileRelays) {
this.replaceableEventFromBigRelaysDataloader.prime(
{ pubkey, kind: kinds.Metadata },
Promise.resolve(fromProfileRelays)
)
await this.indexProfile(fromProfileRelays)
void indexedDb.putReplaceableEvent(fromProfileRelays).catch(() => {})
return fromProfileRelays
}
// Step 1: ALWAYS use DataLoader first (checks IndexedDB, then uses default relays)
// Step 1: DataLoader (IndexedDB + batched profile relay stack)
// 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
// We'll use relay hints in Step 2/3 only if Step 1 fails
@ -984,7 +1032,8 @@ export class ReplaceableEventService { @@ -984,7 +1032,8 @@ export class ReplaceableEventService {
{
replaceableRace: false,
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 { @@ -3696,6 +3696,7 @@ class ClientService extends EventTarget {
const usesNip50TextSearch = filtersArr.some(
(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, {
replaceableRace: false,
eoseTimeout: usesNip50TextSearch ? 10_000 : 4500,
@ -3703,7 +3704,7 @@ class ClientService extends EventTarget { @@ -3703,7 +3704,7 @@ class ClientService extends EventTarget {
? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000
: 9000,
relayOpSource: 'ClientService.searchProfiles',
foreground: usesNip50TextSearch
foreground: usesNip50TextSearch || usesAuthorsLookup
})
const byPk = new Map<string, NEvent>()

Loading…
Cancel
Save