@@ -191,6 +194,7 @@
diff --git a/packages/app/src/composables/useNostr.ts b/packages/app/src/composables/useNostr.ts
index e772a850..3ff03ff3 100644
--- a/packages/app/src/composables/useNostr.ts
+++ b/packages/app/src/composables/useNostr.ts
@@ -21,23 +21,53 @@ export interface NostrNote {
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 RELAYS = [
- 'wss://relay.damus.io',
- 'wss://nos.lol',
- 'wss://relay.snort.social',
+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
([])
const isConnected = ref(false)
-const relayStates = ref<{ url: string; connected: boolean }[]>(
- RELAYS.map(url => ({ url, connected: false }))
-)
+const relayStates = ref([])
let relays: RelayState[] = []
let subscriptionId: string | null = null
@@ -54,7 +84,6 @@ function truncatePubkey(pubkey: string): string {
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') {
@@ -64,7 +93,6 @@ function parseEvent(data: unknown): NostrEvent | null {
}
function addEvent(evt: NostrEvent) {
- // Deduplicate by ID
const existing = events.value.find(e => e.id === evt.id)
if (existing) return
@@ -78,10 +106,9 @@ function addEvent(evt: NostrEvent) {
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
+ .slice(0, 200)
events.value = newEvents
}
@@ -90,22 +117,20 @@ 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)
- // 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)
+ if (relayState.read) {
+ if (!subscriptionId) subscriptionId = generateSubId()
+ ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 50 }]))
+ }
}
ws.onmessage = (msg) => {
@@ -113,21 +138,18 @@ function connectRelay(relayState: RelayState) {
const data = JSON.parse(msg.data)
const evt = parseEvent(data)
if (evt) addEvent(evt)
- } catch {
- // Skip malformed messages
- }
+ } catch { /* skip */ }
}
ws.onclose = () => {
relayState.connected = false
relayState.ws = null
+ relayState.latencyMs = null
updateRelayStates()
isConnected.value = relays.some(r => r.connected)
}
- ws.onerror = () => {
- // Will trigger onclose
- }
+ ws.onerror = () => { /* triggers onclose */ }
} catch {
relayState.connected = false
updateRelayStates()
@@ -138,6 +160,9 @@ function updateRelayStates() {
relayStates.value = relays.map(r => ({
url: r.url,
connected: r.connected,
+ read: r.read,
+ write: r.write,
+ latencyMs: r.latencyMs,
}))
}
@@ -145,7 +170,16 @@ function connect() {
if (initialized) return
initialized = true
- relays = RELAYS.map(url => ({ url, ws: null, connected: false }))
+ 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))
}
@@ -154,9 +188,7 @@ function disconnect() {
if (subscriptionId) {
relays.forEach(r => {
if (r.ws && r.connected) {
- try {
- r.ws.send(JSON.stringify(['CLOSE', subscriptionId]))
- } catch { /* ignore */ }
+ try { r.ws.send(JSON.stringify(['CLOSE', subscriptionId])) } catch { /* ignore */ }
}
})
subscriptionId = null
@@ -175,6 +207,81 @@ function disconnect() {
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 {
+ 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)
+ }
+ }
+}
+
export interface PublishResult {
url: string
success: boolean
@@ -182,12 +289,10 @@ export interface PublishResult {
}
function publishEvent(signedEvent: NostrEvent): Promise {
- const connectedRelays = relays.filter(r => r.connected && r.ws)
- if (connectedRelays.length === 0) {
- return Promise.resolve([])
- }
+ const writeRelays = relays.filter(r => r.connected && r.ws && r.write)
+ if (writeRelays.length === 0) return Promise.resolve([])
- const results = connectedRelays.map(relay => {
+ const results = writeRelays.map(relay => {
return new Promise((resolve) => {
const timer = setTimeout(() => {
resolve({ url: relay.url, success: false, message: 'Timeout' })
@@ -196,7 +301,6 @@ function publishEvent(signedEvent: NostrEvent): Promise {
const handler = (msg: MessageEvent) => {
try {
const data = JSON.parse(msg.data)
- // NIP-01: ["OK", , , ]
if (Array.isArray(data) && data[0] === 'OK' && data[1] === signedEvent.id) {
clearTimeout(timer)
relay.ws?.removeEventListener('message', handler)
@@ -220,14 +324,11 @@ function publishEvent(signedEvent: NostrEvent): Promise {
})
})
- // Also add to our local feed
addEvent(signedEvent)
-
return Promise.all(results)
}
function fetchNote(hexId: string, timeoutMs = 5000): Promise {
- // Check if already cached
const cached = events.value.find(e => e.id === hexId)
if (cached) return Promise.resolve(cached)
@@ -238,8 +339,7 @@ function fetchNote(hexId: string, timeoutMs = 5000): Promise {
if (!resolved) { resolved = true; resolve(null) }
}, timeoutMs)
- // Try the first connected relay
- const relay = relays.find(r => r.connected && r.ws)
+ const relay = relays.find(r => r.connected && r.ws && r.read)
if (!relay?.ws) {
clearTimeout(timer)
resolve(null)
@@ -277,7 +377,6 @@ function fetchNote(hexId: string, timeoutMs = 5000): Promise {
export function useNostr() {
onUnmounted(() => {
- // Clean up on component unmount
disconnect()
})
@@ -289,5 +388,11 @@ export function useNostr() {
disconnect,
fetchNote,
publishEvent,
+ addRelay,
+ removeRelay,
+ toggleRelayRead,
+ toggleRelayWrite,
+ testRelay,
+ importNIP65Relays,
}
}