今天會實作一個分散式的聊天室應用在 Avalanche's Fuji test-network
具體步驟會使用 Solidity 撰寫接收與存儲 Message 在 Avalanche's C-chain 上
並且為了比較好的使用者體會使用 Reactjs 實作一個 Web UI 介面
內容撰寫中
以下將依據功能類別依次介紹
定義了三個 function
定義了三個 function
為了讓使用者間可以傳輸訊息,定義了兩個 function
將會建立三種自定義結構:
另外會維護兩個集合來達成 chatroom
切換 Network 到 Custom RPC
設定 FUJI Testnet 如下
Network Name: Avalanche FUJI C-Chain
New RPC URL: https://api.avax-test.network/ext/bc/C/rpc
ChainID: 43113
Symbol: C-AVAX
Explorer: https://cchain.explorer.avax-test.network
透過 faucet 取得測試幣
建立 Database.sol 如下
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract Database {
// Stores the default name of an user and her friends info
struct user {
string name;
friend[] friendList;
}
// Each friend is identified by its address and name assigned by the second party
struct friend {
address pubkey;
string name;
}
// message construct stores the single chat message and its metadata
struct message {
address sender;
uint256 timestamp;
string msg;
}
// Collection of users registered on the application
mapping(address => user) userList;
// Collection of messages communicated in a channel between two users
mapping(bytes32 => message[]) allMessages; // key : Hash(user1,user2)
// It checks whether a user(identified by its public key)
// has created an account on this application or not
function checkUserExists(address pubkey) public view returns(bool) {
return bytes(userList[pubkey].name).length > 0;
}
// Registers the caller(msg.sender) to our app with a non-empty username
function createAccount(string calldata name) external {
require(checkUserExists(msg.sender)==false, "User already exists!");
require(bytes(name).length>0, "Username cannot be empty!");
userList[msg.sender].name = name;
}
// Returns the default name provided by an user
function getUsername(address pubkey) external view returns(string memory) {
require(checkUserExists(pubkey), "User is not registered!");
return userList[pubkey].name;
}
// Adds new user as your friend with an associated nickname
function addFriend(address friend_key, string calldata name) external {
require(checkUserExists(msg.sender), "Create an account first!");
require(checkUserExists(friend_key), "User is not registered!");
require(msg.sender!=friend_key, "Users cannot add themselves as friends!");
require(checkAlreadyFriends(msg.sender,friend_key)==false, "These users are already friends!");
_addFriend(msg.sender, friend_key, name);
_addFriend(friend_key, msg.sender, userList[msg.sender].name);
}
// Checks if two users are already friends or not
function checkAlreadyFriends(address pubkey1, address pubkey2) internal view returns(bool) {
if(userList[pubkey1].friendList.length > userList[pubkey2].friendList.length)
{
address tmp = pubkey1;
pubkey1 = pubkey2;
pubkey2 = tmp;
}
for(uint i=0; i<userList[pubkey1].friendList.length; ++i)
{
if(userList[pubkey1].friendList[i].pubkey == pubkey2)
return true;
}
return false;
}
// A helper function to update the friendList
function _addFriend(address me, address friend_key, string memory name) internal {
friend memory newFriend = friend(friend_key,name);
userList[me].friendList.push(newFriend);
}
// Returns list of friends of the sender
function getMyFriendList() external view returns(friend[] memory) {
return userList[msg.sender].friendList;
}
// Returns a unique code for the channel created between the two users
// Hash(key1,key2) where key1 is lexicographically smaller than key2
function _getChatCode(address pubkey1, address pubkey2) internal pure returns(bytes32) {
if(pubkey1 < pubkey2)
return keccak256(abi.encodePacked(pubkey1, pubkey2));
else
return keccak256(abi.encodePacked(pubkey2, pubkey1));
}
// Sends a new message to a given friend
function sendMessage(address friend_key, string calldata _msg) external {
require(checkUserExists(msg.sender), "Create an account first!");
require(checkUserExists(friend_key), "User is not registered!");
require(checkAlreadyFriends(msg.sender,friend_key), "You are not friends with the given user");
bytes32 chatCode = _getChatCode(msg.sender, friend_key);
message memory newMsg = message(msg.sender, block.timestamp, _msg);
allMessages[chatCode].push(newMsg);
}
// Returns all the chat messages communicated in a channel
function readMessage(address friend_key) external view returns(message[] memory) {
bytes32 chatCode = _getChatCode(msg.sender, friend_key);
return allMessages[chatCode];
}
}
透過以下指令建構 avalance-chat-app
npx create-react-app avalance-chat-app
安裝所需要的套件
cd avalance-chat-app; yarn add ethers@5.1.4 react-bootstrap@1.5.2 bootstrap@4.6.0
修改 index.html 如下
<!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" />
<title>Chat dApp</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
建立一個資料夾 components
內部建立 4 個 jsx 元件如下
import React from "react";
import { Row, Card } from "react-bootstrap";
export function Message(props) {
return (
<Row style={{ marginRight: "0px" }}>
<Card
border="success"
style={{
width: "80%",
alignSelf: "center",
margin: "0 0 5px " + props.marginLeft,
float: "right",
right: "0px",
}}
>
<Card.Body>
<h6 style={{ float: "right" }}>{props.timeStamp}</h6>
<Card.Subtitle>
<b>{props.sender}</b>
</Card.Subtitle>
<Card.Text>{props.data}</Card.Text>
</Card.Body>
</Card>
</Row>
);
}
import React from "react";
import { Button, Navbar } from "react-bootstrap";
export function NavBar(props) {
return (
<Navbar bg="dark" variant="dark">
<Navbar.Brand href="#home">Avalanche Chat App</Navbar.Brand>
<Navbar.Toggle />
<Navbar.Collapse className="justify-content-end">
<Navbar.Text>
<Button
style={{ display: props.showButton }}
variant="success"
onClick={async () => {
props.login();
}}
>
Connect to Metamask
</Button>
<div
style={{ display: props.showButton === "none" ? "block" : "none" }}
>
Signed in as:
<a href="#">{props.username}</a>
</div>
</Navbar.Text>
</Navbar.Collapse>
</Navbar>
);
}
import React from "react";
import { Row, Card } from "react-bootstrap";
import "bootstrap/dist/css/bootstrap.min.css";
export function ChatCard(props) {
return (
<Row style={{ marginRight: "0px" }}>
<Card
border="success"
style={{ width: "100%", alignSelf: "center", marginLeft: "15px" }}
onClick={() => {
props.getMessages(props.publicKey);
}}
>
<Card.Body>
<Card.Title> {props.name} </Card.Title>
<Card.Subtitle>
{" "}
{props.publicKey.length > 20
? props.publicKey.substring(0, 20) + " ..."
: props.publicKey}{" "}
</Card.Subtitle>
</Card.Body>
</Card>
</Row>
);
}
import React from "react";
import { useState } from "react";
import { Button, Modal, Form } from "react-bootstrap";
export function AddNewChat(props) {
const [show, setShow] = useState(false);
const handleClose = () => setShow(false);
const handleShow = () => setShow(true);
return (
<div
className="AddNewChat"
style={{
position: "absolute",
bottom: "0px",
padding: "10px 45px 0 45px",
margin: "0 95px 0 0",
width: "97%",
}}
>
<Button variant="success" className="mb-2" onClick={handleShow}>
+ NewChat
</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title> Add New Friend </Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group>
<Form.Control
required
id="addPublicKey"
size="text"
type="text"
placeholder="Enter Friends Public Key"
/>
<br />
<Form.Control
required
id="addName"
size="text"
type="text"
placeholder="Name"
/>
<br />
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Close
</Button>
<Button
variant="primary"
onClick={() => {
props.addHandler(
document.getElementById("addName").value,
document.getElementById("addPublicKey").value
);
handleClose();
}}
>
Add Friend
</Button>
</Modal.Footer>
</Modal>
</div>
);
}
建立一個 Components.js 如下
export { NavBar } from "./NavBar";
export { AddNewChat } from "./AddNewChat";
export { Message } from "./Message";
export { ChatCard } from "./ChatCard";
更新 App.js 如下
import React from "react";
import { useState, useEffect } from "react";
import { Container, Row, Col, Card, Form, Button } from "react-bootstrap";
import {
NavBar,
ChatCard,
Message,
AddNewChat,
} from "./components/Components";
import { ethers } from "ethers";
import { abi } from "./abi";
const CONTRACT_ADDRESS = "0x56b5ce8646F90cbC94B1E90063b5749149C5b10A";
export function App(props) {
const [friends, setFriends] = useState(null);
const [myName, setMyName] = useState(null);
const [myPublicKey, setMyPublicKey] = useState(null);
const [activeChat, setActiveChat] = useState({
friendname: null,
publicKey: null,
});
const [activeChatMessages, setActiveChatMessages] = useState(null);
const [showConnectButton, setShowConnectButton] = useState("block");
const [myContract, setMyContract] = useState(null);
// Save the contents of abi in a variable
const contractABI = abi;
let provider;
let signer;
// Login to Metamask and check the if the user exists else creates one
async function login() {
let res = await connectToMetamask();
if (res === true) {
provider = new ethers.providers.Web3Provider(window.ethereum);
signer = provider.getSigner();
try {
const contract = new ethers.Contract(
CONTRACT_ADDRESS,
contractABI,
signer
);
setMyContract(contract);
const address = await signer.getAddress();
let present = await contract.checkUserExists(address);
let username;
if (present) username = await contract.getUsername(address);
else {
username = prompt("Enter a username", "Guest");
if (username === "") username = "Guest";
await contract.createAccount(username);
}
setMyName(username);
setMyPublicKey(address);
setShowConnectButton("none");
} catch (err) {
alert("CONTRACT_ADDRESS not set properly!");
}
} else {
alert("Couldn't connect to Metamask");
}
}
// Check if the Metamask connects
async function connectToMetamask() {
try {
await window.ethereum.enable();
return true;
} catch (err) {
return false;
}
}
// Add a friend to the users' Friends List
async function addChat(name, publicKey) {
try {
let present = await myContract.checkUserExists(publicKey);
if (!present) {
alert("Address not found: Ask them to join the app :)");
return;
}
try {
await myContract.addFriend(publicKey, name);
const frnd = { name: name, publicKey: publicKey };
setFriends(friends.concat(frnd));
} catch (err) {
alert(
"Friend already added! You can't be friends with the same person twice ;P"
);
}
} catch (err) {
alert("Invalid address!");
}
}
// Sends message to an user
async function sendMessage(data) {
if (!(activeChat && activeChat.publicKey)) return;
const receiverAddress = activeChat.publicKey;
await myContract.sendMessage(receiverAddress, data);
}
// Fetch chat messages with a friend
async function getMessage(friendsPublicKey) {
let nickname;
let messages = [];
friends.forEach((item) => {
if (item.publicKey === friendsPublicKey) nickname = item.name;
});
// Get messages
const data = await myContract.readMessage(friendsPublicKey);
data.forEach((item) => {
const timestamp = new Date(1000 * item[1].toNumber()).toUTCString();
messages.push({
publicKey: item[0],
timeStamp: timestamp,
data: item[2],
});
});
setActiveChat({ friendname: nickname, publicKey: friendsPublicKey });
setActiveChatMessages(messages);
}
// This executes every time page renders and when myPublicKey or myContract changes
useEffect(() => {
async function loadFriends() {
let friendList = [];
// Get Friends
try {
const data = await myContract.getMyFriendList();
data.forEach((item) => {
friendList.push({ publicKey: item[0], name: item[1] });
});
} catch (err) {
friendList = null;
}
setFriends(friendList);
}
loadFriends();
}, [myPublicKey, myContract]);
// Makes Cards for each Message
const Messages = activeChatMessages
? activeChatMessages.map((message) => {
let margin = "5%";
let sender = activeChat.friendname;
if (message.publicKey === myPublicKey) {
margin = "15%";
sender = "You";
}
return (
<Message
marginLeft={margin}
sender={sender}
data={message.data}
timeStamp={message.timeStamp}
/>
);
})
: null;
// Displays each card
const chats = friends
? friends.map((friend) => {
return (
<ChatCard
publicKey={friend.publicKey}
name={friend.name}
getMessages={(key) => getMessage(key)}
/>
);
})
: null;
return (
<Container style={{ padding: "0px", border: "1px solid grey" }}>
{/* This shows the navbar with connect button */}
<NavBar
username={myName}
login={async () => login()}
showButton={showConnectButton}
/>
<Row>
{/* Here the friends list is shown */}
<Col style={{ paddingRight: "0px", borderRight: "2px solid #000000" }}>
<div
style={{
backgroundColor: "#DCDCDC",
height: "100%",
overflowY: "auto",
}}
>
<Row style={{ marginRight: "0px" }}>
<Card
style={{
width: "100%",
alignSelf: "center",
marginLeft: "15px",
}}
>
<Card.Header>Chats</Card.Header>
</Card>
</Row>
{chats}
<AddNewChat
myContract={myContract}
addHandler={(name, publicKey) => addChat(name, publicKey)}
/>
</div>
</Col>
<Col xs={8} style={{ paddingLeft: "0px" }}>
<div style={{ backgroundColor: "#DCDCDC", height: "100%" }}>
{/* Chat header with refresh button, username and public key are rendered here */}
<Row style={{ marginRight: "0px" }}>
<Card
style={{
width: "100%",
alignSelf: "center",
margin: "0 0 5px 15px",
}}
>
<Card.Header>
{activeChat.friendname} : {activeChat.publicKey}
<Button
style={{ float: "right" }}
variant="warning"
onClick={() => {
if (activeChat && activeChat.publicKey)
getMessage(activeChat.publicKey);
}}
>
Refresh
</Button>
</Card.Header>
</Card>
</Row>
{/* The messages will be shown here */}
<div
className="MessageBox"
style={{ height: "400px", overflowY: "auto" }}
>
{Messages}
</div>
{/* The form with send button and message input fields */}
<div
className="SendMessage"
style={{
borderTop: "2px solid black",
position: "relative",
bottom: "0px",
padding: "10px 45px 0 45px",
margin: "0 95px 0 0",
width: "97%",
}}
>
<Form
onSubmit={(e) => {
e.preventDefault();
sendMessage(document.getElementById("messageData").value);
document.getElementById("messageData").value = "";
}}
>
<Form.Row className="align-items-center">
<Col xs={9}>
<Form.Control
id="messageData"
className="mb-2"
placeholder="Send Message"
/>
</Col>
<Col>
<Button
className="mb-2"
style={{ float: "right" }}
onClick={() => {
sendMessage(
document.getElementById("messageData").value
);
document.getElementById("messageData").value = "";
}}
>
Send
</Button>
</Col>
</Form.Row>
</Form>
</div>
</div>
</Col>
</Row>
</Container>
);
}
特別要注意的是 CONTRACT_ADDRESS 的值是用 REMIX deploy Contract 的 address
而 abi 也需要根據 compile 之後的 abi 來引入如下
export const abi = [
{
"inputs": [
{
"internalType": "address",
"name": "friend_key",
"type": "address"
},
{
"internalType": "string",
"name": "name",
"type": "string"
}
],
"name": "addFriend",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "pubkey",
"type": "address"
}
],
"name": "checkUserExists",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "name",
"type": "string"
}
],
"name": "createAccount",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "getMyFriendList",
"outputs": [
{
"components": [
{
"internalType": "address",
"name": "pubkey",
"type": "address"
},
{
"internalType": "string",
"name": "name",
"type": "string"
}
],
"internalType": "struct Database.friend[]",
"name": "",
"type": "tuple[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "pubkey",
"type": "address"
}
],
"name": "getUsername",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "friend_key",
"type": "address"
}
],
"name": "readMessage",
"outputs": [
{
"components": [
{
"internalType": "address",
"name": "sender",
"type": "address"
},
{
"internalType": "uint256",
"name": "timestamp",
"type": "uint256"
},
{
"internalType": "string",
"name": "msg",
"type": "string"
}
],
"internalType": "struct Database.message[]",
"name": "",
"type": "tuple[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "friend_key",
"type": "address"
},
{
"internalType": "string",
"name": "_msg",
"type": "string"
}
],
"name": "sendMessage",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
];
這個 dApp 需要使用者具有在 AVAX 鏈上具有 AVAX token 才能使用
而每次運算都需要耗費 fee
因此不能算是很使用者友善
唯一可以有幫助的是 每個對會都會被紀錄在 Contract 上
https://learn.figment.io/tutorials/create-a-chat-application-using-solidity-and-react#introduction