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:
Dorian
2026-03-04 00:16:27 +00:00
co-authored by Claude Opus 4.6
parent 3a0d63f2b1
commit ba6b22bb62
3 changed files with 320 additions and 42 deletions
@@ -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>