diff --git a/src/components/NotFound/index.tsx b/src/components/NotFound/index.tsx
index b51e8ef0..a0bf7cd4 100644
--- a/src/components/NotFound/index.tsx
+++ b/src/components/NotFound/index.tsx
@@ -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 (
-
+
{t('Lost in the void')} 🌌
(404)
+ {children}
)
}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 2cb02ba3..b655e010 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -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(
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({
>
)
}
- if (!profile && !isFetching) return
+ if (!profile && !isFetching) {
+ return (
+
+ {alexandriaNotFoundHref ? : null}
+
+ )
+ }
if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker
diff --git a/src/components/ProfileListBySearch/index.tsx b/src/components/ProfileListBySearch/index.tsx
index f6c1f6f0..19237df0 100644
--- a/src/components/ProfileListBySearch/index.tsx
+++ b/src/components/ProfileListBySearch/index.tsx
@@ -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(
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
([])
@@ -180,12 +185,7 @@ export function ProfileListBySearch({ search }: { search: string }) {
{phase === 'ready' && empty && (
{t('Profile search no results')}
- {(() => {
- const trimmed = search.trim()
- if (!trimmed) return null
- const href = buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profiles', search })
- return href ?
: null
- })()}
+ {alexandriaEmptyHref ?
: null}
)}
{pubkeys.map((pubkey, index) => (
diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx
index bf05db70..e71fd61f 100644
--- a/src/components/Relay/index.tsx
+++ b/src/components/Relay/index.tsx
@@ -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<
[relaySeenMatchKey, normalizedUrl]
)
+ const alexandriaFeedEmptyUrl = useMemo(() => {
+ const q = debouncedInput.trim()
+ if (q) return buildAlexandriaEventsSearchUrlFromNotesQuery(q)
+ return alexandriaEmptyUrl
+ }, [debouncedInput, alexandriaEmptyUrl])
+
if (!normalizedUrl) {
- return
+ return (
+
+ {alexandriaNotFoundHref ? : null}
+
+ )
}
return (
@@ -140,6 +161,7 @@ const Relay = forwardRef<
extraShouldHideEvent={shouldHideEventNotFromThisRelay}
extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay}
relayAuthoritativeFeedOnly
+ alexandriaEmptyUrl={alexandriaFeedEmptyUrl}
/>
)
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index 5f99a4d7..daf7c5a6 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -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({
...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({
: [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({
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({
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({
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))
}
diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx
index 23c4a813..b5705f37 100644
--- a/src/components/SearchResult/FullTextSearchByRelay.tsx
+++ b/src/components/SearchResult/FullTextSearchByRelay.tsx
@@ -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({
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]
diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx
index d8e4f327..4e8263ce 100644
--- a/src/components/SearchResult/index.tsx
+++ b/src/components/SearchResult/index.tsx
@@ -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
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
[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