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
Status: proposal for the post-1.9.0 work. Uses existing components, colors,
spacing, glass cards, typography and motion. No broad navigation redesign has
been deployed. Connection reliability must be qualified before this flow ships.
Status: partial implementation prepared in an isolated worktree, not deployed.
Uses existing components, colors, spacing, glass cards, typography and motion.
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
+62 -50
View File
@@ -21,7 +21,7 @@
<!-- 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
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
v-for="tab in viewTabs"
:key="tab.id"
@@ -31,10 +31,12 @@
:class="{ 'mode-switcher-btn-active': activeView === 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>
</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
vertical space belongs to the map (desktop keeps the header card) -->
<DidCardMobile
@@ -56,7 +58,7 @@
/>
</div>
<template v-if="activeView === 'list'">
<div v-show="activeView === 'discover'">
<QuickActions
:generating-invite="generatingInvite"
:invite-type="inviteType"
@@ -86,8 +88,8 @@
</div>
<p class="text-xs text-white/60 mt-1">
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
panel below for your approval. Off by default.
so other nodes can find you and request to peer. Review incoming requests in
the Requests tab. Off by default.
</p>
<button
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
@@ -104,13 +106,6 @@
>
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</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>
@@ -178,31 +173,43 @@
</div>
</div>
</div>
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
<PendingRequestsPanel
:requests="pendingRequests"
<DiscoverModal
:visible="discoveryEnabled && activeView === 'discover'"
inline
:outbound-sent="pendingRequests"
:peer-dids="nodes.map(node => node.did)"
:polling="pollingHandshake"
:busy-id="pendingBusyId"
@poll="pollHandshake"
@approve="approvePending"
@reject="rejectPending"
@cancel="cancelPending"
@view-request="setView('requests')"
@open-node="onMapSelect"
@sent="loadPendingRequests(); setView('requests')"
/>
</div>
<NodeList
: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"
/>
</template>
<PendingRequestsPanel
v-show="activeView === 'requests'"
show-empty
:requests="pendingRequests"
:peer-dids="nodes.map(node => node.did)"
:polling="pollingHandshake"
:busy-id="pendingBusyId"
@poll="pollHandshake"
@approve="approvePending"
@reject="rejectPending"
@cancel="cancelPending"
/>
<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
:node="selectedNode"
@@ -230,13 +237,6 @@
@join="joinFederation"
/>
<DiscoverModal
:visible="showDiscoverModal"
:outbound-sent="pendingRequests"
@close="showDiscoverModal = false"
@sent="loadPendingRequests"
/>
<PresenceSignModal
:show="showPresenceSignModal"
:server-name="appStore.serverName"
@@ -260,7 +260,8 @@
</template>
<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 { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
@@ -320,21 +321,26 @@ const deploying = ref(false)
const deployResult = ref('')
const viewTabs = [
{ id: 'list', label: 'List View' },
{ id: 'discover', label: 'Discover' },
{ id: 'requests', label: 'Requests' },
{ id: 'connected', label: 'Connected' },
{ id: 'map', label: 'Network Map' },
] as const
type ViewId = typeof viewTabs[number]['id']
const activeView = ref<ViewId>(
(localStorage.getItem('federation-view') as ViewId) || (nodes.value.length >= 3 ? 'map' : 'list')
)
const route = useRoute()
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) {
activeView.value = id
localStorage.setItem('federation-view', id)
void router.replace({ query: { ...route.query, view: id } })
}
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
const mapActive = computed(() => activeView.value === 'map')
/** 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). */
@@ -438,7 +444,6 @@ const cleaningNodes = ref(false)
const discoveryEnabled = ref(false)
const discoveryToggling = ref(false)
const discoveryError = ref('')
const showDiscoverModal = ref(false)
// Signing-details disclosure: who signs the presence event (always the node's
// own discovery key) and a human-readable view of the signed content.
const showSigningInfo = ref(false)
@@ -460,6 +465,10 @@ async function loadNodeNpub() {
}
}
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 pendingBusyId = ref<string | null>(null)
@@ -819,6 +828,7 @@ async function rotateDid(password: string) {
}
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let disposed = false
onMounted(async () => {
loadDwnStatus()
@@ -833,6 +843,7 @@ onMounted(async () => {
} catch {
// Self DID not available
}
if (disposed) return
autoRefreshTimer = setInterval(() => {
availabilityNow.value = Date.now()
void nodesRes.refresh()
@@ -841,6 +852,7 @@ onMounted(async () => {
})
onUnmounted(() => {
disposed = true
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
+47 -24
View File
@@ -1,13 +1,13 @@
<template>
<Teleport to="body">
<Teleport to="body" :disabled="inline">
<Transition name="modal">
<div
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')"
>
<div 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 v-if="!inline" class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<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>
<h2 class="text-xl font-semibold text-white">Discover Nodes</h2>
@@ -17,7 +17,7 @@
an optional message travel inside an encrypted DM.
</p>
</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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -37,6 +37,8 @@
</span>
</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>
<!-- Manual entry: paste an npub directly -->
@@ -67,6 +69,7 @@
</div>
<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">
<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>
@@ -75,22 +78,22 @@
Searching relays...
</div>
<div
v-for="node in nodes"
v-for="node in filteredNodes"
:key="node.nostr_pubkey"
class="p-3 bg-white/5 rounded-lg border border-white/10"
>
<div class="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) }}
{{ node.name || 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="sendingTo === node.nostr_pubkey || alreadySentTo(node.nostr_pubkey)"
@click="sendTo(node)"
:disabled="sendingTo !== null"
@click="actOn(node)"
>
{{ statusFor(node) }}
</button>
@@ -102,7 +105,7 @@
</Transition>
<PeerRequestModal
:show="requestTarget !== null"
:show="visible && requestTarget !== null"
:target-label="requestTarget?.label ?? ''"
:sending="sendingTo !== null && sendingTo === requestTarget?.target"
@send="confirmRequest"
@@ -112,7 +115,7 @@
</template>
<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 PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
@@ -121,17 +124,21 @@ interface DiscoverableNode {
nostr_npub: string
did: string
version: string
name?: string | null
}
const props = defineProps<{
visible: boolean
/// Outbound rows from the parent's pending list, used to grey out
/// "Send Request" buttons for npubs we've already requested.
inline?: boolean
peerDids?: string[]
/// Existing requests select their Requests view instead of sending again.
outboundSent: PendingPeerRequest[]
}>()
const emit = defineEmits<{
close: []
viewRequest: []
openNode: [did: string]
/// Fired after a successful send so the parent can refresh its
/// pending-requests list to show the new "Sent" row.
sent: []
@@ -143,25 +150,37 @@ const error = ref('')
const lastSearchAt = ref('')
const sendingTo = ref<string | null>(null)
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(
() => props.visible,
(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() {
if (loading.value) return
const generation = ++discoveryGeneration
loading.value = true
error.value = ''
try {
const result = await rpcClient.handshakeDiscover()
if (generation !== discoveryGeneration) return
nodes.value = result.nodes
lastSearchAt.value = new Date().toLocaleTimeString()
} 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 {
loading.value = false
if (generation === discoveryGeneration) loading.value = false
}
}
@@ -169,6 +188,14 @@ async function refresh() {
// message before anything is sent (Request/Cancel).
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) {
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) {
const req = requestTarget.value
if (!req) return
if (!req || sendingTo.value !== null) return
await sendInternal(req.target, message)
if (req.clearManual) manualNpub.value = ''
requestTarget.value = null
}
async function sendInternal(target: string, message?: string) {
if (sendingTo.value !== null) return
sendingTo.value = target
error.value = ''
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 {
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'
}
@@ -3,8 +3,8 @@
<BackButton label="Web5" @click="router.push('/dashboard/web5')" />
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
<p class="text-white/70">Connect, sync, and share with trusted nodes</p>
<h1 class="text-3xl font-bold text-white mb-2">Connect with Nodes</h1>
<p class="text-white/70">Discover peers, manage requests, and view your connected nodes</p>
</div>
<!-- Your Node DID — top right card (desktop) -->
<div v-if="selfDid" class="hidden md:block shrink-0">
@@ -1,5 +1,5 @@
<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>
<h2 class="text-base font-semibold text-white">Nostr Connection Requests</h2>
@@ -18,6 +18,7 @@
</button>
</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
v-for="req in visibleRequests"
@@ -81,6 +82,7 @@ const props = defineProps<{
polling: boolean
busyId: string | null
peerDids?: string[]
showEmpty?: boolean
}>()
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')
})
})
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()
})
})
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>
<!-- Quick Actions Container -->
<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">
<!-- 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">
@@ -182,7 +186,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
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()
})