Files
archy/packages/app/src/composables/useNostr.ts
T
DorianandClaude Opus 4.6 ffc6524286 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>
2026-03-04 00:25:24 +00:00

481 lines
12 KiB
TypeScript

import { ref, shallowRef, onUnmounted } from 'vue'
export interface NostrEvent {
id: string
pubkey: string
kind: number
content: string
created_at: number
tags: string[][]
sig: string
}
export interface NostrNote {
id: string
pubkey: string
authorName?: string
nip05?: string
kind: number
content: string
created_at: number
tags: string[][]
}
export interface RelayConfig {
url: string
read: boolean
write: boolean
}
export interface RelayInfo {
url: string
connected: boolean
read: boolean
write: boolean
latencyMs: number | null
}
interface RelayState {
url: string
ws: WebSocket | null
connected: boolean
read: boolean
write: boolean
latencyMs: number | null
connectTime: number | null
}
const DEFAULT_RELAYS: RelayConfig[] = [
{ url: 'wss://relay.damus.io', read: true, write: true },
{ url: 'wss://nos.lol', read: true, write: true },
{ url: 'wss://relay.snort.social', read: true, write: true },
]
const STORAGE_KEY = 'aiui-nostr-relays'
function loadRelayConfig(): RelayConfig[] {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return JSON.parse(stored)
} catch { /* ignore */ }
return DEFAULT_RELAYS
}
function saveRelayConfig(configs: RelayConfig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(configs))
}
const events = shallowRef<NostrNote[]>([])
const isConnected = ref(false)
const relayStates = ref<RelayInfo[]>([])
let relays: RelayState[] = []
let subscriptionId: string | null = null
let initialized = false
function generateSubId(): string {
return 'aiui-' + Math.random().toString(36).slice(2, 10)
}
function truncatePubkey(pubkey: string): string {
if (pubkey.length <= 12) return pubkey
return pubkey.slice(0, 8) + '...' + pubkey.slice(-4)
}
function parseEvent(data: unknown): NostrEvent | null {
if (!Array.isArray(data)) return null
if (data[0] !== 'EVENT' || !data[2]) return null
const evt = data[2]
if (!evt.id || !evt.pubkey || typeof evt.kind !== 'number' || typeof evt.content !== 'string') {
return null
}
return evt as NostrEvent
}
function addEvent(evt: NostrEvent) {
const existing = events.value.find(e => e.id === evt.id)
if (existing) return
const note: NostrNote = {
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
}
const newEvents = [...events.value, note]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 200)
events.value = newEvents
}
function connectRelay(relayState: RelayState) {
if (relayState.ws) return
try {
relayState.connectTime = Date.now()
const ws = new WebSocket(relayState.url)
relayState.ws = ws
ws.onopen = () => {
relayState.connected = true
relayState.latencyMs = relayState.connectTime ? Date.now() - relayState.connectTime : null
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
if (relayState.read) {
if (!subscriptionId) subscriptionId = generateSubId()
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 50 }]))
}
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
const evt = parseEvent(data)
if (evt) addEvent(evt)
} catch { /* skip */ }
}
ws.onclose = () => {
relayState.connected = false
relayState.ws = null
relayState.latencyMs = null
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
}
ws.onerror = () => { /* triggers onclose */ }
} catch {
relayState.connected = false
updateRelayStates()
}
}
function updateRelayStates() {
relayStates.value = relays.map(r => ({
url: r.url,
connected: r.connected,
read: r.read,
write: r.write,
latencyMs: r.latencyMs,
}))
}
function connect() {
if (initialized) return
initialized = true
const configs = loadRelayConfig()
relays = configs.map(c => ({
url: c.url,
ws: null,
connected: false,
read: c.read,
write: c.write,
latencyMs: null,
connectTime: null,
}))
updateRelayStates()
relays.forEach(r => connectRelay(r))
}
function disconnect() {
if (subscriptionId) {
relays.forEach(r => {
if (r.ws && r.connected) {
try { r.ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
}
})
subscriptionId = null
}
relays.forEach(r => {
if (r.ws) {
r.ws.close()
r.ws = null
r.connected = false
}
})
updateRelayStates()
isConnected.value = false
initialized = false
}
function addRelay(url: string, read = true, write = true) {
if (relays.some(r => r.url === url)) return
const state: RelayState = { url, ws: null, connected: false, read, write, latencyMs: null, connectTime: null }
relays.push(state)
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
if (initialized) connectRelay(state)
}
function removeRelay(url: string) {
const idx = relays.findIndex(r => r.url === url)
if (idx === -1) return
const relay = relays[idx]
if (relay.ws) {
relay.ws.close()
relay.ws = null
}
relays.splice(idx, 1)
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
}
function toggleRelayRead(url: string) {
const relay = relays.find(r => r.url === url)
if (!relay) return
relay.read = !relay.read
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
}
function toggleRelayWrite(url: string) {
const relay = relays.find(r => r.url === url)
if (!relay) return
relay.write = !relay.write
saveRelayConfig(relays.map(r => ({ url: r.url, read: r.read, write: r.write })))
updateRelayStates()
}
function testRelay(url: string): Promise<number | null> {
return new Promise((resolve) => {
const start = Date.now()
const timer = setTimeout(() => resolve(null), 5000)
try {
const ws = new WebSocket(url)
ws.onopen = () => {
const latency = Date.now() - start
clearTimeout(timer)
ws.close()
resolve(latency)
}
ws.onerror = () => {
clearTimeout(timer)
resolve(null)
}
} catch {
clearTimeout(timer)
resolve(null)
}
})
}
function importNIP65Relays(event: NostrEvent) {
if (event.kind !== 10002) return
for (const tag of event.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1]
const marker = tag[2]
const read = !marker || marker === 'read'
const write = !marker || marker === 'write'
addRelay(url, read, write)
}
}
}
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
message: string
}
function publishEvent(signedEvent: NostrEvent): Promise<PublishResult[]> {
const writeRelays = relays.filter(r => r.connected && r.ws && r.write)
if (writeRelays.length === 0) return Promise.resolve([])
const results = writeRelays.map(relay => {
return new Promise<PublishResult>((resolve) => {
const timer = setTimeout(() => {
resolve({ url: relay.url, success: false, message: 'Timeout' })
}, 5000)
const handler = (msg: MessageEvent) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'OK' && data[1] === signedEvent.id) {
clearTimeout(timer)
relay.ws?.removeEventListener('message', handler)
resolve({
url: relay.url,
success: !!data[2],
message: data[3] ?? (data[2] ? 'Published' : 'Rejected'),
})
}
} catch { /* skip */ }
}
relay.ws!.addEventListener('message', handler)
try {
relay.ws!.send(JSON.stringify(['EVENT', signedEvent]))
} catch {
clearTimeout(timer)
relay.ws?.removeEventListener('message', handler)
resolve({ url: relay.url, success: false, message: 'Send failed' })
}
})
})
addEvent(signedEvent)
return Promise.all(results)
}
function fetchNote(hexId: string, timeoutMs = 5000): Promise<NostrNote | null> {
const cached = events.value.find(e => e.id === hexId)
if (cached) return Promise.resolve(cached)
return new Promise((resolve) => {
const subId = generateSubId()
let resolved = false
const timer = setTimeout(() => {
if (!resolved) { resolved = true; resolve(null) }
}, timeoutMs)
const relay = relays.find(r => r.connected && r.ws && r.read)
if (!relay?.ws) {
clearTimeout(timer)
resolve(null)
return
}
const handler = (msg: MessageEvent) => {
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
const note: NostrNote = {
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
}
if (!resolved) { resolved = true; clearTimeout(timer); resolve(note) }
relay.ws?.removeEventListener('message', handler)
}
if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === subId) {
if (!resolved) { resolved = true; clearTimeout(timer); resolve(null) }
relay.ws?.removeEventListener('message', handler)
}
} catch { /* skip */ }
}
relay.ws.addEventListener('message', handler)
relay.ws.send(JSON.stringify(['REQ', subId, { ids: [hexId], limit: 1 }]))
})
}
export function useNostr() {
onUnmounted(() => {
disconnect()
})
return {
events,
isConnected,
relayStates,
connect,
disconnect,
fetchNote,
publishEvent,
searchResults,
isSearching,
searchNostr,
addRelay,
removeRelay,
toggleRelayRead,
toggleRelayWrite,
testRelay,
importNIP65Relays,
}
}