feat(app): add Nostr feed integration with real relay connections

Create useNostr composable with raw WebSocket connections to public
relays (relay.damus.io, nos.lol, relay.snort.social). Subscribe to
kind:1 notes with limit 50. Update NostrGrid to use real data instead
of mock notes. Lazy-load on tab activation, clean disconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:32:15 +00:00
co-authored by Claude Opus 4.6
parent 31f2c2b261
commit ebaee4c2b9
2 changed files with 268 additions and 138 deletions
+191
View File
@@ -0,0 +1,191 @@
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[][]
}
interface RelayState {
url: string
ws: WebSocket | null
connected: boolean
}
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
]
const events = shallowRef<NostrNote[]>([])
const isConnected = ref(false)
const relayStates = ref<{ url: string; connected: boolean }[]>(
RELAYS.map(url => ({ url, connected: false }))
)
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
// NIP-01: ["EVENT", <subscription_id>, <event>]
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) {
// Deduplicate by ID
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 ?? [],
}
// Insert sorted by created_at (newest first)
const newEvents = [...events.value, note]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 200) // Cap at 200 events
events.value = newEvents
}
function connectRelay(relayState: RelayState) {
if (relayState.ws) return
try {
const ws = new WebSocket(relayState.url)
relayState.ws = ws
ws.onopen = () => {
relayState.connected = true
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
// Subscribe to kind:1 (text notes) with limit 50
if (!subscriptionId) subscriptionId = generateSubId()
const req = JSON.stringify([
'REQ',
subscriptionId,
{ kinds: [1], limit: 50 },
])
ws.send(req)
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
const evt = parseEvent(data)
if (evt) addEvent(evt)
} catch {
// Skip malformed messages
}
}
ws.onclose = () => {
relayState.connected = false
relayState.ws = null
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
}
ws.onerror = () => {
// Will trigger onclose
}
} catch {
relayState.connected = false
updateRelayStates()
}
}
function updateRelayStates() {
relayStates.value = relays.map(r => ({
url: r.url,
connected: r.connected,
}))
}
function connect() {
if (initialized) return
initialized = true
relays = RELAYS.map(url => ({ url, ws: null, connected: false }))
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
}
export function useNostr() {
onUnmounted(() => {
// Clean up on component unmount
disconnect()
})
return {
events,
isConnected,
relayStates,
connect,
disconnect,
}
}