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 + return ( + + ) } if (searchParams.type === 'profiles') { - return + return ( + + ) } if (searchParams.type === 'notes') { return ( @@ -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 ) } - return + return ( + + ) } diff --git a/src/constants.ts b/src/constants.ts index e010256a..8ea3a6a1 100644 --- a/src/constants.ts +++ b/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 /** - * 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 = [ '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( new Set([...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) diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx index 20240790..91f0f65d 100644 --- a/src/hooks/useQuoteEvents.tsx +++ b/src/hooks/useQuoteEvents.tsx @@ -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' 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) { ? 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([ 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) { 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) { 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 } - })) + } ) } diff --git a/src/lib/alexandria-events-search-url.test.ts b/src/lib/alexandria-events-search-url.test.ts new file mode 100644 index 00000000..77e622e3 --- /dev/null +++ b/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`) + }) +}) diff --git a/src/lib/alexandria-events-search-url.ts b/src/lib/alexandria-events-search-url.ts index 4280669a..610e816f 100644 --- a/src/lib/alexandria-events-search-url.ts +++ b/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)}` } +/** 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 { diff --git a/src/pages/secondary/ProfileListPage/index.tsx b/src/pages/secondary/ProfileListPage/index.tsx index 7889634a..7e8b5b90 100644 --- a/src/pages/secondary/ProfileListPage/index.tsx +++ b/src/pages/secondary/ProfileListPage/index.tsx @@ -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) => { } }, []) + const profileSearchAlexandriaHref = useMemo( + () => + data?.type === 'search' + ? buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profiles', search: data.id }) + : null, + [data] + ) + let content: React.ReactNode = null if (data?.type === 'search') { - content = + content = ( + + ) } else if (data?.type === 'domain') { content = } diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index c1c3d281..0416024b 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -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 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 displayScrollToTopButton ref={ref} > - + ) }) diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 337efa79..1a217507 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -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 { + 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 { // 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 1: ALWAYS use DataLoader first (checks IndexedDB, then uses default relays) + // 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: 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 { { 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 } ) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index d0c3a4ee..614ff12e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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 { ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 : 9000, relayOpSource: 'ClientService.searchProfiles', - foreground: usesNip50TextSearch + foreground: usesNip50TextSearch || usesAuthorsLookup }) const byPk = new Map()