diff --git a/packages/app/src/components/content/NostrGrid.vue b/packages/app/src/components/content/NostrGrid.vue index 21dbfa2c..277e9d8b 100644 --- a/packages/app/src/components/content/NostrGrid.vue +++ b/packages/app/src/components/content/NostrGrid.vue @@ -44,6 +44,25 @@
+ +
+ + + + +

+ Connecting to relays... +

+
+ -
-

+

Relays

- - + + {{ relay.url }}
-
+

No notes match your search

@@ -131,30 +167,15 @@ diff --git a/packages/app/src/composables/useNostr.ts b/packages/app/src/composables/useNostr.ts new file mode 100644 index 00000000..665ec2a9 --- /dev/null +++ b/packages/app/src/composables/useNostr.ts @@ -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([]) +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", , ] + 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, + } +}