65: Few UI improvements r=D4nte a=D4nte

Resolves #51

79: Automatically add new issues to js-waku board r=D4nte a=D4nte



Co-authored-by: Franck Royer <franck@status.im>
Co-authored-by: Franck Royer <franck@royer.one>
This commit is contained in:
bors[bot] 2021-04-28 01:29:55 +00:00 committed by GitHub
commit f53773997f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 360 additions and 208 deletions

View File

@ -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'

View File

@ -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).

View File

@ -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<string>('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 <nickname>: set a new nickname');
commandResponses.push('/info: some information about the node');
commandResponses.push(
'/connect <Multiaddr>: 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 (
<div className="App">
<div className="chat-room">
<WakuContext.Provider value={{ waku: stateWaku }}>
<Paper>
<Room
nick={nick}
lines={stateMessages}
commandHandler={commandHandler}
/>
</Paper>
</WakuContext.Provider>
</div>
<div
className="chat-app"
style={{ height: '100vh', width: '100vw', overflow: 'hidden' }}
>
<WakuContext.Provider value={{ waku: stateWaku }}>
<Room
nick={nick}
lines={stateMessages}
commandHandler={(input: string) => {
const { command, response } = handleCommand(
input,
stateWaku,
setNick
);
const commandMessages = response.map((msg) => {
return new ChatMessage(new Date(), command, msg);
});
copyAndReplace(commandMessages, stateMessages, setMessages);
}}
/>
</WakuContext.Provider>
</div>
);
}
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<T>(
newValues: Array<T>,
currentValues: Array<T>,
setter: (val: Array<T>) => void
) {
const copy = currentValues.slice();
setter(copy.concat(newValues));
}

84
web-chat/src/ChatList.tsx Normal file
View File

@ -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) => (
<ListItem key={message.timestamp.toString()}>
<ListItemText primary={<Message message={message} />} />
</ListItem>
));
return (
<List dense={true}>
{listItems}
<AlwaysScrollToBottom messages={messages} />
</List>
);
}
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 (
<Card className="chat-message" variant="outlined">
<CardContent>
<Typography className="chat-nick" variant="subtitle2">
{chatMsg.nick}
<Typography
className="chat-timestamp"
color="textSecondary"
variant="caption"
style={{ marginLeft: 3 }}
>
{timestamp}
</Typography>
</Typography>
<Typography
className="chat-message-content"
variant="body1"
component="p"
>
{chatMsg.message}
</Typography>
</CardContent>
</Card>
);
}
const AlwaysScrollToBottom = (props: Props) => {
const elementRef = useRef<HTMLDivElement>();
useEffect(() => {
// @ts-ignore
elementRef.current.scrollIntoView();
}, [props.messages]);
// @ts-ignore
return <div ref={elementRef} />;
};

View File

@ -4,16 +4,18 @@ import { useWaku } from './WakuContext';
interface Props {
messageHandler: (msg: string) => void;
sendMessage: () => void;
sendMessage: (() => Promise<void>) | undefined;
}
export default function MessageInput(props: Props) {
const [inputText, setInputText] = useState<string>('');
const { waku } = useWaku();
const sendMessage = () => {
props.sendMessage();
setInputText('');
const sendMessage = async () => {
if (props.sendMessage) {
await props.sendMessage();
setInputText('');
}
};
const messageHandler = (event: ChangeEvent<HTMLInputElement>) => {
@ -21,22 +23,20 @@ export default function MessageInput(props: Props) {
props.messageHandler(event.target.value);
};
const keyPressHandler = (event: KeyboardEvent<HTMLInputElement>) => {
const keyPressHandler = async (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
sendMessage();
await sendMessage();
}
};
return (
<Grid container spacing={2} direction="row" alignItems="center">
<Grid container direction="row" alignItems="center">
<Grid item xs={11}>
<TextField
variant="outlined"
label="Send a message"
value={inputText}
fullWidth
style={{ margin: 8 }}
margin="normal"
fullWidth={true}
InputLabelProps={{
shrink: true,
}}

View File

@ -1,8 +1,8 @@
import { Box, Grid, List, ListItem, ListItemText } from '@material-ui/core';
import React, { useState } from 'react';
import { ChatMessage } from 'waku-chat/chat_message';
import { WakuMessage } from 'waku/waku_message';
import { ChatContentTopic } from './App';
import ChatList from './ChatList';
import MessageInput from './MessageInput';
import { useWaku } from './WakuContext';
@ -16,78 +16,52 @@ export default function Room(props: Props) {
let [messageToSend, setMessageToSend] = useState<string>('');
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 (
<Grid container spacing={2}>
<Grid item xs={12}>
<Box
height={800}
maxHeight={800}
style={{ flex: 1, maxHeight: '100%', overflow: 'scroll' }}
>
<Lines messages={props.lines} />
</Box>
</Grid>
<Grid item xs={12}>
<div
className="chat-container"
style={{ height: '98vh', display: 'flex', flexDirection: 'column' }}
>
<div
className="chat-list"
style={{ display: 'flex', flexGrow: 1, overflowY: 'scroll' }}
>
<ChatList messages={props.lines} />
</div>
<div className="chat-input" style={{ display: 'flex', padding: 20 }}>
<MessageInput
messageHandler={messageHandler}
sendMessage={sendMessage}
messageHandler={setMessageToSend}
sendMessage={
waku
? async () => {
return handleMessage(
messageToSend,
props.nick,
props.commandHandler,
waku.relay.send.bind(waku.relay)
);
}
: undefined
}
/>
</Grid>
</Grid>
</div>
</div>
);
}
interface LinesProps {
messages: ChatMessage[];
}
const Lines = (props: LinesProps) => {
const renderedLines = [];
for (const i in props.messages) {
renderedLines.push(
<ListItem>
<ListItemText
key={'chat-message-' + i}
primary={printMessage(props.messages[i])}
/>
</ListItem>
async function handleMessage(
message: string,
nick: string,
commandHandler: (cmd: string) => void,
messageSender: (msg: WakuMessage) => Promise<void>
) {
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 <List dense={true}>{renderedLines}</List>;
};
// 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}`;
}

114
web-chat/src/command.ts Normal file
View File

@ -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 <nickname>: set a new nickname',
'/info: some information about the node',
'/connect <Multiaddr>: 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(' ');
}

View File

@ -1,3 +1,5 @@
@import-normalize; /* bring in normalize.css styles */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',