archy/neode-ui/src/views/web5/Web5ConnectedNodes.vue
2026-06-12 04:21:18 -04:00

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>