From 074a981760159468dad247d29c23057accba3d2a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 14:54:56 +0200 Subject: [PATCH] bug-fixes --- src/components/ProfileListBySearch/index.tsx | 212 +++++++++++++----- src/components/ReplyNoteList/index.tsx | 88 ++++++-- src/components/SearchBar/index.tsx | 64 ++++-- src/constants.ts | 4 +- src/i18n/locales/en.ts | 3 + src/lib/logger.ts | 4 +- src/lib/profile-search-query.ts | 28 +++ .../client-replaceable-events.service.ts | 108 ++++++++- src/services/client.service.ts | 60 ++++- src/services/indexed-db.service.ts | 10 +- src/types/index.d.ts | 9 +- 11 files changed, 460 insertions(+), 130 deletions(-) create mode 100644 src/lib/profile-search-query.ts diff --git a/src/components/ProfileListBySearch/index.tsx b/src/components/ProfileListBySearch/index.tsx index fa9c129e..1214902d 100644 --- a/src/components/ProfileListBySearch/index.tsx +++ b/src/components/ProfileListBySearch/index.tsx @@ -1,11 +1,13 @@ import { useSecondaryPage } from '@/PageManager' import { PROFILE_FETCH_RELAY_URLS } from '@/constants' -import { normalizeUrl } from '@/lib/url' +import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { toProfile } from '@/lib/link' +import { normalizeUrl } from '@/lib/url' import client from '@/services/client.service' import { cn } from '@/lib/utils' import dayjs from 'dayjs' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import UserItem, { UserItemSkeleton } from '../UserItem' const LIMIT = 50 @@ -15,86 +17,168 @@ const PROFILE_SEARCH_RELAY_URLS = Array.from( ) export function ProfileListBySearch({ search }: { search: string }) { + const { t } = useTranslation() const { push } = useSecondaryPage() - const [until, setUntil] = useState(() => dayjs().unix()) - const [hasMore, setHasMore] = useState(true) - const [pubkeySet, setPubkeySet] = useState(new Set()) + const [pubkeys, setPubkeys] = useState([]) + const [until, setUntil] = useState(() => dayjs().unix()) + const [hasMore, setHasMore] = useState(true) + const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading') + const [empty, setEmpty] = useState(false) const bottomRef = useRef(null) + const loadMoreInFlight = useRef(false) + const untilRef = useRef(until) + untilRef.current = until + /** Initial page: must not read `pubkeySet` from state — it is still the previous search until the next paint. */ useEffect(() => { - setUntil(dayjs().unix()) + let cancelled = false + const untilStart = dayjs().unix() + + setPhase('loading') + setEmpty(false) + setPubkeys([]) setHasMore(true) - setPubkeySet(new Set()) - loadMore() - }, [search]) + setUntil(untilStart) - useEffect(() => { - if (!hasMore) return - const options = { - root: null, - rootMargin: '10px', - threshold: 1 - } + void (async () => { + try { + const seen = new Set() + const batch: string[] = [] - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMore) { - loadMore() - } - }, options) + const cached = await client.searchProfilesFromIndexedDBCache(search, LIMIT) + if (cancelled) return + for (const p of cached) { + const pk = p.pubkey.toLowerCase() + if (seen.has(pk)) continue + seen.add(pk) + batch.push(p.pubkey) + } - const currentBottomRef = bottomRef.current + const directPk = decodeProfileSearchQueryToPubkeyHex(search) + if (directPk && !seen.has(directPk)) { + seen.add(directPk) + batch.push(directPk) + void client.fetchProfileEvent(directPk).catch(() => {}) + } - if (currentBottomRef) { - observerInstance.observe(currentBottomRef) - } + const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { + search, + until: untilStart, + limit: LIMIT + }) + if (cancelled) return - return () => { - if (observerInstance && currentBottomRef) { - observerInstance.unobserve(currentBottomRef) - } - } - }, [hasMore, search, until]) - - const loadMore = async () => { - const nextSeen = new Set(pubkeySet) - const batchPubkeys: string[] = [] - - if (pubkeySet.size === 0) { - const cached = await client.searchProfilesFromIndexedDBCache(search, LIMIT) - for (const p of cached) { - if (!nextSeen.has(p.pubkey)) { - nextSeen.add(p.pubkey) - batchPubkeys.push(p.pubkey) + for (const profile of relayProfiles) { + const pk = profile.pubkey.toLowerCase() + if (seen.has(pk)) continue + seen.add(pk) + batch.push(profile.pubkey) + } + + let nextUntil = untilStart + if (relayProfiles.length > 0) { + const last = relayProfiles[relayProfiles.length - 1]! + const ca = last.created_at + if (typeof ca === 'number' && ca > 0) { + nextUntil = ca - 1 + } + } + + setPubkeys(batch) + setUntil(nextUntil) + setHasMore(relayProfiles.length >= LIMIT) + setEmpty(batch.length === 0) + setPhase('ready') + } catch { + if (!cancelled) { + setPhase('error') + setEmpty(true) + setHasMore(false) } } + })() + + return () => { + cancelled = true } + }, [search]) - const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { - search, - until, - limit: LIMIT - }) - for (const profile of relayProfiles) { - if (!nextSeen.has(profile.pubkey)) { - nextSeen.add(profile.pubkey) - batchPubkeys.push(profile.pubkey) + const loadMore = useCallback(async () => { + if (loadMoreInFlight.current || !hasMore) return + loadMoreInFlight.current = true + try { + const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { + search, + until: untilRef.current, + limit: LIMIT + }) + + if (relayProfiles.length === 0) { + setHasMore(false) + return } - } - if (batchPubkeys.length === 0) { + let added = 0 + setPubkeys((prev) => { + const seen = new Set(prev.map((p) => p.toLowerCase())) + const next = [...prev] + for (const profile of relayProfiles) { + const pk = profile.pubkey.toLowerCase() + if (seen.has(pk)) continue + seen.add(pk) + next.push(profile.pubkey) + } + added = next.length - prev.length + return next + }) + + if (added === 0) { + setHasMore(false) + return + } + + const last = relayProfiles[relayProfiles.length - 1]! + const ca = last.created_at + if (typeof ca === 'number' && ca > 0) { + setUntil(ca - 1) + } + setHasMore(relayProfiles.length >= LIMIT) + } catch { setHasMore(false) - return + } finally { + loadMoreInFlight.current = false } + }, [search, hasMore]) + + useEffect(() => { + if (!hasMore || phase !== 'ready') return + const options = { root: null, rootMargin: '10px', threshold: 1 } + const el = bottomRef.current + if (!el) return - setPubkeySet((prev) => new Set([...prev, ...batchPubkeys])) - setHasMore(relayProfiles.length >= LIMIT) - const last = relayProfiles[relayProfiles.length - 1] - setUntil(last?.created_at ? last.created_at - 1 : 0) - } + const observer = new IntersectionObserver((entries) => { + if (entries[0]?.isIntersecting) { + void loadMore() + } + }, options) + observer.observe(el) + return () => observer.disconnect() + }, [hasMore, phase, loadMore, pubkeys.length]) return (
- {Array.from(pubkeySet).map((pubkey, index) => ( + {phase === 'loading' && ( +
+ +
+ )} + {phase === 'error' && ( +

{t('Profile search failed')}

+ )} + {phase === 'ready' && empty && ( +

{t('Profile search no results')}

+ )} + {pubkeys.map((pubkey, index) => (
))} - {hasMore && } - {hasMore &&
} + {phase === 'ready' && hasMore && pubkeys.length > 0 && ( + <> + +
+ + )}
) } diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 501697f7..9b27540c 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -316,6 +316,11 @@ function isPollVoteKind(evt: Pick): boolean { return evt.kind === ExtendedKind.POLL_RESPONSE } +/** Zap-poll (6969): kind 9735 receipts are paid votes — hide from “Antworten” so amounts/options are not tied to identities here. */ +function isZapPollThreadZapReceipt(evt: Pick, op: Pick): boolean { + return op.kind === ExtendedKind.ZAP_POLL && evt.kind === kinds.Zap +} + function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { if (item.kind === kinds.Highlights) return t('highlighted this note') if (item.kind === kinds.ShortTextNote) return t('quoted this note') @@ -444,6 +449,7 @@ function ReplyNoteList({ if (replyIdSet.has(evt.id)) return if (isNip25ReactionKind(evt.kind)) return if (isPollVoteKind(evt)) return + if (isZapPollThreadZapReceipt(evt, event)) return if ( shouldHideThreadResponseEvent( evt, @@ -475,7 +481,10 @@ function ReplyNoteList({ const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents) - const zaps = filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold) + const zaps = + event.kind === ExtendedKind.ZAP_POLL + ? [] + : filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold) const replyScoreById = sort === 'top' || sort === 'controversial' || sort === 'most-zapped' ? new Map( @@ -566,7 +575,8 @@ function ReplyNoteList({ hideContentMentioningMutedUsers, sort, zapReplyThreshold, - isDiscussionRoot + isDiscussionRoot, + event.kind ]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) @@ -589,10 +599,11 @@ function ReplyNoteList({ /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { const { zaps, nonZaps } = partitionZapReceipts(merged) + const zapsShown = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps const sortedNon = [...nonZaps].sort((a, b) => direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at ) - return moveReportsToEndPreserveOrder(replyFeedZapsFirst(sortedNon, zaps)) + return moveReportsToEndPreserveOrder(replyFeedZapsFirst(sortedNon, zapsShown)) } if (!showQuotes) return replies @@ -602,6 +613,7 @@ function ReplyNoteList({ // E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { const { zaps, nonZaps } = partitionZapReceipts(replies) + const zapsShown = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailSeen = new Set() @@ -614,12 +626,13 @@ function ReplyNoteList({ for (const e of tailFromReplies) pushTail(e) for (const e of quoteOnly) pushTail(e) const tailSorted = partitionAndSortBacklinkTail(tail) - return [...replyFeedZapsFirst(middle, zaps), ...tailSorted] + return [...replyFeedZapsFirst(middle, zapsShown), ...tailSorted] } // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A if (rootInfo?.type === 'I') { const { zaps, nonZaps } = partitionZapReceipts(replies) + const zapsShownI = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) const tailSeen = new Set() @@ -632,7 +645,7 @@ function ReplyNoteList({ for (const e of tailFromReplies) pushTail(e) for (const e of quoteOnly) pushTail(e) const tailSorted = partitionAndSortBacklinkTail(tail) - return [...replyFeedZapsFirst(middle, zaps), ...tailSorted] + return [...replyFeedZapsFirst(middle, zapsShownI), ...tailSorted] } const merged = [...replies, ...quoteOnly] @@ -646,7 +659,7 @@ function ReplyNoteList({ return [...sortedReplies, ...sortedQuotes] } return zapsThenTimeSorted(merged, 'desc') - }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo]) + }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo, event.kind]) useEffect(() => { if (!rootInfo) return @@ -950,7 +963,7 @@ function ReplyNoteList({ try { const ev = await eventService.fetchEvent(id) if (cancelled) return - if (ev && replyMatchesThreadForList(ev, event, threadRoot, true) && !isPollVoteKind(ev)) { + if (ev && replyMatchesThreadForList(ev, event, threadRoot, true) && !isPollVoteKind(ev) && !isZapPollThreadZapReceipt(ev, event)) { batch.push(ev) } else { discussionStatsHydratedReplyIdsRef.current.delete(id) @@ -991,6 +1004,7 @@ function ReplyNoteList({ const onNewReply = useCallback( (evt: NEvent) => { if (isPollVoteKind(evt)) return + if (isZapPollThreadZapReceipt(evt, event)) return if ( shouldHideThreadResponseEvent( evt, @@ -1007,7 +1021,7 @@ function ReplyNoteList({ discussionFeedCache.setCachedReplies(rootInfo, [...without, evt]) } }, - [addReplies, rootInfo, mutePubkeySet, hideContentMentioningMutedUsers] + [addReplies, rootInfo, mutePubkeySet, hideContentMentioningMutedUsers, event] ) useEffect(() => { @@ -1039,8 +1053,12 @@ function ReplyNoteList({ // Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip if (rootInfo.type === 'E' || rootInfo.type === 'A') { const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo) - if (fromSession.length > 0) { - addReplies(fromSession) + const fromSessionForUi = + event.kind === ExtendedKind.ZAP_POLL + ? fromSession.filter((e) => !isZapPollThreadZapReceipt(e, event)) + : fromSession + if (fromSessionForUi.length > 0) { + addReplies(fromSessionForUi) } } @@ -1048,8 +1066,12 @@ function ReplyNoteList({ const cachedData = discussionFeedCache.getCachedReplies(rootInfo) const hasCache = cachedData !== null - if (hasCache) { - addReplies(cachedData) + if (hasCache && cachedData) { + const cachedForUi = + event.kind === ExtendedKind.ZAP_POLL + ? cachedData.filter((e) => !isZapPollThreadZapReceipt(e, event)) + : cachedData + addReplies(cachedForUi) setLoading(false) } else { setLoading(true) @@ -1111,19 +1133,36 @@ function ReplyNoteList({ ]) ).sort((a, b) => a - b) const opRefChunks = chunkKindsForThreadReq(NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT) + const kindsNoteCommentVoiceZap: number[] = [ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + kinds.Zap + ] + const kindsNoteCommentVoice: number[] = [ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT + ] + const kindsPrimaryThread = + event.kind === ExtendedKind.ZAP_POLL ? kindsNoteCommentVoice : kindsNoteCommentVoiceZap + const kindsUpperEThread: number[] = + event.kind === ExtendedKind.ZAP_POLL + ? [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT] + : [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: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], + kinds: kindsPrimaryThread, limit: LIMIT }) // Also fetch with uppercase E tag for replaceable events filters.push({ '#E': [rootInfo.id], - kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], + kinds: kindsUpperEThread, limit: LIMIT }) filters.push({ @@ -1153,12 +1192,12 @@ function ReplyNoteList({ filters.push( { '#a': [rootInfo.id], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], + kinds: kindsPrimaryThread, limit: LIMIT }, { '#A': [rootInfo.id], - kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], + kinds: kindsUpperEThread, limit: LIMIT } ) @@ -1168,12 +1207,12 @@ function ReplyNoteList({ const eSnap = rootInfo.eventId.trim().toLowerCase() filters.push({ '#e': [eSnap], - kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], + kinds: kindsPrimaryThread, limit: LIMIT }) filters.push({ '#E': [eSnap], - kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], + kinds: kindsUpperEThread, limit: LIMIT }) filters.push({ @@ -1225,6 +1264,7 @@ function ReplyNoteList({ ? (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return if (isPollVoteKind(evt)) return + if (isZapPollThreadZapReceipt(evt, event)) return if (!isRssArticleUrlThreadInteraction(evt, urlThreadRootInfo.id)) return if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return @@ -1247,6 +1287,7 @@ function ReplyNoteList({ // Filter and add replies (URL threads include kind 9802 highlights of this page) const regularReplies = allReplies.filter((evt) => { if (isPollVoteKind(evt)) return false + if (isZapPollThreadZapReceipt(evt, event)) return false const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) if (!match) return false return !shouldHideThreadResponseEvent( @@ -1267,7 +1308,11 @@ function ReplyNoteList({ // This ensures we keep all previously seen replies and add any new ones // addReplies will deduplicate, so it's safe to call even if some replies are already displayed if (mergedCachedReplies) { - addReplies(mergedCachedReplies) + const mergedForUi = + event.kind === ExtendedKind.ZAP_POLL + ? mergedCachedReplies.filter((e) => !isZapPollThreadZapReceipt(e, event)) + : mergedCachedReplies + addReplies(mergedForUi) } else { // Fallback: if cache somehow failed, at least add the fetched replies logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') @@ -1468,6 +1513,7 @@ function ReplyNoteList({ const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) const olderEvents = events.filter((evt) => { if (isPollVoteKind(evt)) return false + if (isZapPollThreadZapReceipt(evt, event)) return false if (!rootInfo) return false const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) if (!matchesThread) return false @@ -1515,6 +1561,7 @@ function ReplyNoteList({ const shouldShowFeedItem = useCallback( (item: NEvent) => { if (isPollVoteKind(item)) return false + if (isZapPollThreadZapReceipt(item, event)) return false if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { return false } @@ -1541,7 +1588,8 @@ function ReplyNoteList({ hideUntrustedInteractions, isUserTrusted, rootInfo?.type, - repliesMap + repliesMap, + event ] ) diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index 4fb88de7..f1f024b6 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -11,7 +11,7 @@ import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager import { useScreenSize } from '@/providers/ScreenSizeProvider' import modalManager from '@/services/modal-manager.service' import { TSearchParams } from '@/types' -import { Hash, Notebook, Search, Server, FileText } from 'lucide-react' +import { Hash, Notebook, Search, Server, FileText, Users } from 'lucide-react' import { nip19 } from 'nostr-tools' import { forwardRef, @@ -118,10 +118,13 @@ const SearchBar = forwardRef< const search = input.trim() if (!search) return - if (/^[0-9a-f]{64}$/.test(search)) { + const hex64 = /^[0-9a-f]{64}$/i + if (hex64.test(search)) { + const normalized = search.toLowerCase() setSelectableOptions([ - { type: 'note', search }, - { type: 'profile', search } + { type: 'note', search: normalized }, + { type: 'profile', search: normalized }, + { type: 'profiles', search: normalized } ]) return } @@ -133,7 +136,10 @@ const SearchBar = forwardRef< } const { type } = nip19.decode(id) if (['nprofile', 'npub'].includes(type)) { - setSelectableOptions([{ type: 'profile', search: id }]) + setSelectableOptions([ + { type: 'profile', search: id }, + { type: 'profiles', search: id } + ]) return } if (['nevent', 'naddr', 'note'].includes(type)) { @@ -149,6 +155,7 @@ const SearchBar = forwardRef< setSelectableOptions([ { type: 'notes', search }, + { type: 'profiles', search }, { type: 'hashtag', search: hashtag, input: `#${hashtag}` }, ...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []), ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), @@ -157,8 +164,7 @@ const SearchBar = forwardRef< search: profile.npub, input: profile.username, profile - })), - ...(profiles.length >= 5 ? [{ type: 'profiles', search }] : []) + })) ] as TSearchParams[]) }, [input, debouncedInput, profiles]) @@ -201,6 +207,16 @@ const SearchBar = forwardRef< /> ) } + if (option.type === 'profiles') { + return ( + updateSearch(option)} + /> + ) + } if (option.type === 'hashtag') { return ( ) } - if (option.type === 'profiles') { - return ( - updateSearch(option)} - > -
{t('Show more...')}
-
- ) - } return null })} {isFetchingProfiles && profiles.length < 5 && ( @@ -413,6 +418,29 @@ function NormalItem({ ) } +function ProfilesSearchItem({ + search, + onClick, + selected +}: { + search: string + onClick?: () => void + selected?: boolean +}) { + const { t } = useTranslation() + return ( + +
+ + + {t('Search dropdown profile search')} + +
+
{search}
+
+ ) +} + function HashtagItem({ hashtag, onClick, diff --git a/src/constants.ts b/src/constants.ts index 1b6ff5e3..e142d3f7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -456,8 +456,8 @@ export const FOLLOWS_HISTORY_RELAY_URLS = [ 'wss://hist.nostr.land' ] -// Combined relay URLs for profile fetching: search/index relays, fallback inboxes, and profile-specific relays. -export const PROFILE_FETCH_RELAY_URLS = [...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS] +// Profile reads + NIP-50 profile search: search/index relays first, then fast read + profile mirrors (order preserved; dedupe at use sites). +export const PROFILE_FETCH_RELAY_URLS = [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS] export const ExtendedKind = { PICTURE: 20, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f7bd58f3..78b9057e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -304,6 +304,9 @@ export default { Search: "Search", "The relays you are connected to do not support search": "The relays you are connected to do not support search", "Show more...": "Show more...", + "Search dropdown profile search": "PROFILES", + "Profile search no results": "No matching profiles were found for this search.", + "Profile search failed": "Profile search could not complete. Check your connection or try again.", "All users": "All users", "Display replies": "Display replies", Notes: "Notes", diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 83610c1e..a686ab13 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -6,8 +6,8 @@ * dev + opt-out → info / warn / error (set `imwald-debug` or `jumble-debug` to `false`) * production → warn / error only (bare console — no timestamp string built) * - * Opt out in dev: `localStorage.setItem('imwald-debug', 'false')` then reload. - * Force on (e.g. prod build): `VITE_DEBUG=true` or localStorage … `'true'`. + * Opt out of debug in dev: `localStorage.setItem('imwald-debug', 'false')` then reload. + * Force on: `VITE_DEBUG=true` or localStorage … `'true'` (with dev, enables debug the same as default). */ type LogLevel = 'debug' | 'info' | 'warn' | 'error' diff --git a/src/lib/profile-search-query.ts b/src/lib/profile-search-query.ts new file mode 100644 index 00000000..116ddabf --- /dev/null +++ b/src/lib/profile-search-query.ts @@ -0,0 +1,28 @@ +import { nip19 } from 'nostr-tools' + +const HEX_PUBKEY = /^[0-9a-f]{64}$/i + +/** + * When the search box contains a hex pubkey, `npub`, or `nprofile`, return lowercase hex for + * profile fetch and IndexedDB kind-0 matching. NIP-50 text search does not match bech32 npubs. + */ +export function decodeProfileSearchQueryToPubkeyHex(raw: string): string | undefined { + const q = raw.trim() + if (!q) return undefined + if (HEX_PUBKEY.test(q)) return q.toLowerCase() + let bech = q + if (q.toLowerCase().startsWith('nostr:')) { + bech = q.slice(6).trim() + } + try { + const { type, data } = nip19.decode(bech) + if (type === 'npub' && typeof data === 'string') return data.toLowerCase() + if (type === 'nprofile' && data && typeof data === 'object' && 'pubkey' in data) { + const pk = (data as { pubkey: string }).pubkey + if (typeof pk === 'string' && HEX_PUBKEY.test(pk)) return pk.toLowerCase() + } + } catch { + return undefined + } + return undefined +} diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 1ac14259..f2e0ceff 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -53,6 +53,51 @@ export class ReplaceableEventService { if (next) next() } + /** + * After a full profile fetch (cache + defaults + NIP-65 + comprehensive) returns nothing, + * skip repeating that expensive work for a few minutes. Cleared when we index kind 0 or user forces refresh. + */ + private static profileFetchMissUntil = new Map() + private static readonly PROFILE_FETCH_MISS_TTL_MS = 10 * 60 * 1000 + + private static isProfileFetchMissCached(pubkey: string): boolean { + const k = pubkey.trim().toLowerCase() + const until = ReplaceableEventService.profileFetchMissUntil.get(k) + if (until == null) return false + if (Date.now() >= until) { + ReplaceableEventService.profileFetchMissUntil.delete(k) + return false + } + return true + } + + private static rememberProfileFetchMiss(pubkey: string): void { + ReplaceableEventService.profileFetchMissUntil.set( + pubkey.trim().toLowerCase(), + Date.now() + ReplaceableEventService.PROFILE_FETCH_MISS_TTL_MS + ) + } + + private static clearProfileFetchMiss(pubkey: string): void { + ReplaceableEventService.profileFetchMissUntil.delete(pubkey.trim().toLowerCase()) + } + + /** True when kind 10002 exists locally — {@link client.fetchRelayList} would mostly merge IDB anyway. */ + private static async hasRelayListInLocalCache(pubkey: string): Promise { + try { + const idb = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList) + if (idb && !shouldDropEventOnIngest(idb)) return true + } catch { + /* ignore */ + } + const hits = client.eventService.listSessionEventsAuthoredBy(pubkey, { + kinds: [kinds.RelayList], + limit: 1 + }) + const ses = hits[0] + return Boolean(ses && !shouldDropEventOnIngest(ses)) + } + private queryService: QueryService private onProfileIndexed?: (profileEvent: NEvent) => void | Promise private followingFavoriteRelaysCache = new LRUCache>({ @@ -162,6 +207,18 @@ export class ReplaceableEventService { } } + if ( + kind === kinds.Metadata && + !d && + containingEventRelays.length === 0 && + ReplaceableEventService.isProfileFetchMissCached(pubkey) + ) { + logger.debug('[ReplaceableEventService] Skipping metadata fetch (recent profile miss cache)', { + pubkey + }) + return undefined + } + // Kind 3 / NIP-65: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh. if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) { let idbEv: NEvent | undefined | null @@ -540,6 +597,9 @@ export class ReplaceableEventService { eventsMap.set(`${m.pubkey}:${m.kind}`, ev) continue } + if (ReplaceableEventService.isProfileFetchMissCached(m.pubkey)) { + continue + } } networkMissing.push(m) } @@ -997,13 +1057,20 @@ export class ReplaceableEventService { ) await this.indexProfile(sessionEv) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) + ReplaceableEventService.clearProfileFetchMiss(pubkey) return sessionEv } } - + + // Relay hints from bech32 (nprofile, etc.) — highest priority in later steps + const relayHints = relays.length > 0 ? [...relays] : [] + + if (!_skipCache && relayHints.length === 0 && ReplaceableEventService.isProfileFetchMissCached(pubkey)) { + return undefined + } + // CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available // Relay hints should have highest priority and always be included - const relayHints = relays.length > 0 ? [...relays] : [] // Step 1: ALWAYS use DataLoader first (checks IndexedDB, then uses default relays) // CRITICAL: Do NOT pass relay hints here - passing any relays bypasses DataLoader and creates individual subscriptions @@ -1038,14 +1105,25 @@ export class ReplaceableEventService { let authorRelayList: { read?: string[]; write?: string[] } | null = null try { - const relayListPromise = client.fetchRelayList(pubkey) - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey }) - resolve(null) - }, 10_000) - }) - authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) + const hasLocal10002 = await ReplaceableEventService.hasRelayListInLocalCache(pubkey) + if (hasLocal10002) { + authorRelayList = await client.peekRelayListFromStorage(pubkey) + logger.debug('[ReplaceableEventService] Step 2: using cached kind 10002 (skip fetchRelayList network)', { + pubkey + }) + } else { + const relayListPromise = client.fetchRelayList(pubkey) + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey }) + resolve(null) + }, 2800) + }) + authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) + if (authorRelayList == null) { + authorRelayList = await client.peekRelayListFromStorage(pubkey) + } + } } catch (error) { logger.error('[ReplaceableEventService] Failed to fetch author relay list', { pubkey, @@ -1118,8 +1196,8 @@ export class ReplaceableEventService { undefined, { replaceableRace: true, - eoseTimeout: 300, - globalTimeout: 5000 + eoseTimeout: 220, + globalTimeout: 3500 } ) const queryTime = Date.now() - startTime @@ -1156,6 +1234,9 @@ export class ReplaceableEventService { pubkey, triedRelayHints: relayHints.length > 0 }) + if (!_skipCache && relayHints.length === 0) { + ReplaceableEventService.rememberProfileFetchMiss(pubkey) + } return undefined } @@ -1304,6 +1385,9 @@ export class ReplaceableEventService { * Index profile for search (calls callback if provided) */ private async indexProfile(profileEvent: NEvent): Promise { + if (profileEvent.kind === kinds.Metadata) { + ReplaceableEventService.clearProfileFetchMiss(profileEvent.pubkey) + } if (this.onProfileIndexed) { await this.onProfileIndexed(profileEvent) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 82d8699e..3cc01023 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -107,6 +107,7 @@ import { import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' +import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { buildPrioritizedWriteRelayUrls, @@ -3352,15 +3353,38 @@ class ClientService extends EventTarget { /** =========== Profile =========== */ async searchProfiles(relayUrls: string[], filter: Filter): Promise { - const events = await this.queryService.query(relayUrls, { - ...filter, - kinds: [kinds.Metadata] - }, undefined, { - replaceableRace: true, - // Search spans many relays; sub-second EOSE was cutting off almost all index relays. - eoseTimeout: 4500, - globalTimeout: 9000 - }) + const searchStr = typeof filter.search === 'string' ? filter.search.trim() : '' + const normalizedAll = dedupeNormalizeRelayUrlsOrdered( + relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) + ) + let urls = normalizedAll + if (searchStr.length > 0) { + const searchableSet = new Set([ + ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u) + ]) + const searchCapable = normalizedAll.filter( + (u) => searchableSet.has(u) || nip66Service.isRelaySearchable(u) + ) + if (searchCapable.length > 0) { + urls = searchCapable + } + } + + const events = await this.queryService.query( + urls, + { + ...filter, + kinds: [kinds.Metadata] + }, + undefined, + { + replaceableRace: true, + // Search spans many relays; sub-second EOSE was cutting off almost all index relays. + eoseTimeout: 4500, + globalTimeout: 9000 + } + ) const profileEvents = events.sort((a, b) => b.created_at - a.created_at) await Promise.allSettled(profileEvents.map((profile) => this.addUsernameToIndex(profile))) @@ -3393,7 +3417,7 @@ class ClientService extends EventTarget { /** * Npubs for @-mention dropdown: (1) follow-list profiles matching the query, - * (2) local index, (3) kind-0 relay search on PROFILE_FETCH_RELAY_URLS (deduped). + * (2) local index, (3) kind-0 NIP-50 search on {@link PROFILE_FETCH_RELAY_URLS} (includes search relays + profile mirrors; deduped). * Returns cached results immediately, then streams relay results via callback. */ /** @@ -3519,6 +3543,12 @@ class ClientService extends EventTarget { const matchProfileText = (p: TProfile) => ((p.username ?? '') + ' ' + (p.original_username ?? '') + ' ' + (p.nip05 ?? '')).toLowerCase() + const directPk = decodeProfileSearchQueryToPubkeyHex(q) + if (directPk) { + const np = pubkeyToNpub(directPk) + if (np) addNpub(np) + } + // Relay query starts immediately so it can run in parallel with local + follow work (slow relays). const profileSearchRelayUrls = dedupeNormalizeRelayUrlsOrdered( PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) @@ -3669,6 +3699,16 @@ class ClientService extends EventTarget { const seen = new Set() const out: TProfile[] = [] + const directPk = decodeProfileSearchQueryToPubkeyHex(q) + if (directPk) { + const p = await this.replaceableEventService.fetchProfile(directPk) + if (p) { + seen.add(directPk) + out.push(p) + if (out.length >= limit) return out + } + } + const fromIdb = await this.searchProfilesFromIndexedDBCache(q, limit) for (const p of fromIdb) { const pk = p.pubkey.toLowerCase() diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index d5e1bcca..c8079c6d 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -21,6 +21,7 @@ import { } from '@/lib/event' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' +import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import type { Filter } from 'nostr-tools' @@ -76,10 +77,13 @@ function isLikelyCachedNostrEvent(v: unknown): v is Event { ) } -/** Kind 0 JSON fields for profile search (display name, handle, NIP-05). */ -function profileMetadataMatchesQuery(ev: Event, qLower: string): boolean { +/** Kind 0 JSON fields for profile search (display name, handle, NIP-05, pasted npub/nprofile). */ +function profileMetadataMatchesQuery(ev: Event, qRaw: string): boolean { + const qLower = qRaw.trim().toLowerCase() if (!qLower || ev.kind !== kinds.Metadata) return false if (ev.pubkey.toLowerCase().includes(qLower)) return true + const decodedPk = decodeProfileSearchQueryToPubkeyHex(qRaw) + if (decodedPk && ev.pubkey.toLowerCase() === decodedPk) return true try { const profileObj = JSON.parse(ev.content) as Record const nip05Raw = profileObj.nip05 @@ -895,7 +899,7 @@ class IndexedDbService { } const row = cursor.value as TValue const value = row?.value - if (value && profileMetadataMatchesQuery(value, qLower)) { + if (value && profileMetadataMatchesQuery(value, query.trim())) { const pk = value.pubkey.toLowerCase() const prev = byPubkey.get(pk) if (!prev || value.created_at > prev.created_at) { diff --git a/src/types/index.d.ts b/src/types/index.d.ts index a918ec8d..5ed2ad32 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -240,7 +240,14 @@ export type TPollCreateData = { endsAt?: number } -export type TSearchType = 'profile' | 'profiles' | 'notes' | 'note' | 'hashtag' | 'relay' | 'dtag' +export type TSearchType = + | 'profile' + | 'profiles' + | 'notes' + | 'note' + | 'hashtag' + | 'relay' + | 'dtag' export type TSearchParams = { type: TSearchType