chore: clone web-chat for ethdenver
This commit is contained in:
parent
9f9fa8646b
commit
408f1f9d3f
|
@ -0,0 +1,131 @@
|
|||
{
|
||||
"version": "0.1",
|
||||
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/master/cspell.schema.json",
|
||||
"language": "en",
|
||||
"words": [
|
||||
"AOGECG2SPND25EEFMAJ5WF3KSGJNSGV356DSTL2YVLLZWIV6SAYBM",
|
||||
"asym",
|
||||
"backoff",
|
||||
"backoffs",
|
||||
"bitjson",
|
||||
"bitauth",
|
||||
"bufbuild",
|
||||
"chainsafe",
|
||||
"cimg",
|
||||
"ciphertext",
|
||||
"circleci",
|
||||
"codecov",
|
||||
"commitlint",
|
||||
"dependabot",
|
||||
"dingpu",
|
||||
"Dlazy",
|
||||
"dnsaddr",
|
||||
"Dout",
|
||||
"Dscore",
|
||||
"ecies",
|
||||
"editorconfig",
|
||||
"enr",
|
||||
"enrs",
|
||||
"enrtree",
|
||||
"ephem",
|
||||
"esnext",
|
||||
"ethersproject",
|
||||
"execa",
|
||||
"exponentiate",
|
||||
"fanout",
|
||||
"floodsub",
|
||||
"fontsource",
|
||||
"globby",
|
||||
"gossipsub",
|
||||
"huilong",
|
||||
"iasked",
|
||||
"ihave",
|
||||
"ihaves",
|
||||
"ineed",
|
||||
"ipfs",
|
||||
"iwant",
|
||||
"jdev",
|
||||
"jswaku",
|
||||
"keccak",
|
||||
"lastpub",
|
||||
"libauth",
|
||||
"libp",
|
||||
"lightpush",
|
||||
"livechat",
|
||||
"mkdir",
|
||||
"multiaddr",
|
||||
"multiaddresses",
|
||||
"multiaddrs",
|
||||
"multicodec",
|
||||
"multicodecs",
|
||||
"multiformats",
|
||||
"mplex",
|
||||
"multihashes",
|
||||
"muxed",
|
||||
"muxer",
|
||||
"muxers",
|
||||
"mvps",
|
||||
"nodekey",
|
||||
"nwaku",
|
||||
"opendns",
|
||||
"peerhave",
|
||||
"portfinder",
|
||||
"prettierignore",
|
||||
"proto",
|
||||
"protobuf",
|
||||
"protoc",
|
||||
"reactjs",
|
||||
"recid",
|
||||
"rlnrelay",
|
||||
"roadmap",
|
||||
"sandboxed",
|
||||
"scanf",
|
||||
"secio",
|
||||
"seckey",
|
||||
"secp",
|
||||
"sscanf",
|
||||
"staticnode",
|
||||
"statusim",
|
||||
"submodule",
|
||||
"submodules",
|
||||
"supercrypto",
|
||||
"transpiled",
|
||||
"typedoc",
|
||||
"unencrypted",
|
||||
"unmarshal",
|
||||
"unmount",
|
||||
"unmounts",
|
||||
"untracked",
|
||||
"upgrader",
|
||||
"vacp",
|
||||
"varint",
|
||||
"waku",
|
||||
"wakuconnect",
|
||||
"wakuv",
|
||||
"wakunode",
|
||||
"webfonts",
|
||||
"websockets",
|
||||
"wifi",
|
||||
"xsalsa20",
|
||||
"Alives"
|
||||
],
|
||||
"flagWords": [],
|
||||
"ignorePaths": [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"tsconfig.json",
|
||||
"node_modules/**",
|
||||
"build",
|
||||
"gen",
|
||||
"proto",
|
||||
"*.spec.ts"
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"name": "import",
|
||||
"pattern": "/import .*/"
|
||||
}
|
||||
],
|
||||
"ignoreRegExpList": ["import"]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# Remove ReactJS warning about webpack
|
||||
# because this is not a monorepo, ReactJS projects are examples
|
||||
SKIP_PREFLIGHT_CHECK=true
|
|
@ -0,0 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
|
@ -0,0 +1,3 @@
|
|||
# package.json is formatted by package managers, so we ignore it here
|
||||
package.json
|
||||
gen
|
|
@ -0,0 +1,25 @@
|
|||
# Web Chat App
|
||||
|
||||
**Demonstrates**:
|
||||
|
||||
- Group chat
|
||||
- React/TypeScript
|
||||
- Waku Filter
|
||||
- Waku Light Push
|
||||
- Waku Store
|
||||
- Protobuf using `protons`
|
||||
|
||||
A ReactJS chat app is provided as a showcase of the library used in the browser.
|
||||
It implements [Waku v2 Toy Chat](https://rfc.vac.dev/spec/22/) protocol.
|
||||
A deployed version is available at https://examples.waku.org/web-chat/.
|
||||
|
||||
To run a development version locally, do:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/waku-org/js-waku-examples
|
||||
cd web-chat
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
Use `/help` to see the available commands.
|
|
@ -0,0 +1,6 @@
|
|||
version: v1beta1
|
||||
|
||||
plugins:
|
||||
- name: ts_proto
|
||||
out: ./src/proto
|
||||
opt: grpc_js,esModuleInterop=true
|
|
@ -0,0 +1,5 @@
|
|||
version: v1beta1
|
||||
|
||||
build:
|
||||
roots:
|
||||
- ./src/proto
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"name": "web-chat-dev",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "/web-chat",
|
||||
"dependencies": {
|
||||
"@libp2p/bootstrap": "^5.0.0",
|
||||
"@livechat/ui-kit": "^0.5.0-24",
|
||||
"@multiformats/multiaddr": "11.0.7",
|
||||
"@waku/react": "0.0.1-a",
|
||||
"@waku/byte-utils": "^0.0.2",
|
||||
"@waku/core": "^0.0.10",
|
||||
"@waku/create": "^0.0.4",
|
||||
"@waku/dns-discovery": "0.0.5",
|
||||
"@waku/interfaces": "^0.0.7",
|
||||
"@waku/peer-exchange": "^0.0.3",
|
||||
"process": "^0.11.10",
|
||||
"protons-runtime": "^3.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"server-name-generator": "^1.0.5",
|
||||
"uint8arraylist": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/react": "^18.0.25",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"cspell": "^6.14.3",
|
||||
"gh-pages": "^4.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"protons": "^5.1.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.3",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "GENERATE_SOURCEMAP=false PORT=3003 react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test:unit": "exit 0",
|
||||
"fix": "run-s fix:*",
|
||||
"test": "run-s build test:*",
|
||||
"test:lint": "eslint src --ext .ts --ext .tsx",
|
||||
"test:prettier": "prettier \"src/**/*.{ts,tsx}\" \"./*.json\" --list-different",
|
||||
"test:spelling": "cspell \"{README.md,.github/*.md,src/**/*.{ts,tsx},public/**/*.html}\" -c ./.cspell.json",
|
||||
"fix:prettier": "prettier \"src/**/*.{ts,tsx}\" \"./*.json\" --write",
|
||||
"fix:lint": "eslint src --ext .ts --ext .tsx --fix",
|
||||
"proto": "protons src/proto/*.proto",
|
||||
"js-waku:build": "cd ../; npm run build",
|
||||
"predeploy": "run-s js-waku:build build",
|
||||
"deploy": "gh-pages -d build"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not ie <= 99",
|
||||
"not android <= 4.4.4",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.1 KiB |
|
@ -0,0 +1,44 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
<meta name="description" content="Chat app powered by js-waku" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Waku v2 chat app</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"short_name": "Waku v2 Chat",
|
||||
"name": "Chat app powered by js-waku",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -0,0 +1,38 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import "./App.css";
|
||||
import Room from "./Room";
|
||||
import { PageDirection, LightNode } from "@waku/interfaces";
|
||||
|
||||
import { useWaku, useContentPair } from "@waku/react";
|
||||
|
||||
import { useMessages, usePersistentNick } from "./hooks";
|
||||
|
||||
const startTime = new Date();
|
||||
// Only retrieve a week of history
|
||||
startTime.setTime(Date.now() - 1000 * 60 * 60 * 24 * 7);
|
||||
const endTime = new Date();
|
||||
|
||||
export default function App() {
|
||||
const { node } = useWaku<LightNode>();
|
||||
const { decoder } = useContentPair();
|
||||
const [messages] = useMessages({
|
||||
node,
|
||||
decoder,
|
||||
options: {
|
||||
pageSize: 5,
|
||||
pageDirection: PageDirection.FORWARD,
|
||||
timeFilter: {
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [nick] = usePersistentNick();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chat-app"
|
||||
style={{ height: "100vh", width: "100vw", overflow: "hidden" }}
|
||||
>
|
||||
<Room nick={nick} messages={messages} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Message as LiveMessage,
|
||||
MessageText,
|
||||
MessageList,
|
||||
} from "@livechat/ui-kit";
|
||||
import { Message } from "./Message";
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export default function ChatList(props: Props) {
|
||||
const renderedMessages = props.messages.map((message) => (
|
||||
<LiveMessage
|
||||
key={
|
||||
message.nick +
|
||||
message.payloadAsUtf8 +
|
||||
message.timestamp.valueOf() +
|
||||
message.sentTimestamp?.valueOf()
|
||||
}
|
||||
authorName={message.nick}
|
||||
date={formatDisplayDate(message)}
|
||||
>
|
||||
<MessageText>{message.payloadAsUtf8}</MessageText>
|
||||
</LiveMessage>
|
||||
));
|
||||
|
||||
return (
|
||||
<MessageList active containScrollInSubtree>
|
||||
{renderedMessages}
|
||||
<AlwaysScrollToBottom messages={props.messages} />
|
||||
</MessageList>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDisplayDate(message: Message): string {
|
||||
return message.timestamp.toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
const AlwaysScrollToBottom = (props: { messages: Message[] }) => {
|
||||
const elementRef = useRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-ignore
|
||||
elementRef.current.scrollIntoView();
|
||||
}, [props.messages]);
|
||||
|
||||
// @ts-ignore
|
||||
return <div ref={elementRef} />;
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import { IDecodedMessage } from "@waku/interfaces";
|
||||
import { ChatMessage } from "./chat_message";
|
||||
|
||||
export class Message {
|
||||
public chatMessage: ChatMessage;
|
||||
// WakuMessage timestamp
|
||||
public sentTimestamp: Date | undefined;
|
||||
|
||||
constructor(chatMessage: ChatMessage, sentTimestamp: Date | undefined) {
|
||||
this.chatMessage = chatMessage;
|
||||
this.sentTimestamp = sentTimestamp;
|
||||
}
|
||||
|
||||
static fromWakuMessage(wakuMsg: IDecodedMessage): Message | undefined {
|
||||
if (wakuMsg.payload) {
|
||||
try {
|
||||
const chatMsg = ChatMessage.decode(wakuMsg.payload);
|
||||
if (chatMsg) {
|
||||
return new Message(chatMsg, wakuMsg.timestamp);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to decode chat message", e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
static fromUtf8String(nick: string, text: string): Message {
|
||||
const now = new Date();
|
||||
return new Message(ChatMessage.fromUtf8String(now, nick, text), now);
|
||||
}
|
||||
|
||||
get nick() {
|
||||
return this.chatMessage.nick;
|
||||
}
|
||||
|
||||
get timestamp() {
|
||||
return this.chatMessage.timestamp;
|
||||
}
|
||||
|
||||
get payloadAsUtf8() {
|
||||
return this.chatMessage.payloadAsUtf8;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { ChangeEvent, KeyboardEvent, useEffect, useState } from "react";
|
||||
import { useWaku } from "@waku/react";
|
||||
import { LightNode } from "@waku/interfaces";
|
||||
import {
|
||||
TextInput,
|
||||
TextComposer,
|
||||
Row,
|
||||
Fill,
|
||||
Fit,
|
||||
SendButton,
|
||||
} from "@livechat/ui-kit";
|
||||
|
||||
interface Props {
|
||||
hasLightPushPeers: boolean;
|
||||
sendMessage: ((msg: string) => Promise<void>) | undefined;
|
||||
}
|
||||
|
||||
export default function MessageInput(props: Props) {
|
||||
const { hasLightPushPeers } = props;
|
||||
const { node } = useWaku<LightNode>();
|
||||
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
const [isActive, setActiveButton] = useState<boolean>(false);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (props.sendMessage) {
|
||||
await props.sendMessage(inputText);
|
||||
setInputText("");
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setInputText(event.target.value);
|
||||
};
|
||||
|
||||
const onKeyDown = async (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
await onMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Enable the button if there are peers available or the user is sending a command
|
||||
useEffect(() => {
|
||||
if (inputText.startsWith("/") || hasLightPushPeers) {
|
||||
setActiveButton(true);
|
||||
} else if (node) {
|
||||
setActiveButton(false);
|
||||
}
|
||||
}, [node, inputText, hasLightPushPeers]);
|
||||
|
||||
return (
|
||||
<TextComposer
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={onChange}
|
||||
active={isActive}
|
||||
onButtonClick={sendMessage}
|
||||
>
|
||||
<Row align="center">
|
||||
<Fill>
|
||||
<TextInput value={inputText} />
|
||||
</Fill>
|
||||
<Fit>
|
||||
<SendButton />
|
||||
</Fit>
|
||||
</Row>
|
||||
</TextComposer>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import type { LightNode } from "@waku/interfaces";
|
||||
import ChatList from "./ChatList";
|
||||
import MessageInput from "./MessageInput";
|
||||
import { useWaku, useContentPair, useLightPush, usePeers } from "@waku/react";
|
||||
import { TitleBar } from "@livechat/ui-kit";
|
||||
import { Message } from "./Message";
|
||||
import { ChatMessage } from "./chat_message";
|
||||
import { useNodePeers } from "./hooks";
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
nick: string;
|
||||
}
|
||||
|
||||
export default function Room(props: Props) {
|
||||
const { node } = useWaku<LightNode>();
|
||||
|
||||
const { storePeers, filterPeers, lightPushPeers } = usePeers({ node });
|
||||
const { bootstrapPeers, peerExchangePeers } = useNodePeers(node);
|
||||
|
||||
const { encoder } = useContentPair();
|
||||
const { push: _sendMessage } = useLightPush({ node, encoder });
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!_sendMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date();
|
||||
const chatMessage = ChatMessage.fromUtf8String(timestamp, props.nick, text);
|
||||
const payload = chatMessage.encode();
|
||||
|
||||
await _sendMessage({ payload, timestamp });
|
||||
};
|
||||
|
||||
const lightPushPeersLength = orZero(lightPushPeers?.length);
|
||||
const filterPeersLength = orZero(filterPeers?.length);
|
||||
const storePeersLength = orZero(storePeers?.length);
|
||||
|
||||
const peersMessage = `Peers: ${lightPushPeersLength} light push, ${filterPeersLength} filter, ${storePeersLength} store.`;
|
||||
const bootstrapPeersMessage = `Bootstrap (DNS Discovery): ${bootstrapPeers.size}, Peer exchange: ${peerExchangePeers.size}. `;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chat-container"
|
||||
style={{ height: "98vh", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<TitleBar
|
||||
leftIcons={[peersMessage]}
|
||||
rightIcons={[bootstrapPeersMessage, "View console for more details."]}
|
||||
title="Waku v2 chat app"
|
||||
/>
|
||||
<ChatList messages={props.messages} />
|
||||
<MessageInput
|
||||
hasLightPushPeers={!!lightPushPeers}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function orZero(value: undefined | number): number {
|
||||
return value || 0;
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { utf8ToBytes, bytesToUtf8 } from "@waku/byte-utils";
|
||||
import * as proto from "./proto/chat_message";
|
||||
|
||||
/**
|
||||
* ChatMessage is used by the various show case waku apps that demonstrates
|
||||
* waku used as the network layer for chat group applications.
|
||||
*
|
||||
* This is included to help building PoC and MVPs. Apps that aim to be
|
||||
* production ready should use a more appropriate data structure.
|
||||
*/
|
||||
export class ChatMessage {
|
||||
public constructor(public proto: proto.ChatMessage) {}
|
||||
|
||||
/**
|
||||
* Create Chat Message with a utf-8 string as payload.
|
||||
*/
|
||||
static fromUtf8String(
|
||||
timestamp: Date,
|
||||
nick: string,
|
||||
text: string
|
||||
): ChatMessage {
|
||||
const timestampNumber = BigInt(Math.floor(timestamp.valueOf() / 1000));
|
||||
const payload = utf8ToBytes(text);
|
||||
|
||||
return new ChatMessage({
|
||||
timestamp: timestampNumber,
|
||||
nick,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a protobuf payload to a ChatMessage.
|
||||
* @param bytes The payload to decode.
|
||||
*/
|
||||
static decode(bytes: Uint8Array): ChatMessage {
|
||||
const protoMsg = proto.ChatMessage.decode(bytes);
|
||||
return new ChatMessage(protoMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode this ChatMessage to a byte array, to be used as a protobuf payload.
|
||||
* @returns The encoded payload.
|
||||
*/
|
||||
encode(): Uint8Array {
|
||||
return proto.ChatMessage.encode(this.proto);
|
||||
}
|
||||
|
||||
get timestamp(): Date {
|
||||
return new Date(Number(BigInt(this.proto.timestamp) * BigInt(1000)));
|
||||
}
|
||||
|
||||
get nick(): string {
|
||||
return this.proto.nick;
|
||||
}
|
||||
|
||||
get payloadAsUtf8(): string {
|
||||
if (!this.proto.payload) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return bytesToUtf8(this.proto.payload);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
import { multiaddr } from "@multiformats/multiaddr";
|
||||
import type { LightNode } from "@waku/interfaces";
|
||||
|
||||
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: LightNode | undefined): string[] {
|
||||
if (!waku) {
|
||||
return ["Waku node is starting"];
|
||||
}
|
||||
return [`PeerId: ${waku.libp2p.peerId.toString()}`];
|
||||
}
|
||||
|
||||
function connect(
|
||||
peer: string | undefined,
|
||||
waku: LightNode | 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
|
||||
.dial(peerMultiaddr)
|
||||
.catch((e) => console.error(`Failed to dial ${peerMultiaddr}`, e));
|
||||
return [
|
||||
`${peerId}: ${peerMultiaddr.toString()} added to address book, autodial in progress`,
|
||||
];
|
||||
} catch (e) {
|
||||
return ["Invalid multiaddr: " + e];
|
||||
}
|
||||
}
|
||||
|
||||
async function peers(waku: LightNode | undefined): Promise<string[]> {
|
||||
if (!waku) {
|
||||
return ["Waku node is starting"];
|
||||
}
|
||||
let response: string[] = [];
|
||||
const peers = await waku.libp2p.peerStore.all();
|
||||
|
||||
Array.from(peers).forEach((peer) => {
|
||||
response.push(peer.id.toString() + ":");
|
||||
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;
|
||||
}
|
||||
|
||||
function connections(waku: LightNode | undefined): string[] {
|
||||
if (!waku) {
|
||||
return ["Waku node is starting"];
|
||||
}
|
||||
let response: string[] = [];
|
||||
let strConnections = " connections: \n";
|
||||
waku.libp2p.connectionManager.getConnections().forEach((connection) => {
|
||||
strConnections += connection.remotePeer.toString() + ", ";
|
||||
strConnections += JSON.stringify(connection.stat);
|
||||
strConnections += "; " + JSON.stringify(connection.streams);
|
||||
strConnections += "\n";
|
||||
});
|
||||
response.push(strConnections);
|
||||
if (response.length === 0) {
|
||||
response.push("Not connected to any peer.");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export default async function handleCommand(
|
||||
input: string,
|
||||
waku: LightNode | undefined,
|
||||
setNick: (nick: string) => void
|
||||
): Promise<{ 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":
|
||||
(await peers(waku)).map((str) => response.push(str));
|
||||
break;
|
||||
case "/connections":
|
||||
connections(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(" ");
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { Protocols } from "@waku/interfaces";
|
||||
|
||||
export const CONTENT_TOPIC = "/toy-chat/2/ethdenver/proto";
|
||||
|
||||
const PUBLIC_KEY = "AOGECG2SPND25EEFMAJ5WF3KSGJNSGV356DSTL2YVLLZWIV6SAYBM";
|
||||
const FQDN = "test.waku.nodes.status.im";
|
||||
export const ENR_TREE = `enrtree://${PUBLIC_KEY}@${FQDN}`;
|
||||
|
||||
export const PROTOCOLS = [
|
||||
Protocols.Filter,
|
||||
Protocols.Store,
|
||||
Protocols.LightPush,
|
||||
];
|
|
@ -0,0 +1,46 @@
|
|||
import { wakuDnsDiscovery } from "@waku/dns-discovery";
|
||||
import { wakuPeerExchangeDiscovery } from "@waku/peer-exchange";
|
||||
|
||||
import { ENR_TREE } from "./config";
|
||||
|
||||
export const NODE_OPTIONS = {
|
||||
libp2p: {
|
||||
peerDiscovery: [
|
||||
wakuDnsDiscovery(ENR_TREE, {
|
||||
store: 1,
|
||||
filter: 2,
|
||||
lightpush: 2,
|
||||
}),
|
||||
wakuPeerExchangeDiscovery(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const THEMES = {
|
||||
AuthorName: {
|
||||
css: {
|
||||
fontSize: "1.1em",
|
||||
},
|
||||
},
|
||||
Message: {
|
||||
css: {
|
||||
margin: "0em",
|
||||
padding: "0em",
|
||||
fontSize: "0.83em",
|
||||
},
|
||||
},
|
||||
MessageText: {
|
||||
css: {
|
||||
margin: "0em",
|
||||
padding: "0.1em",
|
||||
paddingLeft: "1em",
|
||||
fontSize: "1.1em",
|
||||
},
|
||||
},
|
||||
MessageGroup: {
|
||||
css: {
|
||||
margin: "0em",
|
||||
padding: "0.2em",
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { generate } from "server-name-generator";
|
||||
import { Message } from "./Message";
|
||||
import { Decoder } from "@waku/core/lib/message/version_0";
|
||||
import { LightNode, StoreQueryOptions } from "@waku/interfaces";
|
||||
|
||||
import { useFilterMessages, useStoreMessages } from "@waku/react";
|
||||
|
||||
export const usePersistentNick = (): [
|
||||
string,
|
||||
React.Dispatch<React.SetStateAction<string>>
|
||||
] => {
|
||||
const [nick, setNick] = useState<string>(() => {
|
||||
const persistedNick = window.localStorage.getItem("nick");
|
||||
return persistedNick !== null ? persistedNick : generate();
|
||||
});
|
||||
useEffect(() => {
|
||||
localStorage.setItem("nick", nick);
|
||||
}, [nick]);
|
||||
|
||||
return [nick, setNick];
|
||||
};
|
||||
|
||||
type UseMessagesParams = {
|
||||
node: undefined | LightNode;
|
||||
decoder: undefined | Decoder;
|
||||
options: StoreQueryOptions;
|
||||
};
|
||||
|
||||
type UseMessagesResult = [Message[], (v: Message[]) => void];
|
||||
|
||||
export const useMessages = (params: UseMessagesParams): UseMessagesResult => {
|
||||
const { messages: newMessages } = useFilterMessages(params);
|
||||
const { messages: storedMessages } = useStoreMessages(params);
|
||||
const [localMessages, setLocalMessages] = useState<Message[]>([]);
|
||||
|
||||
const pushMessages = (msgs: Message[]) => {
|
||||
if (!msgs || !msgs.length) {
|
||||
return;
|
||||
}
|
||||
setLocalMessages((prev) => [...prev, ...msgs]);
|
||||
};
|
||||
|
||||
const allMessages = React.useMemo((): Message[] => {
|
||||
return [...storedMessages, ...newMessages]
|
||||
.map(Message.fromWakuMessage)
|
||||
.concat(localMessages)
|
||||
.filter((v): v is Message => !!v)
|
||||
.sort(
|
||||
(left, right) => left.timestamp.getTime() - right.timestamp.getTime()
|
||||
);
|
||||
}, [storedMessages, newMessages, localMessages]);
|
||||
|
||||
return [allMessages, pushMessages];
|
||||
};
|
||||
|
||||
// can be safely ignored
|
||||
// this is for experiments on waku side around new discovery options
|
||||
export const useNodePeers = (node: undefined | LightNode) => {
|
||||
const [bootstrapPeers, setBootstrapPeers] = useState(new Set<string>());
|
||||
const [peerExchangePeers, setPeerExchangePeers] = useState(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
|
||||
node.libp2p.peerStore.all().then(async (peers) => {
|
||||
for (const peer of peers) {
|
||||
const tags = (await node.libp2p.peerStore.getTags(peer.id)).map(
|
||||
(t) => t.name
|
||||
);
|
||||
if (tags.includes("peer-exchange")) {
|
||||
setPeerExchangePeers((peers) =>
|
||||
new Set(peers).add(peer.id.toString())
|
||||
);
|
||||
} else {
|
||||
setBootstrapPeers((peers) => new Set(peers).add(peer.id.toString()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update store peer when new peer connected & identified
|
||||
node.libp2p.peerStore.addEventListener("change:protocols", async (evt) => {
|
||||
const { peerId } = evt.detail;
|
||||
const tags = (await node.libp2p.peerStore.getTags(peerId)).map(
|
||||
(t) => t.name
|
||||
);
|
||||
if (tags.includes("peer-exchange")) {
|
||||
setPeerExchangePeers((peers) => new Set(peers).add(peerId.toString()));
|
||||
} else {
|
||||
setBootstrapPeers((peers) => new Set(peers).add(peerId.toString()));
|
||||
}
|
||||
});
|
||||
}, [node]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Bootstrap Peers:");
|
||||
console.table(bootstrapPeers);
|
||||
|
||||
console.log("Peer Exchange Peers:");
|
||||
console.table(peerExchangePeers);
|
||||
}, [bootstrapPeers, peerExchangePeers]);
|
||||
|
||||
return {
|
||||
bootstrapPeers,
|
||||
peerExchangePeers,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
@import-normalize; /* bring in normalize.css styles */
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
.room-row {
|
||||
text-align: left;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.room-row:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
.chat-room {
|
||||
margin: 2px;
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { ThemeProvider } from "@livechat/ui-kit";
|
||||
import { LightNodeProvider, ContentPairProvider } from "@waku/react";
|
||||
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { CONTENT_TOPIC, PROTOCOLS } from "./config";
|
||||
import { NODE_OPTIONS, THEMES } from "./constants";
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={THEMES}>
|
||||
<LightNodeProvider options={NODE_OPTIONS} protocols={PROTOCOLS}>
|
||||
<ContentPairProvider contentTopic={CONTENT_TOPIC}>
|
||||
<App />
|
||||
</ContentPairProvider>
|
||||
</LightNodeProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
|
@ -0,0 +1,7 @@
|
|||
syntax = "proto3";
|
||||
|
||||
message ChatMessage {
|
||||
uint64 timestamp = 1;
|
||||
string nick = 2;
|
||||
bytes payload = 3;
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
/* eslint-disable import/export */
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
|
||||
import { encodeMessage, decodeMessage, message } from "protons-runtime";
|
||||
import type { Uint8ArrayList } from "uint8arraylist";
|
||||
import type { Codec } from "protons-runtime";
|
||||
|
||||
export interface ChatMessage {
|
||||
timestamp: bigint;
|
||||
nick: string;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export namespace ChatMessage {
|
||||
let _codec: Codec<ChatMessage>;
|
||||
|
||||
export const codec = (): Codec<ChatMessage> => {
|
||||
if (_codec == null) {
|
||||
_codec = message<ChatMessage>(
|
||||
(obj, writer, opts = {}) => {
|
||||
if (opts.lengthDelimited !== false) {
|
||||
writer.fork();
|
||||
}
|
||||
|
||||
if (obj.timestamp != null) {
|
||||
writer.uint32(8);
|
||||
writer.uint64(obj.timestamp);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Protocol error: required field "timestamp" was not found in object'
|
||||
);
|
||||
}
|
||||
|
||||
if (obj.nick != null) {
|
||||
writer.uint32(18);
|
||||
writer.string(obj.nick);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Protocol error: required field "nick" was not found in object'
|
||||
);
|
||||
}
|
||||
|
||||
if (obj.payload != null) {
|
||||
writer.uint32(26);
|
||||
writer.bytes(obj.payload);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Protocol error: required field "payload" was not found in object'
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.lengthDelimited !== false) {
|
||||
writer.ldelim();
|
||||
}
|
||||
},
|
||||
(reader, length) => {
|
||||
const obj: any = {
|
||||
timestamp: 0n,
|
||||
nick: "",
|
||||
payload: new Uint8Array(0),
|
||||
};
|
||||
|
||||
const end = length == null ? reader.len : reader.pos + length;
|
||||
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
|
||||
switch (tag >>> 3) {
|
||||
case 1:
|
||||
obj.timestamp = reader.uint64();
|
||||
break;
|
||||
case 2:
|
||||
obj.nick = reader.string();
|
||||
break;
|
||||
case 3:
|
||||
obj.payload = reader.bytes();
|
||||
break;
|
||||
default:
|
||||
reader.skipType(tag & 7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.timestamp == null) {
|
||||
throw new Error(
|
||||
'Protocol error: value for required field "timestamp" was not found in protobuf'
|
||||
);
|
||||
}
|
||||
|
||||
if (obj.nick == null) {
|
||||
throw new Error(
|
||||
'Protocol error: value for required field "nick" was not found in protobuf'
|
||||
);
|
||||
}
|
||||
|
||||
if (obj.payload == null) {
|
||||
throw new Error(
|
||||
'Protocol error: value for required field "payload" was not found in protobuf'
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return _codec;
|
||||
};
|
||||
|
||||
export const encode = (obj: ChatMessage): Uint8Array => {
|
||||
return encodeMessage(obj, ChatMessage.codec());
|
||||
};
|
||||
|
||||
export const decode = (buf: Uint8Array | Uint8ArrayList): ChatMessage => {
|
||||
return decodeMessage(buf, ChatMessage.codec());
|
||||
};
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
|
@ -0,0 +1,5 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import "@testing-library/jest-dom";
|
|
@ -0,0 +1,2 @@
|
|||
declare module "@livechat/ui-kit";
|
||||
declare module "@waku/dns-discovery";
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"target": "es2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"typeRoots": ["node_modules/@types", "src/types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
Loading…
Reference in New Issue