frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
+118 -34
View File
@@ -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
}
}
+11 -3
View File
@@ -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>
+21 -2
View File
@@ -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
}
}
+51 -10
View File
@@ -13,15 +13,31 @@
<p class="text-xs text-white/60">Federated nodes &amp; 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>
+15 -12
View File
@@ -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
}
+5 -4
View File
@@ -13,11 +13,8 @@
<p class="text-xs text-white/60">System resources &amp; 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>
+26 -4
View File
@@ -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
}
+20 -3
View File
@@ -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...')
})
})