Good morning!
In this tutorial we will create a simple chat dapp on Soroban Testnet (LIVE VERSION HERE 🕺). We will be using the create-soroban-dapp boilerplate and we will cover the smart contract implementation, the deployment using the deploy script provided and finally how to set up and connect the front-end.
What we should manage to build:
You can check the final source code in my repo.
First step: Initiate the boilerplate
We will initiate a new soroban react dapp project using the @create-soroban-dapp script.
Go to the directory you want to put the project in and run
npx create-soroban-dapp@latest
Then follow along the script to set the name of your project, your favorite package manager and whether you want to have your dependencies installed by the script or manually.
Arriving on the project, you can personalize the project with your own info and project name. You can follow the Soroban-Dapp-Instructions.md
for this. To change the displayed title you can go in src/components/home/HomePageTitle.tsx
.
After this you are all set to start working on your dapp. You can run the development server to see the initial state of the dapp.
Contract creation
You can start by copy pasting the existing greeting contract folder to have a point to start.
Then change the repository name:
And the package name and author in the cargo.toml
file:
Implement the contract
So we have this very simple Greeting contract template to start from, it's often a good idea to start from an existing code if you are not too familiar with the language or the environment to see where to write what:
#![no_std]
use soroban_sdk::{contract, contractimpl, Env, Symbol, symbol_short, String};
const TITLE: Symbol = symbol_short!("TITLE");
#[contract]
pub struct TitleContract;
#[contractimpl]
impl TitleContract {
pub fn set_title(env: Env, title: String) {
env.storage().instance().set(&TITLE, &title)
}
pub fn read_title(env: Env) -> String {
env.storage().instance().get(&TITLE)
.unwrap_or(String::from_slice(&env, "Default Title"))
}
}
mod test;
Now let's reflect on what we want in our contract.
Storage Items
What is a chat?
First we need to define what is the base data type we will use for representing a chat.
Nowadays, every messaging app is built around having a "conversation" with someone composed of an ordered list of "messages". These conversations being sorted by destination's user we are having conversation with.
So here we can decide that we will represent every message by a struct containing a string and a from address representing the address that sent the chat. And every conversation can be a list of messages so the rust type Vec<Message>
:
#[contracttype]
#[derive(Clone, Debug)]
pub struct Message {
msg: String,
from: Address,
}
type Conversation = Vec<Message>;
How to store a chat
We want to have every conversation sorted by destination address in order to have something close to the intuitive UI we are all so used to.
We can think of storing the conversations in a Map where the key is a tuple (address, address)
and the value is a conversation i.e. Vec<Message>
.
Let's add this line to define the key we will use to retrieve and write the storage. A good practice is to define a DataKey enum to store all the keys used in the contract:
#[derive(Clone)]
#[contracttype]
pub struct ConversationsKey(pub Address, pub Address);
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Conversations(ConversationsKey),
}
Don't forget to add the necessary imports
#![no_std]
use soroban_sdk::{contracttype, Address};
Then every time we want to access the conversations between address1 and address2 we will write
let key = DataKey::Conversations(ConversationsKey(address1,address2));
// To read
if let Some(conversation) = env.storage().instance().get::<_, Conversation>(&key) { /* do things */ }
// To write
env.storage().instance().set(&key.clone(), &conversation);
Little trick for helping us in the front-end
A good thing to have when doing full stack development is to be able to think a bit forward in the process and anticipate what could help us build the front-end more easily.
When we will want to retrieve the chats for a given address it will be difficult to do it with only the conversations mapping. We could use another mapping to store for each user, what are the chats they have already started with someone.
We can add an additional key to our DataKey enum:
type ConversationsInitiated = Vec<Address>;
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Conversations(ConversationsKey),
ConversationsInitiated(Address) // We add this line
}
Define the contract struct and implementation
Let's now remove this greeting contract to replace it with our chat contract, so that we now have in our lib.rs
file:
#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec};
#[derive(Clone)]
#[contracttype]
pub struct ConversationsKey(pub Address, pub Address);
#[contracttype]
#[derive(Clone, Debug)]
pub struct Message {
msg: String,
from: Address,
}
type Conversation = Vec<Message>;
type ConversationsInitiated = Vec<Address>;
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Conversations(ConversationsKey),
ConversationsInitiated(Address),
}
#[contract]
pub struct ChatContract;
#[contractimpl]
impl ChatContract { }
mod test;
Implementing our first function: Write a message
Now, we are getting towards the functional implementation of the contract. Let's implement the write_message function.
Let's have a look at it
pub fn write_message(env: Env, from: Address, to: Address, msg: String) {
// Ensure that the message sender is the from address
from.require_auth();
// First we need to retrieve the possibly already existing conversation between from and to
let key = DataKey::Conversations(ConversationsKey(from.clone(), to.clone()));
// We want to update the Conversation Initiated storage if it's the first time we have a conversation between from and to
let conversation_exists =
env.storage().instance().has(&key) && env.storage().instance().has(&key);
if !conversation_exists {
update_conversations_initiated(&env, from.clone(), to.clone())
}
// Then we can retrieve the conversation
let mut conversation = env
.storage()
.instance()
.get::<_, Conversation>(&key)
.unwrap_or(vec![&env]);
// Then we can add a new message to the conversation
let new_message = Message {
msg,
from: from.clone(),
};
conversation.push_back(new_message);
// And we don't forget to set the state storage with the new value ON BOTH SIDES if not conversation to self
env.storage().instance().set(&key.clone(), &conversation);
if from != to {
let key_other_side = DataKey::Conversations(ConversationsKey(to.clone(), from.clone()));
env.storage()
.instance()
.set(&key_other_side.clone(), &conversation);
}
}
First we are ensuring that the from
address correspond to the actual sender of the transaction to avoid impersonating. This is done via the require_auth
method:
from.require_auth();
Then we are checking if the conversation exists with the has()
method which returns a boolean:
let conversation_exists =
env.storage().instance().has(&key) && env.storage().instance().has(&key);
Then if the conversation does not exist already we add it to the list of the initiated adresses for both addresses from
and to
:
if !conversation_exists {
update_conversations_initiated(&env, from.clone(), to.clone())
}
The implementation of the helper method update_conversations_initiated
is the following:
pub fn update_conversations_initiated(env: &Env, from: Address, to: Address) {
let mut conversations_initiated_from = env
.storage()
.instance()
.get::<_, ConversationsInitiated>(&DataKey::ConversationsInitiated(from.clone()))
.unwrap_or(vec![&env]);
conversations_initiated_from.push_back(to.clone());
env.storage().instance().set(
&DataKey::ConversationsInitiated(from.clone()),
&conversations_initiated_from,
);
// If we are sending chat to ourselves, we don't want to have two different conversations
if from != to {
let mut conversations_initiated_to = env
.storage()
.instance()
.get::<_, ConversationsInitiated>(&DataKey::ConversationsInitiated(to.clone()))
.unwrap_or(vec![&env]);
conversations_initiated_to.push_back(from.clone());
env.storage().instance().set(
&DataKey::ConversationsInitiated(to.clone()),
&conversations_initiated_to,
);
}
}
In this helper method we are retrieving the list of the initiated conversations from both the from
and to
addresses to push_back
the new conversation address to the Vec
and then set it back in the storage. You will notice the condition if from != to
for the second action. It is because, in the case where we are sending a message to ourselves (it's allowed here) we will have from
equal to from
and it will result in putting twice the from
address to the list of initiated conversations, and we want to avoid this.
Then we are retrieving the conversation from the storage
let key = DataKey::Conversations(ConversationsKey(from, to));
let mut conversation = env
.storage()
.instance()
.get::<_, Conversation>(&key)
.unwrap_or(vec![&env]);
We can see that we are using the DataKey enum to retrieve the conversation that we are interested in.
The get function is returning an Option that we can unwrap or provide a value if the Option is None. Here the option is none if there is not a conversation initiated, in this situation we provide an empty Vec using the vec! macro.
Then we create the new message and we add it to the conversation:
let new_message = Message {
msg,
from: from.clone(),
};
conversation.push_back(new_message);
Finally, we put the conversation back in the storage. We once again check if we are not sending a message to ourselves so that we don't do the useless action of setting the same storage twice:
env.storage().instance().set(&key.clone(), &conversation);
if from != to {
let key_other_side = DataKey::Conversations(ConversationsKey(to.clone(), from.clone()));
env.storage()
.instance()
.set(&key_other_side.clone(), &conversation);
}
Add functions to read the state of the contract
We now need to add some getter function to retrieve the data on the frontend, we add one for each storage type:
pub fn read_conversation(env: Env, from: Address, to: Address) -> Conversation {
let key = DataKey::Conversations(ConversationsKey(from.clone(), to.clone()));
let conversation = env
.storage()
.instance()
.get::<_, Conversation>(&key)
.unwrap_or(vec![&env]);
conversation
}
pub fn read_conversations_initiated(env: Env, from: Address) -> ConversationsInitiated {
let key = DataKey::ConversationsInitiated(from);
let conversations_initiated = env
.storage()
.instance()
.get::<_, ConversationsInitiated>(&key)
.unwrap_or(vec![&env]);
conversations_initiated
}
I will add here one last function which is here just for the sake of helping us see if we connect well the contract when we start implementing the front-end part:
pub fn read_title(env: Env) -> String {
String::from_slice(&env, "Welcome to Sorochat!")
}
You can notice that it is the same function that the one which was present on the greeting contract. It's on purpose it is to test the connection without having to change the front-end.
Final contract
We can have a look at the final version of the contract to see if we didn't miss anything:
#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, String, Vec};
#[derive(Clone)]
#[contracttype]
pub struct ConversationsKey(pub Address, pub Address);
#[contracttype]
#[derive(Clone, Debug)]
pub struct Message {
msg: String,
from: Address,
}
type Conversation = Vec<Message>;
type ConversationsInitiated = Vec<Address>;
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
Conversations(ConversationsKey),
ConversationsInitiated(Address),
}
pub fn update_conversations_initiated(env: &Env, from: Address, to: Address) {
let mut conversations_initiated_from = env
.storage()
.instance()
.get::<_, ConversationsInitiated>(&DataKey::ConversationsInitiated(from.clone()))
.unwrap_or(vec![&env]);
conversations_initiated_from.push_back(to.clone());
env.storage().instance().set(
&DataKey::ConversationsInitiated(from.clone()),
&conversations_initiated_from,
);
// If we are sending chat to ourselves, we don't want to have two different conversations
if from != to {
let mut conversations_initiated_to = env
.storage()
.instance()
.get::<_, ConversationsInitiated>(&DataKey::ConversationsInitiated(to.clone()))
.unwrap_or(vec![&env]);
conversations_initiated_to.push_back(from.clone());
env.storage().instance().set(
&DataKey::ConversationsInitiated(to.clone()),
&conversations_initiated_to,
);
}
}
#[contract]
pub struct ChatContract;
#[contractimpl]
impl ChatContract {
pub fn write_message(env: Env, from: Address, to: Address, msg: String) {
// Ensure that the message sender is the from address
from.require_auth();
// First we need to retrieve the possibly already existing conversation between from and to
let key = DataKey::Conversations(ConversationsKey(from.clone(), to.clone()));
// We want to update the Conversation Initiated storage if it's the first time we have a conversation between from and to
let conversation_exists =
env.storage().instance().has(&key) && env.storage().instance().has(&key);
if !conversation_exists {
update_conversations_initiated(&env, from.clone(), to.clone())
}
// Then we can retrieve the conversation
let mut conversation = env
.storage()
.instance()
.get::<_, Conversation>(&key)
.unwrap_or(vec![&env]);
// Then we can add a new message to the conversation
let new_message = Message {
msg,
from: from.clone(),
};
conversation.push_back(new_message);
// And we don't forget to set the state storage with the new value ON BOTH SIDES if not conversation to self
env.storage().instance().set(&key.clone(), &conversation);
if from != to {
let key_other_side = DataKey::Conversations(ConversationsKey(to.clone(), from.clone()));
env.storage()
.instance()
.set(&key_other_side.clone(), &conversation);
}
}
pub fn read_conversation(env: Env, from: Address, to: Address) -> Conversation {
let key = DataKey::Conversations(ConversationsKey(from.clone(), to.clone()));
let conversation = env
.storage()
.instance()
.get::<_, Conversation>(&key)
.unwrap_or(vec![&env]);
conversation
}
pub fn read_conversations_initiated(env: Env, from: Address) -> ConversationsInitiated {
let key = DataKey::ConversationsInitiated(from);
let conversations_initiated = env
.storage()
.instance()
.get::<_, ConversationsInitiated>(&key)
.unwrap_or(vec![&env]);
conversations_initiated
}
pub fn read_title(env: Env) -> String {
String::from_slice(&env, "Welcome to Sorochat!")
}
}
mod test;
Test the contract
It's good practices to write unit tests along the development of your code. I will not dive in this tutorial on how to do testing with Soroban but here are the (non exhaustive) tests I wrote for this contract:
#![cfg(test)]
use crate::{ChatContract, ChatContractClient, Message};
use soroban_sdk::{testutils::Address as _, vec, Address, Env, String};
#[test]
fn test() {
let env = Env::default();
env.mock_all_auths();
let from = Address::random(&env);
let to = Address::random(&env);
let to_2 = Address::random(&env);
let contract_id = env.register_contract(None, ChatContract);
let client = ChatContractClient::new(&env, &contract_id);
let conversation_before = client.read_conversation(&from, &to);
assert_eq!(conversation_before.len(), 0);
let conversations_initiated = client.read_conversations_initiated(&from);
assert_eq!(conversations_initiated, vec![&env]);
client.write_message(&from, &to, &String::from_slice(&env, "Bonjour l'ami!"));
let conversation_after = client.read_conversation(&from, &to);
// log!(&env, "{:?}", conversation_after);
assert_eq!(conversation_after.len(), 1);
assert_eq!(
conversation_after,
vec![
&env,
Message {
msg: String::from_slice(&env, "Bonjour l'ami!"),
from: from.clone()
}
]
);
let conversations_initiated = client.read_conversations_initiated(&from);
assert_eq!(conversations_initiated, vec![&env, to.clone()]);
let conversations_initiated = client.read_conversations_initiated(&to);
assert_eq!(conversations_initiated, vec![&env, from.clone()]);
client.write_message(&from, &to_2, &String::from_slice(&env, "Bonjour l'ami!"));
let conversation_after = client.read_conversation(&from, &to_2);
assert_eq!(conversation_after.len(), 1);
assert_eq!(
conversation_after,
vec![
&env,
Message {
msg: String::from_slice(&env, "Bonjour l'ami!"),
from: from.clone()
}
]
);
let conversations_initiated = client.read_conversations_initiated(&from);
assert_eq!(
conversations_initiated,
vec![&env, to.clone(), to_2.clone()]
);
let conversations_initiated = client.read_conversations_initiated(&to_2);
assert_eq!(conversations_initiated, vec![&env, from]);
}
Deploy the contract
We will now deploy our contract on Soroban Testnet. create-soroban-dapp
equips us with a command line script to deploy the contract easily.
In the subdirectory contracts/ run
./deploy_on_testnet chat
You should see a bunch of information displayed as the script is first:
- Build your contract using the Makefile associated to your contract
- Set a deployer identity using soroban-cli
- Fund the deployer address
- Set the soroban-cli for testnet deployment
- Deploy contract on testnet
- Register contract's address in
contracts/contracts_ids.json
for the front-end to fetch it
Now you can check the contracts_ids.json
file and you should see the newly deployed contract address written inside.
Congrats you made it with the contract. Now the front-end work.
Front-end
So all the front-end things are happening in the src
folder.
This front end is configured to interact with the greeting contract which only exposes two functions read_title
and set_title
, the goal is to inspire from how the connection is done to adapt it to our contract which is exposing three different functions, one write function write_message
and to read functions read_conversation
and read_conversations_initiated
.
We can start by removing the ChainInfo
component from index.tsx
to make a bit of room for our project:
// src/pages/index.tsx
<div tw="mt-10 flex w-full flex-wrap items-start justify-center gap-4">
{/* Chain Metadata Information */}
{/* <ChainInfo /> */} // Commented this line
{/* Greeter Read/Write Contract Interactions */}
<GreeterContractInteractions />
</div>
Then we will test the connection to the contract deployed on testnet. For this we can just modify the line 55 of src/components/web3/GreeterContractInteractions.tsx
in fetchGreeting
from
// src/components/web3/GreeterContractInteractions.tsx 44
const contract = useRegisteredContract("greeting")
to
src/components/web3/GreeterContractInteractions.tsx 44
const contract = useRegisteredContract("chat")
Normally on recompiling, the front-end should display "Welcome to Sorochat!" in the first card. It means that the front-end and the contract are communicating!
Fetch the intiated conversation list.
Now we will adapt the fetchGreeting to make it able to fetch the list of conversations initiated for the connected user.
Let's check the code:
// src/components/web3/GreeterContractInteractions.tsx
// We add this state variable:
const [fetchedConversationsInitiatedList, setConversationsInitiatedList] = useState<Array<string>>([])
// and fetchGreeting on line 44 becomes:
const fetchConversationsInitiated = useCallback(async () => {
if (!sorobanContext.server) return
const currentChain = sorobanContext.activeChain?.name?.toLocaleLowerCase()
// We are now checking for the user to be connected before displaying as we need to know the user's address to fetch their chats
if (!address) {
return
}
else if (!currentChain) {
console.log("No active chain")
toast.error('Wallet not connected. Try again…')
return
}
else {
const contractAddress = (contracts_ids as Record<string,Record<string,string>>)[currentChain]?.chat;
setContractAddressStored(contractAddress)
try {
const result = await contractInvoke({
contractAddress,
method: 'read_conversations_initiated',
args: [new SorobanClient.Address(address).toScVal()],
sorobanContext
})
if (!result) throw new Error("Error while fetching. Try Again")
// Value needs to be cast into a string as we fetch a ScVal which is not readable as is.
const conversationsInitiated: Array<string> = SorobanClient.scValToNative(result as SorobanClient.xdr.ScVal) as Array<string>
setConversationsInitiatedList(conversationsInitiated)
} catch (e) {
console.error(e)
toast.error('Error while fetching list of conversations. Try again…')
setConversationsInitiatedList([])
} finally {
}
}
},[sorobanContext, address])
useEffect(() => {void fetchConversationsInitiated()}, [updateFrontend,fetchConversationsInitiated])
We can notice here than the behavior is very similar. The useEffect ensures that the function is called at every update on the connected address (address
). And the function stores the list of the addresses the connected user has already opened a conversation with in the fetchedConversationsInitiatedList
state variable.
Some important thing to also notice is how we convert normal types to ScVal using the soroban-client
methods:
args: [new SorobanClient.Address(address).toScVal()],
And how we translate back from a ScVal to a native typescript type:
const conversationsInitiated: Array<string> = SorobanClient.scValToNative(result as SorobanClient.xdr.ScVal) as Array<string>
Send message functionality
Let's implement the message sending now.
For that we will first adapt the form to take two arguments the message itself and the destination address (the from address being the connected user's address)
Here it comes, this
// src/components/web3/GreeterContractInteractions.tsx
<form onSubmit={handleSubmit(updateGreeting)}>
<Stack direction="row" spacing={2} align="end">
<FormControl>
<FormLabel>Update Greeting</FormLabel>
<Input disabled={updateIsLoading} {...register('newMessage')} />
</FormControl>
becomes this
// src/components/web3/GreeterContractInteractions.tsx
<form onSubmit={handleSubmit(sendMessage)}>
<Stack direction="row" spacing={2} align="end">
<FormControl>
<FormLabel>Send new Message</FormLabel>
<Input disabled={updateIsLoading} {...register('destinationAddress')} />
<Input disabled={updateIsLoading} placeholder='Message' {...register('newMessage')} />
</FormControl>
If you notice we added a new input to the form and we renamed the updateGreeting function to sendMessage.
So now we need to adapt the useForm
types. Use React Form
is a great implementation of react hooks for form handling. For more about the useForm
pattern check this out: Use React Form.
Check the modifications:
// This line:
const { register, handleSubmit } = useForm<UpdateGreetingValues>()
// Becomes this
const { register, handleSubmit } = useForm<NewMessageData>()
// While the UpdateGreetingValues type
type UpdateGreetingValues = { newMessage: string }
// Becomes the NewMessageData type:
type NewMessageData = { newMessage: string, destinationAddress: string }
So now that the form is adapted we need to adapt the updateGreeting
function to call the write_message
method of the contract. So the updateGreeting
function becomes:
// updateGreeting function becomes this
const sendMessage = async ({ newMessage, destinationAddress }: NewMessageData ) => {
if (!address) {
console.log("Address is not defined")
toast.error('Wallet is not connected. Try again...')
return
}
else if (!server) {
console.log("Server is not setup")
toast.error('Server is not defined. Unabled to connect to the blockchain')
return
}
else {
const currentChain = activeChain?.name?.toLocaleLowerCase()
if (!currentChain) {
console.log("No active chain")
toast.error('Wallet not connected. Try again…')
return
}
else {
const contractAddress = (contracts_ids as Record<string,Record<string,string>>)[currentChain]?.chat;
setUpdateIsLoading(true)
try {
const result = await contractInvoke({
contractAddress,
method: 'write_message',
args: [new SorobanClient.Address(address).toScVal(),new SorobanClient.Address(destinationAddress).toScVal(), stringToScVal(newMessage)],
sorobanContext,
signAndSend: true
})
if (result) {
toast.success("New chat successfully published!")
}
else {
toast.error("Chat publishing unsuccessful...")
}
} catch (e) {
console.error(e)
toast.error('Error while sending tx. Try again…')
} finally {
setUpdateIsLoading(false)
toggleUpdate(!updateFrontend)
}
await sorobanContext.connect();
}
}
}
Display the conversations list
Now that we can write a new message and fetch the updated list of conversation the connected user has initiated we can display the conversation list.
Let's create a new component. Create a new component in a new folder chat in src/components/chat/ConversationsList.tsx
.
The component will be the following:
import { Card } from "@chakra-ui/react";
interface ConversationsListProps {
conversationsList: Array<string>,
setDisplayedConversationAddress: (newDestinationAddress: string) => void
}
export default function ConversationsList({conversationsList, setDisplayedConversationAddress}:ConversationsListProps) {
return (
<>
{conversationsList.map((address: string, index: number) => (
<>
<Card variant="outline" key={index} p={2} bgColor="whiteAlpha.0"
onClick={() => setDisplayedConversationAddress(address)}
cursor="pointer"
_hover={{
background: "whiteAlpha.100",
}}
>
{address}
</Card>
</>
))}
</>
);
}
We can see here the introduction of a new state variable that we will put in the GreeterContractInteractions.tsx file and pass its setter as a prop to the Conversation element:
const [conversationDisplayedAddress, setConversationDisplayedAddress] = useState<string>("")
This conversationDisplayedAddress will be used for chosing which conversation to display at the screen.
Now let's add this component to the GreeterContractInteractions.tsx
file. Don't forget to import and replace the first Card
// src/components/web3/GreeterContractInteraction.tsx l146
{/* Fetched Greeting */}
<Card variant="outline" p={4} bgColor="whiteAlpha.100">
<FormControl>
<FormLabel>Fetched Greeting</FormLabel>
<Input
placeholder={fetchedGreeting}
disabled={true}
/>
</FormControl>
</Card>
by the newly created Element, passing the setter and the conversations list as props:
// src/components/web3/GreeterContractInteraction.tsx l146
<Card variant="outline" p={4} bgColor="whiteAlpha.100">
<FormControl>
<FormLabel>Your Chats</FormLabel>
<ConversationsList conversationsList={fetchedConversationsInitiatedList} setDisplayedConversationAddress={setConversationDisplayedAddress}></ConversationsList>
</FormControl>
</Card>
Try to send a few messages, you should see something like this on the screen appearing:
Display a conversation
Let's now display a conversation. We will add these two components
The Message one:
// src/components/chat/Message.tsx
import { Box, Text } from "@chakra-ui/react";
export type MessageType = {
msg: string,
from: string
}
interface MessageProps {
message: MessageType,
userConnected: string
}
export function Message({message,userConnected}:MessageProps) {
const bg_color = userConnected == message.from ? "whiteAlpha.200" : "whiteAlpha.500";
return (
<>
<Box ml='3' bg={bg_color} borderRadius="12" padding="4" margin="4" position="relative" >
<Text fontWeight='bold' fontSize={12}>
{message.from}
</Text>
<Text fontSize='sm'>{message.msg}</Text>
</Box>
<p></p>
</>
);
}
And the Conversation one which will rely on the Message Component to display messages:
import {type MessageType as ContractMessageType, Message} from './Message'
interface ConversationProps {
conversation: Array<ContractMessageType>,
destinationAddress: string,
userConnected: string
}
export default function Conversation({conversation, destinationAddress,userConnected}:ConversationProps) {
return (
destinationAddress ?
<div>Conversation with {destinationAddress}
{conversation.map((message: ContractMessageType, index: number) => (
<Message key={index} message={message} userConnected={userConnected}/>
))}
</div>
:
<div>Choose a conversation or send a message to new destination</div>
);
}
Now we need to implement the function to fetch the conversation from the user's address and the selected address. Add this to the GreeterContractInteraction.tsx file:
// src/components/web3/GreeterContractInteraction.tsx
const fetchConversation = useCallback(async () => {
if (!sorobanContext.server) return
if (!address) {
console.log("Address is not defined")
toast.error('Wallet is not connected. Try again...')
return
}
else if (!server) {
console.log("Server is not setup")
toast.error('Server is not defined. Unabled to connect to the blockchain')
return
}
else {
const currentChain = activeChain?.name?.toLocaleLowerCase()
if (!currentChain) {
console.log("No active chain")
toast.error('Wallet not connected. Try again…')
return
}
else if (conversationDisplayedAddress) {
const contractAddress = (contracts_ids as Record<string,Record<string,string>>)[currentChain]?.chat;
setConversationIsLoading(true)
try {
const result = await contractInvoke({
contractAddress,
method: 'read_conversation',
args: [new SorobanClient.Address(address).toScVal(),new SorobanClient.Address(conversationDisplayedAddress).toScVal()],
sorobanContext
})
if (!result) throw new Error("Error while fetching. Try Again")
// Value needs to be cast into a string as we fetch a ScVal which is not readable as is.
// You can check out the scValConversion.tsx file to see how it's done
console.log("CONVERSATION FETCHED =",SorobanClient.scValToNative(result as SorobanClient.xdr.ScVal))
const conversation = SorobanClient.scValToNative(result as SorobanClient.xdr.ScVal) as Array<MessageType>
// const result_string = scvalToString(result as SorobanClient.xdr.ScVal)
setConversationDisplayed(conversation)
} catch (e) {
console.error(e)
toast.error('Error while fetching conversation. Try again…')
setConversationDisplayed([])
} finally {
setConversationIsLoading(false)
}
}
}
}, [conversationDisplayedAddress])
useEffect(() => {void fetchConversation()}, [conversationDisplayedAddress,updateFrontend,fetchConversation,sorobanContext])
useEffect(() => {
setConversationDisplayedAddress("")
}, [address])
We can see that we are introducing two new state variables
const [conversationDisplayed, setConversationDisplayed] = useState<Array<MessageType>>([])
const [conversationIsLoading, setConversationIsLoading] = useState<boolean>(false)
conversationDisplayed
is the fetched conversation that we will pass in the props of the Conversation element.
conversationIsLoading
fills the same role as updateIsLoading
and is here to display a loading screen while the function fetches the conversation.
Finally we can add the Conversation element in the returned jsx:
// src/components/web3/GreeterContractInteractions.tsx
<Card variant="outline" p={4} bgColor="whiteAlpha.100">
{!conversationIsLoading ?
<Conversation userConnected={address} destinationAddress={conversationDisplayedAddress} conversation={conversationDisplayed}></Conversation>
:
"Loading ..."
}
</Card>
And we should get something like that:
Display only if address is connected
Finally as we need to user to connect, let's only display the connect button if the user is not already connected, to your GreeterContractInteractions.tsx
file, add this
return (
<> {address ?
</div>
<-- everything -->
</div>
:
<div></div>}
</>
)
You should have this on refresh:
And we should be finished!
Conclusion
Yeey! You manage to make it to the end. You have built a simple React Dapp on soroban.
This chat now only awaits for you to make it better with more functionalities like moving the button to send a chat in the conversation zone to me closer to the classical UI we are used to, deleting chats, replying to an existing chat with a new chat.
I already have added some little adjustments out of scope for this tutorial, so be sure to check the repo after being finished!
Useful Resources I personally used building this dapp:
https://github.com/stellar/soroban-examples/blob/v20.0.0-rc2/token/src/
https://soroban.stellar.org/docs/fundamentals-and-concepts/built-in-types
https://docs.rs/soroban-sdk/20.0.0/soroban_sdk/
Top comments (1)
Amazing! Congrats!