Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
074a981760
  1. 184
      src/components/ProfileListBySearch/index.tsx
  2. 88
      src/components/ReplyNoteList/index.tsx
  3. 64
      src/components/SearchBar/index.tsx
  4. 4
      src/constants.ts
  5. 3
      src/i18n/locales/en.ts
  6. 4
      src/lib/logger.ts
  7. 28
      src/lib/profile-search-query.ts
  8. 92
      src/services/client-replaceable-events.service.ts
  9. 48
      src/services/client.service.ts
  10. 10
      src/services/indexed-db.service.ts
  11. 9
      src/types/index.d.ts

184
src/components/ProfileListBySearch/index.tsx

@ -1,11 +1,13 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { PROFILE_FETCH_RELAY_URLS } from '@/constants' 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 { toProfile } from '@/lib/link'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import dayjs from 'dayjs' 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' import UserItem, { UserItemSkeleton } from '../UserItem'
const LIMIT = 50 const LIMIT = 50
@ -15,86 +17,168 @@ const PROFILE_SEARCH_RELAY_URLS = Array.from(
) )
export function ProfileListBySearch({ search }: { search: string }) { export function ProfileListBySearch({ search }: { search: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [pubkeys, setPubkeys] = useState<string[]>([])
const [hasMore, setHasMore] = useState<boolean>(true) const [until, setUntil] = useState(() => dayjs().unix())
const [pubkeySet, setPubkeySet] = useState(new Set<string>()) const [hasMore, setHasMore] = useState(true)
const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading')
const [empty, setEmpty] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(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(() => { useEffect(() => {
setUntil(dayjs().unix()) let cancelled = false
const untilStart = dayjs().unix()
setPhase('loading')
setEmpty(false)
setPubkeys([])
setHasMore(true) setHasMore(true)
setPubkeySet(new Set<string>()) setUntil(untilStart)
loadMore()
}, [search])
useEffect(() => { void (async () => {
if (!hasMore) return try {
const options = { const seen = new Set<string>()
root: null, const batch: string[] = []
rootMargin: '10px',
threshold: 1 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 observerInstance = new IntersectionObserver((entries) => { const directPk = decodeProfileSearchQueryToPubkeyHex(search)
if (entries[0].isIntersecting && hasMore) { if (directPk && !seen.has(directPk)) {
loadMore() seen.add(directPk)
batch.push(directPk)
void client.fetchProfileEvent(directPk).catch(() => {})
} }
}, options)
const currentBottomRef = bottomRef.current const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, {
search,
until: untilStart,
limit: LIMIT
})
if (cancelled) return
if (currentBottomRef) { for (const profile of relayProfiles) {
observerInstance.observe(currentBottomRef) const pk = profile.pubkey.toLowerCase()
if (seen.has(pk)) continue
seen.add(pk)
batch.push(profile.pubkey)
} }
return () => { let nextUntil = untilStart
if (observerInstance && currentBottomRef) { if (relayProfiles.length > 0) {
observerInstance.unobserve(currentBottomRef) const last = relayProfiles[relayProfiles.length - 1]!
const ca = last.created_at
if (typeof ca === 'number' && ca > 0) {
nextUntil = ca - 1
} }
} }
}, [hasMore, search, until])
const loadMore = async () => { setPubkeys(batch)
const nextSeen = new Set(pubkeySet) setUntil(nextUntil)
const batchPubkeys: string[] = [] setHasMore(relayProfiles.length >= LIMIT)
setEmpty(batch.length === 0)
if (pubkeySet.size === 0) { setPhase('ready')
const cached = await client.searchProfilesFromIndexedDBCache(search, LIMIT) } catch {
for (const p of cached) { if (!cancelled) {
if (!nextSeen.has(p.pubkey)) { setPhase('error')
nextSeen.add(p.pubkey) setEmpty(true)
batchPubkeys.push(p.pubkey) setHasMore(false)
} }
} }
})()
return () => {
cancelled = true
} }
}, [search])
const loadMore = useCallback(async () => {
if (loadMoreInFlight.current || !hasMore) return
loadMoreInFlight.current = true
try {
const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, { const relayProfiles = await client.searchProfiles(PROFILE_SEARCH_RELAY_URLS, {
search, search,
until, until: untilRef.current,
limit: LIMIT limit: LIMIT
}) })
for (const profile of relayProfiles) {
if (!nextSeen.has(profile.pubkey)) { if (relayProfiles.length === 0) {
nextSeen.add(profile.pubkey) setHasMore(false)
batchPubkeys.push(profile.pubkey) return
} }
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 (batchPubkeys.length === 0) { if (added === 0) {
setHasMore(false) setHasMore(false)
return return
} }
setPubkeySet((prev) => new Set([...prev, ...batchPubkeys])) const last = relayProfiles[relayProfiles.length - 1]!
const ca = last.created_at
if (typeof ca === 'number' && ca > 0) {
setUntil(ca - 1)
}
setHasMore(relayProfiles.length >= LIMIT) setHasMore(relayProfiles.length >= LIMIT)
const last = relayProfiles[relayProfiles.length - 1] } catch {
setUntil(last?.created_at ? last.created_at - 1 : 0) setHasMore(false)
} 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
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) {
void loadMore()
}
}, options)
observer.observe(el)
return () => observer.disconnect()
}, [hasMore, phase, loadMore, pubkeys.length])
return ( return (
<div className="px-4"> <div className="px-4">
{Array.from(pubkeySet).map((pubkey, index) => ( {phase === 'loading' && (
<div className="px-2 py-4">
<UserItemSkeleton hideFollowButton />
</div>
)}
{phase === 'error' && (
<p className="py-6 text-center text-sm text-muted-foreground">{t('Profile search failed')}</p>
)}
{phase === 'ready' && empty && (
<p className="py-6 text-center text-sm text-muted-foreground">{t('Profile search no results')}</p>
)}
{pubkeys.map((pubkey, index) => (
<div <div
key={`${index}-${pubkey}`} key={`${index}-${pubkey}`}
role="button" role="button"
@ -115,8 +199,12 @@ export function ProfileListBySearch({ search }: { search: string }) {
<UserItem pubkey={pubkey} /> <UserItem pubkey={pubkey} />
</div> </div>
))} ))}
{hasMore && <UserItemSkeleton />} {phase === 'ready' && hasMore && pubkeys.length > 0 && (
{hasMore && <div ref={bottomRef} />} <>
<UserItemSkeleton hideFollowButton />
<div ref={bottomRef} />
</>
)}
</div> </div>
) )
} }

88
src/components/ReplyNoteList/index.tsx

@ -316,6 +316,11 @@ function isPollVoteKind(evt: Pick<NEvent, 'kind'>): boolean {
return evt.kind === ExtendedKind.POLL_RESPONSE 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<NEvent, 'kind'>, op: Pick<NEvent, 'kind'>): boolean {
return op.kind === ExtendedKind.ZAP_POLL && evt.kind === kinds.Zap
}
function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string {
if (item.kind === kinds.Highlights) return t('highlighted this note') if (item.kind === kinds.Highlights) return t('highlighted this note')
if (item.kind === kinds.ShortTextNote) return t('quoted 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 (replyIdSet.has(evt.id)) return
if (isNip25ReactionKind(evt.kind)) return if (isNip25ReactionKind(evt.kind)) return
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
if ( if (
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
@ -475,7 +481,10 @@ function ReplyNoteList({
const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents) const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents)
const zaps = filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold) const zaps =
event.kind === ExtendedKind.ZAP_POLL
? []
: filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold)
const replyScoreById = const replyScoreById =
sort === 'top' || sort === 'controversial' || sort === 'most-zapped' sort === 'top' || sort === 'controversial' || sort === 'most-zapped'
? new Map( ? new Map(
@ -566,7 +575,8 @@ function ReplyNoteList({
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
sort, sort,
zapReplyThreshold, zapReplyThreshold,
isDiscussionRoot isDiscussionRoot,
event.kind
]) ])
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) 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 */ /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */
const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => {
const { zaps, nonZaps } = partitionZapReceipts(merged) const { zaps, nonZaps } = partitionZapReceipts(merged)
const zapsShown = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps
const sortedNon = [...nonZaps].sort((a, b) => const sortedNon = [...nonZaps].sort((a, b) =>
direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at 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 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) // 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') { if (rootInfo?.type === 'E' || rootInfo?.type === 'A') {
const { zaps, nonZaps } = partitionZapReceipts(replies) const { zaps, nonZaps } = partitionZapReceipts(replies)
const zapsShown = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps
const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo)) const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -614,12 +626,13 @@ function ReplyNoteList({
for (const e of tailFromReplies) pushTail(e) for (const e of tailFromReplies) pushTail(e)
for (const e of quoteOnly) pushTail(e) for (const e of quoteOnly) pushTail(e)
const tailSorted = partitionAndSortBacklinkTail(tail) 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 // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A
if (rootInfo?.type === 'I') { if (rootInfo?.type === 'I') {
const { zaps, nonZaps } = partitionZapReceipts(replies) const { zaps, nonZaps } = partitionZapReceipts(replies)
const zapsShownI = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps
const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind))
const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -632,7 +645,7 @@ function ReplyNoteList({
for (const e of tailFromReplies) pushTail(e) for (const e of tailFromReplies) pushTail(e)
for (const e of quoteOnly) pushTail(e) for (const e of quoteOnly) pushTail(e)
const tailSorted = partitionAndSortBacklinkTail(tail) const tailSorted = partitionAndSortBacklinkTail(tail)
return [...replyFeedZapsFirst(middle, zaps), ...tailSorted] return [...replyFeedZapsFirst(middle, zapsShownI), ...tailSorted]
} }
const merged = [...replies, ...quoteOnly] const merged = [...replies, ...quoteOnly]
@ -646,7 +659,7 @@ function ReplyNoteList({
return [...sortedReplies, ...sortedQuotes] return [...sortedReplies, ...sortedQuotes]
} }
return zapsThenTimeSorted(merged, 'desc') return zapsThenTimeSorted(merged, 'desc')
}, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo]) }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo, event.kind])
useEffect(() => { useEffect(() => {
if (!rootInfo) return if (!rootInfo) return
@ -950,7 +963,7 @@ function ReplyNoteList({
try { try {
const ev = await eventService.fetchEvent(id) const ev = await eventService.fetchEvent(id)
if (cancelled) return 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) batch.push(ev)
} else { } else {
discussionStatsHydratedReplyIdsRef.current.delete(id) discussionStatsHydratedReplyIdsRef.current.delete(id)
@ -991,6 +1004,7 @@ function ReplyNoteList({
const onNewReply = useCallback( const onNewReply = useCallback(
(evt: NEvent) => { (evt: NEvent) => {
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
if ( if (
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
@ -1007,7 +1021,7 @@ function ReplyNoteList({
discussionFeedCache.setCachedReplies(rootInfo, [...without, evt]) discussionFeedCache.setCachedReplies(rootInfo, [...without, evt])
} }
}, },
[addReplies, rootInfo, mutePubkeySet, hideContentMentioningMutedUsers] [addReplies, rootInfo, mutePubkeySet, hideContentMentioningMutedUsers, event]
) )
useEffect(() => { useEffect(() => {
@ -1039,8 +1053,12 @@ function ReplyNoteList({
// Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip // Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip
if (rootInfo.type === 'E' || rootInfo.type === 'A') { if (rootInfo.type === 'E' || rootInfo.type === 'A') {
const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo) const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo)
if (fromSession.length > 0) { const fromSessionForUi =
addReplies(fromSession) 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 cachedData = discussionFeedCache.getCachedReplies(rootInfo)
const hasCache = cachedData !== null const hasCache = cachedData !== null
if (hasCache) { if (hasCache && cachedData) {
addReplies(cachedData) const cachedForUi =
event.kind === ExtendedKind.ZAP_POLL
? cachedData.filter((e) => !isZapPollThreadZapReceipt(e, event))
: cachedData
addReplies(cachedForUi)
setLoading(false) setLoading(false)
} else { } else {
setLoading(true) setLoading(true)
@ -1111,19 +1133,36 @@ function ReplyNoteList({
]) ])
).sort((a, b) => a - b) ).sort((a, b) => a - b)
const opRefChunks = chunkKindsForThreadReq(NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT) 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') { if (rootInfo.type === 'E') {
// Fetch all reply types for event-based replies (keep ≤4 kinds per filter — some relays // 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). // NOTICE "too many kinds N" and drop the whole REQ if kind 7 is bundled with four others).
filters.push({ filters.push({
'#e': [rootInfo.id], '#e': [rootInfo.id],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], kinds: kindsPrimaryThread,
limit: LIMIT limit: LIMIT
}) })
// Also fetch with uppercase E tag for replaceable events // Also fetch with uppercase E tag for replaceable events
filters.push({ filters.push({
'#E': [rootInfo.id], '#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], kinds: kindsUpperEThread,
limit: LIMIT limit: LIMIT
}) })
filters.push({ filters.push({
@ -1153,12 +1192,12 @@ function ReplyNoteList({
filters.push( filters.push(
{ {
'#a': [rootInfo.id], '#a': [rootInfo.id],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], kinds: kindsPrimaryThread,
limit: LIMIT limit: LIMIT
}, },
{ {
'#A': [rootInfo.id], '#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], kinds: kindsUpperEThread,
limit: LIMIT limit: LIMIT
} }
) )
@ -1168,12 +1207,12 @@ function ReplyNoteList({
const eSnap = rootInfo.eventId.trim().toLowerCase() const eSnap = rootInfo.eventId.trim().toLowerCase()
filters.push({ filters.push({
'#e': [eSnap], '#e': [eSnap],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], kinds: kindsPrimaryThread,
limit: LIMIT limit: LIMIT
}) })
filters.push({ filters.push({
'#E': [eSnap], '#E': [eSnap],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap], kinds: kindsUpperEThread,
limit: LIMIT limit: LIMIT
}) })
filters.push({ filters.push({
@ -1225,6 +1264,7 @@ function ReplyNoteList({
? (evt: NEvent) => { ? (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
if (!isRssArticleUrlThreadInteraction(evt, urlThreadRootInfo.id)) return if (!isRssArticleUrlThreadInteraction(evt, urlThreadRootInfo.id)) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))
return return
@ -1247,6 +1287,7 @@ function ReplyNoteList({
// Filter and add replies (URL threads include kind 9802 highlights of this page) // Filter and add replies (URL threads include kind 9802 highlights of this page)
const regularReplies = allReplies.filter((evt) => { const regularReplies = allReplies.filter((evt) => {
if (isPollVoteKind(evt)) return false if (isPollVoteKind(evt)) return false
if (isZapPollThreadZapReceipt(evt, event)) return false
const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
if (!match) return false if (!match) return false
return !shouldHideThreadResponseEvent( return !shouldHideThreadResponseEvent(
@ -1267,7 +1308,11 @@ function ReplyNoteList({
// This ensures we keep all previously seen replies and add any new ones // 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 // addReplies will deduplicate, so it's safe to call even if some replies are already displayed
if (mergedCachedReplies) { if (mergedCachedReplies) {
addReplies(mergedCachedReplies) const mergedForUi =
event.kind === ExtendedKind.ZAP_POLL
? mergedCachedReplies.filter((e) => !isZapPollThreadZapReceipt(e, event))
: mergedCachedReplies
addReplies(mergedForUi)
} else { } else {
// Fallback: if cache somehow failed, at least add the fetched replies // Fallback: if cache somehow failed, at least add the fetched replies
logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') 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 events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderEvents = events.filter((evt) => { const olderEvents = events.filter((evt) => {
if (isPollVoteKind(evt)) return false if (isPollVoteKind(evt)) return false
if (isZapPollThreadZapReceipt(evt, event)) return false
if (!rootInfo) return false if (!rootInfo) return false
const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
if (!matchesThread) return false if (!matchesThread) return false
@ -1515,6 +1561,7 @@ function ReplyNoteList({
const shouldShowFeedItem = useCallback( const shouldShowFeedItem = useCallback(
(item: NEvent) => { (item: NEvent) => {
if (isPollVoteKind(item)) return false if (isPollVoteKind(item)) return false
if (isZapPollThreadZapReceipt(item, event)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
return false return false
} }
@ -1541,7 +1588,8 @@ function ReplyNoteList({
hideUntrustedInteractions, hideUntrustedInteractions,
isUserTrusted, isUserTrusted,
rootInfo?.type, rootInfo?.type,
repliesMap repliesMap,
event
] ]
) )

64
src/components/SearchBar/index.tsx

@ -11,7 +11,7 @@ import { useSmartNoteNavigation, useSmartHashtagNavigation } from '@/PageManager
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TSearchParams } from '@/types' 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 { nip19 } from 'nostr-tools'
import { import {
forwardRef, forwardRef,
@ -118,10 +118,13 @@ const SearchBar = forwardRef<
const search = input.trim() const search = input.trim()
if (!search) return 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([ setSelectableOptions([
{ type: 'note', search }, { type: 'note', search: normalized },
{ type: 'profile', search } { type: 'profile', search: normalized },
{ type: 'profiles', search: normalized }
]) ])
return return
} }
@ -133,7 +136,10 @@ const SearchBar = forwardRef<
} }
const { type } = nip19.decode(id) const { type } = nip19.decode(id)
if (['nprofile', 'npub'].includes(type)) { if (['nprofile', 'npub'].includes(type)) {
setSelectableOptions([{ type: 'profile', search: id }]) setSelectableOptions([
{ type: 'profile', search: id },
{ type: 'profiles', search: id }
])
return return
} }
if (['nevent', 'naddr', 'note'].includes(type)) { if (['nevent', 'naddr', 'note'].includes(type)) {
@ -149,6 +155,7 @@ const SearchBar = forwardRef<
setSelectableOptions([ setSelectableOptions([
{ type: 'notes', search }, { type: 'notes', search },
{ type: 'profiles', search },
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` }, { type: 'hashtag', search: hashtag, input: `#${hashtag}` },
...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []), ...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []),
...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
@ -157,8 +164,7 @@ const SearchBar = forwardRef<
search: profile.npub, search: profile.npub,
input: profile.username, input: profile.username,
profile profile
})), }))
...(profiles.length >= 5 ? [{ type: 'profiles', search }] : [])
] as TSearchParams[]) ] as TSearchParams[])
}, [input, debouncedInput, profiles]) }, [input, debouncedInput, profiles])
@ -201,6 +207,16 @@ const SearchBar = forwardRef<
/> />
) )
} }
if (option.type === 'profiles') {
return (
<ProfilesSearchItem
key={`profiles-${option.search}`}
search={option.search}
selected={selectedIndex === index}
onClick={() => updateSearch(option)}
/>
)
}
if (option.type === 'hashtag') { if (option.type === 'hashtag') {
return ( return (
<HashtagItem <HashtagItem
@ -231,17 +247,6 @@ const SearchBar = forwardRef<
/> />
) )
} }
if (option.type === 'profiles') {
return (
<Item
key={index}
selected={selectedIndex === index}
onClick={() => updateSearch(option)}
>
<div className="font-semibold">{t('Show more...')}</div>
</Item>
)
}
return null return null
})} })}
{isFetchingProfiles && profiles.length < 5 && ( {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 (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<Users className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Search dropdown profile search')}
</span>
</div>
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function HashtagItem({ function HashtagItem({
hashtag, hashtag,
onClick, onClick,

4
src/constants.ts

@ -456,8 +456,8 @@ export const FOLLOWS_HISTORY_RELAY_URLS = [
'wss://hist.nostr.land' 'wss://hist.nostr.land'
] ]
// Combined relay URLs for profile fetching: search/index relays, fallback inboxes, and profile-specific relays. // 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 = [...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS] export const PROFILE_FETCH_RELAY_URLS = [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS]
export const ExtendedKind = { export const ExtendedKind = {
PICTURE: 20, PICTURE: 20,

3
src/i18n/locales/en.ts

@ -304,6 +304,9 @@ export default {
Search: "Search", Search: "Search",
"The relays you are connected to do not support search": "The relays you are connected to do not support 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...", "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", "All users": "All users",
"Display replies": "Display replies", "Display replies": "Display replies",
Notes: "Notes", Notes: "Notes",

4
src/lib/logger.ts

@ -6,8 +6,8 @@
* dev + opt-out info / warn / error (set `imwald-debug` or `jumble-debug` to `false`) * dev + opt-out info / warn / error (set `imwald-debug` or `jumble-debug` to `false`)
* production warn / error only (bare console no timestamp string built) * production warn / error only (bare console no timestamp string built)
* *
* Opt out in dev: `localStorage.setItem('imwald-debug', 'false')` then reload. * Opt out of debug in dev: `localStorage.setItem('imwald-debug', 'false')` then reload.
* Force on (e.g. prod build): `VITE_DEBUG=true` or localStorage `'true'`. * Force on: `VITE_DEBUG=true` or localStorage `'true'` (with dev, enables debug the same as default).
*/ */
type LogLevel = 'debug' | 'info' | 'warn' | 'error' type LogLevel = 'debug' | 'info' | 'warn' | 'error'

28
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
}

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

@ -53,6 +53,51 @@ export class ReplaceableEventService {
if (next) next() 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<string, number>()
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<boolean> {
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 queryService: QueryService
private onProfileIndexed?: (profileEvent: NEvent) => void | Promise<void> private onProfileIndexed?: (profileEvent: NEvent) => void | Promise<void>
private followingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({ private followingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
@ -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. // Kind 3 / NIP-65: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh.
if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) { if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) {
let idbEv: NEvent | undefined | null let idbEv: NEvent | undefined | null
@ -540,6 +597,9 @@ export class ReplaceableEventService {
eventsMap.set(`${m.pubkey}:${m.kind}`, ev) eventsMap.set(`${m.pubkey}:${m.kind}`, ev)
continue continue
} }
if (ReplaceableEventService.isProfileFetchMissCached(m.pubkey)) {
continue
}
} }
networkMissing.push(m) networkMissing.push(m)
} }
@ -997,13 +1057,20 @@ export class ReplaceableEventService {
) )
await this.indexProfile(sessionEv) await this.indexProfile(sessionEv)
void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {})
ReplaceableEventService.clearProfileFetchMiss(pubkey)
return sessionEv 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 // CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available
// Relay hints should have highest priority and always be included // 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) // 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 // 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 let authorRelayList: { read?: string[]; write?: string[] } | null = null
try { try {
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 relayListPromise = client.fetchRelayList(pubkey)
const timeoutPromise = new Promise<null>((resolve) => { const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => { setTimeout(() => {
logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey }) logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey })
resolve(null) resolve(null)
}, 10_000) }, 2800)
}) })
authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) authorRelayList = await Promise.race([relayListPromise, timeoutPromise])
if (authorRelayList == null) {
authorRelayList = await client.peekRelayListFromStorage(pubkey)
}
}
} catch (error) { } catch (error) {
logger.error('[ReplaceableEventService] Failed to fetch author relay list', { logger.error('[ReplaceableEventService] Failed to fetch author relay list', {
pubkey, pubkey,
@ -1118,8 +1196,8 @@ export class ReplaceableEventService {
undefined, undefined,
{ {
replaceableRace: true, replaceableRace: true,
eoseTimeout: 300, eoseTimeout: 220,
globalTimeout: 5000 globalTimeout: 3500
} }
) )
const queryTime = Date.now() - startTime const queryTime = Date.now() - startTime
@ -1156,6 +1234,9 @@ export class ReplaceableEventService {
pubkey, pubkey,
triedRelayHints: relayHints.length > 0 triedRelayHints: relayHints.length > 0
}) })
if (!_skipCache && relayHints.length === 0) {
ReplaceableEventService.rememberProfileFetchMiss(pubkey)
}
return undefined return undefined
} }
@ -1304,6 +1385,9 @@ export class ReplaceableEventService {
* Index profile for search (calls callback if provided) * Index profile for search (calls callback if provided)
*/ */
private async indexProfile(profileEvent: NEvent): Promise<void> { private async indexProfile(profileEvent: NEvent): Promise<void> {
if (profileEvent.kind === kinds.Metadata) {
ReplaceableEventService.clearProfileFetchMiss(profileEvent.pubkey)
}
if (this.onProfileIndexed) { if (this.onProfileIndexed) {
await this.onProfileIndexed(profileEvent) await this.onProfileIndexed(profileEvent)
} }

48
src/services/client.service.ts

@ -107,6 +107,7 @@ import {
import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning' import { applyRelayNip42AckTimeout } from '@/lib/relay-nip42-tuning'
import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events'
import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { import {
buildPrioritizedWriteRelayUrls, buildPrioritizedWriteRelayUrls,
@ -3352,15 +3353,38 @@ class ClientService extends EventTarget {
/** =========== Profile =========== */ /** =========== Profile =========== */
async searchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> { async searchProfiles(relayUrls: string[], filter: Filter): Promise<TProfile[]> {
const events = await this.queryService.query(relayUrls, { 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, ...filter,
kinds: [kinds.Metadata] kinds: [kinds.Metadata]
}, undefined, { },
undefined,
{
replaceableRace: true, replaceableRace: true,
// Search spans many relays; sub-second EOSE was cutting off almost all index relays. // Search spans many relays; sub-second EOSE was cutting off almost all index relays.
eoseTimeout: 4500, eoseTimeout: 4500,
globalTimeout: 9000 globalTimeout: 9000
}) }
)
const profileEvents = events.sort((a, b) => b.created_at - a.created_at) const profileEvents = events.sort((a, b) => b.created_at - a.created_at)
await Promise.allSettled(profileEvents.map((profile) => this.addUsernameToIndex(profile))) 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, * 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. * Returns cached results immediately, then streams relay results via callback.
*/ */
/** /**
@ -3519,6 +3543,12 @@ class ClientService extends EventTarget {
const matchProfileText = (p: TProfile) => const matchProfileText = (p: TProfile) =>
((p.username ?? '') + ' ' + (p.original_username ?? '') + ' ' + (p.nip05 ?? '')).toLowerCase() ((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). // Relay query starts immediately so it can run in parallel with local + follow work (slow relays).
const profileSearchRelayUrls = dedupeNormalizeRelayUrlsOrdered( const profileSearchRelayUrls = dedupeNormalizeRelayUrlsOrdered(
PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
@ -3669,6 +3699,16 @@ class ClientService extends EventTarget {
const seen = new Set<string>() const seen = new Set<string>()
const out: TProfile[] = [] 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) const fromIdb = await this.searchProfilesFromIndexedDBCache(q, limit)
for (const p of fromIdb) { for (const p of fromIdb) {
const pk = p.pubkey.toLowerCase() const pk = p.pubkey.toLowerCase()

10
src/services/indexed-db.service.ts

@ -21,6 +21,7 @@ import {
} from '@/lib/event' } from '@/lib/event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
import type { Filter } from 'nostr-tools' 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). */ /** Kind 0 JSON fields for profile search (display name, handle, NIP-05, pasted npub/nprofile). */
function profileMetadataMatchesQuery(ev: Event, qLower: string): boolean { function profileMetadataMatchesQuery(ev: Event, qRaw: string): boolean {
const qLower = qRaw.trim().toLowerCase()
if (!qLower || ev.kind !== kinds.Metadata) return false if (!qLower || ev.kind !== kinds.Metadata) return false
if (ev.pubkey.toLowerCase().includes(qLower)) return true if (ev.pubkey.toLowerCase().includes(qLower)) return true
const decodedPk = decodeProfileSearchQueryToPubkeyHex(qRaw)
if (decodedPk && ev.pubkey.toLowerCase() === decodedPk) return true
try { try {
const profileObj = JSON.parse(ev.content) as Record<string, unknown> const profileObj = JSON.parse(ev.content) as Record<string, unknown>
const nip05Raw = profileObj.nip05 const nip05Raw = profileObj.nip05
@ -895,7 +899,7 @@ class IndexedDbService {
} }
const row = cursor.value as TValue<Event> const row = cursor.value as TValue<Event>
const value = row?.value const value = row?.value
if (value && profileMetadataMatchesQuery(value, qLower)) { if (value && profileMetadataMatchesQuery(value, query.trim())) {
const pk = value.pubkey.toLowerCase() const pk = value.pubkey.toLowerCase()
const prev = byPubkey.get(pk) const prev = byPubkey.get(pk)
if (!prev || value.created_at > prev.created_at) { if (!prev || value.created_at > prev.created_at) {

9
src/types/index.d.ts vendored

@ -240,7 +240,14 @@ export type TPollCreateData = {
endsAt?: number 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 = { export type TSearchParams = {
type: TSearchType type: TSearchType

Loading…
Cancel
Save