frontend: polish app launch and release experience
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<div class="web5-card-actions-top gap-2 shrink-0">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
@@ -20,9 +20,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? '...' : t('common.refresh') }}
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,32 +37,26 @@
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
{{ loadingPeers ? '...' : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Peers | Messages | Requests -->
|
||||
<!-- Tabs: Trusted | Observers | Messages | Requests -->
|
||||
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||
<button
|
||||
@click="nodesContainerTab = 'peers'"
|
||||
@click="nodesContainerTab = 'trusted'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="nodesContainerTab === 'peers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
:class="nodesContainerTab === 'trusted' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
{{ t('web5.peers') }}
|
||||
{{ t('web5.trusted') }}
|
||||
<span v-if="peers.length > 0" class="ml-1.5 text-xs text-white/50">({{ peers.length }})</span>
|
||||
</button>
|
||||
<button
|
||||
@click="nodesContainerTab = 'observers'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="nodesContainerTab === 'observers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
{{ t('web5.observers') }}
|
||||
<span v-if="observers.length > 0" class="ml-1.5 text-xs text-white/50">({{ observers.length }})</span>
|
||||
</button>
|
||||
<button
|
||||
@click="switchToMessagesTab"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
|
||||
@@ -82,11 +77,17 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Peers tab -->
|
||||
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
<!-- Trusted tab -->
|
||||
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noPeers') }}
|
||||
</div>
|
||||
<div v-else-if="loadingPeers" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="p in peers"
|
||||
:key="p.pubkey"
|
||||
@@ -108,14 +109,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observers tab -->
|
||||
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingPeers && observers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="observers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noObservers') }}
|
||||
</div>
|
||||
<div v-else-if="loadingPeers" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="p in observers"
|
||||
:key="p.pubkey"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
|
||||
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-300 shrink-0">
|
||||
{{ t('web5.observer') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages tab -->
|
||||
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingMessages" class="p-4 text-center text-white/60 text-sm">
|
||||
<div v-if="loadingMessages && receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noMessages') }}
|
||||
</div>
|
||||
<div v-else-if="loadingMessages" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, idx) in receivedMessages"
|
||||
:key="idx"
|
||||
@@ -131,12 +164,15 @@
|
||||
|
||||
<!-- Requests tab -->
|
||||
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingRequests" class="p-4 text-center text-white/60 text-sm">
|
||||
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noRequests') }}
|
||||
</div>
|
||||
<div v-else-if="loadingRequests" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="req in connectionRequests"
|
||||
:key="req.id"
|
||||
@@ -168,15 +204,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4">
|
||||
<div class="mt-auto pt-4 space-y-3">
|
||||
<div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="nodesContainerTab === 'peers'"
|
||||
v-if="nodesContainerTab === 'trusted'"
|
||||
@click="discoverAndAddPeers"
|
||||
:disabled="discovering"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="nodesContainerTab === 'observers'"
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="nodesContainerTab === 'messages'"
|
||||
@click="loadReceivedMessages"
|
||||
@@ -266,10 +325,11 @@ const web5Badge = useWeb5BadgeStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const nodesContainerRef = ref<HTMLElement | null>(null)
|
||||
const nodesContainerTab = ref<'peers' | 'messages' | 'requests'>('peers')
|
||||
const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
|
||||
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
|
||||
|
||||
const peers = ref<Peer[]>([])
|
||||
const observers = ref<Peer[]>([])
|
||||
const loadingPeers = ref(false)
|
||||
const peerReachableLocal = ref<Record<string, boolean>>({})
|
||||
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
|
||||
@@ -300,11 +360,21 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
function peerNameFromPubkey(pubkey: string): string {
|
||||
const peer = peers.value.find(p => p.pubkey === pubkey || p.onion === pubkey)
|
||||
const peer = [...peers.value, ...observers.value].find(p => p.pubkey === pubkey || p.onion === pubkey)
|
||||
if (peer?.name) return peer.name
|
||||
return (pubkey || '').slice(0, 16) + '...'
|
||||
}
|
||||
|
||||
type FederationNode = Awaited<ReturnType<typeof rpcClient.federationListNodes>>['nodes'][number]
|
||||
|
||||
function federationNodeToPeer(node: FederationNode): Peer {
|
||||
return {
|
||||
onion: node.onion,
|
||||
pubkey: node.pubkey,
|
||||
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
|
||||
}
|
||||
}
|
||||
|
||||
function switchToMessagesTab() {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
@@ -322,13 +392,25 @@ async function loadPeers() {
|
||||
try {
|
||||
const res = await rpcClient.listPeers()
|
||||
const peerList = res.peers || []
|
||||
const observerList: Peer[] = []
|
||||
|
||||
try {
|
||||
const fedRes = await rpcClient.federationListNodes()
|
||||
const fedNodes = fedRes.nodes || []
|
||||
for (const n of fedNodes) {
|
||||
if (n.onion && !peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
|
||||
peerList.push({ onion: n.onion, pubkey: n.pubkey, name: n.name || `Federation: ${n.did?.slice(0, 16) || 'node'}` })
|
||||
if (!n.onion || n.trust_level === 'untrusted') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (n.trust_level === 'observer') {
|
||||
if (!observerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
|
||||
observerList.push(federationNodeToPeer(n))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
|
||||
peerList.push(federationNodeToPeer(n))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -336,7 +418,8 @@ async function loadPeers() {
|
||||
}
|
||||
|
||||
peers.value = peerList
|
||||
for (const p of peers.value) {
|
||||
observers.value = observerList
|
||||
for (const p of [...peers.value, ...observers.value]) {
|
||||
try {
|
||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||
peerReachableLocal.value[p.onion] = check.reachable
|
||||
@@ -394,13 +477,14 @@ async function discoverAndAddPeers() {
|
||||
}
|
||||
|
||||
async function loadConnectionRequests() {
|
||||
const hadRequests = connectionRequests.value.length > 0
|
||||
loadingRequests.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
|
||||
connectionRequests.value = res.requests || []
|
||||
web5Badge.pendingRequestCount = connectionRequests.value.length
|
||||
} catch {
|
||||
connectionRequests.value = []
|
||||
if (!hadRequests) connectionRequests.value = []
|
||||
} finally {
|
||||
loadingRequests.value = false
|
||||
}
|
||||
@@ -443,5 +527,5 @@ function scrollToMessages() {
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, scrollToMessages })
|
||||
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, observers, scrollToMessages })
|
||||
</script>
|
||||
|
||||
@@ -51,7 +51,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Credentials List (summary) -->
|
||||
<div v-if="vcCredentials.length" class="space-y-2">
|
||||
<div v-if="credentialsLoading && vcCredentials.length === 0" class="text-center text-white/40 text-sm py-4">
|
||||
Loading credentials...
|
||||
</div>
|
||||
<div v-else-if="vcCredentials.length" class="space-y-2">
|
||||
<div v-if="credentialsLoading" 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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing credentials...
|
||||
</div>
|
||||
<div v-else-if="credentialsError" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ credentialsError }}
|
||||
</div>
|
||||
<div v-for="vc in vcCredentials.slice(0, 3)" :key="vc.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-white font-medium">{{ vc.type }}</div>
|
||||
@@ -86,13 +99,21 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const vcCredentials = ref<VCData[]>([])
|
||||
const credentialsLoading = ref(false)
|
||||
const credentialsError = ref('')
|
||||
|
||||
async function loadCredentials() {
|
||||
const hadCredentials = vcCredentials.value.length > 0
|
||||
credentialsLoading.value = true
|
||||
credentialsError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ credentials: VCData[] }>({ method: 'identity.list-credentials' })
|
||||
vcCredentials.value = res.credentials || []
|
||||
} catch {
|
||||
vcCredentials.value = []
|
||||
} catch (e: unknown) {
|
||||
credentialsError.value = e instanceof Error ? e.message : 'Failed to load credentials'
|
||||
if (!hadCredentials) vcCredentials.value = []
|
||||
} finally {
|
||||
credentialsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,9 +119,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showDwnMessages">
|
||||
<div v-if="loadingDwnMessages" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
|
||||
<div v-if="loadingDwnMessages && dwnMessages.length === 0" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
|
||||
<div v-else-if="dwnMessages.length === 0" class="text-xs text-white/30 italic py-2">No messages stored</div>
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div v-if="loadingDwnMessages" 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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing messages...
|
||||
</div>
|
||||
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
|
||||
@@ -261,16 +268,17 @@ async function toggleDwnMessages() {
|
||||
}
|
||||
|
||||
async function loadDwnMessages() {
|
||||
const hadMessages = dwnMessages.value.length > 0
|
||||
loadingDwnMessages.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ messages: DwnMessageEntry[]; count: number }>({ method: 'dwn.query-messages', params: { limit: 50 } })
|
||||
dwnMessages.value = res.messages || []
|
||||
} catch {
|
||||
dwnMessages.value = []
|
||||
if (!hadMessages) dwnMessages.value = []
|
||||
} finally {
|
||||
loadingDwnMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ loadDwnStatus, loadDwnProtocols })
|
||||
defineExpose({ loadDwnStatus, loadDwnProtocols, loadDwnMessages, dwnMessages, showDwnMessages })
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div v-if="domainsLoading && registeredNames.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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing domains...
|
||||
</div>
|
||||
<div v-else-if="domainsLoadError && registeredNames.length > 0" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ domainsLoadError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -152,6 +163,8 @@ const newDomainDomain = ref('')
|
||||
const newDomainIdentityId = ref('')
|
||||
const domainError = ref('')
|
||||
const domainRegistering = ref(false)
|
||||
const domainsLoading = ref(false)
|
||||
const domainsLoadError = ref('')
|
||||
const verifyNip05Input = ref('')
|
||||
const nip05Verifying = ref(false)
|
||||
const nip05Result = ref<Nip05Result | null>(null)
|
||||
@@ -160,11 +173,17 @@ const activeNamesCount = computed(() => registeredNames.value.filter(n => n.stat
|
||||
const expiringNamesCount = computed(() => registeredNames.value.filter(n => n.status === 'expired' || n.expires_at).length)
|
||||
|
||||
async function loadDomainNames() {
|
||||
const hadNames = registeredNames.value.length > 0
|
||||
domainsLoading.value = true
|
||||
domainsLoadError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ names: RegisteredNameData[] }>({ method: 'identity.list-names' })
|
||||
registeredNames.value = res.names || []
|
||||
} catch {
|
||||
registeredNames.value = []
|
||||
} catch (e: unknown) {
|
||||
domainsLoadError.value = e instanceof Error ? e.message : 'Failed to load domains'
|
||||
if (!hadNames) registeredNames.value = []
|
||||
} finally {
|
||||
domainsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,31 @@
|
||||
<p class="text-xs text-white/60">Federated nodes & peers</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Details
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<div class="web5-card-actions-top items-center gap-2">
|
||||
<RouterLink to="/dashboard/server/federation" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Find Nodes
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Fleet
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-if="loadingFederation && hasLoadedFederation" 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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing federation...
|
||||
</div>
|
||||
<div v-else-if="federationError && hasLoadedFederation" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ federationError }}
|
||||
</div>
|
||||
<div v-else-if="loadingFederation" class="p-2 text-center text-white/45 text-xs">
|
||||
Loading federation...
|
||||
</div>
|
||||
|
||||
<!-- Node Count -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -55,6 +71,15 @@
|
||||
<span class="text-sm font-medium text-white/40 truncate max-w-[140px]">{{ selfDid || 'Not set' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="web5-card-actions-bottom-grid mt-6 grid-cols-2 gap-3">
|
||||
<RouterLink to="/dashboard/server/federation" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
|
||||
Find Nodes
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/fleet" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
|
||||
Fleet
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,8 +92,13 @@ const nodeCount = ref(0)
|
||||
const onlineCount = ref(0)
|
||||
const pendingCount = ref(0)
|
||||
const selfDid = ref('')
|
||||
const loadingFederation = ref(false)
|
||||
const hasLoadedFederation = ref(false)
|
||||
const federationError = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadFederationSummary() {
|
||||
loadingFederation.value = true
|
||||
federationError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
nodes: Array<{ status: string }>
|
||||
@@ -78,11 +108,22 @@ onMounted(async () => {
|
||||
nodeCount.value = nodes.length
|
||||
onlineCount.value = nodes.filter(n => n.status === 'online' || n.status === 'connected').length
|
||||
pendingCount.value = (res.pending_requests || []).length
|
||||
} catch { /* unavailable */ }
|
||||
hasLoadedFederation.value = true
|
||||
} catch (e: unknown) {
|
||||
federationError.value = e instanceof Error ? e.message : 'Federation unavailable'
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rpcClient.getNodeDid()
|
||||
selfDid.value = res.did || ''
|
||||
} catch { /* unavailable */ }
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
if (!federationError.value) federationError.value = e instanceof Error ? e.message : 'Node identity unavailable'
|
||||
} finally {
|
||||
loadingFederation.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadFederationSummary)
|
||||
|
||||
defineExpose({ loadFederationSummary })
|
||||
</script>
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
<p class="text-xs text-white/60">{{ t('web5.identitiesDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showCreateIdentityModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<button @click="showCreateIdentityModal = true" class="web5-card-actions-top glass-button glass-button-sm px-3 rounded-lg text-sm font-medium items-center gap-2">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
@@ -32,16 +29,10 @@
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mb-3">{{ t('web5.identitiesDesc') }}</p>
|
||||
<button @click="showCreateIdentityModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="identitiesLoading" class="py-6 text-center">
|
||||
<div v-if="identitiesLoading && managedIdentities.length === 0" class="py-6 text-center">
|
||||
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -60,6 +51,13 @@
|
||||
|
||||
<!-- Identity List -->
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="identitiesLoading" 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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing identities...
|
||||
</div>
|
||||
<div
|
||||
v-for="(identity, idx) in managedIdentities"
|
||||
:key="identity.id"
|
||||
@@ -136,6 +134,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="showCreateIdentityModal = true" class="web5-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Identity Modal -->
|
||||
@@ -501,12 +503,13 @@ const profileError = ref('')
|
||||
const profileSuccess = ref('')
|
||||
|
||||
async function loadIdentities() {
|
||||
const hadIdentities = managedIdentities.value.length > 0
|
||||
identitiesLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
|
||||
managedIdentities.value = res.identities || []
|
||||
} catch {
|
||||
managedIdentities.value = []
|
||||
if (!hadIdentities) managedIdentities.value = []
|
||||
} finally {
|
||||
identitiesLoading.value = false
|
||||
}
|
||||
|
||||
@@ -13,11 +13,8 @@
|
||||
<p class="text-xs text-white/60">System resources & health</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/monitoring" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
<RouterLink to="/dashboard/monitoring" class="web5-card-actions-top glass-button glass-button-sm px-3 rounded-lg text-sm font-medium items-center gap-2 no-underline">
|
||||
Details
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +64,10 @@
|
||||
<span class="text-sm font-medium text-white/60">{{ uptimeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink to="/dashboard/monitoring" class="web5-card-actions-bottom mt-6 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0 no-underline">
|
||||
Details
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div v-if="relaysLoading && hasRelayData" 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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing relays...
|
||||
</div>
|
||||
<div v-else-if="relaysLoadError && hasRelayData" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ relaysLoadError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -99,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { NostrRelayData, NostrRelayStatsData } from './types'
|
||||
@@ -115,8 +126,14 @@ const nostrRelayStats = ref<NostrRelayStatsData | null>(null)
|
||||
const showRelaysModal = ref(false)
|
||||
const newRelayUrl = ref('')
|
||||
const relayError = ref('')
|
||||
const relaysLoading = ref(false)
|
||||
const relaysLoadError = ref('')
|
||||
const hasRelayData = computed(() => nostrRelays.value.length > 0 || nostrRelayStats.value !== null)
|
||||
|
||||
async function loadNostrRelays() {
|
||||
const hadRelayData = hasRelayData.value
|
||||
relaysLoading.value = true
|
||||
relaysLoadError.value = ''
|
||||
try {
|
||||
const [relayRes, statsRes] = await Promise.all([
|
||||
rpcClient.call<{ relays: NostrRelayData[] }>({ method: 'nostr.list-relays' }),
|
||||
@@ -124,9 +141,14 @@ async function loadNostrRelays() {
|
||||
])
|
||||
nostrRelays.value = relayRes.relays || []
|
||||
nostrRelayStats.value = statsRes
|
||||
} catch {
|
||||
nostrRelays.value = []
|
||||
nostrRelayStats.value = null
|
||||
} catch (e: unknown) {
|
||||
relaysLoadError.value = e instanceof Error ? e.message : 'Failed to load relays'
|
||||
if (!hadRelayData) {
|
||||
nostrRelays.value = []
|
||||
nostrRelayStats.value = null
|
||||
}
|
||||
} finally {
|
||||
relaysLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
</div>
|
||||
<div v-else class="mb-3 text-center">
|
||||
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
||||
<p v-if="unifiedReceiveProcessing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -181,6 +182,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -475,6 +477,9 @@ async function unifiedReceive() {
|
||||
receiveInvoiceResult.value = res.payment_request
|
||||
} else if (receiveMethod.value === 'onchain') {
|
||||
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
|
||||
if (!res.address) {
|
||||
throw new Error('LND did not return a Bitcoin address')
|
||||
}
|
||||
receiveOnchainAddress.value = res.address
|
||||
nextTick(() => renderQrCode(res.address, onchainQrCanvas.value))
|
||||
} else {
|
||||
@@ -491,7 +496,9 @@ async function unifiedReceive() {
|
||||
emit('balancesChanged')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
unifiedReceiveError.value = err instanceof Error ? err.message : t('web5.receiveFailed')
|
||||
unifiedReceiveError.value = receiveMethod.value === 'onchain'
|
||||
? explainReceiveAddressFailure(err)
|
||||
: err instanceof Error ? err.message : t('web5.receiveFailed')
|
||||
} finally {
|
||||
unifiedReceiveProcessing.value = false
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@
|
||||
<p class="text-white/40 text-xs">{{ t('web5.addFilesToShare') }}</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="contentLoading" 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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing shared content...
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, idx) in contentItems"
|
||||
:key="item.id"
|
||||
@@ -168,7 +175,7 @@
|
||||
|
||||
<!-- Browse Peers tab -->
|
||||
<div v-show="contentTab === 'browse'">
|
||||
<div v-if="browsingPeerContent" class="py-4 text-center">
|
||||
<div v-if="browsingPeerContent && peerContentItems.length === 0" class="py-4 text-center">
|
||||
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -186,6 +193,13 @@
|
||||
<p class="text-white/60 text-sm">{{ t('web5.peerNoContent') }}</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="browsingPeerContent" 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>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing peer content...
|
||||
</div>
|
||||
<div
|
||||
v-for="(pItem, idx) in peerContentItems"
|
||||
:key="pItem.id"
|
||||
@@ -383,6 +397,7 @@ const accessOptions = [
|
||||
|
||||
// Browse peers
|
||||
const browsePeerOnion = ref('')
|
||||
const activeBrowsePeerOnion = ref('')
|
||||
const browsingPeerContent = ref(false)
|
||||
const browsePeerError = ref('')
|
||||
const peerContentItems = ref<PeerContentItem[]>([])
|
||||
@@ -405,7 +420,7 @@ async function loadContentItems() {
|
||||
const res = await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })
|
||||
contentItems.value = res.items || []
|
||||
} catch {
|
||||
contentItems.value = []
|
||||
if (contentItems.value.length === 0) contentItems.value = []
|
||||
} finally {
|
||||
contentLoading.value = false
|
||||
}
|
||||
@@ -501,14 +516,16 @@ async function updateItemPrice(item: ContentItemData, value: string) {
|
||||
|
||||
async function browsePeerContent() {
|
||||
if (!browsePeerOnion.value || browsingPeerContent.value) return
|
||||
const isRefreshingActivePeer = browsePeerOnion.value === activeBrowsePeerOnion.value
|
||||
browsingPeerContent.value = true
|
||||
browsePeerError.value = ''
|
||||
peerContentItems.value = []
|
||||
if (!isRefreshingActivePeer) peerContentItems.value = []
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: PeerContentItem[] }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion: browsePeerOnion.value },
|
||||
})
|
||||
activeBrowsePeerOnion.value = browsePeerOnion.value
|
||||
peerContentItems.value = res.items || []
|
||||
} catch (err: unknown) {
|
||||
browsePeerError.value = err instanceof Error ? err.message : t('web5.failedToConnectPeer')
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const messageState = vi.hoisted(() => ({
|
||||
receivedMessages: { __v_isRef: true, value: [] as Array<{ from_pubkey: string; timestamp: number; message: string }> },
|
||||
loadingMessages: { __v_isRef: true, value: false },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
listPeers: vi.fn(() => new Promise(() => {})),
|
||||
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
|
||||
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMessageToast', () => ({
|
||||
useMessageToast: () => ({
|
||||
receivedMessages: messageState.receivedMessages,
|
||||
loadingMessages: messageState.loadingMessages,
|
||||
unreadCount: { value: 0 },
|
||||
loadReceivedMessages: vi.fn(),
|
||||
markAsRead: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/web5Badge', () => ({
|
||||
useWeb5BadgeStore: () => ({ pendingRequestCount: 0 }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ peerHealth: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Web5ConnectedNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
messageState.receivedMessages.value = []
|
||||
messageState.loadingMessages.value = false
|
||||
})
|
||||
|
||||
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
|
||||
wrapper.vm.loadPeers()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('common.loading')
|
||||
expect(wrapper.text()).not.toContain('web5.noPeers')
|
||||
})
|
||||
|
||||
it('keeps received messages visible during refresh', async () => {
|
||||
messageState.receivedMessages.value = [{
|
||||
from_pubkey: 'peer-pubkey',
|
||||
timestamp: 1760000000,
|
||||
message: 'Existing message',
|
||||
}]
|
||||
messageState.loadingMessages.value = true
|
||||
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
wrapper.vm.nodesContainerTab = 'messages'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Existing message')
|
||||
expect(wrapper.text()).toContain('common.loading')
|
||||
})
|
||||
|
||||
it('keeps connection requests visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
requests: [{
|
||||
id: 'req-1',
|
||||
from_did: 'did:key:alice',
|
||||
from_pubkey: 'alice-pubkey',
|
||||
message: 'Please connect',
|
||||
created_at: '2026-06-10T10:00:00Z',
|
||||
}],
|
||||
})
|
||||
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
await (wrapper.vm as unknown as { loadConnectionRequests: () => Promise<void> }).loadConnectionRequests()
|
||||
|
||||
expect(wrapper.text()).toContain('Please connect')
|
||||
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('offline')), 0)
|
||||
}))
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadConnectionRequests: () => Promise<void> }).loadConnectionRequests()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Please connect')
|
||||
expect(wrapper.text()).toContain('common.loading')
|
||||
|
||||
await refresh
|
||||
|
||||
expect(wrapper.text()).toContain('Please connect')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Web5CredentialsSummary from '../Web5CredentialsSummary.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeCredential() {
|
||||
return {
|
||||
id: 'cred-one',
|
||||
issuer: 'did:key:issuer',
|
||||
subject: 'did:key:subject',
|
||||
type: 'NodeOperator',
|
||||
claims: {},
|
||||
issued_at: '2026-06-10T10:00:00Z',
|
||||
expires_at: null,
|
||||
status: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Web5CredentialsSummary', () => {
|
||||
it('keeps credential rows visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ credentials: [makeCredential()] })
|
||||
|
||||
const wrapper = mount(Web5CredentialsSummary, {
|
||||
props: { identityCount: 1 },
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('did:key:subject')
|
||||
|
||||
const pending = deferred<{ credentials: [] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('Refreshing credentials...')
|
||||
expect(wrapper.text()).not.toContain('Loading credentials...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing credentials...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Web5DWN from '../Web5DWN.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useSyncStore } from '@/stores/sync'
|
||||
import { PackageState } from '@/types/api'
|
||||
import type { DwnMessageEntry } from '../types'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function makeMessage(recordId: string): DwnMessageEntry {
|
||||
return {
|
||||
record_id: recordId,
|
||||
author: 'did:key:alice',
|
||||
date_created: '2026-06-10T10:00:00Z',
|
||||
descriptor: {
|
||||
interface: 'Records',
|
||||
method: 'Write',
|
||||
protocol: 'https://example.com/protocol',
|
||||
},
|
||||
data: { title: recordId },
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('Web5DWN', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
useSyncStore().data = {
|
||||
'package-data': {
|
||||
dwn: {
|
||||
state: PackageState.Running,
|
||||
manifest: { id: 'dwn', title: 'DWN' },
|
||||
'static-files': {},
|
||||
},
|
||||
},
|
||||
} as never
|
||||
})
|
||||
|
||||
it('keeps stored messages visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
messages: [makeMessage('record-one')],
|
||||
count: 1,
|
||||
})
|
||||
|
||||
const wrapper = mount(Web5DWN, {
|
||||
global: {
|
||||
stubs: { RouterLink: true },
|
||||
},
|
||||
})
|
||||
|
||||
;(wrapper.vm as unknown as { showDwnMessages: boolean }).showDwnMessages = true
|
||||
await (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('record-o')
|
||||
|
||||
const pending = deferred<{ messages: DwnMessageEntry[]; count: number }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('record-o')
|
||||
expect(wrapper.text()).toContain('Refreshing messages...')
|
||||
expect(wrapper.text()).not.toContain('Loading messages...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('record-o')
|
||||
expect(wrapper.text()).not.toContain('Refreshing messages...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Web5Domains from '../Web5Domains.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeDomain() {
|
||||
return {
|
||||
id: 'name-one',
|
||||
name: 'satoshi',
|
||||
domain: 'example.com',
|
||||
nip05: 'satoshi@example.com',
|
||||
identity_id: 'identity-one',
|
||||
did: 'did:key:identity',
|
||||
nostr_pubkey: null,
|
||||
status: 'active',
|
||||
registered_at: '2026-06-10T10:00:00Z',
|
||||
expires_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Web5Domains', () => {
|
||||
it('keeps registered names visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ names: [makeDomain()] })
|
||||
|
||||
const wrapper = mount(Web5Domains, {
|
||||
props: { showStagger: false, managedIdentities: [] },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await (wrapper.vm as unknown as { loadDomainNames: () => Promise<void> }).loadDomainNames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('1 name')
|
||||
expect(wrapper.text()).toContain('1 Active')
|
||||
|
||||
const pending = deferred<{ names: [] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadDomainNames: () => Promise<void> }).loadDomainNames()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('1 name')
|
||||
expect(wrapper.text()).toContain('1 Active')
|
||||
expect(wrapper.text()).toContain('Refreshing domains...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('1 name')
|
||||
expect(wrapper.text()).toContain('1 Active')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing domains...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import Web5Federation from '../Web5Federation.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({ nodes: [], pending_requests: [] }),
|
||||
getNodeDid: vi.fn().mockResolvedValue({ did: 'did:key:test' }),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function mountFederation() {
|
||||
return mount(Web5Federation, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
props: ['to'],
|
||||
template: '<a :href="to"><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Web5Federation', () => {
|
||||
it('surfaces Find Nodes and Fleet routes', () => {
|
||||
const wrapper = mountFederation()
|
||||
|
||||
const links = wrapper.findAll('a').map(link => link.attributes('href'))
|
||||
expect(links).toContain('/dashboard/server/federation')
|
||||
expect(links).toContain('/dashboard/fleet')
|
||||
expect(wrapper.text()).toContain('Find Nodes')
|
||||
expect(wrapper.text()).toContain('Fleet')
|
||||
})
|
||||
|
||||
it('shows federation refresh state without replacing existing counts', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
nodes: [{ status: 'online' }, { status: 'offline' }],
|
||||
pending_requests: [{}],
|
||||
})
|
||||
vi.mocked(rpcClient.getNodeDid).mockResolvedValueOnce({ did: 'did:key:node' })
|
||||
|
||||
const wrapper = mountFederation()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Known Nodes')
|
||||
expect(wrapper.text()).toContain('2')
|
||||
expect(wrapper.text()).toContain('did:key:node')
|
||||
|
||||
const pending = deferred<{ nodes: []; pending_requests: [] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
vi.mocked(rpcClient.getNodeDid).mockResolvedValueOnce({ did: 'did:key:node' })
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadFederationSummary: () => Promise<void> }).loadFederationSummary()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('2')
|
||||
expect(wrapper.text()).toContain('did:key:node')
|
||||
expect(wrapper.text()).toContain('Refreshing federation...')
|
||||
expect(wrapper.text()).not.toContain('Loading federation...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('2')
|
||||
expect(wrapper.text()).toContain('did:key:node')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing federation...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Web5Identities from '../Web5Identities.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { ManagedIdentity } from '../types'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
safeClipboardWrite: vi.fn(),
|
||||
}))
|
||||
|
||||
function makeIdentity(name: string): ManagedIdentity {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
purpose: 'personal',
|
||||
pubkey: `${name}-pubkey`,
|
||||
did: `did:key:${name}`,
|
||||
created_at: '2026-06-10T10:00:00Z',
|
||||
is_default: true,
|
||||
profile: {},
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('Web5Identities', () => {
|
||||
it('keeps identities visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
identities: [makeIdentity('Personal')],
|
||||
})
|
||||
|
||||
const wrapper = mount(Web5Identities, {
|
||||
props: { showStagger: false },
|
||||
})
|
||||
|
||||
await (wrapper.vm as unknown as { loadIdentities: () => Promise<void> }).loadIdentities()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Personal')
|
||||
|
||||
const pending = deferred<{ identities: ManagedIdentity[] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadIdentities: () => Promise<void> }).loadIdentities()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Personal')
|
||||
expect(wrapper.text()).toContain('Refreshing identities...')
|
||||
expect(wrapper.text()).not.toContain('common.loading')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Personal')
|
||||
expect(wrapper.text()).not.toContain('Refreshing identities...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Web5NostrRelays from '../Web5NostrRelays.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function mockRelayCalls() {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'nostr.list-relays') {
|
||||
return Promise.resolve({
|
||||
relays: [{ url: 'wss://relay.example', connected: true, enabled: true, added_at: '2026-06-10T10:00:00Z' }],
|
||||
})
|
||||
}
|
||||
if (request.method === 'nostr.get-stats') {
|
||||
return Promise.resolve({ connected_count: 2, total_relays: 3, enabled_count: 1 })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
}
|
||||
|
||||
describe('Web5NostrRelays', () => {
|
||||
it('keeps relay stats visible while refresh is pending or fails', async () => {
|
||||
mockRelayCalls()
|
||||
|
||||
const wrapper = mount(Web5NostrRelays, {
|
||||
props: { showStagger: false },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await (wrapper.vm as unknown as { loadNostrRelays: () => Promise<void> }).loadNostrRelays()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('2 active')
|
||||
expect(wrapper.text()).toContain('3 configured')
|
||||
|
||||
const pending = deferred<{ relays: [] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'nostr.list-relays') return pending.promise
|
||||
if (request.method === 'nostr.get-stats') {
|
||||
return Promise.resolve({ connected_count: 0, total_relays: 0, enabled_count: 0 })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadNostrRelays: () => Promise<void> }).loadNostrRelays()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('2 active')
|
||||
expect(wrapper.text()).toContain('3 configured')
|
||||
expect(wrapper.text()).toContain('Refreshing relays...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('2 active')
|
||||
expect(wrapper.text()).toContain('3 configured')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing relays...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Web5SharedContent from '../Web5SharedContent.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { ContentItemData, PeerContentItem } from '../types'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function makePeerItem(filename: string): PeerContentItem {
|
||||
return {
|
||||
id: filename,
|
||||
filename,
|
||||
mime_type: 'text/plain',
|
||||
size_bytes: 128,
|
||||
description: '',
|
||||
access: 'free',
|
||||
}
|
||||
}
|
||||
|
||||
function makeContentItem(filename: string): ContentItemData {
|
||||
return {
|
||||
...makePeerItem(filename),
|
||||
added_at: '2026-06-10T10:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function getButtonByText(wrapper: ReturnType<typeof mount>, text: string) {
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text)
|
||||
if (!button) throw new Error(`Button not found: ${text}`)
|
||||
return button
|
||||
}
|
||||
|
||||
describe('Web5SharedContent', () => {
|
||||
it('keeps my content visible while a refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({ items: [makeContentItem('notes.txt')] })
|
||||
|
||||
const wrapper = mount(Web5SharedContent, {
|
||||
props: {
|
||||
showStagger: false,
|
||||
peers: [],
|
||||
},
|
||||
})
|
||||
|
||||
await (wrapper.vm as unknown as { loadContentItems: () => Promise<void> }).loadContentItems()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
|
||||
const pending = deferred<{ items: ContentItemData[] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadContentItems: () => Promise<void> }).loadContentItems()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).toContain('Refreshing shared content...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).not.toContain('Refreshing shared content...')
|
||||
})
|
||||
|
||||
it('keeps peer content visible while refreshing the same peer tab', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({ items: [makePeerItem('episode-one.txt')] })
|
||||
|
||||
const wrapper = mount(Web5SharedContent, {
|
||||
props: {
|
||||
showStagger: false,
|
||||
peers: [{ onion: 'peer-a.onion', pubkey: 'peer-a', name: 'Peer A' }],
|
||||
},
|
||||
})
|
||||
|
||||
await getButtonByText(wrapper, 'web5.browsePeers').trigger('click')
|
||||
await wrapper.get('select').setValue('peer-a.onion')
|
||||
await getButtonByText(wrapper, 'web5.browse').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('episode-one.txt')
|
||||
|
||||
const pending = deferred<{ items: PeerContentItem[] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
await getButtonByText(wrapper, 'web5.browse').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('episode-one.txt')
|
||||
expect(wrapper.text()).toContain('Refreshing peer content...')
|
||||
|
||||
pending.resolve({ items: [makePeerItem('episode-two.txt')] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('episode-two.txt')
|
||||
expect(wrapper.text()).not.toContain('Refreshing peer content...')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user