Import browser chat

This commit is contained in:
Oskar Thoren 2020-09-21 12:30:41 +08:00
parent f6f5d9b01e
commit 7bdd23bbce
No known key found for this signature in database
GPG Key ID: B2ECCFD3BC2EF77E
18 changed files with 11957 additions and 0 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
tmp/
node_modules
.cache

11
browser/index.html Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en" dir="ltr" class="h-100">
<head>
<meta charset="utf-8">
<title>Waku Web chat</title>
</head>
<body class="h-100">
<div id="root" class="h-100"></div>
<script src="./index.js" ></script>
</body>
</html>

7
browser/index.js Normal file
View File

@ -0,0 +1,7 @@
import 'regenerator-runtime/runtime.js'
import React from 'react'
import ReactDOM from 'react-dom'
import App from './views/App'
import createLibp2p from './libp2p'
ReactDOM.render(<App createLibp2p={createLibp2p} />, document.getElementById('root'))

56
browser/libp2p.js Normal file
View File

@ -0,0 +1,56 @@
// Libp2p Core
import Libp2p from 'libp2p'
// Transports
import Websockets from 'libp2p-websockets'
import WebrtcStar from 'libp2p-webrtc-star'
// Stream Muxer
import Mplex from 'libp2p-mplex'
// Connection Encryption
import { NOISE } from 'libp2p-noise'
import Secio from 'libp2p-secio'
// Peer Discovery
import Bootstrap from 'libp2p-bootstrap'
import KadDHT from 'libp2p-kad-dht'
// Gossipsub
import Gossipsub from 'libp2p-gossipsub'
const createLibp2p = async (peerId) => {
// Create the Node
const libp2p = await Libp2p.create({
peerId,
addresses: {
listen: [
// Add the signaling server multiaddr
'/ip4/127.0.0.1/tcp/15555/ws/p2p-webrtc-star'
]
},
modules: {
transport: [Websockets, WebrtcStar],
streamMuxer: [Mplex],
connEncryption: [NOISE, Secio],
peerDiscovery: [Bootstrap],
dht: KadDHT,
pubsub: Gossipsub
},
config: {
peerDiscovery: {
bootstrap: {
list: ['/ip4/127.0.0.1/tcp/63786/ws/p2p/QmWjz6xb8v9K4KnYEwP5Yk75k5mMBCehzWFLCvvQpYxF3d']
}
},
dht: {
enabled: true,
randomWalk: {
enabled: true
}
}
}
})
// Automatically start libp2p
await libp2p.start()
return libp2p
}
export default createLibp2p

191
browser/libs/chat.js Normal file
View File

@ -0,0 +1,191 @@
const protons = require('protons')
const EventEmitter = require('events')
const { Request, Stats } = protons(`
message Request {
enum Type {
SEND_MESSAGE = 0;
UPDATE_PEER = 1;
STATS = 2;
}
required Type type = 1;
optional SendMessage sendMessage = 2;
optional UpdatePeer updatePeer = 3;
optional Stats stats = 4;
}
message SendMessage {
required bytes data = 1;
required int64 created = 2;
required bytes id = 3;
}
message UpdatePeer {
optional bytes userHandle = 1;
}
message Stats {
enum NodeType {
GO = 0;
NODEJS = 1;
BROWSER = 2;
}
repeated bytes connectedPeers = 1;
optional NodeType nodeType = 2;
}
`)
class Chat extends EventEmitter {
/**
*
* @param {Libp2p} libp2p A Libp2p node to communicate through
* @param {string} topic The topic to subscribe to
*/
constructor (libp2p, topic) {
super()
this.libp2p = libp2p
this.topic = topic
this.connectedPeers = new Set()
this.stats = new Map()
this.libp2p.connectionManager.on('peer:connect', (connection) => {
console.log('Connected to', connection.remotePeer.toB58String())
if (this.connectedPeers.has(connection.remotePeer.toB58String())) return
this.connectedPeers.add(connection.remotePeer.toB58String())
this.sendStats(Array.from(this.connectedPeers))
})
this.libp2p.connectionManager.on('peer:disconnect', (connection) => {
console.log('Disconnected from', connection.remotePeer.toB58String())
if (this.connectedPeers.delete(connection.remotePeer.toB58String())) {
this.sendStats(Array.from(this.connectedPeers))
}
})
// Join if libp2p is already on
if (this.libp2p.isStarted()) this.join()
}
/**
* Subscribes to `Chat.topic`. All messages will be
* forwarded to `messageHandler`
* @private
*/
join () {
this.libp2p.pubsub.subscribe(this.topic, (message) => {
try {
const request = Request.decode(message.data)
switch (request.type) {
case Request.Type.UPDATE_PEER:
this.emit('peer:update', {
id: message.from,
name: request.updatePeer.userHandle.toString()
})
break
case Request.Type.STATS:
this.stats.set(message.from, request.stats)
console.log('Incoming Stats:', message.from, request.stats)
this.emit('stats', this.stats)
break
default:
this.emit('message', {
from: message.from,
...request.sendMessage
})
}
} catch (err) {
console.error(err)
}
})
}
/**
* Unsubscribes from `Chat.topic`
* @private
*/
leave () {
this.libp2p.pubsub.unsubscribe(this.topic)
}
/**
* Crudely checks the input for a command. If no command is
* found `false` is returned. If the input contains a command,
* that command will be processed and `true` will be returned.
* @param {Buffer|string} input Text submitted by the user
* @returns {boolean} Whether or not there was a command
*/
checkCommand (input) {
const str = input.toString()
if (str.startsWith('/')) {
const args = str.slice(1).split(' ')
switch (args[0]) {
case 'name':
this.updatePeer(args[1])
return true
}
}
return false
}
/**
* Sends a message over pubsub to update the user handle
* to the provided `name`.
* @param {Buffer|string} name Username to change to
*/
async updatePeer (name) {
const msg = Request.encode({
type: Request.Type.UPDATE_PEER,
updatePeer: {
userHandle: Buffer.from(name)
}
})
try {
await this.libp2p.pubsub.publish(this.topic, msg)
} catch (err) {
console.error('Could not publish name change')
}
}
/**
* Sends the updated stats to the pubsub network
* @param {Array<Buffer>} connectedPeers
*/
async sendStats (connectedPeers) {
const msg = Request.encode({
type: Request.Type.STATS,
stats: {
connectedPeers,
nodeType: Stats.NodeType.BROWSER
}
})
try {
await this.libp2p.pubsub.publish(this.topic, msg)
} catch (err) {
console.error('Could not publish stats update')
}
}
/**
* Publishes the given `message` to pubsub peers
* @param {Buffer|string} message The chat message to send
*/
async send (message) {
const msg = Request.encode({
type: Request.Type.SEND_MESSAGE,
sendMessage: {
id: (~~(Math.random() * 1e9)).toString(36) + Date.now(),
data: Buffer.from(message),
created: Date.now()
}
})
await this.libp2p.pubsub.publish(this.topic, msg)
}
}
module.exports = Chat
module.exports.TOPIC = '/libp2p/example/chat/1.0.0'

18
browser/libs/peer-id.js Normal file
View File

@ -0,0 +1,18 @@
import PeerId from 'peer-id'
export async function getOrCreatePeerId () {
let peerId
try {
// eslint-disable-next-line
peerId = JSON.parse(localStorage.getItem('peerId'))
peerId = await PeerId.createFromJSON(peerId)
} catch (err) {
console.info('Could not get the stored peer id, a new one will be generated', err)
peerId = await PeerId.create()
console.info('Storing our peer id in local storage so it can be reused')
// eslint-disable-next-line
localStorage.setItem('peerId', JSON.stringify(peerId.toJSON()))
}
return peerId
}

124
browser/libs/viz.js Normal file
View File

@ -0,0 +1,124 @@
'use strict'
module.exports.layout = {
name: 'euler',
// The ideal length of a spring
// - This acts as a hint for the edge length
// - The edge length can be longer or shorter if the forces are set to extreme values
springLength: edge => 80,
// Hooke's law coefficient
// - The value ranges on [0, 1]
// - Lower values give looser springs
// - Higher values give tighter springs
springCoeff: edge => 0.0008,
// The mass of the node in the physics simulation
// - The mass affects the gravity node repulsion/attraction
mass: node => 4,
// Coulomb's law coefficient
// - Makes the nodes repel each other for negative values
// - Makes the nodes attract each other for positive values
gravity: -1.2,
// A force that pulls nodes towards the origin (0, 0)
// Higher values keep the components less spread out
pull: 0.001,
// Theta coefficient from Barnes-Hut simulation
// - Value ranges on [0, 1]
// - Performance is better with smaller values
// - Very small values may not create enough force to give a good result
theta: 0.666,
// Friction / drag coefficient to make the system stabilise over time
dragCoeff: 0.02,
// When the total of the squared position deltas is less than this value, the simulation ends
movementThreshold: 1,
// The amount of time passed per tick
// - Larger values result in faster runtimes but might spread things out too far
// - Smaller values produce more accurate results
timeStep: 20,
// The number of ticks per frame for animate:true
// - A larger value reduces rendering cost but can be jerky
// - A smaller value increases rendering cost but is smoother
refresh: 10,
// Whether to animate the layout
// - true : Animate while the layout is running
// - false : Just show the end result
// - 'end' : Animate directly to the end result
animate: true,
// Animation duration used for animate:'end'
animationDuration: undefined,
// Easing for animate:'end'
animationEasing: undefined,
// Maximum iterations and time (in ms) before the layout will bail out
// - A large value may allow for a better result
// - A small value may make the layout end prematurely
// - The layout may stop before this if it has settled
maxIterations: 1000,
maxSimulationTime: 4000,
// Prevent the user grabbing nodes during the layout (usually with animate:true)
ungrabifyWhileSimulating: false,
// Whether to fit the viewport to the repositioned graph
// true : Fits at end of layout for animate:false or animate:'end'; fits on each frame for animate:true
fit: true,
// Padding in rendered co-ordinates around the layout
padding: 30,
// Constrain layout bounds with one of
// - { x1, y1, x2, y2 }
// - { x1, y1, w, h }
// - undefined / null : Unconstrained
boundingBox: undefined,
// Layout event callbacks; equivalent to `layout.one('layoutready', callback)` for example
ready: function () {}, // on layoutready
stop: function () {}, // on layoutstop
// Whether to randomize the initial positions of the nodes
// true : Use random positions within the bounding box
// false : Use the current node positions as the initial positions
randomize: false
}
module.exports.style = [{
selector: '.node-type-me', // ME
style: {
'background-color': '#791B89'
}
}, {
selector: '.node-type-2', // BROWSER
style: {
'background-color': '#E88900'
}
}, {
selector: '.node-type-1', // NODEJS
style: {
'background-color': '#A3AA12'
}
}, {
selector: '.node-type-0', // GO
style: {
'background-color': '#22616A'
}
}, {
selector: 'edge',
style: {
width: 1,
'line-color': '#ccc',
'target-arrow-color': '#ccc'
}
}]

11161
browser/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
browser/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "js-webapp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "parcel $INIT_CWD/index.html -d ./dist",
"lint": "standard"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"cytoscape": "^3.15.0",
"cytoscape-euler": "^1.2.2",
"it-pipe": "^1.1.0",
"libp2p": "^0.28.0",
"libp2p-bootstrap": "^0.11.0",
"libp2p-floodsub": "^0.21.3",
"libp2p-gossipsub": "^0.4.5",
"libp2p-kad-dht": "^0.19.5",
"libp2p-mplex": "^0.9.5",
"libp2p-noise": "^1.1.1",
"libp2p-secio": "^0.12.5",
"libp2p-webrtc-star": "^0.18.3",
"libp2p-websockets": "^0.13.6",
"peer-id": "^0.13.12",
"protons": "^1.2.0"
},
"devDependencies": {
"babel-eslint": "^10.1.0",
"parcel": "^1.12.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"regenerator-runtime": "^0.13.5",
"standard": "^14.3.4",
"tachyons": "^4.12.0"
},
"browserslist": [
"last 2 Chrome versions"
],
"standard": {
"parser": "babel-eslint"
}
}

Binary file not shown.

Binary file not shown.

60
browser/styles/index.css Normal file
View File

@ -0,0 +1,60 @@
@import 'tachyons';
@font-face {
font-family: 'Nexa';
font-style: normal;
font-weight: 400;
src: url('fonts/Nexa_Free_Light.otf') format('opentype');
}
@font-face {
font-family: 'Nexa';
font-style: bold;
font-weight: 700;
src: url('fonts/Nexa_Free_Bold.otf') format('opentype');
}
.nexa { font-family: 'Nexa', 'Verdana', system-ui, sans-serif; }
.bg-dark-blue { background-color: #14162D; }
.chat-header {
font-size: 0.8em;
}
.chat-body {
margin: 1em 0;
}
.chat-body p {
margin: 0.25em 0;
}
.chat-time {
float: right;
}
.right { text-align: right; }
.right .chat-time {
float: left;
}
.dot {
height: 18px;
width: 18px;
background-color: #999;
border-radius: 50%;
display: inline-block;
}
.bg-libp2p-dark-aqua {
background-color: #22616A;
}
.bg-libp2p-dark-purple {
background-color: #791B89;
}
.bg-libp2p-dark-orange {
background-color: #E88900;
}
.bg-libp2p-dark-green {
background-color: #A3AA12;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

52
browser/views/App.js Normal file
View File

@ -0,0 +1,52 @@
/* eslint-env browser */
import React, { useState, useEffect } from 'react'
import EventEmitter from 'events'
import Header from '../views/Header'
import Metrics from './Metrics'
import Chat from './Chat'
import { getOrCreatePeerId } from '../libs/peer-id'
import '../styles/index.css'
export default function App ({
createLibp2p
}) {
const [peerId, setPeerId] = useState(null)
const [libp2p, setLibp2p] = useState(null)
const [started, setStarted] = useState(false)
const eventBus = new EventEmitter()
/**
* Leverage use effect to act on state changes
*/
useEffect(() => {
// If we don't have a PeerId, let's get or create it
// This will attempt to leverage localStorage so we can reuse our key
if (!peerId) {
console.info('Getting our PeerId')
getOrCreatePeerId().then(setPeerId)
return
}
// If the libp2p instance is not created, create it with our PeerId instance
if (!libp2p) {
;(async () => {
console.info('Creating our Libp2p instance')
const node = await createLibp2p(peerId)
setLibp2p(node)
setStarted(true)
})()
}
})
return (
<div className='avenir flex flex-column h-100'>
<div className='flex-none'>
<Header started={started} />
</div>
<div className='flex h-100'>
<Metrics libp2p={libp2p} eventBus={eventBus} />
<Chat libp2p={libp2p} eventBus={eventBus} />
</div>
</div>
)
}

91
browser/views/Chat.js Normal file
View File

@ -0,0 +1,91 @@
import React, { useState, useEffect } from 'react'
import Message from './Message'
// Chat over Pubsub
import PubsubChat from '../libs/chat'
export default function Chat ({
libp2p,
eventBus
}) {
const [message, setMessage] = useState('')
const [messages, setMessages] = useState([])
const [chatClient, setChatClient] = useState(null)
const [peers, setPeers] = useState({})
/**
* Sends the current message in the chat field
*/
const sendMessage = async () => {
setMessage('')
if (!message) return
if (chatClient.checkCommand(message)) return
try {
await chatClient.send(message)
console.info('Publish done')
} catch (err) {
console.error('Could not send message', err)
}
}
/**
* Calls `sendMessage` if enter was pressed
* @param {KeyDownEvent} e
*/
const onKeyDown = (e) => {
if (e.keyCode === 13) {
sendMessage()
}
}
/**
* Leverage use effect to act on state changes
*/
useEffect(() => {
// Wait for libp2p
if (!libp2p) return
// Create the pubsub chatClient
if (!chatClient) {
const pubsubChat = new PubsubChat(libp2p, PubsubChat.TOPIC)
// Listen for messages
pubsubChat.on('message', (message) => {
if (message.from === libp2p.peerId.toB58String()) {
message.isMine = true
}
setMessages((messages) => [...messages, message])
})
// Listen for peer updates
pubsubChat.on('peer:update', ({ id, name }) => {
setPeers((peers) => {
const newPeers = { ...peers }
newPeers[id] = { name }
return newPeers
})
})
// Forward stats events to the eventBus
pubsubChat.on('stats', (stats) => eventBus.emit('stats', stats))
setChatClient(pubsubChat)
}
})
return (
<div className='flex flex-column w-50 pa3 h-100 bl b--black-10'>
<div className='w-100 flex-auto'>
<ul className='list pa0'>
{messages.map((message, index) => {
return <Message peers={peers} message={message} key={message.message ? message.message.id : index} />
})}
</ul>
</div>
<div className='w-100 h-auto'>
<input onChange={e => setMessage(e.target.value)} onKeyDown={(e) => onKeyDown(e)} className='f6 f5-l input-reset fl ba b--black-20 bg-white pa3 lh-solid w-100 w-75-m w-80-l br2-ns br--left-ns' type='text' name='send' value={message} placeholder='Type your message...' />
<input onClick={() => sendMessage()} className='f6 f5-l button-reset fl pv3 tc bn bg-animate bg-black-70 hover-bg-black white pointer w-100 w-25-m w-20-l br2-ns br--right-ns' type='submit' value='Send' />
</div>
</div>
)
}

15
browser/views/Header.js Normal file
View File

@ -0,0 +1,15 @@
import React from 'react'
import Logo from '../styles/libp2p-logo.svg'
export default function Header ({
started
}) {
return (
<header className='flex items-center pa2 bg-dark-blue'>
<a href='https://libp2p.io' title='home' className='w-50'>
<img className={started ? 'libp2p-on' : 'libp2p-off'} src={Logo} style={{ height: 50 }} />
</a>
<h1 className='w-50 ma0 tr f3 fw2 nexa white'><span className='fw7'>libp</span>p2p chat</h1>
</header>
)
}

21
browser/views/Message.js Normal file
View File

@ -0,0 +1,21 @@
import React from 'react'
/**
* Message renderer
* @param {object} param0
*/
export default function Message ({ peers, message }) {
const from = peers[message.from] ? peers[message.from].name : message.from.substring(0, 20)
return (
<li>
<div className={'chat-body ' + (message.isMine ? 'right' : '')}>
<div className='chat-header'>
<strong className='chat-name'>{from}</strong>
<small className='chat-time'>{new Date(message.created).toLocaleTimeString()}</small>
</div>
<p>{message.data.toString()}</p>
</div>
</li>
)
}

103
browser/views/Metrics.js Normal file
View File

@ -0,0 +1,103 @@
/* eslint-env browser */
import React, { useState, useEffect, createRef } from 'react'
import cytoscape from 'cytoscape'
import euler from 'cytoscape-euler'
import { layout, style } from '../libs/viz'
function Graph ({
graphRoot
}) {
return (
<div ref={graphRoot} className='bg-snow-muted h-100' />
)
}
export default function Metrics ({
libp2p,
eventBus
}) {
const [listening, setListening] = useState(false)
const [peerCount, setPeerCount] = useState(0)
const [stats, setStats] = useState(new Map())
const _graphRoot = createRef()
cytoscape.use(euler)
/**
* Leverage use effect to act on state changes
*/
useEffect(() => {
// Wait for libp2p
if (!libp2p) return
// Set up listeners for setting metrics
if (!listening) {
eventBus.on('stats', (stats) => {
setStats(stats)
})
libp2p.peerStore.on('peer', (peerId) => {
const num = libp2p.peerStore.peers.size
setPeerCount(num)
})
setListening(true)
return
}
const nodes = {}
const edges = []
Array.from(stats).forEach(([peerId, stat], index) => {
let { connectedPeers, nodeType } = stat
if (peerId === libp2p.peerId.toB58String()) {
nodeType = 'me'
}
const classname = `node-type-${nodeType}`
nodes[peerId] = {
data: { id: peerId },
classes: classname
}
connectedPeers.forEach(peer => {
peer = peer.toString()
if (!nodes[peer]) {
nodes[peer] = {
data: { id: peer }
}
}
edges.push({
data: {
id: `${peerId}-${peer}`,
source: peerId,
target: peer
}
})
})
})
const cy = cytoscape({
elements: [
...Object.values(nodes),
...edges
],
container: _graphRoot.current,
style
})
cy.layout(layout).run()
})
return (
<div className='flex flex-column w-50 pa3 h-100'>
<div className='dt dt--fixed w-100'>
<p className='dtc'><span className='dot bg-libp2p-dark-purple' /> Me</p>
<p className='dtc'><span className='dot bg-libp2p-dark-aqua' /> Go</p>
<p className='dtc'><span className='dot bg-libp2p-dark-orange' /> Browser</p>
<p className='dtc'><span className='dot bg-libp2p-dark-green' /> Node.js</p>
<p className='dtc'><span className='dot' /> Unknown</p>
</div>
<h3>Peers Known: {peerCount}</h3>
<Graph graphRoot={_graphRoot} />
</div>
)
}