diff --git a/.github/workflows/add-action-project.yml b/.github/workflows/add-action-project.yml new file mode 100644 index 0000000000..a33e8d954b --- /dev/null +++ b/.github/workflows/add-action-project.yml @@ -0,0 +1,16 @@ +on: + issues: + types: opened + +jobs: + assign_issue_to_project: + runs-on: ubuntu-latest + name: Assign new issue to project + steps: + - name: Create new project card with issue + id: list + uses: qmacro/action-add-issue-to-project-column@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + project: 'js-waku' + column: 'New' diff --git a/README.md b/README.md index 25a4ce69c4..7d2ec09416 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,29 @@ For support, questions & more general topics, please join the discussion on the ## Examples -## Chat app +## Web Chat App (ReactJS) + +A ReactJS web app is provided as an a show case of the library used in the browser. + +A deployed version is available at https://status-im.github.io/js-waku/ +Do note that due to some technical restrictions, it does not currently work out-of-the-box. +If you wish to try it out, follow the instructions on the [Vac forum](status-im.github.io/js-waku/). +It is currently unstable and likely to break. + +To run a development version locally, do: + +```shell +git clone https://github.com/status-im/js-waku/ ; cd js-waku +npm install +npm run build +cd web-chat +npm install +npm run start +``` + +Then, you can use `/help` to change your nick and connect to a server. + +## CLI Chat App (NodeJS) A node chat app is provided as a working example of the library. It is interoperable with the [nim-waku chat app example](https://github.com/status-im/nim-waku/blob/master/examples/v2/chat2.nim). diff --git a/web-chat/src/App.tsx b/web-chat/src/App.tsx index 0d77832c92..1907d2d819 100644 --- a/web-chat/src/App.tsx +++ b/web-chat/src/App.tsx @@ -1,4 +1,3 @@ -import { Paper } from '@material-ui/core'; import { multiaddr } from 'multiaddr'; import PeerId from 'peer-id'; import React, { useEffect, useState } from 'react'; @@ -6,6 +5,7 @@ import './App.css'; import { ChatMessage } from 'waku-chat/chat_message'; import { WakuMessage } from 'waku/waku_message'; import { RelayDefaultTopic } from 'waku/waku_relay'; +import handleCommand from './command'; import Room from './Room'; import Waku from 'waku/waku'; import { WakuContext } from './WakuContext'; @@ -18,45 +18,15 @@ export default function App() { let [nick, setNick] = useState('web-chat'); useEffect(() => { - async function initWaku() { - try { - const waku = await Waku.create({ - config: { - pubsub: { - enabled: true, - emitSelf: true, - }, - }, - }); - - setWaku(waku); - - // FIXME: Connect to a go-waku instance by default, temporary hack until - // we have a go-waku instance in the fleet - waku.libp2p.peerStore.addressBook.add( - PeerId.createFromB58String( - '16Uiu2HAmVVi6Q4j7MAKVibquW8aA27UNrA4Q8Wkz9EetGViu8ZF1' - ), - [multiaddr('/ip4/134.209.113.86/tcp/9001/ws')] - ); - } catch (e) { - console.log('Issue starting waku ', e); - } - } - const handleNewMessages = (event: { data: Uint8Array }) => { - const wakuMsg = WakuMessage.decode(event.data); - if (wakuMsg.payload) { - const chatMsg = ChatMessage.decode(wakuMsg.payload); - const messages = stateMessages.slice(); - messages.push(chatMsg); - console.log('setState on ', messages); - setMessages(messages); + const chatMsg = decodeWakuMessage(event.data); + if (chatMsg) { + copyAndReplace([chatMsg], stateMessages, setMessages); } }; if (!stateWaku) { - initWaku() + initWaku(setWaku) .then(() => console.log('Waku init done')) .catch((e) => console.log('Waku init failed ', e)); } else { @@ -70,103 +40,73 @@ export default function App() { ); }; } - }); - - const commandHandler = (input: string) => { - let commandResponses: string[] = []; - const args = input.split(' '); - const cmd = args.shift()!; - if (!stateWaku) { - commandResponses.push('Waku is not yet initialized'); - } else { - switch (cmd) { - case '/help': - commandResponses.push('/nick : set a new nickname'); - commandResponses.push('/info: some information about the node'); - commandResponses.push( - '/connect : connect to the given peer' - ); - commandResponses.push('/help: Display this help'); - break; - case '/nick': - const arg = args.shift(); - if (!arg) { - commandResponses.push('No nick provided'); - } else { - setNick(arg); - commandResponses.push(`New nick: ${arg}`); - } - break; - case '/info': - if (!stateWaku) { - commandResponses.push(`Waku node is starting`); - } else { - commandResponses.push( - `PeerId: ${stateWaku.libp2p.peerId.toB58String()}` - ); - } - break; - case '/connect': - const peer = args.shift(); - if (!peer) { - commandResponses.push('No peer provided'); - } else { - try { - const peerMultiaddr = multiaddr(peer); - const peerId = peerMultiaddr.getPeerId(); - if (!peerId) { - commandResponses.push('Peer Id needed to dial'); - } else { - stateWaku.libp2p.peerStore.addressBook.add( - PeerId.createFromB58String(peerId), - [peerMultiaddr] - ); - } - } catch (e) { - commandResponses.push('Invalid multiaddr: ' + e); - } - } - break; - case '/peers': - stateWaku.libp2p.peerStore.peers.forEach((peer, peerId) => { - commandResponses.push(peerId + ':'); - let addresses = ' addresses: ['; - peer.addresses.forEach(({ multiaddr }) => { - addresses += ' ' + multiaddr.toString() + ','; - }); - addresses = addresses.replace(/,$/, ''); - addresses += ']'; - commandResponses.push(addresses); - let protocols = ' protocols: ['; - protocols += peer.protocols; - protocols += ']'; - commandResponses.push(protocols); - }); - break; - default: - commandResponses.push('Unknown Command'); - } - } - const messages = stateMessages.slice(); - commandResponses.forEach((res) => { - messages.push(new ChatMessage(new Date(), cmd, res)); - }); - setMessages(messages); - }; + }, [stateWaku, stateMessages]); return ( -
-
- - - - - -
+
+ + { + const { command, response } = handleCommand( + input, + stateWaku, + setNick + ); + const commandMessages = response.map((msg) => { + return new ChatMessage(new Date(), command, msg); + }); + copyAndReplace(commandMessages, stateMessages, setMessages); + }} + /> +
); } + +async function initWaku(setter: (waku: Waku) => void) { + try { + const waku = await Waku.create({ + config: { + pubsub: { + enabled: true, + emitSelf: true, + }, + }, + }); + + setter(waku); + + // FIXME: Connect to a go-waku instance by default, temporary hack until + // we have a go-waku instance in the fleet + waku.libp2p.peerStore.addressBook.add( + PeerId.createFromB58String( + '16Uiu2HAmVVi6Q4j7MAKVibquW8aA27UNrA4Q8Wkz9EetGViu8ZF1' + ), + [multiaddr('/ip4/134.209.113.86/tcp/9001/ws')] + ); + } catch (e) { + console.log('Issue starting waku ', e); + } +} + +function decodeWakuMessage(data: Uint8Array): null | ChatMessage { + const wakuMsg = WakuMessage.decode(data); + if (!wakuMsg.payload) { + return null; + } + return ChatMessage.decode(wakuMsg.payload); +} + +function copyAndReplace( + newValues: Array, + currentValues: Array, + setter: (val: Array) => void +) { + const copy = currentValues.slice(); + setter(copy.concat(newValues)); +} diff --git a/web-chat/src/ChatList.tsx b/web-chat/src/ChatList.tsx new file mode 100644 index 0000000000..e74c48f235 --- /dev/null +++ b/web-chat/src/ChatList.tsx @@ -0,0 +1,84 @@ +import { + Card, + CardContent, + List, + ListItem, + ListItemText, + Typography, +} from '@material-ui/core'; +import React, { useEffect, useRef } from 'react'; +import { ChatMessage } from '../../build/main/chat/chat_message'; + +interface Props { + messages: ChatMessage[]; +} + +export default function ChatList(props: Props) { + const messages = props.messages; + + const listItems = messages.map((message) => ( + + } /> + + )); + + return ( + + {listItems} + + + ); +} + +interface MessageProps { + message: ChatMessage; +} + +function Message(props: MessageProps) { + const chatMsg = props.message; + const timestamp = chatMsg.timestamp.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: false, + }); + + // {`<${timestamp}> ${chatMsg.nick}: ${chatMsg.message}`} + return ( + + + + {chatMsg.nick} + + {timestamp} + + + + {chatMsg.message} + + + + ); +} + +const AlwaysScrollToBottom = (props: Props) => { + const elementRef = useRef(); + + useEffect(() => { + // @ts-ignore + elementRef.current.scrollIntoView(); + }, [props.messages]); + + // @ts-ignore + return
; +}; diff --git a/web-chat/src/MessageInput.tsx b/web-chat/src/MessageInput.tsx index f3fc69d688..e8ba597ef4 100644 --- a/web-chat/src/MessageInput.tsx +++ b/web-chat/src/MessageInput.tsx @@ -4,16 +4,18 @@ import { useWaku } from './WakuContext'; interface Props { messageHandler: (msg: string) => void; - sendMessage: () => void; + sendMessage: (() => Promise) | undefined; } export default function MessageInput(props: Props) { const [inputText, setInputText] = useState(''); const { waku } = useWaku(); - const sendMessage = () => { - props.sendMessage(); - setInputText(''); + const sendMessage = async () => { + if (props.sendMessage) { + await props.sendMessage(); + setInputText(''); + } }; const messageHandler = (event: ChangeEvent) => { @@ -21,22 +23,20 @@ export default function MessageInput(props: Props) { props.messageHandler(event.target.value); }; - const keyPressHandler = (event: KeyboardEvent) => { + const keyPressHandler = async (event: KeyboardEvent) => { if (event.key === 'Enter') { - sendMessage(); + await sendMessage(); } }; return ( - + (''); const { waku } = useWaku(); - const messageHandler = (msg: string) => { - setMessageToSend(msg); - }; - - const sendMessage = async () => { - if (messageToSend.startsWith('/')) { - props.commandHandler(messageToSend); - } else { - const chatMessage = new ChatMessage( - new Date(), - props.nick, - messageToSend - ); - const wakuMsg = WakuMessage.fromBytes( - chatMessage.encode(), - ChatContentTopic - ); - await waku!.relay.send(wakuMsg); - } - }; - return ( - - - - - - - - +
+
+ +
+
{ + return handleMessage( + messageToSend, + props.nick, + props.commandHandler, + waku.relay.send.bind(waku.relay) + ); + } + : undefined + } /> - - +
+
); } -interface LinesProps { - messages: ChatMessage[]; -} - -const Lines = (props: LinesProps) => { - const renderedLines = []; - - for (const i in props.messages) { - renderedLines.push( - - - +async function handleMessage( + message: string, + nick: string, + commandHandler: (cmd: string) => void, + messageSender: (msg: WakuMessage) => Promise +) { + if (message.startsWith('/')) { + commandHandler(message); + } else { + const chatMessage = new ChatMessage(new Date(), nick, message); + const wakuMsg = WakuMessage.fromBytes( + chatMessage.encode(), + ChatContentTopic ); + return messageSender(wakuMsg); } - - return {renderedLines}; -}; - -// TODO: Make it a proper component -function printMessage(chatMsg: ChatMessage) { - const timestamp = chatMsg.timestamp.toLocaleString([], { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: false, - }); - return `<${timestamp}> ${chatMsg.nick}: ${chatMsg.message}`; } diff --git a/web-chat/src/command.ts b/web-chat/src/command.ts new file mode 100644 index 0000000000..3034c686ec --- /dev/null +++ b/web-chat/src/command.ts @@ -0,0 +1,114 @@ +import { multiaddr } from 'multiaddr'; +import PeerId from 'peer-id'; +import Waku from '../../build/main/lib/waku'; + +function help(): string[] { + return [ + '/nick : set a new nickname', + '/info: some information about the node', + '/connect : connect to the given peer', + '/help: Display this help', + ]; +} + +function nick( + nick: string | undefined, + setNick: (nick: string) => void +): string[] { + if (!nick) { + return ['No nick provided']; + } + setNick(nick); + return [`New nick: ${nick}`]; +} + +function info(waku: Waku | undefined): string[] { + if (!waku) { + return ['Waku node is starting']; + } + return [`PeerId: ${waku.libp2p.peerId.toB58String()}`]; +} + +function connect(peer: string | undefined, waku: Waku | undefined): string[] { + if (!waku) { + return ['Waku node is starting']; + } + if (!peer) { + return ['No peer provided']; + } + try { + const peerMultiaddr = multiaddr(peer); + const peerId = peerMultiaddr.getPeerId(); + if (!peerId) { + return ['Peer Id needed to dial']; + } + waku.libp2p.peerStore.addressBook.add(PeerId.createFromB58String(peerId), [ + peerMultiaddr, + ]); + return [ + `${peerId}: ${peerMultiaddr.toString()} added to address book, autodial in progress`, + ]; + } catch (e) { + return ['Invalid multiaddr: ' + e]; + } +} + +function peers(waku: Waku | undefined): string[] { + if (!waku) { + return ['Waku node is starting']; + } + let response: string[] = []; + waku.libp2p.peerStore.peers.forEach((peer, peerId) => { + response.push(peerId + ':'); + let addresses = ' addresses: ['; + peer.addresses.forEach(({ multiaddr }) => { + addresses += ' ' + multiaddr.toString() + ','; + }); + addresses = addresses.replace(/,$/, ''); + addresses += ']'; + response.push(addresses); + let protocols = ' protocols: ['; + protocols += peer.protocols; + protocols += ']'; + response.push(protocols); + }); + if (response.length === 0) { + response.push('Not connected to any peer.'); + } + return response; +} + +export default function handleCommand( + input: string, + waku: Waku | undefined, + setNick: (nick: string) => void +): { command: string; response: string[] } { + let response: string[] = []; + const args = parseInput(input); + const command = args.shift()!; + switch (command) { + case '/help': + help().map((str) => response.push(str)); + break; + case '/nick': + nick(args.shift(), setNick).map((str) => response.push(str)); + break; + case '/info': + info(waku).map((str) => response.push(str)); + break; + case '/connect': + connect(args.shift(), waku).map((str) => response.push(str)); + break; + case '/peers': + peers(waku).map((str) => response.push(str)); + break; + default: + response.push(`Unknown Command '${command}'`); + } + return { command, response }; +} + +export function parseInput(input: string): string[] { + const clean = input.trim().replaceAll(/\s\s+/g, ' '); + return clean.split(' '); +} diff --git a/web-chat/src/index.css b/web-chat/src/index.css index 16a2e803f3..a16a6141ff 100644 --- a/web-chat/src/index.css +++ b/web-chat/src/index.css @@ -1,3 +1,5 @@ +@import-normalize; /* bring in normalize.css styles */ + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',