Unify node connection discovery requests and connected views

This commit is contained in:
archipelago
2026-10-08 06:36:13 -04:00
parent b2ecd940de
commit 2ca50ae36c
10 changed files with 283 additions and 81 deletions
+38 -3
View File
@@ -1,8 +1,43 @@
# Node connection flow plan # Node connection flow plan
Status: proposal for the post-1.9.0 work. Uses existing components, colors, Status: partial implementation prepared in an isolated worktree, not deployed.
spacing, glass cards, typography and motion. No broad navigation redesign has Uses existing components, colors, spacing, glass cards, typography and motion.
been deployed. Connection reliability must be qualified before this flow ships. Connection reliability and the remaining acceptance below must be qualified
before this flow ships.
## Implemented UI step — 2026-10-08
- Web5 exposes Connect with Nodes and Connected Nodes above the mobile collapse.
- Existing Federation route has Discover, Requests, Connected and Network Map
tabs even with zero peers. The route query retains selection; legacy `view=list`
opens Connected. Existing node detail modals preserve the underlying view.
- Discovery reuses the existing component inline, with local name/identity search,
retained results/search across tab switches and stale-response rejection.
The existing 30-second RPC timeout and signing confirmation remain in place.
Presence advertisements explicitly do not imply reachability or authorization.
- Existing requests open Requests and existing peers open their detail; fresh
requests still use the existing confirmation and Observer semantics. Own-node
Trusted linking remains the separate, existing authenticated invite action.
- Requests has an empty state and active count. Action errors remain visible
across tabs. Approved—connecting and Peer added remain distinct: persisted
local membership does not assert reciprocal connection confirmation.
- Navigating away during initial identity loading cannot start a later orphaned
polling timer. No backend, API contract, trust elevation or payment changes.
Validation: twelve focused tests across five files pass, plus app-project
`vue-tsc -p tsconfig.app.json --noEmit`. The initial combined focused run had
11 passes and one incorrect test assertion against a shallow component stub;
correcting the assertion to inspect its Vue prop yielded 2/2 targeted navigation
passes. No production source fix was needed for that assertion. Source browser
checks at 390/1440px are prepared but resource-queued behind IndeeHub; no visual,
production-artifact, actual companion or live-node acceptance is claimed.
Evidence: `release-qualification/connection-journey-20261008/` under the local
Archipelago state directory. Browser fixture allows only loopback synthetic RPC.
Still open: Cloud/Fleet return paths and explicit own-node entry, authenticated
reciprocal confirmation/retry acceptance, stale-advertisement age when supplied
by the protocol, desktop/phone UAT and before/after timings. This step does not
complete task 6 or satisfy the release gates below.
## Entry and return paths ## Entry and return paths
+62 -50
View File
@@ -21,7 +21,7 @@
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) --> <!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
<!-- md:self-start: in map view the root is a flex column, and stretch <!-- md:self-start: in map view the root is a flex column, and stretch
alignment would otherwise pull the pill full-width on desktop --> alignment would otherwise pull the pill full-width on desktop -->
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto md:self-start"> <div role="tablist" aria-label="Node connections" class="mode-switcher mb-6 max-w-full overflow-x-auto whitespace-nowrap md:self-start">
<button <button
v-for="tab in viewTabs" v-for="tab in viewTabs"
:key="tab.id" :key="tab.id"
@@ -31,10 +31,12 @@
:class="{ 'mode-switcher-btn-active': activeView === tab.id }" :class="{ 'mode-switcher-btn-active': activeView === tab.id }"
@click="setView(tab.id)" @click="setView(tab.id)"
> >
{{ tab.label }} {{ tab.label }}<span v-if="tab.id === 'requests' && requestCount" class="ml-1 text-xs">({{ requestCount }})</span>
</button> </button>
</div> </div>
<p v-if="discoveryError" role="alert" class="mb-4 text-sm text-red-400">{{ discoveryError }}</p>
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where <!-- Mobile DID card: below the tabs per UX; hidden on the map tab where
vertical space belongs to the map (desktop keeps the header card) --> vertical space belongs to the map (desktop keeps the header card) -->
<DidCardMobile <DidCardMobile
@@ -56,7 +58,7 @@
/> />
</div> </div>
<template v-if="activeView === 'list'"> <div v-show="activeView === 'discover'">
<QuickActions <QuickActions
:generating-invite="generatingInvite" :generating-invite="generatingInvite"
:invite-type="inviteType" :invite-type="inviteType"
@@ -86,8 +88,8 @@
</div> </div>
<p class="text-xs text-white/60 mt-1"> <p class="text-xs text-white/60 mt-1">
When on, this node publishes a presence event (DID + npub only — never an onion) When on, this node publishes a presence event (DID + npub only — never an onion)
so other nodes can find you and request to peer. Inbound requests land in the so other nodes can find you and request to peer. Review incoming requests in
panel below for your approval. Off by default. the Requests tab. Off by default.
</p> </p>
<button <button
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1" class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
@@ -104,13 +106,6 @@
> >
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }} {{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</button> </button>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!discoveryEnabled"
@click="showDiscoverModal = true"
>
Discover Nodes
</button>
</div> </div>
</div> </div>
@@ -178,31 +173,43 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
<PendingRequestsPanel <DiscoverModal
:requests="pendingRequests" :visible="discoveryEnabled && activeView === 'discover'"
inline
:outbound-sent="pendingRequests"
:peer-dids="nodes.map(node => node.did)" :peer-dids="nodes.map(node => node.did)"
:polling="pollingHandshake" @view-request="setView('requests')"
:busy-id="pendingBusyId" @open-node="onMapSelect"
@poll="pollHandshake" @sent="loadPendingRequests(); setView('requests')"
@approve="approvePending"
@reject="rejectPending"
@cancel="cancelPending"
/> />
</div>
<NodeList <PendingRequestsPanel
:nodes="nodes" v-show="activeView === 'requests'"
:loading="loading" show-empty
:error="error" :requests="pendingRequests"
:sync-results="syncResults" :peer-dids="nodes.map(node => node.did)"
:dwn-sync-dot-class="dwnSyncDotClass" :polling="pollingHandshake"
:cleaning-nodes="cleaningNodes" :busy-id="pendingBusyId"
@select-node="selectedNode = $event" @poll="pollHandshake"
@clear-sync-results="syncResults = []" @approve="approvePending"
@cleanup-dead="cleanupDeadNodes" @reject="rejectPending"
/> @cancel="cancelPending"
</template> />
<NodeList
v-show="activeView === 'connected'"
:nodes="nodes"
:loading="loading"
:error="error"
:sync-results="syncResults"
:dwn-sync-dot-class="dwnSyncDotClass"
:cleaning-nodes="cleaningNodes"
@select-node="selectedNode = $event"
@clear-sync-results="syncResults = []"
@cleanup-dead="cleanupDeadNodes"
/>
<NodeDetailModal <NodeDetailModal
:node="selectedNode" :node="selectedNode"
@@ -230,13 +237,6 @@
@join="joinFederation" @join="joinFederation"
/> />
<DiscoverModal
:visible="showDiscoverModal"
:outbound-sent="pendingRequests"
@close="showDiscoverModal = false"
@sent="loadPendingRequests"
/>
<PresenceSignModal <PresenceSignModal
:show="showPresenceSignModal" :show="showPresenceSignModal"
:server-name="appStore.serverName" :server-name="appStore.serverName"
@@ -260,7 +260,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validContact, lastContactLabel } from './web5/nodeAvailability' import { validContact, lastContactLabel } from './web5/nodeAvailability'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource' import { useCachedResource } from '@/composables/useCachedResource'
@@ -320,21 +321,26 @@ const deploying = ref(false)
const deployResult = ref('') const deployResult = ref('')
const viewTabs = [ const viewTabs = [
{ id: 'list', label: 'List View' }, { id: 'discover', label: 'Discover' },
{ id: 'requests', label: 'Requests' },
{ id: 'connected', label: 'Connected' },
{ id: 'map', label: 'Network Map' }, { id: 'map', label: 'Network Map' },
] as const ] as const
type ViewId = typeof viewTabs[number]['id'] type ViewId = typeof viewTabs[number]['id']
const activeView = ref<ViewId>( const route = useRoute()
(localStorage.getItem('federation-view') as ViewId) || (nodes.value.length >= 3 ? 'map' : 'list') const router = useRouter()
) function normalizedView(value: unknown): ViewId {
if (value === 'list') return 'connected'
return viewTabs.some(tab => tab.id === value) ? value as ViewId : 'discover'
}
const activeView = ref<ViewId>(normalizedView(route.query.view))
watch(() => route.query.view, value => { activeView.value = normalizedView(value) })
function setView(id: ViewId) { function setView(id: ViewId) {
activeView.value = id activeView.value = id
localStorage.setItem('federation-view', id) void router.replace({ query: { ...route.query, view: id } })
} }
const mapActive = computed(() => activeView.value === 'map')
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
/** Map click-through: tapping a peer opens the same detail modal as the list /** Map click-through: tapping a peer opens the same detail modal as the list
* view. Tapping the self node is a no-op (its actions live in the header). */ * view. Tapping the self node is a no-op (its actions live in the header). */
@@ -438,7 +444,6 @@ const cleaningNodes = ref(false)
const discoveryEnabled = ref(false) const discoveryEnabled = ref(false)
const discoveryToggling = ref(false) const discoveryToggling = ref(false)
const discoveryError = ref('') const discoveryError = ref('')
const showDiscoverModal = ref(false)
// Signing-details disclosure: who signs the presence event (always the node's // Signing-details disclosure: who signs the presence event (always the node's
// own discovery key) and a human-readable view of the signed content. // own discovery key) and a human-readable view of the signed content.
const showSigningInfo = ref(false) const showSigningInfo = ref(false)
@@ -460,6 +465,10 @@ async function loadNodeNpub() {
} }
} }
const pendingRequests = ref<PendingPeerRequest[]>([]) const pendingRequests = ref<PendingPeerRequest[]>([])
const requestCount = computed(() => pendingRequests.value.filter(r =>
r.state === 'pending' || r.state === 'sent' ||
(r.state === 'approved' && !nodes.value.some(node => node.did === r.from_did))
).length)
const pollingHandshake = ref(false) const pollingHandshake = ref(false)
const pendingBusyId = ref<string | null>(null) const pendingBusyId = ref<string | null>(null)
@@ -819,6 +828,7 @@ async function rotateDid(password: string) {
} }
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let disposed = false
onMounted(async () => { onMounted(async () => {
loadDwnStatus() loadDwnStatus()
@@ -833,6 +843,7 @@ onMounted(async () => {
} catch { } catch {
// Self DID not available // Self DID not available
} }
if (disposed) return
autoRefreshTimer = setInterval(() => { autoRefreshTimer = setInterval(() => {
availabilityNow.value = Date.now() availabilityNow.value = Date.now()
void nodesRes.refresh() void nodesRes.refresh()
@@ -841,6 +852,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
disposed = true
if (autoRefreshTimer) { if (autoRefreshTimer) {
clearInterval(autoRefreshTimer) clearInterval(autoRefreshTimer)
autoRefreshTimer = null autoRefreshTimer = null
+47 -24
View File
@@ -1,13 +1,13 @@
<template> <template>
<Teleport to="body"> <Teleport to="body" :disabled="inline">
<Transition name="modal"> <Transition name="modal">
<div <div
v-if="visible" v-if="visible"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4" :class="inline ? 'mb-6' : 'fixed inset-0 z-[3000] flex items-center justify-center p-4'"
@click.self="$emit('close')" @click.self="$emit('close')"
> >
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div> <div v-if="!inline" class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto relative z-10"> <div :class="inline ? 'glass-card p-6' : 'glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto relative z-10'">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h2 class="text-xl font-semibold text-white">Discover Nodes</h2> <h2 class="text-xl font-semibold text-white">Discover Nodes</h2>
@@ -17,7 +17,7 @@
an optional message travel inside an encrypted DM. an optional message travel inside an encrypted DM.
</p> </p>
</div> </div>
<button @click="$emit('close')" class="text-white/40 hover:text-white/70 transition-colors"> <button v-if="!inline" aria-label="Close discovery" @click="$emit('close')" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
@@ -37,6 +37,8 @@
</span> </span>
</div> </div>
<input v-model="search" aria-label="Search discovered nodes" placeholder="Search node identity…" class="w-full mb-3 bg-black/30 text-white text-sm rounded px-3 py-2 border border-white/10" />
<p class="mb-4 text-xs text-white/50">Presence is a discovery advertisement. It does not confirm that a node is reachable or connected. New requests grant Observer access.</p>
<div v-if="error" class="mb-4 text-sm text-red-400">{{ error }}</div> <div v-if="error" class="mb-4 text-sm text-red-400">{{ error }}</div>
<!-- Manual entry: paste an npub directly --> <!-- Manual entry: paste an npub directly -->
@@ -67,6 +69,7 @@
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<p v-if="nodes.length && !filteredNodes.length" class="text-sm text-white/60">No nodes match your search.</p>
<div v-if="loading && nodes.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2"> <div v-if="loading && nodes.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -75,22 +78,22 @@
Searching relays... Searching relays...
</div> </div>
<div <div
v-for="node in nodes" v-for="node in filteredNodes"
:key="node.nostr_pubkey" :key="node.nostr_pubkey"
class="p-3 bg-white/5 rounded-lg border border-white/10" class="p-3 bg-white/5 rounded-lg border border-white/10"
> >
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm text-white truncate"> <div class="text-sm text-white truncate">
{{ shortNpub(node.nostr_npub) }} {{ node.name || shortNpub(node.nostr_npub) }}
</div> </div>
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</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 class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
</div> </div>
<button <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" 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="sendingTo === node.nostr_pubkey || alreadySentTo(node.nostr_pubkey)" :disabled="sendingTo !== null"
@click="sendTo(node)" @click="actOn(node)"
> >
{{ statusFor(node) }} {{ statusFor(node) }}
</button> </button>
@@ -102,7 +105,7 @@
</Transition> </Transition>
<PeerRequestModal <PeerRequestModal
:show="requestTarget !== null" :show="visible && requestTarget !== null"
:target-label="requestTarget?.label ?? ''" :target-label="requestTarget?.label ?? ''"
:sending="sendingTo !== null && sendingTo === requestTarget?.target" :sending="sendingTo !== null && sendingTo === requestTarget?.target"
@send="confirmRequest" @send="confirmRequest"
@@ -112,7 +115,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { computed, ref, watch, onUnmounted } from 'vue'
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client' import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue' import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
@@ -121,17 +124,21 @@ interface DiscoverableNode {
nostr_npub: string nostr_npub: string
did: string did: string
version: string version: string
name?: string | null
} }
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
/// Outbound rows from the parent's pending list, used to grey out inline?: boolean
/// "Send Request" buttons for npubs we've already requested. peerDids?: string[]
/// Existing requests select their Requests view instead of sending again.
outboundSent: PendingPeerRequest[] outboundSent: PendingPeerRequest[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
viewRequest: []
openNode: [did: string]
/// Fired after a successful send so the parent can refresh its /// Fired after a successful send so the parent can refresh its
/// pending-requests list to show the new "Sent" row. /// pending-requests list to show the new "Sent" row.
sent: [] sent: []
@@ -143,25 +150,37 @@ const error = ref('')
const lastSearchAt = ref('') const lastSearchAt = ref('')
const sendingTo = ref<string | null>(null) const sendingTo = ref<string | null>(null)
const manualNpub = ref('') const manualNpub = ref('')
const search = ref('')
const filteredNodes = computed(() => {
const term = search.value.trim().toLowerCase()
return nodes.value.filter(node => `${node.name ?? ''} ${node.did} ${node.nostr_npub}`.toLowerCase().includes(term))
})
let discoveryGeneration = 0
watch( watch(
() => props.visible, () => props.visible,
(v) => { (v) => {
if (v && nodes.value.length === 0) refresh() if (v && nodes.value.length === 0) void refresh()
if (!v) { discoveryGeneration++; loading.value = false }
}, },
{ immediate: true },
) )
onUnmounted(() => { discoveryGeneration++ })
async function refresh() { async function refresh() {
if (loading.value) return
const generation = ++discoveryGeneration
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const result = await rpcClient.handshakeDiscover() const result = await rpcClient.handshakeDiscover()
if (generation !== discoveryGeneration) return
nodes.value = result.nodes nodes.value = result.nodes
lastSearchAt.value = new Date().toLocaleTimeString() lastSearchAt.value = new Date().toLocaleTimeString()
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Discovery failed' if (generation === discoveryGeneration) error.value = e instanceof Error ? e.message : 'Discovery failed'
} finally { } finally {
loading.value = false if (generation === discoveryGeneration) loading.value = false
} }
} }
@@ -169,6 +188,14 @@ async function refresh() {
// message before anything is sent (Request/Cancel). // message before anything is sent (Request/Cancel).
const requestTarget = ref<{ target: string; label: string; clearManual: boolean } | null>(null) const requestTarget = ref<{ target: string; label: string; clearManual: boolean } | null>(null)
function actOn(node: DiscoverableNode) {
if (props.peerDids?.includes(node.did)) { emit('openNode', node.did); return }
if (hasRequest(node.nostr_pubkey)) { emit('viewRequest'); return }
sendTo(node)
}
function hasRequest(key: string): boolean {
return props.outboundSent.some(r => r.from_nostr_pubkey === key && ['pending', 'sent', 'approved'].includes(r.state))
}
function sendTo(node: DiscoverableNode) { function sendTo(node: DiscoverableNode) {
requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false } requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false }
} }
@@ -181,13 +208,14 @@ function sendDirect() {
async function confirmRequest(message: string | undefined) { async function confirmRequest(message: string | undefined) {
const req = requestTarget.value const req = requestTarget.value
if (!req) return if (!req || sendingTo.value !== null) return
await sendInternal(req.target, message) await sendInternal(req.target, message)
if (req.clearManual) manualNpub.value = '' if (req.clearManual) manualNpub.value = ''
requestTarget.value = null requestTarget.value = null
} }
async function sendInternal(target: string, message?: string) { async function sendInternal(target: string, message?: string) {
if (sendingTo.value !== null) return
sendingTo.value = target sendingTo.value = target
error.value = '' error.value = ''
try { try {
@@ -200,15 +228,10 @@ async function sendInternal(target: string, message?: string) {
} }
} }
function alreadySentTo(npubHex: string): boolean {
return props.outboundSent.some(
(r) => r.outbound && r.from_nostr_pubkey === npubHex && r.state === 'sent',
)
}
function statusFor(node: DiscoverableNode): string { function statusFor(node: DiscoverableNode): string {
if (sendingTo.value === node.nostr_pubkey) return 'Sending…' if (sendingTo.value === node.nostr_pubkey) return 'Sending…'
if (alreadySentTo(node.nostr_pubkey)) return 'Already sent' if (props.peerDids?.includes(node.did)) return 'Open node'
if (hasRequest(node.nostr_pubkey)) return 'View request'
return 'Send Request' return 'Send Request'
} }
@@ -3,8 +3,8 @@
<BackButton label="Web5" @click="router.push('/dashboard/web5')" /> <BackButton label="Web5" @click="router.push('/dashboard/web5')" />
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1> <h1 class="text-3xl font-bold text-white mb-2">Connect with Nodes</h1>
<p class="text-white/70">Connect, sync, and share with trusted nodes</p> <p class="text-white/70">Discover peers, manage requests, and view your connected nodes</p>
</div> </div>
<!-- Your Node DID — top right card (desktop) --> <!-- Your Node DID — top right card (desktop) -->
<div v-if="selfDid" class="hidden md:block shrink-0"> <div v-if="selfDid" class="hidden md:block shrink-0">
@@ -1,5 +1,5 @@
<template> <template>
<div id="connection-requests" v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6"> <div id="connection-requests" v-if="showEmpty || visibleRequests.length > 0" class="glass-card p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h2 class="text-base font-semibold text-white">Nostr Connection Requests</h2> <h2 class="text-base font-semibold text-white">Nostr Connection Requests</h2>
@@ -18,6 +18,7 @@
</button> </button>
</div> </div>
<p v-if="visibleRequests.length === 0" class="text-sm text-white/60" role="status">No current requests. Discover a node or share an invite to get started.</p>
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="req in visibleRequests" v-for="req in visibleRequests"
@@ -81,6 +82,7 @@ const props = defineProps<{
polling: boolean polling: boolean
busyId: string | null busyId: string | null
peerDids?: string[] peerDids?: string[]
showEmpty?: boolean
}>() }>()
defineEmits<{ defineEmits<{
@@ -0,0 +1,57 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import Federation from '../../Federation.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/stores/transport', () => ({ useTransportStore: () => ({ fetchPeers: vi.fn() }) }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) }))
vi.mock('@/stores/sync', () => ({ useSyncStore: () => ({ serverInfo: null }) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: {
call: vi.fn().mockResolvedValue({}), getNodeDid: vi.fn().mockResolvedValue({ did: 'did:key:self' }),
nostrDiscoveryStatus: vi.fn().mockResolvedValue({ enabled: false }),
federationListPendingRequests: vi.fn().mockResolvedValue({ requests: [] }),
} }))
vi.mock('@/composables/useCachedResource', async () => {
const { ref } = await import('vue')
return { useCachedResource: () => ({ data: ref([]), loadState: ref('ready'), error: ref(null), refresh: vi.fn() }) }
})
afterEach(() => { vi.useRealTimers() })
describe('connection journey navigation', () => {
it('renders all tabs with zero peers, accepts deep links and reacts to navigation', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: Federation }] })
await router.push('/?view=requests')
await router.isReady()
const wrapper = shallowMount(Federation, { global: { plugins: [router] } })
await flushPromises()
const selected = () => wrapper.get('[role="tab"][aria-selected="true"]').text()
expect(selected()).toBe('Requests')
expect(wrapper.findAll('[role="tab"]').map(t => t.text())).toEqual(['Discover', 'Requests', 'Connected', 'Network Map'])
expect(wrapper.findComponent({ name: 'PendingRequestsPanel' }).props('showEmpty')).toBe(true)
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.view).toBe('connected')
await router.push('/?view=discover')
await flushPromises()
expect(selected()).toBe('Discover')
await router.push('/?view=list')
await flushPromises()
expect(selected()).toBe('Connected')
wrapper.unmount()
})
})
it('does not start a poll timer after navigating away during initial identity loading', async () => {
let resolve!: (value: Awaited<ReturnType<typeof rpcClient.getNodeDid>>) => void
vi.mocked(rpcClient.getNodeDid).mockReturnValueOnce(new Promise(done => { resolve = done }))
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: Federation }] })
await router.push('/')
const timer = vi.spyOn(globalThis, 'setInterval')
const wrapper = shallowMount(Federation, { global: { plugins: [router] } })
wrapper.unmount()
resolve({ did: 'did:key:late', pubkey: 'fixture' })
await flushPromises()
expect(timer).not.toHaveBeenCalled()
timer.mockRestore()
})
@@ -67,3 +67,47 @@ describe('DiscoverModal', () => {
expect(wrapper.text()).not.toContain('No discoverable nodes found') expect(wrapper.text()).not.toContain('No discoverable nodes found')
}) })
}) })
describe('inline connection discovery', () => {
it('loads on initial entry and preserves local search across tab switches', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode(), { ...makeNode(), nostr_pubkey: 'two', did: 'did:key:other' }] })
const wrapper = mount(DiscoverModal, { props: { visible: true, inline: true, outboundSent: [] }, global: { stubs: { Teleport: true } } })
await flushPromises()
const search = wrapper.get('input[aria-label="Search discovered nodes"]')
await search.setValue('other')
expect(wrapper.text()).toContain('did:key:other')
expect(wrapper.text()).not.toContain('did:key:node')
await wrapper.setProps({ visible: false })
await wrapper.setProps({ visible: true })
expect(wrapper.get('input[aria-label="Search discovered nodes"]').element).toHaveProperty('value', 'other')
expect(wrapper.text()).toContain('does not confirm')
wrapper.unmount()
})
it('ignores a late search from a departed tab', async () => {
const old = deferred<{ nodes: ReturnType<typeof makeNode>[] }>()
vi.mocked(rpcClient.handshakeDiscover).mockReturnValueOnce(old.promise).mockResolvedValueOnce({ nodes: [{ ...makeNode(), did: 'did:key:fresh' }] })
const wrapper = mount(DiscoverModal, { props: { visible: true, inline: true, outboundSent: [] }, global: { stubs: { Teleport: true } } })
await wrapper.setProps({ visible: false })
await wrapper.setProps({ visible: true })
await flushPromises()
old.resolve({ nodes: [makeNode()] })
await flushPromises()
expect(wrapper.text()).toContain('did:key:fresh')
expect(wrapper.text()).not.toContain('did:key:node')
wrapper.unmount()
})
it('routes existing requests and peers without sending another request or claiming confirmation', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
const wrapper = mount(DiscoverModal, { props: { visible: true, inline: true, outboundSent: [{ id: 'r', from_did: 'did:key:node', from_nostr_pubkey: 'pubkey-one', from_nostr_npub: 'npub', from_name: null, message: null, state: 'approved', outbound: true, received_at: new Date().toISOString() }] }, global: { stubs: { Teleport: true } } })
await flushPromises()
await wrapper.findAll('button').find(b => b.text() === 'View request')!.trigger('click')
expect(wrapper.emitted('viewRequest')).toHaveLength(1)
await wrapper.setProps({ peerDids: ['did:key:node'] })
await wrapper.findAll('button').find(b => b.text() === 'Open node')!.trigger('click')
expect(wrapper.emitted('openNode')).toEqual([['did:key:node']])
expect(wrapper.text()).not.toContain('Connected')
wrapper.unmount()
})
})
@@ -23,3 +23,10 @@ describe('Nostr connection progress', () => {
wrapper.unmount() wrapper.unmount()
}) })
}) })
it('keeps the Requests tab useful when there are no requests', () => {
const wrapper = mount(PendingRequestsPanel, { props: { requests: [], polling: false, busyId: null, showEmpty: true } })
expect(wrapper.get('[role="status"]').text()).toContain('No current requests')
expect(wrapper.findAll('button').some(b => b.text() === 'Poll Now')).toBe(true)
wrapper.unmount()
})
+5 -1
View File
@@ -1,6 +1,10 @@
<template> <template>
<!-- Quick Actions Container --> <!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6"> <div class="glass-card p-6 mb-6">
<div class="flex flex-wrap gap-3 mb-4" aria-label="Node connections">
<RouterLink :to="{ name: 'federation', query: { view: 'discover' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connect with Nodes</RouterLink>
<RouterLink :to="{ name: 'federation', query: { view: 'connected' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connected Nodes</RouterLink>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5 gap-4 stagger-grid"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5 gap-4 stagger-grid">
<!-- Networking Profits --> <!-- Networking Profits -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0"> <div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
@@ -182,7 +186,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types' import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
@@ -0,0 +1,18 @@
import { mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import Web5QuickActions from '../Web5QuickActions.vue'
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
it('keeps Connect and Connected links outside the collapsed mobile controls', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ name: 'federation', path: '/connections', component: { template: '<div />' } }] })
const wrapper = mount(Web5QuickActions, { props: {
showStagger: false, profitsBreakdown: null, networkingProfitsDisplay: '0 sats', userDid: null,
didStatus: 'inactive', didCopied: false, creatingDid: false, dhtDid: null, dhtDidCopied: false,
publishingDht: false, nostrRelayStats: null, connectedNodesCount: 0, detectedHwWallets: [],
}, global: { plugins: [router] } })
const entry = wrapper.get('[aria-label="Node connections"]')
expect(entry.element.closest('.hidden')).toBeNull()
expect(entry.findAll('a').map(a => a.attributes('href'))).toEqual(['/connections?view=discover', '/connections?view=connected'])
expect(wrapper.text()).toContain('Show more')
wrapper.unmount()
})