feat(ui): Node Visibility = one public-discoverability switch + inline node list
The old tri-state (hidden/discoverable/public) was confusing — its 'discoverable' level implied discovery by already-connected federated peers, which is meaningless. The card is now a single Enable switch (global ToggleSwitch style) that drives nostr presence publishing: ON = public — anyone can find the node and request a connection (requests join as Peer/observer on approval, never trusted). The card also lists nostr-discoverable nodes inline with Request-to-Peer buttons (same handshake.connect flow as the Federation Discover modal, which stays available in Federation & Peers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5a21ac47eb
commit
85c29cd928
@ -8,9 +8,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
|
||||
<p class="text-white/70 text-sm mb-4">{{ t('web5.nodeVisibilityDesc') }}</p>
|
||||
<p class="text-white/70 text-sm">
|
||||
Make your node publicly discoverable. When enabled, anyone on the Nostr
|
||||
network can find your node and request a connection — requests always
|
||||
wait for your approval and join as a Peer, never trusted.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="visibilityLoading" class="shrink-0">
|
||||
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
|
||||
@ -20,32 +24,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Options -->
|
||||
<div class="space-y-2 flex-1 min-h-0">
|
||||
<button
|
||||
v-for="opt in visibilityOptions"
|
||||
:key="opt.value"
|
||||
@click="setVisibility(opt.value)"
|
||||
<!-- Enable switch -->
|
||||
<div class="flex items-center justify-between gap-3 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Enable</p>
|
||||
<p class="text-xs text-white/50">
|
||||
{{ discoverEnabled ? 'Your node is public — anyone can discover it and request to peer' : 'Your node is hidden from discovery' }}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="discoverEnabled"
|
||||
:disabled="settingVisibility"
|
||||
class="w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left"
|
||||
:class="nodeVisibility === opt.value
|
||||
? 'bg-white/10 border-white/25 text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/60 hover:bg-white/8 hover:text-white/80'"
|
||||
>
|
||||
<div class="w-3 h-3 rounded-full shrink-0 border-2 flex items-center justify-center"
|
||||
:class="nodeVisibility === opt.value ? 'border-green-400' : 'border-white/30'"
|
||||
>
|
||||
<div v-if="nodeVisibility === opt.value" class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{{ opt.label }}</p>
|
||||
<p class="text-xs text-white/50">{{ opt.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
aria-label="Enable public discoverability"
|
||||
@update:model-value="toggleDiscoverable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Onion address (shown when discoverable/public) -->
|
||||
<div v-if="nodeVisibility !== 'hidden' && nodeOnionAddress" class="mt-4 p-3 bg-white/5 rounded-lg">
|
||||
<!-- Onion address (shown when public) -->
|
||||
<div v-if="discoverEnabled && nodeOnionAddress" class="mt-4 p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourTorAddress') }}</p>
|
||||
@ -59,8 +55,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discoverable nodes -->
|
||||
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium text-white">Discoverable nodes</p>
|
||||
<button
|
||||
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||
:disabled="discovering"
|
||||
@click="discoverNodes"
|
||||
>
|
||||
{{ discovering ? 'Searching…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
|
||||
Searching relays…
|
||||
</div>
|
||||
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
|
||||
No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="node in discoveredNodes"
|
||||
:key="node.nostr_pubkey"
|
||||
class="p-3 bg-white/5 rounded-lg border border-white/10 flex items-start justify-between gap-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-white truncate">{{ shortNpub(node.nostr_npub) }}</div>
|
||||
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
|
||||
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
|
||||
:disabled="requestingPeer === node.nostr_pubkey || requestedPeers.has(node.nostr_pubkey)"
|
||||
@click="requestToPeer(node)"
|
||||
>
|
||||
{{ requestedPeers.has(node.nostr_pubkey) ? 'Requested' : requestingPeer === node.nostr_pubkey ? 'Sending…' : 'Request to Peer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<p v-if="nodeVisibility !== 'hidden'" class="mt-3 text-xs text-amber-400/80">
|
||||
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
|
||||
{{ t('web5.discoverableWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
@ -70,6 +106,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
import type { VisibilityLevel } from './types'
|
||||
|
||||
@ -87,37 +124,65 @@ const nodeVisibility = ref<VisibilityLevel>('hidden')
|
||||
const nodeOnionAddress = ref<string | null>(null)
|
||||
const visibilityLoading = ref(false)
|
||||
const settingVisibility = ref(false)
|
||||
const discoverEnabled = ref(false)
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: 'hidden' as VisibilityLevel, label: 'Hidden', description: 'Your node is not discoverable by others' },
|
||||
{ value: 'discoverable' as VisibilityLevel, label: 'Discoverable', description: 'Federated peers can find and connect to your node' },
|
||||
{ value: 'public' as VisibilityLevel, label: 'Public', description: 'Accepting connections from any Archipelago node' },
|
||||
]
|
||||
interface DiscoverableNode {
|
||||
nostr_pubkey: string
|
||||
nostr_npub: string
|
||||
did: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const discoveredNodes = ref<DiscoverableNode[]>([])
|
||||
const discovering = ref(false)
|
||||
const requestingPeer = ref<string | null>(null)
|
||||
const requestedPeers = ref(new Set<string>())
|
||||
|
||||
function shortNpub(npub: string): string {
|
||||
if (!npub) return 'unknown'
|
||||
return npub.length > 21 ? `${npub.slice(0, 12)}…${npub.slice(-6)}` : npub
|
||||
}
|
||||
|
||||
async function loadVisibility() {
|
||||
visibilityLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ visibility: string; onion_address?: string }>({ method: 'network.get-visibility' })
|
||||
nodeVisibility.value = (res.visibility as VisibilityLevel) || 'hidden'
|
||||
nodeOnionAddress.value = res.onion_address || null
|
||||
// Nostr discovery is the functional flag: when on, a presence event
|
||||
// (DID + npub — never the onion) is published to public relays so
|
||||
// ANYONE can find this node and request a connection. The legacy
|
||||
// network.get-visibility tri-state is only read for the onion display.
|
||||
const [disc, vis] = await Promise.all([
|
||||
rpcClient.nostrDiscoveryStatus(),
|
||||
rpcClient
|
||||
.call<{ visibility: string; onion_address?: string; tor_address?: string }>({ method: 'network.get-visibility' })
|
||||
.catch(() => null),
|
||||
])
|
||||
discoverEnabled.value = !!disc.enabled
|
||||
nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden'
|
||||
nodeOnionAddress.value = vis?.onion_address || vis?.tor_address || null
|
||||
if (discoverEnabled.value) void discoverNodes()
|
||||
} catch {
|
||||
nodeVisibility.value = 'hidden'
|
||||
discoverEnabled.value = false
|
||||
} finally {
|
||||
visibilityLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setVisibility(level: VisibilityLevel) {
|
||||
if (settingVisibility.value || nodeVisibility.value === level) return
|
||||
async function toggleDiscoverable(enabled: boolean) {
|
||||
if (settingVisibility.value) return
|
||||
settingVisibility.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ visibility: string; onion_address?: string }>({
|
||||
method: 'network.set-visibility',
|
||||
params: { visibility: level },
|
||||
})
|
||||
nodeVisibility.value = (res.visibility as VisibilityLevel) || level
|
||||
nodeOnionAddress.value = res.onion_address || nodeOnionAddress.value
|
||||
emit('toast', t('web5.visibilitySetTo', { level }))
|
||||
// Public means public: the switch drives nostr presence publishing.
|
||||
const res = await rpcClient.nostrSetDiscovery(enabled)
|
||||
discoverEnabled.value = !!res.enabled
|
||||
// Keep the legacy visibility string in sync (cosmetic; best-effort).
|
||||
const level: VisibilityLevel = enabled ? 'public' : 'hidden'
|
||||
rpcClient
|
||||
.call({ method: 'network.set-visibility', params: { visibility: level } })
|
||||
.then(() => { nodeVisibility.value = level })
|
||||
.catch(() => {})
|
||||
emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery')
|
||||
if (enabled) void discoverNodes()
|
||||
else discoveredNodes.value = []
|
||||
} catch {
|
||||
emit('toast', t('web5.failedToUpdateVisibility'))
|
||||
} finally {
|
||||
@ -125,6 +190,36 @@ async function setVisibility(level: VisibilityLevel) {
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverNodes() {
|
||||
if (discovering.value) return
|
||||
discovering.value = true
|
||||
try {
|
||||
const res = await rpcClient.handshakeDiscover()
|
||||
discoveredNodes.value = res.nodes || []
|
||||
} catch {
|
||||
// keep whatever we had; the empty-state copy explains relay gossip lag
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function requestToPeer(node: DiscoverableNode) {
|
||||
if (requestingPeer.value) return
|
||||
requestingPeer.value = node.nostr_pubkey
|
||||
try {
|
||||
// Connection requests always land as Peer (observer) on approval —
|
||||
// never trusted — so a mistaken request can't hand over fleet access.
|
||||
await rpcClient.handshakeConnect(node.nostr_pubkey)
|
||||
requestedPeers.value.add(node.nostr_pubkey)
|
||||
requestedPeers.value = new Set(requestedPeers.value)
|
||||
emit('toast', 'Peer request sent — awaiting their approval')
|
||||
} catch (e) {
|
||||
emit('toast', e instanceof Error ? e.message : 'Failed to send peer request')
|
||||
} finally {
|
||||
requestingPeer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function copyOnionAddress() {
|
||||
if (!nodeOnionAddress.value) return
|
||||
safeClipboardWrite(nodeOnionAddress.value)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user