mirror of
https://github.com/vacp2p/universal-connectivity.git
synced 2026-01-09 15:18:05 -05:00
js-peer: overhaul ui and load that chat by default (#233)
* feat: overhaul ui and load chat by default * fix: simplify nav bar and load chat on index route * fix: linting --------- Co-authored-by: Daniel N <2color@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,11 @@ import React, { useEffect, useState } from 'react'
|
|||||||
import type { PeerId } from '@libp2p/interface'
|
import type { PeerId } from '@libp2p/interface'
|
||||||
import { PeerWrapper } from './peer'
|
import { PeerWrapper } from './peer'
|
||||||
|
|
||||||
export function ChatPeerList() {
|
interface ChatPeerListProps {
|
||||||
|
hideHeader?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPeerList({ hideHeader = false }: ChatPeerListProps) {
|
||||||
const { libp2p } = useLibp2pContext()
|
const { libp2p } = useLibp2pContext()
|
||||||
const [subscribers, setSubscribers] = useState<PeerId[]>([])
|
const [subscribers, setSubscribers] = useState<PeerId[]>([])
|
||||||
|
|
||||||
@@ -22,8 +26,8 @@ export function ChatPeerList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-l border-gray-300 lg:col-span-1">
|
<div className="border-l border-gray-300 lg:col-span-1">
|
||||||
<h2 className="my-2 mb-2 ml-2 text-lg text-gray-600">Peers</h2>
|
{!hideHeader && <h2 className="my-2 mb-2 ml-2 text-lg text-gray-600">Peers</h2>}
|
||||||
<div className="overflow-auto h-[32rem]">
|
<div className="overflow-auto h-[20rem] lg:h-[32rem]">
|
||||||
<div className="px-3 py-2 border-b border-gray-300 focus:outline-none">
|
<div className="px-3 py-2 border-b border-gray-300 focus:outline-none">
|
||||||
{<PeerWrapper peer={libp2p.peerId} self withName={true} withUnread={false} />}
|
{<PeerWrapper peer={libp2p.peerId} self withName={true} withUnread={false} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Message } from './message'
|
|||||||
import { forComponent } from '@/lib/logger'
|
import { forComponent } from '@/lib/logger'
|
||||||
import { ChatPeerList } from './chat-peer-list'
|
import { ChatPeerList } from './chat-peer-list'
|
||||||
import { ChevronLeftIcon } from '@heroicons/react/20/solid'
|
import { ChevronLeftIcon } from '@heroicons/react/20/solid'
|
||||||
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||||
import Blockies from 'react-18-blockies'
|
import Blockies from 'react-18-blockies'
|
||||||
import { peerIdFromString } from '@libp2p/peer-id'
|
import { peerIdFromString } from '@libp2p/peer-id'
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export default function ChatContainer() {
|
|||||||
const [input, setInput] = useState<string>('')
|
const [input, setInput] = useState<string>('')
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [showMobilePeerList, setShowMobilePeerList] = useState(false)
|
||||||
|
|
||||||
// Send message to public chat over gossipsub
|
// Send message to public chat over gossipsub
|
||||||
const sendPublicMessage = useCallback(async () => {
|
const sendPublicMessage = useCallback(async () => {
|
||||||
@@ -183,6 +185,10 @@ export default function ChatContainer() {
|
|||||||
setMessages(messageHistory)
|
setMessages(messageHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMobilePeerList = () => {
|
||||||
|
setShowMobilePeerList(!showMobilePeerList)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// assumes a chat room is a peerId thus a direct message
|
// assumes a chat room is a peerId thus a direct message
|
||||||
if (roomId === PUBLIC_CHAT_ROOM_ID) {
|
if (roomId === PUBLIC_CHAT_ROOM_ID) {
|
||||||
@@ -193,26 +199,71 @@ export default function ChatContainer() {
|
|||||||
}, [roomId, directMessages, messageHistory])
|
}, [roomId, directMessages, messageHistory])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto w-full px-0">
|
||||||
<div className="min-w-full border rounded lg:grid lg:grid-cols-6">
|
<div className="min-w-full border-0 rounded-none lg:rounded grid grid-cols-1 lg:grid-cols-6">
|
||||||
<div className="lg:col-span-5 lg:block">
|
<div className="col-span-1 lg:col-span-5">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative flex items-center p-3 border-b border-gray-300">
|
<div className="relative flex items-center p-3 border-b border-gray-300">
|
||||||
{roomId === PUBLIC_CHAT_ROOM_ID && (
|
{roomId === PUBLIC_CHAT_ROOM_ID && (
|
||||||
<span className="block ml-2 font-bold text-gray-600">{PUBLIC_CHAT_ROOM_NAME}</span>
|
<>
|
||||||
|
<span className="block ml-2 font-bold text-gray-600">{PUBLIC_CHAT_ROOM_NAME}</span>
|
||||||
|
<button
|
||||||
|
onClick={toggleMobilePeerList}
|
||||||
|
className="ml-auto lg:hidden flex items-center text-gray-500 hover:text-gray-700"
|
||||||
|
aria-label="Toggle peer list"
|
||||||
|
>
|
||||||
|
<UsersIcon className="h-5 w-5" />
|
||||||
|
<span className="ml-1 text-sm">Peers</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{roomId !== PUBLIC_CHAT_ROOM_ID && (
|
{roomId !== PUBLIC_CHAT_ROOM_ID && (
|
||||||
<>
|
<>
|
||||||
<Blockies seed={roomId} size={8} scale={3} className="rounded mr-2 max-h-10 max-w-10" />
|
<Blockies seed={roomId} size={8} scale={3} className="rounded mr-2 max-h-10 max-w-10" />
|
||||||
<span className={`text-gray-500 flex`}>{roomId.toString().slice(-7)}</span>
|
<span className={`text-gray-500 flex`}>{roomId.toString().slice(-7)}</span>
|
||||||
<button onClick={handleBackToPublic} className="text-gray-500 flex ml-auto">
|
<div className="flex items-center ml-auto">
|
||||||
<ChevronLeftIcon className="w-6 h-6 text-gray-500" />
|
<button
|
||||||
<span>Back to Public Chat</span>
|
onClick={toggleMobilePeerList}
|
||||||
</button>
|
className="lg:hidden flex items-center text-gray-500 hover:text-gray-700 mr-4"
|
||||||
|
aria-label="Toggle peer list"
|
||||||
|
>
|
||||||
|
<UsersIcon className="h-5 w-5" />
|
||||||
|
<span className="ml-1 text-sm">Peers</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={handleBackToPublic} className="text-gray-500 flex">
|
||||||
|
<ChevronLeftIcon className="w-6 h-6 text-gray-500" />
|
||||||
|
<span className="hidden sm:inline">Back to Public Chat</span>
|
||||||
|
<span className="sm:hidden">Back</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full flex flex-col-reverse p-3 overflow-y-auto h-[40rem] bg-gray-100">
|
|
||||||
|
{/* Show mobile peer list when toggled */}
|
||||||
|
{showMobilePeerList && (
|
||||||
|
<div className="lg:hidden border-b border-gray-300">
|
||||||
|
<div className="flex items-center justify-between p-2 bg-gray-50">
|
||||||
|
<h2 className="text-lg text-gray-600">Peers</h2>
|
||||||
|
<button
|
||||||
|
onClick={toggleMobilePeerList}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
aria-label="Close peer list"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ChatPeerList hideHeader={true} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative w-full flex flex-col-reverse p-3 overflow-y-auto h-[calc(60vh-8rem)] sm:h-[40rem] bg-gray-100">
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{messages.map(({ msgId, msg, fileObjectUrl, peerId, read, receivedAt }: ChatMessage) => (
|
{messages.map(({ msgId, msg, fileObjectUrl, peerId, read, receivedAt }: ChatMessage) => (
|
||||||
<Message
|
<Message
|
||||||
@@ -229,7 +280,7 @@ export default function ChatContainer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between w-full p-3 border-t border-gray-300">
|
<div className="flex items-center justify-between w-full p-2 sm:p-3 border-t border-gray-300">
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -241,7 +292,7 @@ export default function ChatContainer() {
|
|||||||
onClick={handleFileSend}
|
onClick={handleFileSend}
|
||||||
disabled={roomId !== PUBLIC_CHAT_ROOM_ID}
|
disabled={roomId !== PUBLIC_CHAT_ROOM_ID}
|
||||||
title={roomId === PUBLIC_CHAT_ROOM_ID ? 'Upload file' : "Unsupported in DM's"}
|
title={roomId === PUBLIC_CHAT_ROOM_ID ? 'Upload file' : "Unsupported in DM's"}
|
||||||
className={roomId === PUBLIC_CHAT_ROOM_ID ? '' : 'cursor-not-allowed'}
|
className={`${roomId === PUBLIC_CHAT_ROOM_ID ? '' : 'cursor-not-allowed'} p-1`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -265,7 +316,7 @@ export default function ChatContainer() {
|
|||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Message"
|
placeholder="Message"
|
||||||
className="block w-full py-2 pl-4 mx-3 bg-gray-100 rounded-full outline-none focus:text-gray-700"
|
className="block w-full py-2 pl-2 sm:pl-4 mx-2 sm:mx-3 bg-gray-100 rounded-full outline-none focus:text-gray-700 text-sm sm:text-base"
|
||||||
name="message"
|
name="message"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -282,7 +333,9 @@ export default function ChatContainer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChatPeerList />
|
<div className="hidden lg:block">
|
||||||
|
<ChatPeerList />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
20
js-peer/src/components/connection-info-button.tsx
Normal file
20
js-peer/src/components/connection-info-button.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ServerIcon } from '@heroicons/react/24/outline'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ConnectionInfoButtonProps {
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConnectionInfoButton({ onClick }: ConnectionInfoButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="rounded-md bg-indigo-600 py-1.5 px-2 sm:px-3 text-xs sm:text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 flex items-center"
|
||||||
|
>
|
||||||
|
<ServerIcon className="h-4 w-4 sm:h-5 sm:w-5 mr-1 sm:mr-2" aria-hidden="true" />
|
||||||
|
<span className="hidden sm:inline">libp2p node info</span>
|
||||||
|
<span className="sm:hidden">Node</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
252
js-peer/src/components/connection-panel.tsx
Normal file
252
js-peer/src/components/connection-panel.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { useLibp2pContext } from '@/context/ctx'
|
||||||
|
import type { PeerUpdate, Connection } from '@libp2p/interface'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Multiaddr, multiaddr } from '@multiformats/multiaddr'
|
||||||
|
import { connectToMultiaddr } from '../lib/libp2p'
|
||||||
|
import Spinner from '@/components/spinner'
|
||||||
|
import PeerList from '@/components/peer-list'
|
||||||
|
import { Dialog, DialogTitle, DialogBody } from '@/components/dialog'
|
||||||
|
import {
|
||||||
|
XMarkIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
ClipboardIcon,
|
||||||
|
ClipboardDocumentCheckIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
export default function ConnectionPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
const { libp2p } = useLibp2pContext()
|
||||||
|
const [connections, setConnections] = useState<Connection[]>([])
|
||||||
|
const [listenAddresses, setListenAddresses] = useState<Multiaddr[]>([])
|
||||||
|
const [maddr, setMultiaddr] = useState('')
|
||||||
|
const [dialling, setDialling] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [addressesExpanded, setAddressesExpanded] = useState(true)
|
||||||
|
const [connectionsExpanded, setConnectionsExpanded] = useState(true)
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState<number | null>(null)
|
||||||
|
const [copiedPeerId, setCopiedPeerId] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onConnection = () => {
|
||||||
|
const connections = libp2p.getConnections()
|
||||||
|
setConnections(connections)
|
||||||
|
}
|
||||||
|
onConnection()
|
||||||
|
libp2p.addEventListener('connection:open', onConnection)
|
||||||
|
libp2p.addEventListener('connection:close', onConnection)
|
||||||
|
return () => {
|
||||||
|
libp2p.removeEventListener('connection:open', onConnection)
|
||||||
|
libp2p.removeEventListener('connection:close', onConnection)
|
||||||
|
}
|
||||||
|
}, [libp2p, setConnections])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPeerUpdate = (evt: CustomEvent<PeerUpdate>) => {
|
||||||
|
const maddrs = evt.detail.peer.addresses?.map((p) => p.multiaddr)
|
||||||
|
setListenAddresses(maddrs ?? [])
|
||||||
|
}
|
||||||
|
libp2p.addEventListener('self:peer:update', onPeerUpdate)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
libp2p.removeEventListener('self:peer:update', onPeerUpdate)
|
||||||
|
}
|
||||||
|
}, [libp2p, setListenAddresses])
|
||||||
|
|
||||||
|
const handleConnectToMultiaddr = useCallback(
|
||||||
|
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setErr('')
|
||||||
|
if (!maddr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDialling(true)
|
||||||
|
try {
|
||||||
|
await connectToMultiaddr(libp2p)(multiaddr(maddr))
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message ?? 'Error connecting')
|
||||||
|
} finally {
|
||||||
|
setDialling(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[libp2p, maddr],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMultiaddrChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setMultiaddr(e.target.value)
|
||||||
|
},
|
||||||
|
[setMultiaddr],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleAddresses = () => {
|
||||||
|
setAddressesExpanded(!addressesExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleConnections = () => {
|
||||||
|
setConnectionsExpanded(!connectionsExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyAddress = (index: number, address: string) => {
|
||||||
|
navigator.clipboard.writeText(address).then(() => {
|
||||||
|
setCopiedAddress(index)
|
||||||
|
setTimeout(() => setCopiedAddress(null), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyPeerId = () => {
|
||||||
|
navigator.clipboard.writeText(libp2p.peerId.toString()).then(() => {
|
||||||
|
setCopiedPeerId(true)
|
||||||
|
setTimeout(() => setCopiedPeerId(false), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onClose={onClose} size="2xl">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<DialogTitle>Connection Information</DialogTitle>
|
||||||
|
<button type="button" className="rounded-md text-gray-400 hover:text-gray-500" onClick={onClose}>
|
||||||
|
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="space-y-6 px-2">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">This PeerID:</h3>
|
||||||
|
<div className="mt-1 flex items-center bg-white p-2 rounded border border-gray-200">
|
||||||
|
<p className="text-sm text-gray-700 break-all font-mono flex-grow">{libp2p.peerId.toString()}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyPeerId}
|
||||||
|
className="ml-2 flex-shrink-0 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Copy PeerID"
|
||||||
|
>
|
||||||
|
{copiedPeerId ? (
|
||||||
|
<ClipboardDocumentCheckIcon className="h-5 w-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<ClipboardIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-2 rounded transition-colors"
|
||||||
|
onClick={toggleAddresses}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Addresses ({listenAddresses.length}):</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
aria-expanded={addressesExpanded}
|
||||||
|
aria-label={addressesExpanded ? 'Collapse addresses' : 'Expand addresses'}
|
||||||
|
>
|
||||||
|
{addressesExpanded ? (
|
||||||
|
<ChevronUpIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{addressesExpanded && (
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto bg-white rounded border border-gray-200 p-2">
|
||||||
|
{listenAddresses.length === 0 ? (
|
||||||
|
<p className="p-2 text-sm text-gray-500 italic">No addresses available</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100">
|
||||||
|
{listenAddresses.map((ma, index) => (
|
||||||
|
<li
|
||||||
|
className="text-xs text-gray-700 font-mono p-2 flex justify-between items-center hover:bg-gray-50"
|
||||||
|
key={`ma-${index}`}
|
||||||
|
>
|
||||||
|
<span className="break-all mr-2">{ma.toString()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
copyAddress(index, ma.toString())
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Copy address"
|
||||||
|
>
|
||||||
|
{copiedAddress === index ? (
|
||||||
|
<ClipboardDocumentCheckIcon className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<ClipboardIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!addressesExpanded && listenAddresses.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500 italic">Click to show {listenAddresses.length} addresses</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||||
|
<label htmlFor="peer-id" className="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
Multiaddr to connect to
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
value={maddr}
|
||||||
|
type="text"
|
||||||
|
name="peer-id"
|
||||||
|
id="peer-id"
|
||||||
|
className="block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="12D3Koo..."
|
||||||
|
aria-describedby="multiaddr-id-description"
|
||||||
|
onChange={handleMultiaddrChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
'rounded-md bg-indigo-600 mt-3 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600' +
|
||||||
|
(dialling ? ' cursor-not-allowed' : '')
|
||||||
|
}
|
||||||
|
onClick={handleConnectToMultiaddr}
|
||||||
|
disabled={dialling}
|
||||||
|
>
|
||||||
|
{dialling && <Spinner />} Connect{dialling && 'ing'} to multiaddr
|
||||||
|
</button>
|
||||||
|
{err && <p className="mt-2 text-sm text-red-500">{err}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connections.length > 0 && (
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-2 rounded transition-colors"
|
||||||
|
onClick={toggleConnections}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Connections ({connections.length}):</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
aria-expanded={connectionsExpanded}
|
||||||
|
aria-label={connectionsExpanded ? 'Collapse connections' : 'Expand connections'}
|
||||||
|
>
|
||||||
|
{connectionsExpanded ? (
|
||||||
|
<ChevronUpIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{connectionsExpanded && (
|
||||||
|
<div className="mt-2 max-h-60 overflow-y-auto bg-white rounded border border-gray-200 p-2">
|
||||||
|
<PeerList connections={connections} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!connectionsExpanded && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500 italic">Click to show {connections.length} connections</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,17 @@
|
|||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { Disclosure, Menu, Transition } from '@headlessui/react'
|
import { Disclosure, DisclosureButton, DisclosurePanel, Menu, Transition } from '@headlessui/react'
|
||||||
import { Bars3Icon, BellIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
import { Bars3Icon, BellIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [{ name: 'Source', href: 'https://github.com/libp2p/universal-connectivity' }]
|
||||||
{ name: 'Connecting to a Peer', href: '/' },
|
|
||||||
{ name: 'Chat', href: '/chat' },
|
|
||||||
{ name: 'Source', href: 'https://github.com/libp2p/universal-connectivity' },
|
|
||||||
]
|
|
||||||
const userNavigation = [
|
|
||||||
{ name: 'Your Profile', href: '#' },
|
|
||||||
{ name: 'Settings', href: '#' },
|
|
||||||
{ name: 'Sign out', href: '#' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function classNames(...classes: string[]) {
|
function classNames(...classes: string[]) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navigation() {
|
export default function Navigation({ connectionInfoButton }: { connectionInfoButton?: React.ReactNode }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,12 +19,25 @@ export default function Navigation() {
|
|||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex h-16 justify-between">
|
<div className="flex h-16 justify-between items-center">
|
||||||
<div className="flex">
|
<div className="flex items-center">
|
||||||
<div className="flex flex-shrink-0 items-center">
|
<div className="flex flex-shrink-0 items-center">
|
||||||
<Image src="/libp2p-logo.svg" alt="libp2p logo" height="46" width="46" />
|
<Image src="/libp2p-logo.svg" alt="libp2p logo" height="46" width="46" />
|
||||||
|
<div className="ml-3 flex items-center">
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900 hidden sm:block">Universal Connectivity</h1>
|
||||||
|
<Image
|
||||||
|
src="/libp2p-hero.svg"
|
||||||
|
alt="libp2p hero"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
className="ml-2 hidden sm:block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex space-x-4">
|
||||||
{navigationItems.map((item) => (
|
{navigationItems.map((item) => (
|
||||||
<Link key={item.href} href={item.href} legacyBehavior>
|
<Link key={item.href} href={item.href} legacyBehavior>
|
||||||
<a
|
<a
|
||||||
@@ -42,7 +46,7 @@ export default function Navigation() {
|
|||||||
router.pathname === item.href
|
router.pathname === item.href
|
||||||
? 'border-indigo-500 text-gray-900'
|
? 'border-indigo-500 text-gray-900'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
||||||
'inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium',
|
'inline-flex items-center px-1 pt-1 text-sm font-medium',
|
||||||
)}
|
)}
|
||||||
aria-current={router.pathname === item.href ? 'page' : undefined}
|
aria-current={router.pathname === item.href ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
@@ -51,93 +55,10 @@ export default function Navigation() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center">{connectionInfoButton}</div>
|
||||||
<div className="hidden sm:ml-6 sm:flex sm:items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-full bg-white p-1 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<span className="sr-only">View notifications</span>
|
|
||||||
<BellIcon className="h-6 w-6" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Profile dropdown */}
|
|
||||||
<Menu as="div" className="relative ml-3">
|
|
||||||
<div>
|
|
||||||
{/* <Menu.Button className="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
|
||||||
<span className="sr-only">Open user menu</span>
|
|
||||||
<img
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
src={user.imageUrl}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</Menu.Button> */}
|
|
||||||
</div>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-out duration-200"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
|
||||||
{userNavigation.map((item) => (
|
|
||||||
<Menu.Item key={item.name}>
|
|
||||||
{({ active }) => (
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
className={classNames(
|
|
||||||
active ? 'bg-gray-100' : '',
|
|
||||||
'block px-4 py-2 text-sm text-gray-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu.Items>
|
|
||||||
</Transition>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
<div className="-mr-2 flex items-center sm:hidden">
|
|
||||||
{/* Mobile menu button */}
|
|
||||||
<Disclosure.Button className="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
|
||||||
<span className="sr-only">Open main menu</span>
|
|
||||||
{open ? (
|
|
||||||
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Bars3Icon className="block h-6 w-6" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Disclosure.Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Disclosure.Panel className="sm:hidden">
|
|
||||||
<div className="space-y-1 pt-2 pb-3">
|
|
||||||
{navigationItems.map((item) => (
|
|
||||||
<Link key={item.href} href={item.href} legacyBehavior>
|
|
||||||
<Disclosure.Button
|
|
||||||
key={item.href}
|
|
||||||
// as="a"
|
|
||||||
// href={item.href}
|
|
||||||
className={classNames(
|
|
||||||
router.pathname === item.href
|
|
||||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
|
|
||||||
: 'border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800',
|
|
||||||
'block border-l-4 py-2 pl-3 pr-4 text-base font-medium',
|
|
||||||
)}
|
|
||||||
aria-current={router.pathname === item.href ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Disclosure.Button>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Disclosure.Panel>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function Peer({ connection }: PeerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={connection.id} className="flex justify-between gap-x-6 py-3">
|
<li key={connection.id} className="flex justify-between gap-x-6 py-3">
|
||||||
<div className="flex min-w-0 gap-x-4">
|
<div className="flex min-w-0 gap-x-4 flex-grow">
|
||||||
<div className="mt-1 flex items-center gap-x-1.5">
|
<div className="mt-1 flex items-center gap-x-1.5">
|
||||||
<div className="flex-none rounded-full bg-emerald-500/20 p-1">
|
<div className="flex-none rounded-full bg-emerald-500/20 p-1">
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
@@ -59,14 +59,14 @@ function Peer({ connection }: PeerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div className="flex gap-x-2 items-center "> */}
|
<div className="flex-shrink-0 ml-2">
|
||||||
<div className="hidden sm:flex sm:flex-col sm:items-end">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDisconnectPeer(connection.remotePeer)}
|
onClick={() => handleDisconnectPeer(connection.remotePeer)}
|
||||||
className="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded flex flex-row"
|
className="bg-red-500 hover:bg-red-600 text-white rounded-full p-2 flex items-center justify-center"
|
||||||
|
title="Disconnect peer"
|
||||||
>
|
>
|
||||||
<XCircleIcon className="w-6 h-6" />
|
<XCircleIcon className="w-5 h-5" />
|
||||||
<span className="pl-1">Disconnect</span>
|
<span className="sr-only">Disconnect</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import Head from 'next/head'
|
|
||||||
import Nav from '@/components/nav'
|
|
||||||
import ChatContainer from '@/components/chat'
|
|
||||||
|
|
||||||
export default function Chat() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>js-libp2p Chat</title>
|
|
||||||
<meta name="description" content="Generated by create next app" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
<main className="min-h-full">
|
|
||||||
<Nav />
|
|
||||||
<div className="">
|
|
||||||
<main>
|
|
||||||
<ChatContainer />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,72 +1,17 @@
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import Nav from '@/components/nav'
|
import Nav from '@/components/nav'
|
||||||
|
import ChatContainer from '@/components/chat'
|
||||||
|
import ConnectionPanel from '@/components/connection-panel'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useLibp2pContext } from '@/context/ctx'
|
import { useLibp2pContext } from '@/context/ctx'
|
||||||
import type { PeerUpdate, Connection } from '@libp2p/interface'
|
import ConnectionInfoButton from '@/components/connection-info-button'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { Multiaddr, multiaddr } from '@multiformats/multiaddr'
|
|
||||||
import { connectToMultiaddr } from '../lib/libp2p'
|
|
||||||
import Spinner from '@/components/spinner'
|
|
||||||
import PeerList from '@/components/peer-list'
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Chat() {
|
||||||
const { libp2p } = useLibp2pContext()
|
const [isConnectionPanelOpen, setIsConnectionPanelOpen] = useState(false)
|
||||||
const [connections, setConnections] = useState<Connection[]>([])
|
|
||||||
const [listenAddresses, setListenAddresses] = useState<Multiaddr[]>([])
|
|
||||||
const [maddr, setMultiaddr] = useState('')
|
|
||||||
const [dialling, setDialling] = useState(false)
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenConnectionPanel = () => {
|
||||||
const onConnection = () => {
|
setIsConnectionPanelOpen(true)
|
||||||
const connections = libp2p.getConnections()
|
}
|
||||||
setConnections(connections)
|
|
||||||
}
|
|
||||||
onConnection()
|
|
||||||
libp2p.addEventListener('connection:open', onConnection)
|
|
||||||
libp2p.addEventListener('connection:close', onConnection)
|
|
||||||
return () => {
|
|
||||||
libp2p.removeEventListener('connection:open', onConnection)
|
|
||||||
libp2p.removeEventListener('connection:clone', onConnection)
|
|
||||||
}
|
|
||||||
}, [libp2p, setConnections])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onPeerUpdate = (evt: CustomEvent<PeerUpdate>) => {
|
|
||||||
const maddrs = evt.detail.peer.addresses?.map((p) => p.multiaddr)
|
|
||||||
setListenAddresses(maddrs ?? [])
|
|
||||||
}
|
|
||||||
libp2p.addEventListener('self:peer:update', onPeerUpdate)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
libp2p.removeEventListener('self:peer:update', onPeerUpdate)
|
|
||||||
}
|
|
||||||
}, [libp2p, setListenAddresses])
|
|
||||||
|
|
||||||
const handleConnectToMultiaddr = useCallback(
|
|
||||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
setErr('')
|
|
||||||
if (!maddr) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDialling(true)
|
|
||||||
try {
|
|
||||||
await connectToMultiaddr(libp2p)(multiaddr(maddr))
|
|
||||||
} catch (e: any) {
|
|
||||||
setErr(e?.message ?? 'Error connecting')
|
|
||||||
} finally {
|
|
||||||
setDialling(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[libp2p, maddr],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleMultiaddrChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setMultiaddr(e.target.value)
|
|
||||||
},
|
|
||||||
[setMultiaddr],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -76,71 +21,15 @@ export default function Home() {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<main className="min-h-full">
|
<main className="min-h-full flex flex-col">
|
||||||
<Nav />
|
<Nav connectionInfoButton={<ConnectionInfoButton onClick={handleOpenConnectionPanel} />} />
|
||||||
<div className="py-10">
|
<div className="flex-1 mx-auto w-full max-w-7xl px-0 sm:px-2 pt-0 pb-2 lg:px-8">
|
||||||
<header>
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
|
||||||
<div className="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
|
<ChatContainer />
|
||||||
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900 flex flex-row">
|
</div>
|
||||||
<p className="mr-4">Universal Connectivity</p>
|
|
||||||
<Image src="/libp2p-hero.svg" alt="libp2p logo" height="46" width="46" />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
||||||
<ul className="my-2 space-y-2 break-all">
|
|
||||||
<li className="">This PeerID: {libp2p.peerId.toString()}</li>
|
|
||||||
</ul>
|
|
||||||
Addresses:
|
|
||||||
<ul className="my-2 space-y-2 break-all">
|
|
||||||
{listenAddresses.map((ma, index) => (
|
|
||||||
<li className="text-xs text-gray-700" key={`ma-${index}`}>
|
|
||||||
{ma.toString()}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="my-6 w-1/2">
|
|
||||||
<label htmlFor="peer-id" className="block text-sm font-medium leading-6 text-gray-900">
|
|
||||||
multiaddr to connect to
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
value={maddr}
|
|
||||||
type="text"
|
|
||||||
name="peer-id"
|
|
||||||
id="peer-id"
|
|
||||||
className="block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
|
||||||
placeholder="12D3Koo..."
|
|
||||||
aria-describedby="multiaddr-id-description"
|
|
||||||
onChange={handleMultiaddrChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
'rounded-md bg-indigo-600 my-2 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600' +
|
|
||||||
(dialling ? ' cursor-not-allowed' : '')
|
|
||||||
}
|
|
||||||
onClick={handleConnectToMultiaddr}
|
|
||||||
disabled={dialling}
|
|
||||||
>
|
|
||||||
{dialling && <Spinner />} Connect{dialling && 'ing'} to multiaddr
|
|
||||||
</button>
|
|
||||||
{err && <p className="text-red-500">{err}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{connections.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<h3 className="text-xl">Connections ({connections.length}):</h3>
|
|
||||||
<PeerList connections={connections} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<ConnectionPanel isOpen={isConnectionPanelOpen} onClose={() => setIsConnectionPanelOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user