16 changed files with 469 additions and 164 deletions
@ -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 ( |
||||||
|
<Button variant="outline" size="sm" className="mt-3 gap-2" asChild> |
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer"> |
||||||
|
<BookOpen className="h-4 w-4 shrink-0" aria-hidden /> |
||||||
|
{t('Search on Alexandria')} |
||||||
|
</a> |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
@ -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)}` |
||||||
|
} |
||||||
@ -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<Event[]> { |
||||||
|
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<string>() |
||||||
|
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 |
||||||
|
} |
||||||
@ -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<string>() |
||||||
|
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<string, unknown> |
||||||
|
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<string, unknown> |
||||||
|
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 |
||||||
|
} |
||||||
Loading…
Reference in new issue