feat(nostr): relay management UI with latency & read/write toggles (M11.3)
Configurable relay list persisted to localStorage. Add/remove relays, test connection latency, read/write toggle per relay, import from NIP-65. Relays sub-tab in Nostr section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3a0d63f2b1
commit
ba6b22bb62
@@ -18,6 +18,9 @@
|
||||
<!-- DMs sub-tab -->
|
||||
<NostrDMs v-if="activeSubTab === 'dms'" />
|
||||
|
||||
<!-- Relays sub-tab -->
|
||||
<NostrRelayManager v-else-if="activeSubTab === 'relays'" />
|
||||
|
||||
<!-- Feed sub-tab -->
|
||||
<template v-else>
|
||||
<div class="p-4 space-y-3 border-b border-white/[0.08]">
|
||||
@@ -191,6 +194,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import NostrDMs from './NostrDMs.vue'
|
||||
import NostrRelayManager from './NostrRelayManager.vue'
|
||||
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
|
||||
@@ -199,10 +203,11 @@ defineEmits<{ selectNote: [note: NostrNote] }>()
|
||||
const { events: notes, isConnected, relayStates, connect, publishEvent } = useNostr()
|
||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||
|
||||
const activeSubTab = ref<'feed' | 'dms'>('feed')
|
||||
const activeSubTab = ref<'feed' | 'dms' | 'relays'>('feed')
|
||||
const subTabs = [
|
||||
{ id: 'feed' as const, label: 'Feed' },
|
||||
{ id: 'dms' as const, label: 'Messages' },
|
||||
{ id: 'relays' as const, label: 'Relays' },
|
||||
]
|
||||
|
||||
const search = ref('')
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-4 border-b border-white/[0.08]">
|
||||
<h3 class="text-sm font-bold text-white/90 mb-3">Relay Management</h3>
|
||||
|
||||
<!-- Add relay -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newRelayUrl"
|
||||
type="text"
|
||||
placeholder="wss://relay.example.com"
|
||||
class="flex-1 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
@keydown.enter="addNewRelay"
|
||||
/>
|
||||
<button
|
||||
class="px-3 py-2 rounded-lg text-[10px] bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
|
||||
:disabled="!newRelayUrl.trim()"
|
||||
@click="addNewRelay"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
|
||||
<div
|
||||
v-for="relay in relayStates"
|
||||
:key="relay.url"
|
||||
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
|
||||
>
|
||||
<!-- Relay URL + status -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="relay.connected ? 'bg-emerald-500' : 'bg-red-400/60'"
|
||||
/>
|
||||
<span class="text-xs font-mono text-white/70 truncate flex-1">{{ relay.url }}</span>
|
||||
<span
|
||||
v-if="relay.latencyMs !== null"
|
||||
class="text-[9px] tabular-nums shrink-0"
|
||||
:class="relay.latencyMs < 200 ? 'text-emerald-400/60' : relay.latencyMs < 500 ? 'text-yellow-400/60' : 'text-red-400/60'"
|
||||
>
|
||||
{{ relay.latencyMs }}ms
|
||||
</span>
|
||||
<span
|
||||
class="text-[9px] shrink-0"
|
||||
:class="relay.connected ? 'text-emerald-400/60' : 'text-red-400/60'"
|
||||
>
|
||||
{{ relay.connected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-[9px] px-2 py-1 rounded transition-colors"
|
||||
:class="relay.read
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/30 hover:text-white/50'"
|
||||
@click="toggleRelayRead(relay.url)"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
<button
|
||||
class="text-[9px] px-2 py-1 rounded transition-colors"
|
||||
:class="relay.write
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/30 hover:text-white/50'"
|
||||
@click="toggleRelayWrite(relay.url)"
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
class="text-[9px] px-2 py-1 rounded bg-white/5 text-white/30 hover:text-white/50 transition-colors"
|
||||
:disabled="testingRelay === relay.url"
|
||||
@click="testConnection(relay.url)"
|
||||
>
|
||||
{{ testingRelay === relay.url ? 'Testing...' : 'Test' }}
|
||||
</button>
|
||||
<button
|
||||
class="text-[9px] px-2 py-1 rounded bg-white/5 text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
|
||||
@click="removeRelay(relay.url)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Test result -->
|
||||
<p
|
||||
v-if="testResults[relay.url] !== undefined"
|
||||
class="text-[9px]"
|
||||
:class="testResults[relay.url] !== null ? 'text-emerald-400/60' : 'text-red-400/60'"
|
||||
>
|
||||
{{ testResults[relay.url] !== null ? `Reachable (${testResults[relay.url]}ms)` : 'Unreachable' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Import NIP-65 -->
|
||||
<div v-if="isLoggedIn" class="mt-4 pt-4 border-t border-white/5">
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-[10px] bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10 transition-colors"
|
||||
:disabled="isImporting"
|
||||
@click="importFromNIP65"
|
||||
>
|
||||
{{ isImporting ? 'Importing...' : 'Import relays from NIP-65 (kind:10002)' }}
|
||||
</button>
|
||||
<p v-if="importMessage" class="text-[9px] mt-1 text-white/30">{{ importMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useNostr } from '@/composables/useNostr'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
|
||||
const { relayStates, addRelay, removeRelay, toggleRelayRead, toggleRelayWrite, testRelay, importNIP65Relays, fetchNote } = useNostr()
|
||||
const { isLoggedIn, pubkey } = useNostrIdentity()
|
||||
|
||||
const newRelayUrl = ref('')
|
||||
const testingRelay = ref<string | null>(null)
|
||||
const testResults = reactive<Record<string, number | null>>({})
|
||||
const isImporting = ref(false)
|
||||
const importMessage = ref('')
|
||||
|
||||
function addNewRelay() {
|
||||
let url = newRelayUrl.value.trim()
|
||||
if (!url) return
|
||||
if (!url.startsWith('wss://') && !url.startsWith('ws://')) {
|
||||
url = 'wss://' + url
|
||||
}
|
||||
addRelay(url)
|
||||
newRelayUrl.value = ''
|
||||
}
|
||||
|
||||
async function testConnection(url: string) {
|
||||
testingRelay.value = url
|
||||
const latency = await testRelay(url)
|
||||
testResults[url] = latency
|
||||
testingRelay.value = null
|
||||
}
|
||||
|
||||
async function importFromNIP65() {
|
||||
if (!pubkey.value) return
|
||||
isImporting.value = true
|
||||
importMessage.value = 'Fetching relay list...'
|
||||
|
||||
// Fetch kind:10002 for our pubkey from connected relays
|
||||
const note = await fetchNote(pubkey.value, 5000)
|
||||
if (note) {
|
||||
importNIP65Relays({
|
||||
id: note.id,
|
||||
pubkey: note.pubkey,
|
||||
kind: 10002,
|
||||
content: note.content,
|
||||
created_at: note.created_at,
|
||||
tags: note.tags,
|
||||
sig: '',
|
||||
})
|
||||
importMessage.value = 'Imported relays from NIP-65'
|
||||
} else {
|
||||
importMessage.value = 'No NIP-65 relay list found'
|
||||
}
|
||||
isImporting.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -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<NostrNote[]>([])
|
||||
const isConnected = ref(false)
|
||||
const relayStates = ref<{ url: string; connected: boolean }[]>(
|
||||
RELAYS.map(url => ({ url, connected: false }))
|
||||
)
|
||||
const relayStates = ref<RelayInfo[]>([])
|
||||
|
||||
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", <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') {
|
||||
@@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
url: string
|
||||
success: boolean
|
||||
@@ -182,12 +289,10 @@ export interface PublishResult {
|
||||
}
|
||||
|
||||
function publishEvent(signedEvent: NostrEvent): Promise<PublishResult[]> {
|
||||
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<PublishResult>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({ url: relay.url, success: false, message: 'Timeout' })
|
||||
@@ -196,7 +301,6 @@ function publishEvent(signedEvent: NostrEvent): Promise<PublishResult[]> {
|
||||
const handler = (msg: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data)
|
||||
// NIP-01: ["OK", <event_id>, <accepted>, <message>]
|
||||
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<PublishResult[]> {
|
||||
})
|
||||
})
|
||||
|
||||
// Also add to our local feed
|
||||
addEvent(signedEvent)
|
||||
|
||||
return Promise.all(results)
|
||||
}
|
||||
|
||||
function fetchNote(hexId: string, timeoutMs = 5000): Promise<NostrNote | null> {
|
||||
// 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<NostrNote | null> {
|
||||
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<NostrNote | null> {
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user