feat: updated and refactored web-chat example (#214)

* make custom hooks
* use @waku/react
This commit is contained in:
Sasha 2023-02-28 00:36:17 +01:00 committed by GitHub
parent 52e3fb4afa
commit 9f9fa8646b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 2407 additions and 22631 deletions

File diff suppressed because it is too large Load Diff

View File

@ -7,11 +7,12 @@
"@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.5",
"@waku/interfaces": "^0.0.7",
"@waku/peer-exchange": "^0.0.3",
"process": "^0.11.10",
"protons-runtime": "^3.1.0",

View File

@ -1,230 +1,51 @@
import { useEffect, useReducer, useState } from "react";
import "./App.css";
import handleCommand from "./command";
import Room from "./Room";
import { WakuContext } from "./WakuContext";
import { ThemeProvider } from "@livechat/ui-kit";
import { generate } from "server-name-generator";
import { Message } from "./Message";
import { wakuDnsDiscovery } from "@waku/dns-discovery";
import { wakuPeerExchangeDiscovery } from "@waku/peer-exchange";
import { waitForRemotePeer } from "@waku/core";
import { Protocols, WakuLight } from "@waku/interfaces";
import { createLightNode } from "@waku/create";
import { DecodedMessage, Decoder } from "@waku/core/lib/message/version_0";
import { PageDirection } from "@waku/interfaces";
import { PageDirection, LightNode } from "@waku/interfaces";
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",
},
},
};
import { useWaku, useContentPair } from "@waku/react";
export const ChatContentTopic = "/toy-chat/2/huilong/proto";
const ChatDecoder = new Decoder(ChatContentTopic);
import { useMessages, usePersistentNick } from "./hooks";
async function retrieveStoreMessages(
waku: WakuLight,
setArchivedMessages: (value: Message[]) => void
): Promise<void> {
const startTime = new Date();
// Only retrieve a week of history
startTime.setTime(Date.now() - 1000 * 60 * 60 * 24 * 7);
const endTime = new Date();
try {
for await (const messagesPromises of waku.store.queryGenerator(
[ChatDecoder],
{
pageSize: 5,
pageDirection: PageDirection.FORWARD,
timeFilter: {
startTime,
endTime,
},
}
)) {
const wakuMessages = await Promise.all(messagesPromises);
const messages: Message[] = [];
wakuMessages
.filter(isMessageDefined)
.map((wakuMsg) => Message.fromWakuMessage(wakuMsg))
.forEach((message) => {
if (message) {
messages.push(message);
}
});
setArchivedMessages(messages);
}
} catch (e) {
console.log("Failed to retrieve messages", e);
}
}
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 [messages, dispatchMessages] = useReducer(reduceMessages, []);
const [waku, setWaku] = useState<WakuLight | undefined>(undefined);
const [nick, setNick] = useState<string>(() => {
const persistedNick = window.localStorage.getItem("nick");
return persistedNick !== null ? persistedNick : generate();
});
const [historicalMessagesRetrieved, setHistoricalMessagesRetrieved] =
useState(false);
useEffect(() => {
localStorage.setItem("nick", nick);
}, [nick]);
useEffect(() => {
initWaku(setWaku)
.then(() => console.log("Waku init done"))
.catch((e) => console.log("Waku init failed ", e));
}, []);
useEffect(() => {
if (!waku) return;
// Let's retrieve previous messages before listening to new messages
if (!historicalMessagesRetrieved) return;
const handleIncomingMessage = (wakuMsg: DecodedMessage) => {
console.log("Message received: ", wakuMsg);
const msg = Message.fromWakuMessage(wakuMsg);
if (msg) {
dispatchMessages([msg]);
}
};
let unsubscribe: undefined | (() => Promise<void>);
waku.filter.subscribe([ChatDecoder], handleIncomingMessage).then(
(_unsubscribe) => {
console.log("subscribed to ", ChatContentTopic);
unsubscribe = _unsubscribe;
const { node } = useWaku<LightNode>();
const { decoder } = useContentPair();
const [messages, pushLocalMessages] = useMessages({
node,
decoder,
options: {
pageSize: 5,
pageDirection: PageDirection.FORWARD,
timeFilter: {
startTime,
endTime,
},
(e) => {
console.error("Failed to subscribe", e);
}
);
},
});
return function cleanUp() {
if (!waku) return;
if (typeof unsubscribe === "undefined") return;
unsubscribe().then(
() => {
console.log("unsubscribed to ", ChatContentTopic);
},
(e) => console.error("Failed to unsubscribe", e)
);
};
}, [waku, historicalMessagesRetrieved]);
const [nick, setNick] = usePersistentNick();
useEffect(() => {
if (!waku) return;
if (historicalMessagesRetrieved) return;
const retrieveMessages = async () => {
await waitForRemotePeer(waku, [
Protocols.Store,
Protocols.Filter,
Protocols.LightPush,
]);
console.log(`Retrieving archived messages`);
try {
retrieveStoreMessages(waku, dispatchMessages).then((length) => {
console.log(`Messages retrieved:`, length);
setHistoricalMessagesRetrieved(true);
});
} catch (e) {
console.log(`Error encountered when retrieving archived messages`, e);
}
};
retrieveMessages();
}, [waku, historicalMessagesRetrieved]);
const onCommand = (text: string): void => {
handleCommand(text, node, setNick).then(({ command, response }) => {
const commandMessages = response.map((msg) => {
return Message.fromUtf8String(command, msg);
});
pushLocalMessages(commandMessages);
});
};
return (
<div
className="chat-app"
style={{ height: "100vh", width: "100vw", overflow: "hidden" }}
>
<WakuContext.Provider value={{ waku: waku }}>
<ThemeProvider theme={themes}>
<Room
nick={nick}
messages={messages}
commandHandler={(input: string) => {
handleCommand(input, waku, setNick).then(
({ command, response }) => {
const commandMessages = response.map((msg) => {
return Message.fromUtf8String(command, msg);
});
dispatchMessages(commandMessages);
}
);
}}
/>
</ThemeProvider>
</WakuContext.Provider>
<Room nick={nick} messages={messages} commandHandler={onCommand} />
</div>
);
}
async function initWaku(setter: (waku: WakuLight) => void) {
try {
const publicKey = "AOGECG2SPND25EEFMAJ5WF3KSGJNSGV356DSTL2YVLLZWIV6SAYBM";
const fqdn = "test.waku.nodes.status.im";
const enrTree = `enrtree://${publicKey}@${fqdn}`;
const waku = await createLightNode({
libp2p: {
peerDiscovery: [
wakuDnsDiscovery(enrTree, {
store: 1,
filter: 2,
lightpush: 2,
}),
wakuPeerExchangeDiscovery(),
],
},
});
await waku.start();
setter(waku);
} catch (e) {
console.log("Issue starting waku ", e);
}
}
function reduceMessages(state: Message[], newMessages: Message[]) {
return state.concat(newMessages);
}
const isMessageDefined = (
msg: DecodedMessage | undefined
): msg is DecodedMessage => {
return !!msg;
};

View File

@ -1,4 +1,4 @@
import { memo, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import {
Message as LiveMessage,
MessageText,
@ -10,8 +10,6 @@ interface Props {
messages: Message[];
}
memo(ChatList);
export default function ChatList(props: Props) {
const renderedMessages = props.messages.map((message) => (
<LiveMessage

View File

@ -1,4 +1,4 @@
import { DecodedMessage } from "@waku/core/lib/message/version_0";
import { IDecodedMessage } from "@waku/interfaces";
import { ChatMessage } from "./chat_message";
export class Message {
@ -11,7 +11,7 @@ export class Message {
this.sentTimestamp = sentTimestamp;
}
static fromWakuMessage(wakuMsg: DecodedMessage): Message | undefined {
static fromWakuMessage(wakuMsg: IDecodedMessage): Message | undefined {
if (wakuMsg.payload) {
try {
const chatMsg = ChatMessage.decode(wakuMsg.payload);

View File

@ -1,5 +1,6 @@
import { ChangeEvent, KeyboardEvent, useEffect, useState } from "react";
import { useWaku } from "./WakuContext";
import { useWaku } from "@waku/react";
import { LightNode } from "@waku/interfaces";
import {
TextInput,
TextComposer,
@ -10,58 +11,54 @@ import {
} from "@livechat/ui-kit";
interface Props {
hasLightPushPeers: boolean;
sendMessage: ((msg: string) => Promise<void>) | undefined;
}
export default function MessageInput(props: Props) {
const [inputText, setInputText] = useState<string>("");
const [activeButton, setActiveButton] = useState<boolean>(false);
const { waku } = useWaku();
const { hasLightPushPeers } = props;
const { node } = useWaku<LightNode>();
const sendMessage = async () => {
const [inputText, setInputText] = useState<string>("");
const [isActive, setActiveButton] = useState<boolean>(false);
const onMessage = async () => {
if (props.sendMessage) {
await props.sendMessage(inputText);
setInputText("");
}
};
const messageHandler = (event: ChangeEvent<HTMLInputElement>) => {
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setInputText(event.target.value);
};
const keyPressHandler = async (event: KeyboardEvent<HTMLInputElement>) => {
const onKeyDown = async (event: KeyboardEvent<HTMLInputElement>) => {
if (
event.key === "Enter" &&
!event.altKey &&
!event.ctrlKey &&
!event.shiftKey
) {
await sendMessage();
await onMessage();
}
};
// Enable the button if there are peers available or the user is sending a command
useEffect(() => {
if (inputText.startsWith("/")) {
if (inputText.startsWith("/") || hasLightPushPeers) {
setActiveButton(true);
} else if (waku) {
(async () => {
const peers = await waku.lightPush.peers();
if (!!peers) {
setActiveButton(true);
} else {
setActiveButton(false);
}
})();
} else if (node) {
setActiveButton(false);
}
}, [activeButton, inputText, waku]);
}, [node, inputText, hasLightPushPeers]);
return (
<TextComposer
onKeyDown={keyPressHandler}
onChange={messageHandler}
active={activeButton}
onButtonClick={sendMessage}
onKeyDown={onKeyDown}
onChange={onChange}
active={isActive}
onButtonClick={onMessage}
>
<Row align="center">
<Fill>

View File

@ -1,13 +1,11 @@
import type { Message as WakuMessage } from "@waku/interfaces";
import { ChatContentTopic } from "./App";
import type { LightNode } from "@waku/interfaces";
import ChatList from "./ChatList";
import MessageInput from "./MessageInput";
import { useWaku } from "./WakuContext";
import { useWaku, useContentPair, useLightPush, usePeers } from "@waku/react";
import { TitleBar } from "@livechat/ui-kit";
import { Message } from "./Message";
import { ChatMessage } from "./chat_message";
import { useEffect, useState } from "react";
import { Encoder } from "@waku/core/lib/message/version_0";
import { useNodePeers } from "./hooks";
interface Props {
messages: Message[];
@ -16,50 +14,39 @@ interface Props {
}
export default function Room(props: Props) {
const { waku } = useWaku();
const { node } = useWaku<LightNode>();
const { encoder } = useContentPair();
const { push: onPush } = useLightPush({ node, encoder });
const [storePeers, setStorePeers] = useState(0);
const [filterPeers, setFilterPeers] = useState(0);
const [lightPushPeers, setLightPushPeers] = useState(0);
const { bootstrapPeers, peerExchangePeers } = useNodePeers(node);
const { storePeers, filterPeers, lightPushPeers } = usePeers({ node });
const [bootstrapPeers, setBootstrapPeers] = useState(new Set<string>());
const [peerExchangePeers, setPeerExchangePeers] = useState(new Set<string>());
const onSend = async (text: string) => {
if (!onPush) {
return;
}
const ChatEncoder = new Encoder(ChatContentTopic);
useEffect(() => {
if (!waku) return;
// Update store peer when new peer connected & identified
waku.libp2p.peerStore.addEventListener("change:protocols", async (evt) => {
const { peerId } = evt.detail;
const tags = (await waku.libp2p.peerStore.getTags(peerId)).map(
(t) => t.name
if (text.startsWith("/")) {
props.commandHandler(text);
} else {
const timestamp = new Date();
const chatMessage = ChatMessage.fromUtf8String(
timestamp,
props.nick,
text
);
if (tags.includes("peer-exchange")) {
setPeerExchangePeers((peers) => new Set(peers).add(peerId.toString()));
} else {
setBootstrapPeers((peers) => new Set(peers).add(peerId.toString()));
}
const payload = chatMessage.encode();
const storePeers = await waku.store.peers();
setStorePeers(storePeers.length);
await onPush({ payload, timestamp });
}
};
const filterPeers = await waku.filter.peers();
setFilterPeers(filterPeers.length);
const lightPushPeersLength = orZero(lightPushPeers?.length);
const filterPeersLength = orZero(filterPeers?.length);
const storePeersLength = orZero(storePeers?.length);
const lightPushPeers = await waku.lightPush.peers();
setLightPushPeers(lightPushPeers.length);
});
}, [waku]);
useEffect(() => {
console.log("Bootstrap Peers:");
console.table(bootstrapPeers);
console.log("Peer Exchange Peers:");
console.table(peerExchangePeers);
}, [bootstrapPeers, peerExchangePeers]);
const peersMessage = `Peers: ${lightPushPeersLength} light push, ${filterPeersLength} filter, ${storePeersLength} store.`;
const bootstrapPeersMessage = `Bootstrap (DNS Discovery): ${bootstrapPeers.size}, Peer exchange: ${peerExchangePeers.size}. `;
return (
<div
@ -67,49 +54,16 @@ export default function Room(props: Props) {
style={{ height: "98vh", display: "flex", flexDirection: "column" }}
>
<TitleBar
leftIcons={[
`Peers: ${lightPushPeers} light push, ${filterPeers} filter, ${storePeers} store.`,
]}
rightIcons={[
`Bootstrap (DNS Discovery): ${bootstrapPeers.size}, Peer exchange: ${peerExchangePeers.size}. `,
"View console for more details.",
]}
leftIcons={[peersMessage]}
rightIcons={[bootstrapPeersMessage, "View console for more details."]}
title="Waku v2 chat app"
/>
<ChatList messages={props.messages} />
<MessageInput
sendMessage={
waku
? async (messageToSend) => {
return handleMessage(
messageToSend,
props.nick,
props.commandHandler,
async (msg) => {
await waku.lightPush.push(ChatEncoder, msg);
}
);
}
: undefined
}
/>
<MessageInput hasLightPushPeers={!!lightPushPeers} sendMessage={onSend} />
</div>
);
}
async function handleMessage(
message: string,
nick: string,
commandHandler: (cmd: string) => void,
sender: (wakuMsg: Partial<WakuMessage>) => Promise<void>
) {
if (message.startsWith("/")) {
commandHandler(message);
} else {
const timestamp = new Date();
const chatMessage = ChatMessage.fromUtf8String(timestamp, nick, message);
const payload = chatMessage.encode();
await sender({ payload, timestamp });
}
function orZero(value: undefined | number): number {
return value || 0;
}

View File

@ -1,9 +0,0 @@
import { createContext, useContext } from "react";
import type { WakuLight } from "@waku/interfaces";
export type WakuContextType = {
waku?: WakuLight;
};
export const WakuContext = createContext<WakuContextType>({ waku: undefined });
export const useWaku = () => useContext(WakuContext);

View File

@ -1,5 +1,5 @@
import { multiaddr } from "@multiformats/multiaddr";
import type { WakuLight } from "@waku/interfaces";
import type { LightNode } from "@waku/interfaces";
function help(): string[] {
return [
@ -21,7 +21,7 @@ function nick(
return [`New nick: ${nick}`];
}
function info(waku: WakuLight | undefined): string[] {
function info(waku: LightNode | undefined): string[] {
if (!waku) {
return ["Waku node is starting"];
}
@ -30,7 +30,7 @@ function info(waku: WakuLight | undefined): string[] {
function connect(
peer: string | undefined,
waku: WakuLight | undefined
waku: LightNode | undefined
): string[] {
if (!waku) {
return ["Waku node is starting"];
@ -55,7 +55,7 @@ function connect(
}
}
async function peers(waku: WakuLight | undefined): Promise<string[]> {
async function peers(waku: LightNode | undefined): Promise<string[]> {
if (!waku) {
return ["Waku node is starting"];
}
@ -82,7 +82,7 @@ async function peers(waku: WakuLight | undefined): Promise<string[]> {
return response;
}
function connections(waku: WakuLight | undefined): string[] {
function connections(waku: LightNode | undefined): string[] {
if (!waku) {
return ["Waku node is starting"];
}
@ -103,7 +103,7 @@ function connections(waku: WakuLight | undefined): string[] {
export default async function handleCommand(
input: string,
waku: WakuLight | undefined,
waku: LightNode | undefined,
setNick: (nick: string) => void
): Promise<{ command: string; response: string[] }> {
let response: string[] = [];

View File

@ -0,0 +1,13 @@
import { Protocols } from "@waku/interfaces";
export const CONTENT_TOPIC = "/toy-chat/2/huilong/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,
];

View File

@ -0,0 +1,92 @@
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;
// 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,
};
};

View File

@ -1,11 +1,65 @@
import React from "react";
import ReactDOM from "react-dom";
import { ThemeProvider } from "@livechat/ui-kit";
import { LightNodeProvider, ContentPairProvider } from "@waku/react";
import { wakuDnsDiscovery } from "@waku/dns-discovery";
import { wakuPeerExchangeDiscovery } from "@waku/peer-exchange";
import "./index.css";
import App from "./App";
import { ENR_TREE, CONTENT_TOPIC, PROTOCOLS } from "./config";
const NODE_OPTIONS = {
libp2p: {
peerDiscovery: [
wakuDnsDiscovery(ENR_TREE, {
store: 1,
filter: 2,
lightpush: 2,
}),
wakuPeerExchangeDiscovery(),
],
},
};
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",
},
},
};
ReactDOM.render(
<React.StrictMode>
<App />
<ThemeProvider theme={THEMES}>
<LightNodeProvider options={NODE_OPTIONS} protocols={PROTOCOLS}>
<ContentPairProvider contentTopic={CONTENT_TOPIC}>
<App />
</ContentPairProvider>
</LightNodeProvider>
</ThemeProvider>
</React.StrictMode>,
document.getElementById("root")
);