Unify node connection discovery requests and connected views
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user