From 2e4d6a49e7f01007c61b017febe301003f6e3c61 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Wed, 13 May 2026 23:44:41 +0200
Subject: [PATCH] bug-fixes
---
.../AlexandriaEventsSearchEmptyCta.tsx | 16 +++
src/components/NormalFeed/index.tsx | 6 +-
src/components/NoteList/index.tsx | 31 +++--
src/components/ProfileListBySearch/index.tsx | 12 +-
.../SearchResult/FullTextSearchByRelay.tsx | 53 ++++----
src/components/SearchResult/index.tsx | 15 ++-
src/lib/alexandria-events-search-url.ts | 116 ++++++++++++++++++
src/lib/local-nip50-search-merge.ts | 85 +++++++++++++
src/lib/nip50-local-text-match.ts | 24 +---
src/lib/profile-metadata-search.ts | 105 ++++++++++++++++
src/lib/profile-relay-search-filters.ts | 40 ++++--
src/pages/secondary/NoteListPage/index.tsx | 17 ++-
src/services/client-events.service.ts | 24 +---
src/services/client.service.ts | 13 +-
src/services/indexed-db.service.ts | 38 +-----
src/services/mention-event-search.service.ts | 38 ++----
16 files changed, 469 insertions(+), 164 deletions(-)
create mode 100644 src/components/AlexandriaEventsSearchEmptyCta.tsx
create mode 100644 src/lib/alexandria-events-search-url.ts
create mode 100644 src/lib/local-nip50-search-merge.ts
create mode 100644 src/lib/profile-metadata-search.ts
diff --git a/src/components/AlexandriaEventsSearchEmptyCta.tsx b/src/components/AlexandriaEventsSearchEmptyCta.tsx
new file mode 100644
index 00000000..7571cc5a
--- /dev/null
+++ b/src/components/AlexandriaEventsSearchEmptyCta.tsx
@@ -0,0 +1,16 @@
+import { Button } from '@/components/ui/button'
+import { BookOpen } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+/** Shown when local/relay search finished with zero events; opens Alexandria with a matching query. */
+export function AlexandriaEventsSearchEmptyCta({ href }: { href: string }) {
+ const { t } = useTranslation()
+ return (
+
+ )
+}
diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx
index 6b1e6603..bb4d4516 100644
--- a/src/components/NormalFeed/index.tsx
+++ b/src/components/NormalFeed/index.tsx
@@ -106,6 +106,8 @@ const NormalFeed = forwardRef(function NormalFeed(
{
subRequests,
@@ -138,7 +140,8 @@ const NormalFeed = forwardRef
>
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index 10889ffc..662e8642 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -1,4 +1,5 @@
import NewNotesButton from '@/components/NewNotesButton'
+import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import {
ExtendedKind,
FAST_READ_RELAY_URLS,
@@ -25,6 +26,8 @@ import {
import logger from '@/lib/logger'
import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
+import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
+import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
@@ -399,6 +402,7 @@ function applyProgressiveSessionSearchLayer(params: ProgressiveSearchLocalLayerO
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP
let boot = client.getSessionEventsMatchingSearch(warmQ, cap, kindsForWarm)
+ boot = boot.filter((ev) => eventMatchesNip50LocalFullTextQuery(ev, warmQ))
if (warmMatch) boot = boot.filter(warmMatch)
const sortCreated = (evs: Event[]) => [...evs].sort((a, b) => b.created_at - a.created_at)
const finalizeOrder = (evs: Event[]) => (afterSort ? [...evs].sort(afterSort) : sortCreated(evs))
@@ -413,14 +417,17 @@ function startProgressiveIdbSearchLayer(params: ProgressiveSearchLocalLayerOpts)
const cap = FEED_FULL_SEARCH_MERGE_CAP
void (async () => {
try {
- const idbE = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(
- warmQ,
- cap,
- kindsForWarm,
- { archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS }
- )
+ const local = await collectLocalEventsForTextSearch({
+ query: warmQ,
+ allowedKinds: kindsForWarm,
+ sessionCap: 0,
+ idbMergedLimit: cap,
+ archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS,
+ includeOtherStoresFullText: true,
+ fullTextStoreHitCap: Math.min(400, Math.max(cap, 120))
+ })
if (isStale()) return
- const idbUse = warmMatch ? idbE.filter(warmMatch) : idbE
+ const idbUse = warmMatch ? local.filter(warmMatch) : local
if (idbUse.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, idbUse, afterSort))
setLoading(false)
@@ -763,7 +770,12 @@ const NoteList = forwardRef(
* When true (multi-relay home feeds): if every relay in the subscribe wave fails before EOSE, run one
* {@link client.fetchEvents} against {@link FAST_READ_RELAY_URLS} so the feed is not stuck on stale cache only.
*/
- timelinePublicReadFallback = false
+ timelinePublicReadFallback = false,
+ /**
+ * When set and the timeline is empty (after relays finish), show a link to Alexandria with a matching query
+ * (hashtag / d-tag browse from {@link NormalFeed}).
+ */
+ alexandriaEmptyUrl = null
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
@@ -820,6 +832,8 @@ const NoteList = forwardRef(
/** When true, render events as an Instagram-style 3-column square media grid. */
gridLayout?: boolean
timelinePublicReadFallback?: boolean
+ /** Optional Alexandria `/events` URL when this feed’s timeline is empty (search / tag browse). */
+ alexandriaEmptyUrl?: string | null
},
ref
) => {
@@ -4456,6 +4470,7 @@ const NoteList = forwardRef(
role="status"
>
{t('No posts loaded for this feed. Try refreshing.')}
+ {alexandriaEmptyUrl ? : null}
)}
{phase === 'ready' && empty && (
- {t('Profile search no results')}
+
+
{t('Profile search no results')}
+ {(() => {
+ const trimmed = search.trim()
+ if (!trimmed) return null
+ const href = buildAlexandriaEventsSearchUrlForTSearchParams({ type: 'profiles', search })
+ return href ?
: null
+ })()}
+
)}
{pubkeys.map((pubkey, index) => (
normalizeRelayList(relayUrls), [relayUrls])
const q = searchQuery.trim()
+ const alexandriaEmptyHref = useMemo(
+ () => (q ? buildAlexandriaEventsSearchUrlFromNotesQuery(q) : null),
+ [q]
+ )
const searchProfileResetKey = useMemo(
() => `${q}\n${normalizedRelays.join('\n')}`,
[q, normalizedRelays]
@@ -401,34 +406,17 @@ export default function FullTextSearchByRelay({
}
void (async () => {
- const fromSession = client.getSessionEventsMatchingSearch(q, 220, kindsArr)
- let fromIdb: Event[] = []
- try {
- fromIdb = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(q, 120, kindsArr, {
- archiveScanMaxMs: 15_000
- })
- } catch {
- fromIdb = []
- }
+ const mergedLocal = await collectLocalEventsForTextSearch({
+ query: q,
+ allowedKinds: kindsArr,
+ sessionCap: 220,
+ idbMergedLimit: 120,
+ archiveScanMaxMs: 15_000,
+ includeOtherStoresFullText: true,
+ fullTextStoreHitCap: 260
+ })
if (myRun !== runGeneration.current || abort.signal.aborted) return
- const seen = new Set
()
- const mergedLocal: Event[] = []
- for (const e of fromSession) {
- if (seen.has(e.id)) continue
- seen.add(e.id)
- mergedLocal.push(e)
- }
- for (const e of fromIdb) {
- if (seen.has(e.id)) continue
- seen.add(e.id)
- mergedLocal.push(e)
- }
- const mergedLocalMatching = mergedLocal.filter(
- (e) =>
- kindsArr.includes(e.kind) &&
- eventMatchesNip50LocalFullTextQuery(e, q) &&
- mergedSearchNoteHasPreviewBody(e)
- )
+ const mergedLocalMatching = mergedLocal.filter((e) => mergedSearchNoteHasPreviewBody(e))
if (mergedLocalMatching.length === 0) return
applyMergedUpdate((map) => {
for (const ev of mergedLocalMatching) {
@@ -619,9 +607,10 @@ export default function FullTextSearchByRelay({
{allTerminal && mergedHits.length === 0 && (
-
- {t('Full-text search empty merged')}
-
+
+
{t('Full-text search empty merged')}
+ {alexandriaEmptyHref ?
: null}
+
)}
{allTerminal && mergedHits.length > 0 && (
diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx
index 6dccd7f9..d8e4f327 100644
--- a/src/components/SearchResult/index.tsx
+++ b/src/components/SearchResult/index.tsx
@@ -9,6 +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 { useLayoutEffect, useMemo } from 'react'
function relayDedupeKey(url: string): string {
@@ -73,6 +74,14 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
[combinedRelays, searchableKeySet]
)
+ const alexandriaEmptyUrlForHashtag = useMemo(
+ () =>
+ searchParams?.type === 'hashtag'
+ ? buildAlexandriaEventsUrlForHashtagParam(searchParams.search)
+ : null,
+ [searchParams?.type, searchParams?.search]
+ )
+
if (!searchParams) {
return null
}
@@ -98,7 +107,11 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
...(nonSearchableRelays.length > 0 ? [{ urls: nonSearchableRelays, filter: hashtagFilter }] : [])
]
return (
-
+
)
}
return
diff --git a/src/lib/alexandria-events-search-url.ts b/src/lib/alexandria-events-search-url.ts
new file mode 100644
index 00000000..4280669a
--- /dev/null
+++ b/src/lib/alexandria-events-search-url.ts
@@ -0,0 +1,116 @@
+import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
+import type { TSearchParams } from '@/types'
+import { nip19 } from 'nostr-tools'
+
+export const ALEXANDRIA_NEXT_EVENTS_BASE = 'https://next-alexandria.gitcitadel.eu/events'
+
+const NIP05_STANDALONE = /^\s*[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\s*$/
+
+/** `id` query for Alexandria: hex for note/nevent; full bech32 for naddr. */
+function nip19ToAlexandriaIdValue(bech32Input: string): string | null {
+ try {
+ let id = bech32Input.trim()
+ if (id.startsWith('nostr:')) id = id.slice(6)
+ const decoded = nip19.decode(id)
+ if (decoded.type === 'note' || decoded.type === 'nevent') {
+ const d = decoded.data as string | { id: string }
+ const hex = typeof d === 'string' ? d : d.id
+ return hex ? hex.toLowerCase() : null
+ }
+ if (decoded.type === 'naddr') {
+ return id
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Maps a free-form “notes” / NIP-50 style query to Alexandria `/events` query params
+ * (`d`, `t`, `n`, `q`, `id`) per https://next-alexandria.gitcitadel.eu/events.
+ */
+export function buildAlexandriaEventsSearchUrlFromNotesQuery(query: string): string | null {
+ const q = query.trim()
+ if (!q) return null
+
+ const lower = q.toLowerCase()
+
+ if (lower.startsWith('d:')) {
+ const inner = q.slice(2).trim()
+ const d = normalizeToDTag(inner) || inner.toLowerCase().replace(/\s+/g, '-').replace(/^-+|-+$/g, '')
+ if (!d) return null
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?d=${encodeURIComponent(d)}`
+ }
+
+ if (lower.startsWith('t:')) {
+ const inner = q.slice(2).trim().toLowerCase()
+ if (!inner) return null
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(inner)}`
+ }
+
+ if (lower.startsWith('n:')) {
+ const inner = q.slice(2).trim()
+ if (!inner) return null
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?n=${encodeURIComponent(inner)}`
+ }
+
+ if (q.startsWith('#')) {
+ const inner = q.slice(1).trim().toLowerCase()
+ if (!inner) return null
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(inner)}`
+ }
+
+ if (/^[0-9a-f]{64}$/i.test(q)) {
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?id=${encodeURIComponent(q.toLowerCase())}`
+ }
+
+ const nip19Id = nip19ToAlexandriaIdValue(q)
+ if (nip19Id) {
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?id=${encodeURIComponent(nip19Id)}`
+ }
+
+ if (NIP05_STANDALONE.test(q)) {
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?q=${encodeURIComponent(q.trim())}`
+ }
+
+ const adv = parseAdvancedSearch(q)
+ if (adv.hashtag) {
+ const raw = Array.isArray(adv.hashtag) ? adv.hashtag[0] : adv.hashtag
+ const tag = raw?.toString().trim().toLowerCase()
+ if (tag) return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(tag)}`
+ }
+
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?q=${encodeURIComponent(q)}`
+}
+
+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)
+ }
+ return null
+}
+
+export function buildAlexandriaEventsUrlForHashtagParam(tag: string): string | null {
+ const t = tag.trim().toLowerCase()
+ if (!t) return null
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?t=${encodeURIComponent(t)}`
+}
+
+export function buildAlexandriaEventsUrlForDTagParam(d: string): string | null {
+ const v = d.trim()
+ if (!v) return null
+ const normalized = normalizeToDTag(v) || v
+ return `${ALEXANDRIA_NEXT_EVENTS_BASE}?d=${encodeURIComponent(normalized)}`
+}
diff --git a/src/lib/local-nip50-search-merge.ts b/src/lib/local-nip50-search-merge.ts
new file mode 100644
index 00000000..65c31b0c
--- /dev/null
+++ b/src/lib/local-nip50-search-merge.ts
@@ -0,0 +1,85 @@
+import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
+import indexedDb from '@/services/indexed-db.service'
+import { eventService } from '@/services/client.service'
+import type { Event } from 'nostr-tools'
+
+export type CollectLocalTextSearchParams = {
+ query: string
+ /** Kind filter (same semantics as NIP-50 `kinds` on relays). */
+ allowedKinds: readonly number[]
+ /**
+ * Session LRU scan cap for {@link EventService.getSessionEventsMatchingSearch}.
+ * Use `0` when the caller already merged the session layer synchronously.
+ */
+ sessionCap: number
+ /** `limit` passed to {@link IndexedDbService.getCachedAndArchivedEventsMatchingLocalSearch}. */
+ idbMergedLimit: number
+ archiveScanMaxMs?: number
+ /**
+ * When true, also scan non–event-archive stores via {@link IndexedDbService.searchAllCachedEventsFullText}
+ * (same extra coverage as the mention / citation picker path).
+ */
+ includeOtherStoresFullText?: boolean
+ /** Max rows from {@link IndexedDbService.searchAllCachedEventsFullText} when enabled. */
+ fullTextStoreHitCap?: number
+}
+
+/**
+ * Merges local session + publication + event-archive (and optionally other IndexedDB stores) for the same
+ * text query and kind filter, deduped by id, sorted newest-first. Every row must satisfy
+ * {@link eventMatchesNip50LocalFullTextQuery} (defense in depth on top of store-specific scans).
+ */
+export async function collectLocalEventsForTextSearch(
+ params: CollectLocalTextSearchParams
+): Promise {
+ const q = params.query.trim()
+ if (!q) return []
+
+ const kindsArr = [...params.allowedKinds]
+ if (kindsArr.length === 0) return []
+
+ const kindSet = new Set(kindsArr)
+ const seen = new Set()
+ const out: Event[] = []
+
+ const push = (ev: Event) => {
+ if (!kindSet.has(ev.kind)) return
+ if (!eventMatchesNip50LocalFullTextQuery(ev, q)) return
+ if (seen.has(ev.id)) return
+ seen.add(ev.id)
+ out.push(ev)
+ }
+
+ if (params.sessionCap > 0) {
+ for (const ev of eventService.getSessionEventsMatchingSearch(q, params.sessionCap, kindsArr)) {
+ push(ev)
+ }
+ }
+
+ const idbOpts =
+ params.archiveScanMaxMs !== undefined ? { archiveScanMaxMs: params.archiveScanMaxMs } : undefined
+ const fromPubArchive = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(
+ q,
+ params.idbMergedLimit,
+ kindsArr,
+ idbOpts
+ )
+ for (const ev of fromPubArchive) {
+ push(ev)
+ }
+
+ if (params.includeOtherStoresFullText) {
+ const cap = params.fullTextStoreHitCap ?? 260
+ try {
+ const hits = await indexedDb.searchAllCachedEventsFullText(q, { limit: cap })
+ for (const hit of hits) {
+ if (hit.value) push(hit.value as Event)
+ }
+ } catch {
+ /* optional cross-store scan */
+ }
+ }
+
+ out.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
+ return out
+}
diff --git a/src/lib/nip50-local-text-match.ts b/src/lib/nip50-local-text-match.ts
index bb0c6ca4..1afbe269 100644
--- a/src/lib/nip50-local-text-match.ts
+++ b/src/lib/nip50-local-text-match.ts
@@ -1,3 +1,4 @@
+import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
@@ -15,6 +16,8 @@ export function eventMatchesNip50LocalFullTextQuery(ev: Event, query: string): b
const decodedAuthor = decodeProfileSearchQueryToPubkeyHex(raw)
if (decodedAuthor && ev.pubkey.toLowerCase() === decodedAuthor) return true
+ if (ev.kind === kinds.Metadata && profileKind0MatchesSearchQuery(ev, raw)) return true
+
if (ev.id.toLowerCase().includes(q)) return true
if (ev.pubkey.toLowerCase().includes(q)) return true
if (String(ev.kind).includes(q)) return true
@@ -26,26 +29,5 @@ export function eventMatchesNip50LocalFullTextQuery(ev: Event, query: string): b
}
}
- if (ev.kind === kinds.Metadata) {
- try {
- const o = JSON.parse(ev.content || '{}') as {
- name?: unknown
- display_name?: unknown
- about?: unknown
- nip05?: unknown
- }
- const pick = (v: unknown) => (typeof v === 'string' ? v.toLowerCase() : '')
- const nip05 = pick(o.nip05)
- const blob = [pick(o.name), pick(o.display_name), pick(o.about), nip05]
- .filter(Boolean)
- .join(' ')
- if (blob.includes(q)) return true
- const qNeedle = q.startsWith('@') ? q.slice(1) : q
- if (q.startsWith('@') && qNeedle.length > 0 && blob.includes(qNeedle)) return true
- } catch {
- /* ignore invalid profile JSON */
- }
- }
-
return false
}
diff --git a/src/lib/profile-metadata-search.ts b/src/lib/profile-metadata-search.ts
new file mode 100644
index 00000000..b3bb390f
--- /dev/null
+++ b/src/lib/profile-metadata-search.ts
@@ -0,0 +1,105 @@
+import { splitNip05Identifier } from '@/lib/nip05'
+import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
+import type { Event } from 'nostr-tools'
+import { kinds } from 'nostr-tools'
+
+/**
+ * Trim, lowercase, and collapse whitespace for case-insensitive profile / NIP-05 matching.
+ */
+export function normalizeProfileSearchQueryForMatch(raw: string): string {
+ return raw.trim().toLowerCase().replace(/\s+/g, ' ')
+}
+
+/** Every `nip05` tag value plus JSON `nip05` (string or string[]). */
+export function collectNip05ValuesFromKind0(ev: Event): string[] {
+ const set = new Set()
+ for (const t of ev.tags ?? []) {
+ if (!Array.isArray(t) || t.length < 2) continue
+ if (String(t[0]).toLowerCase() !== 'nip05') continue
+ for (let i = 1; i < t.length; i++) {
+ const v = String(t[i] ?? '').trim()
+ if (v) set.add(v)
+ }
+ }
+ try {
+ const o = JSON.parse(ev.content || '{}') as Record
+ const n = o.nip05
+ if (typeof n === 'string' && n.trim()) set.add(n.trim())
+ else if (Array.isArray(n)) {
+ for (const x of n) {
+ if (typeof x === 'string' && x.trim()) set.add(x.trim())
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ return [...set]
+}
+
+function haystackMatchesNeedle(haystack: string, needle: string, needleNoAt: string): boolean {
+ const h = haystack.toLowerCase()
+ if (needle && h.includes(needle)) return true
+ if (needleNoAt && needleNoAt.length > 0 && h.includes(needleNoAt)) return true
+ return false
+}
+
+/**
+ * True when kind-0 `ev` matches a profile search string: pubkey / npub decode, raw JSON `content`,
+ * JSON name / display_name / about, every `nip05` tag, and JSON `nip05` (including multiple values).
+ */
+export function profileKind0MatchesSearchQuery(ev: Event, rawQuery: string): boolean {
+ if (ev.kind !== kinds.Metadata) return false
+ const trimmed = rawQuery.trim()
+ if (!trimmed) return false
+
+ const decodedPk = decodeProfileSearchQueryToPubkeyHex(trimmed)
+ if (decodedPk && ev.pubkey.toLowerCase() === decodedPk) return true
+
+ const needle = normalizeProfileSearchQueryForMatch(trimmed)
+ if (!needle) return false
+ const needleNoAt = needle.startsWith('@') ? needle.slice(1).trim() : needle
+
+ const pkLower = ev.pubkey.toLowerCase()
+ if (pkLower.includes(needle) || (needleNoAt && pkLower.includes(needleNoAt))) return true
+
+ const contentRaw = ev.content ?? ''
+ if (haystackMatchesNeedle(contentRaw, needle, needleNoAt)) return true
+
+ for (const nip of collectNip05ValuesFromKind0(ev)) {
+ const nl = nip.toLowerCase()
+ if (nl === needle || (needleNoAt && nl === needleNoAt)) return true
+ if (haystackMatchesNeedle(nip, needle, needleNoAt)) return true
+ const sp = splitNip05Identifier(nip)
+ if (sp) {
+ const compact = `${sp.name}@${sp.domain}`.toLowerCase()
+ const spaced = `${sp.name} ${sp.domain}`.toLowerCase()
+ if (
+ compact.includes(needle) ||
+ needle.includes(compact) ||
+ spaced.includes(needle) ||
+ needle.includes(spaced)
+ ) {
+ return true
+ }
+ if (needleNoAt) {
+ if (compact.includes(needleNoAt) || spaced.includes(needleNoAt)) return true
+ }
+ }
+ }
+
+ try {
+ const profileObj = JSON.parse(ev.content || '{}') as Record
+ const blobs = [
+ typeof profileObj.display_name === 'string' ? profileObj.display_name : '',
+ typeof profileObj.name === 'string' ? profileObj.name : '',
+ typeof profileObj.about === 'string' ? profileObj.about : ''
+ ]
+ for (const p of blobs) {
+ if (haystackMatchesNeedle(p, needle, needleNoAt)) return true
+ }
+ } catch {
+ return false
+ }
+
+ return false
+}
diff --git a/src/lib/profile-relay-search-filters.ts b/src/lib/profile-relay-search-filters.ts
index 23aad8b3..a0189e20 100644
--- a/src/lib/profile-relay-search-filters.ts
+++ b/src/lib/profile-relay-search-filters.ts
@@ -1,5 +1,7 @@
import type { Filter } from 'nostr-tools'
import { kinds } from 'nostr-tools'
+import { splitNip05Identifier } from '@/lib/nip05'
+import { normalizeProfileSearchQueryForMatch } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
/**
@@ -14,19 +16,21 @@ export function buildProfileKind0SearchFilters(opts: {
limit: number
until?: number
}): Filter[] {
- const search = opts.search.trim()
- if (!search) return []
+ const searchRaw = opts.search.trim()
+ if (!searchRaw) return []
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500))
const time =
typeof opts.until === 'number' && opts.until > 0 ? ({ until: opts.until } as Pick) : {}
const k = [kinds.Metadata] as number[]
- const pubkeyHex = decodeProfileSearchQueryToPubkeyHex(search)
+ const pubkeyHex = decodeProfileSearchQueryToPubkeyHex(searchRaw)
if (pubkeyHex) {
return [{ kinds: k, authors: [pubkeyHex], limit, ...time }]
}
+ const searchNorm = normalizeProfileSearchQueryForMatch(searchRaw)
+
const seen = new Set()
const out: Filter[] = []
const add = (f: Filter) => {
@@ -36,17 +40,31 @@ export function buildProfileKind0SearchFilters(opts: {
out.push(f)
}
- add({ kinds: k, search, limit, ...time })
+ add({ kinds: k, search: searchRaw, limit, ...time })
+ if (searchNorm.length > 0 && searchNorm !== searchRaw) {
+ add({ kinds: k, search: searchNorm, limit, ...time })
+ }
- if (search.includes('@')) {
- const firstToken = search.split(/\s+/)[0] ?? search
- const nipLower = firstToken.trim().toLowerCase()
- if (nipLower) add({ kinds: k, '#nip05': [nipLower], limit, ...time })
- const nipExact = firstToken.trim()
- if (nipExact && nipExact !== nipLower) add({ kinds: k, '#nip05': [nipExact], limit, ...time })
+ if (searchRaw.includes('@')) {
+ const firstToken = (searchRaw.split(/\s+/)[0] ?? searchRaw).trim()
+ const nipVariants = new Set()
+ if (firstToken) {
+ nipVariants.add(firstToken.toLowerCase())
+ nipVariants.add(firstToken)
+ }
+ if (searchNorm) nipVariants.add(searchNorm)
+ const sp = splitNip05Identifier(firstToken)
+ if (sp) {
+ nipVariants.add(`${sp.name}@${sp.domain}`.toLowerCase())
+ nipVariants.add(`${sp.name}@${sp.domain}`)
+ }
+ for (const v of nipVariants) {
+ if (!v) continue
+ add({ kinds: k, '#nip05': [v], limit, ...time })
+ }
}
- const token = search.startsWith('@') ? search.slice(1).trim() : search.trim()
+ const token = searchRaw.startsWith('@') ? searchRaw.slice(1).trim() : searchRaw.trim()
if (
token &&
!/\s/.test(token) &&
diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx
index 5754c604..6b788d33 100644
--- a/src/pages/secondary/NoteListPage/index.tsx
+++ b/src/pages/secondary/NoteListPage/index.tsx
@@ -16,6 +16,10 @@ import {
} from '@/lib/favorites-feed-relays'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link'
+import {
+ buildAlexandriaEventsUrlForDTagParam,
+ buildAlexandriaEventsUrlForHashtagParam
+} from '@/lib/alexandria-events-search-url'
import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search'
import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
@@ -63,6 +67,16 @@ const NoteListPage = forwardRef(({ index, hid
>(null)
const [subRequests, setSubRequests] = useState([])
+ const alexandriaEmptyUrl = useMemo(() => {
+ if (!data) return null
+ if (data.type === 'dtag' && data.dtag) return buildAlexandriaEventsUrlForDTagParam(data.dtag)
+ if (data.type === 'hashtag' || data.type === 'hashtagSearch') {
+ const t = new URLSearchParams(window.location.search).get('t') ?? ''
+ return buildAlexandriaEventsUrlForHashtagParam(t)
+ }
+ return null
+ }, [data])
+
// Get hashtag from URL if this is a hashtag page
const hashtag = useMemo(() => {
if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') {
@@ -355,9 +369,10 @@ const NoteListPage = forwardRef(({ index, hid
oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(data.dtag!, a, b)}
extraShouldHideEvent={(ev) => !eventMatchesDTagLooseQuery(data.dtag!, ev)}
oneShotMergedCap={400}
+ alexandriaEmptyUrl={alexandriaEmptyUrl}
/>
) : (
-
+
)
}
diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts
index b54d3fcb..1fdb6e52 100644
--- a/src/services/client-events.service.ts
+++ b/src/services/client-events.service.ts
@@ -35,6 +35,7 @@ import {
import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config'
import { isCalendarEventKind } from '@/lib/calendar-event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
+import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest, type ShouldDropEventOnIngestOptions } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
@@ -636,31 +637,14 @@ export class EventService {
* Pubkeys whose session-cached kind 0 matches a name / display_name / nip-05 substring (for search without IDB).
*/
searchSessionProfilePubkeys(query: string, limit: number): string[] {
- const q = query.trim().toLowerCase()
+ const q = query.trim()
if (!q || limit <= 0) return []
const out: string[] = []
for (const ev of this.sessionMetadataByPubkey.values()) {
if (shouldDropEventOnIngest(ev)) continue
if (out.length >= limit) break
- try {
- const o = JSON.parse(ev.content) as Record
- const nip05 =
- typeof o.nip05 === 'string'
- ? o.nip05
- .split('@')
- .map((s: string) => s.trim())
- .join(' ')
- : ''
- const blob = [o.display_name, o.name, nip05]
- .map((x) => (typeof x === 'string' ? x : ''))
- .join(' ')
- .toLowerCase()
- const qNeedle = q.startsWith('@') ? q.slice(1) : q
- if (blob.includes(q) || (qNeedle.length > 0 && blob.includes(qNeedle))) {
- out.push(ev.pubkey.toLowerCase())
- }
- } catch {
- /* invalid JSON */
+ if (profileKind0MatchesSearchQuery(ev, q)) {
+ out.push(ev.pubkey.toLowerCase())
}
}
return out
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 55a9c785..b4f27812 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -109,6 +109,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 { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import {
@@ -3906,15 +3907,9 @@ class ClientService extends EventTarget {
private async addUsernameToIndex(profileEvent: NEvent) {
try {
const profileObj = JSON.parse(profileEvent.content)
- const text = [
- profileObj.display_name?.trim() ?? '',
- profileObj.name?.trim() ?? '',
- profileObj.nip05
- ?.split('@')
- .map((s: string) => s.trim())
- .join(' ') ?? ''
- ].join(' ')
- if (!text) return
+ const nip05All = collectNip05ValuesFromKind0(profileEvent).join(' ')
+ const text = [profileObj.display_name?.trim() ?? '', profileObj.name?.trim() ?? '', nip05All].join(' ')
+ if (!text.trim()) return
await this.userIndex.addAsync(profileEvent.pubkey, text)
} catch {
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index a030b7f3..97b7ca7c 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -21,7 +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 { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
@@ -78,36 +78,6 @@ function isLikelyCachedNostrEvent(v: unknown): v is Event {
)
}
-/** 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
- const nip05 =
- typeof nip05Raw === 'string'
- ? nip05Raw
- .split('@')
- .map((s: string) => s.trim())
- .join(' ')
- : ''
- const text = [
- typeof profileObj.display_name === 'string' ? profileObj.display_name.trim() : '',
- typeof profileObj.name === 'string' ? profileObj.name.trim() : '',
- nip05
- ]
- .join(' ')
- .toLowerCase()
- return text.includes(qLower)
- } catch {
- return false
- }
-}
-
export const StoreNames = {
PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents',
@@ -799,8 +769,8 @@ class IndexedDbService {
}
/**
- * Scan cached kind-0 rows for a handle / display name / NIP-05 substring (case-insensitive).
- * Newest replaceable wins per pubkey.
+ * Scan cached kind-0 rows for pubkey / npub / name / about / NIP-05 in JSON `content`, JSON `nip05`,
+ * and every `nip05` tag (see {@link profileKind0MatchesSearchQuery}). Newest replaceable wins per pubkey.
*/
async searchProfileEventsInCache(query: string, limit: number): Promise {
const qLower = query.trim().toLowerCase()
@@ -824,7 +794,7 @@ class IndexedDbService {
}
const row = cursor.value as TValue
const value = row?.value
- if (value && profileMetadataMatchesQuery(value, query.trim())) {
+ if (value && profileKind0MatchesSearchQuery(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/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts
index d82b814a..ae877e9b 100644
--- a/src/services/mention-event-search.service.ts
+++ b/src/services/mention-event-search.service.ts
@@ -13,6 +13,7 @@ import { normalizeUrl } from '@/lib/url'
import { kinds, type Event as NEvent } from 'nostr-tools'
import client, { eventService, queryService } from './client.service'
import indexedDb from './indexed-db.service'
+import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
const DEFAULT_NOTES_LIMIT = 20
@@ -175,33 +176,20 @@ export async function searchEventsForPicker(
}
const sessionCap = Math.min(1500, Math.max(limit * 8, 200))
- const fromSession = eventService.getSessionEventsMatchingSearch(q, sessionCap, kindsList)
- fromSession.forEach(addUnique)
- if (out.length >= limit) return out.slice(0, limit)
-
const localMergeTarget = Math.min(PICKER_LOCAL_DB_MERGE_CAP, Math.max(limit * 10, 240))
- const [fromLocalDb, userCentricRelayUrls] = await Promise.all([
- indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(q, localMergeTarget, kindsList, {
- archiveScanMaxMs: 24_000
- }),
- buildCitationPickerSearchRelayUrls()
- ])
- fromLocalDb.forEach(addUnique)
-
- try {
- const fullTextHits = await indexedDb.searchAllCachedEventsFullText(q, {
- limit: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200))
- })
- const kindSet = new Set(kindsList)
- for (const hit of fullTextHits) {
- const ev = hit.value
- if (ev && kindSet.has(ev.kind)) addUnique(ev as NEvent)
- if (out.length >= limit) break
- }
- } catch {
- /* best-effort: other stores optional */
- }
+ const fromLocalMerged = await collectLocalEventsForTextSearch({
+ query: q,
+ allowedKinds: kindsList,
+ sessionCap,
+ idbMergedLimit: localMergeTarget,
+ archiveScanMaxMs: 24_000,
+ includeOtherStoresFullText: true,
+ fullTextStoreHitCap: Math.min(PICKER_FULLTEXT_DB_CAP, Math.max(localMergeTarget, 200))
+ })
+ fromLocalMerged.forEach(addUnique)
+
+ const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls()
if (out.length >= limit) return out.slice(0, limit)