596 lines
24 KiB
Vue
596 lines
24 KiB
Vue
<template>
|
|
<!-- Connected Nodes (P2P over Tor) -->
|
|
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
|
|
<!-- Desktop: side-by-side layout -->
|
|
<div class="hidden md:flex items-start gap-4 mb-4">
|
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1">
|
|
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
|
|
</div>
|
|
<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"
|
|
>
|
|
{{ t('web5.findNodes') }}
|
|
</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.loading') : t('common.refresh') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Mobile: stacked layout -->
|
|
<div class="md:hidden mb-4">
|
|
<div class="flex items-center gap-4 mb-2">
|
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs: Trusted | Observers | Messages | Requests -->
|
|
<div class="flex gap-1 mb-4 border-b border-white/10">
|
|
<button
|
|
@click="nodesContainerTab = 'trusted'"
|
|
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
|
:class="nodesContainerTab === 'trusted' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
|
>
|
|
{{ 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"
|
|
:class="nodesContainerTab === 'messages' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
|
>
|
|
{{ t('web5.messages') }}
|
|
<span v-if="receivedMessages.length > 0" class="ml-1.5 text-xs" :class="unreadCount > 0 ? 'text-orange-400' : 'text-white/50'">({{ receivedMessages.length }})</span>
|
|
<span v-if="unreadCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
|
|
</button>
|
|
<button
|
|
@click="switchToRequestsTab"
|
|
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
|
|
:class="nodesContainerTab === 'requests' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
|
>
|
|
{{ t('web5.requests') }}
|
|
<span v-if="connectionRequests.length > 0" class="ml-1.5 text-xs text-orange-400">({{ connectionRequests.length }})</span>
|
|
<span v-if="connectionRequests.length > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 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"
|
|
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>
|
|
<button
|
|
@click="router.push('/dashboard/mesh')"
|
|
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
|
|
>
|
|
{{ t('web5.message') }}
|
|
</button>
|
|
</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 && 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"
|
|
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
|
|
>
|
|
<div class="flex items-center justify-between gap-2 mb-1">
|
|
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ peerNameFromPubkey(m.from_pubkey) }}</p>
|
|
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
|
|
</div>
|
|
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Requests tab -->
|
|
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-1 overflow-y-auto">
|
|
<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"
|
|
class="p-3 bg-white/5 rounded-lg border-l-2 border-blue-500/50"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-xs font-mono text-white/70 truncate" :title="req.from_did">{{ peerNameFromPubkey(req.from_did) }}</p>
|
|
<p v-if="req.message" class="text-sm text-white/80 mt-1 break-words">{{ req.message }}</p>
|
|
<p class="text-xs text-white/40 mt-1">{{ formatMessageTime(req.created_at) }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<button
|
|
@click="acceptRequest(req.id)"
|
|
:disabled="processingRequestId === req.id"
|
|
class="px-3 py-1.5 text-xs rounded-lg bg-green-500/20 text-green-400 hover:bg-green-500/30 transition-colors disabled:opacity-50"
|
|
>
|
|
{{ t('web5.accept') }}
|
|
</button>
|
|
<button
|
|
@click="rejectRequest(req.id)"
|
|
:disabled="processingRequestId === req.id"
|
|
class="px-3 py-1.5 text-xs rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
|
>
|
|
{{ t('web5.reject') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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 === '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"
|
|
:disabled="loadingMessages"
|
|
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"
|
|
>
|
|
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
|
|
</button>
|
|
<button
|
|
v-else
|
|
@click="loadConnectionRequests"
|
|
:disabled="loadingRequests"
|
|
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"
|
|
>
|
|
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Send Message Modal -->
|
|
<Teleport to="body">
|
|
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeSendMessageModal()">
|
|
<div ref="sendMessageModalRef" class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
<h3 class="text-lg font-semibold text-white mb-4">{{ t('web5.sendMessageTitle') }}</h3>
|
|
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.to') }}</label>
|
|
<select
|
|
v-model="sendMessageTo"
|
|
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
|
>
|
|
<option value="">{{ t('web5.selectPeer') }}</option>
|
|
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
|
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('web5.message') }}</label>
|
|
<textarea
|
|
v-model="sendMessageText"
|
|
rows="3"
|
|
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
|
:placeholder="t('web5.messagePlaceholder')"
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 mt-6">
|
|
<button
|
|
@click="sendMessage"
|
|
:disabled="!sendMessageTo || !sendMessageText.trim() || sendingMessage"
|
|
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{{ sendingMessage ? t('common.sending') : t('common.send') }}
|
|
</button>
|
|
<button
|
|
@click="closeSendMessageModal()"
|
|
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
</div>
|
|
<p v-if="sendMessageError" class="mt-3 text-sm text-red-400">{{ sendMessageError }}</p>
|
|
<p v-if="sendMessageSuccess" class="mt-3 text-sm text-green-400">{{ sendMessageSuccess }}</p>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, nextTick } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import { useMessageToast } from '@/composables/useMessageToast'
|
|
import { useWeb5BadgeStore } from '@/stores/web5Badge'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
|
import { formatMessageTime } from './utils'
|
|
import type { Peer, ConnectionRequest } from './types'
|
|
|
|
const router = useRouter()
|
|
const { t } = useI18n()
|
|
const messageToast = useMessageToast()
|
|
const web5Badge = useWeb5BadgeStore()
|
|
const appStore = useAppStore()
|
|
|
|
const CONNECTED_NODES_CACHE_KEY = 'archipelago.web5.connected-nodes.v1'
|
|
type ConnectedNodesCache = {
|
|
peers: Peer[]
|
|
observers: Peer[]
|
|
peerReachable: Record<string, boolean>
|
|
connectionRequests: ConnectionRequest[]
|
|
}
|
|
|
|
function readConnectedNodesCache(): Partial<ConnectedNodesCache> {
|
|
if (typeof window === 'undefined') return {}
|
|
try {
|
|
const raw = window.sessionStorage.getItem(CONNECTED_NODES_CACHE_KEY)
|
|
if (!raw) return {}
|
|
const parsed = JSON.parse(raw) as Partial<ConnectedNodesCache>
|
|
return {
|
|
peers: Array.isArray(parsed.peers) ? parsed.peers : [],
|
|
observers: Array.isArray(parsed.observers) ? parsed.observers : [],
|
|
peerReachable: parsed.peerReachable && typeof parsed.peerReachable === 'object' ? parsed.peerReachable : {},
|
|
connectionRequests: Array.isArray(parsed.connectionRequests) ? parsed.connectionRequests : [],
|
|
}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function writeConnectedNodesCache(state: ConnectedNodesCache) {
|
|
if (typeof window === 'undefined') return
|
|
try {
|
|
window.sessionStorage.setItem(CONNECTED_NODES_CACHE_KEY, JSON.stringify(state))
|
|
} catch {
|
|
// Cache is best-effort.
|
|
}
|
|
}
|
|
|
|
const nodesContainerRef = ref<HTMLElement | null>(null)
|
|
const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
|
|
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
|
|
|
|
const cached = readConnectedNodesCache()
|
|
const peers = ref<Peer[]>(cached.peers ?? [])
|
|
const observers = ref<Peer[]>(cached.observers ?? [])
|
|
const loadingPeers = ref(false)
|
|
const peerReachableLocal = ref<Record<string, boolean>>(cached.peerReachable ?? {})
|
|
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
|
|
const discovering = ref(false)
|
|
|
|
// Send message modal
|
|
const showSendMessageModal = ref(false)
|
|
const sendMessageModalRef = ref<HTMLElement | null>(null)
|
|
const sendMessageRestoreFocusRef = ref<HTMLElement | null>(null)
|
|
function closeSendMessageModal() {
|
|
sendMessageRestoreFocusRef.value?.focus?.()
|
|
showSendMessageModal.value = false
|
|
}
|
|
useModalKeyboard(sendMessageModalRef, showSendMessageModal, closeSendMessageModal, { restoreFocusRef: sendMessageRestoreFocusRef })
|
|
const sendMessageTo = ref('')
|
|
const sendMessageText = ref('')
|
|
const sendingMessage = ref(false)
|
|
const sendMessageError = ref('')
|
|
const sendMessageSuccess = ref('')
|
|
|
|
// Connection requests
|
|
const connectionRequests = ref<ConnectionRequest[]>(cached.connectionRequests ?? [])
|
|
const loadingRequests = ref(false)
|
|
const processingRequestId = ref<string | null>(null)
|
|
|
|
const emit = defineEmits<{
|
|
toast: [text: string]
|
|
}>()
|
|
|
|
function peerNameFromPubkey(pubkey: string): string {
|
|
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()
|
|
}
|
|
|
|
function switchToRequestsTab() {
|
|
nodesContainerTab.value = 'requests'
|
|
if (connectionRequests.value.length === 0 && !loadingRequests.value) {
|
|
loadConnectionRequests()
|
|
}
|
|
}
|
|
|
|
async function loadPeers() {
|
|
const hadPeers = peers.value.length > 0 || observers.value.length > 0
|
|
loadingPeers.value = true
|
|
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 || 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 {
|
|
// Federation may not be set up
|
|
}
|
|
|
|
peers.value = peerList
|
|
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
|
|
} catch {
|
|
peerReachableLocal.value[p.onion] = false
|
|
}
|
|
}
|
|
writeConnectedNodesCache({
|
|
peers: peers.value,
|
|
observers: observers.value,
|
|
peerReachable: peerReachableLocal.value,
|
|
connectionRequests: connectionRequests.value,
|
|
})
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
|
|
if (!hadPeers) {
|
|
peers.value = []
|
|
observers.value = []
|
|
}
|
|
} finally {
|
|
loadingPeers.value = false
|
|
}
|
|
}
|
|
|
|
async function sendMessage() {
|
|
if (!sendMessageTo.value || !sendMessageText.value.trim()) return
|
|
sendingMessage.value = true
|
|
sendMessageError.value = ''
|
|
sendMessageSuccess.value = ''
|
|
try {
|
|
await rpcClient.sendMessageToPeer(sendMessageTo.value, sendMessageText.value.trim())
|
|
sendMessageSuccess.value = t('web5.messageSent')
|
|
sendMessageText.value = ''
|
|
setTimeout(() => {
|
|
showSendMessageModal.value = false
|
|
sendMessageSuccess.value = ''
|
|
}, 1500)
|
|
} catch (e) {
|
|
sendMessageError.value = e instanceof Error ? e.message : t('web5.failedToSend')
|
|
} finally {
|
|
sendingMessage.value = false
|
|
}
|
|
}
|
|
|
|
async function discoverAndAddPeers() {
|
|
discovering.value = true
|
|
try {
|
|
const res = await rpcClient.discoverNodes()
|
|
const nodes = res.nodes || []
|
|
for (const n of nodes) {
|
|
if (n.onion && n.pubkey) {
|
|
try {
|
|
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.warn('Peer may already exist', e)
|
|
}
|
|
}
|
|
}
|
|
await loadPeers()
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.error('Discover failed:', e)
|
|
} finally {
|
|
discovering.value = false
|
|
}
|
|
}
|
|
|
|
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
|
|
writeConnectedNodesCache({
|
|
peers: peers.value,
|
|
observers: observers.value,
|
|
peerReachable: peerReachableLocal.value,
|
|
connectionRequests: connectionRequests.value,
|
|
})
|
|
} catch {
|
|
if (!hadRequests) connectionRequests.value = []
|
|
} finally {
|
|
loadingRequests.value = false
|
|
}
|
|
}
|
|
|
|
async function acceptRequest(requestId: string) {
|
|
processingRequestId.value = requestId
|
|
try {
|
|
await rpcClient.call({ method: 'network.accept-request', params: { request_id: requestId } })
|
|
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
|
|
web5Badge.pendingRequestCount = connectionRequests.value.length
|
|
writeConnectedNodesCache({
|
|
peers: peers.value,
|
|
observers: observers.value,
|
|
peerReachable: peerReachableLocal.value,
|
|
connectionRequests: connectionRequests.value,
|
|
})
|
|
await loadPeers()
|
|
emit('toast', t('web5.connectionAccepted'))
|
|
} catch {
|
|
emit('toast', t('web5.failedToAcceptRequest'))
|
|
} finally {
|
|
processingRequestId.value = null
|
|
}
|
|
}
|
|
|
|
async function rejectRequest(requestId: string) {
|
|
processingRequestId.value = requestId
|
|
try {
|
|
await rpcClient.call({ method: 'network.reject-request', params: { request_id: requestId } })
|
|
connectionRequests.value = connectionRequests.value.filter(r => r.id !== requestId)
|
|
web5Badge.pendingRequestCount = connectionRequests.value.length
|
|
writeConnectedNodesCache({
|
|
peers: peers.value,
|
|
observers: observers.value,
|
|
peerReachable: peerReachableLocal.value,
|
|
connectionRequests: connectionRequests.value,
|
|
})
|
|
emit('toast', t('web5.requestRejected'))
|
|
} catch {
|
|
emit('toast', t('web5.failedToRejectRequest'))
|
|
} finally {
|
|
processingRequestId.value = null
|
|
}
|
|
}
|
|
|
|
function scrollToMessages() {
|
|
nodesContainerTab.value = 'messages'
|
|
markAsRead()
|
|
nextTick(() => {
|
|
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
})
|
|
}
|
|
|
|
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, observers, scrollToMessages })
|
|
</script>
|