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:
archipelago 2026-07-14 06:45:15 -04:00
parent 5a21ac47eb
commit 85c29cd928

View File

@ -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)