feat(nostr): NIP-50 search via relay.nostr.band and nostr.wine (M11.7)
Search input sends REQ with search field to NIP-50 supporting relays. Results shown as note cards with author, content, timestamp. Filter by content type. NIP-50 button triggers relay search on Enter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
49c9f71aa2
commit
ffc6524286
@@ -93,12 +93,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search notes, npubs..."
|
||||
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search notes, npubs..."
|
||||
class="flex-1 px-3 py-2 rounded-lg text-xs outline-none transition-colors bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10"
|
||||
@keydown.enter="triggerNostrSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="search.trim()"
|
||||
class="px-2.5 py-2 rounded-lg text-[10px] bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30 shrink-0"
|
||||
:disabled="isSearching"
|
||||
@click="triggerNostrSearch"
|
||||
>
|
||||
{{ isSearching ? '...' : 'NIP-50' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
@@ -229,7 +240,7 @@ import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
|
||||
defineEmits<{ selectNote: [note: NostrNote] }>()
|
||||
|
||||
const { events: notes, isConnected, relayStates, connect, publishEvent } = useNostr()
|
||||
const { events: notes, isConnected, relayStates, connect, publishEvent, searchResults, isSearching, searchNostr } = useNostr()
|
||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||
const { verifyNip05 } = useNip05Verification()
|
||||
|
||||
@@ -277,7 +288,18 @@ function formatTime(ts: number): string {
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
|
||||
const usingNostrSearch = ref(false)
|
||||
|
||||
const filteredNotes = computed(() => {
|
||||
// If NIP-50 search active, show those results
|
||||
if (usingNostrSearch.value && searchResults.value.length > 0) {
|
||||
let result = searchResults.value
|
||||
if (activeKind.value !== null) {
|
||||
result = result.filter(n => n.kind === activeKind.value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
let result = notes.value
|
||||
if (activeKind.value !== null) {
|
||||
result = result.filter(n => n.kind === activeKind.value)
|
||||
@@ -293,6 +315,17 @@ const filteredNotes = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
function triggerNostrSearch() {
|
||||
if (!search.value.trim()) return
|
||||
usingNostrSearch.value = true
|
||||
searchNostr(search.value.trim(), activeKind.value ? [activeKind.value] : undefined)
|
||||
}
|
||||
|
||||
// Clear NIP-50 mode when search is cleared
|
||||
watch(search, (val) => {
|
||||
if (!val.trim()) usingNostrSearch.value = false
|
||||
})
|
||||
|
||||
// Verify NIP-05 for notes that have it
|
||||
watch(filteredNotes, (visibleNotes) => {
|
||||
for (const note of visibleNotes) {
|
||||
|
||||
@@ -282,6 +282,85 @@ function importNIP65Relays(event: NostrEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
const NIP50_RELAYS = [
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nostr.wine',
|
||||
]
|
||||
|
||||
const searchResults = shallowRef<NostrNote[]>([])
|
||||
const isSearching = ref(false)
|
||||
|
||||
function searchNostr(query: string, kinds?: number[]): void {
|
||||
if (!query.trim()) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
isSearching.value = true
|
||||
searchResults.value = []
|
||||
|
||||
const subId = generateSubId()
|
||||
const filter: Record<string, unknown> = { search: query, limit: 50 }
|
||||
if (kinds && kinds.length > 0) filter.kinds = kinds
|
||||
|
||||
const results: NostrNote[] = []
|
||||
let closedCount = 0
|
||||
|
||||
for (const relayUrl of NIP50_RELAYS) {
|
||||
try {
|
||||
const ws = new WebSocket(relayUrl)
|
||||
const timer = setTimeout(() => {
|
||||
ws.close()
|
||||
}, 8000)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify(['REQ', subId, filter]))
|
||||
}
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data)
|
||||
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === subId && data[2]) {
|
||||
const evt = data[2] as NostrEvent
|
||||
if (!results.find(r => r.id === evt.id)) {
|
||||
results.push({
|
||||
id: evt.id,
|
||||
pubkey: evt.pubkey,
|
||||
authorName: truncatePubkey(evt.pubkey),
|
||||
kind: evt.kind,
|
||||
content: evt.content,
|
||||
created_at: evt.created_at,
|
||||
tags: evt.tags ?? [],
|
||||
})
|
||||
searchResults.value = [...results].sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === subId) {
|
||||
clearTimeout(timer)
|
||||
ws.close()
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
closedCount++
|
||||
if (closedCount >= NIP50_RELAYS.length) {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
} catch {
|
||||
closedCount++
|
||||
if (closedCount >= NIP50_RELAYS.length) {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
url: string
|
||||
success: boolean
|
||||
@@ -388,6 +467,9 @@ export function useNostr() {
|
||||
disconnect,
|
||||
fetchNote,
|
||||
publishEvent,
|
||||
searchResults,
|
||||
isSearching,
|
||||
searchNostr,
|
||||
addRelay,
|
||||
removeRelay,
|
||||
toggleRelayRead,
|
||||
|
||||
Reference in New Issue
Block a user