DEV Community

Benjamin Salon
Benjamin Salon

Posted on • Updated on

SOROCHAT: How to build a simple chat Dapp using create-soroban-dapp

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:

Preview of final product

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
Enter fullscreen mode Exit fullscreen mode

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:

Change Repository Name

And the package name and author in the cargo.toml file:

Change Author and Name

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;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

Don't forget to add the necessary imports

#![no_std]
use soroban_sdk::{contracttype, Address};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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())
        }
Enter fullscreen mode Exit fullscreen mode

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,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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 fromand 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]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!")
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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]);
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

to

src/components/web3/GreeterContractInteractions.tsx 44
      const contract = useRegisteredContract("chat")
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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()],
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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();
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

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>
      </>
    ))}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>("")
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Try to send a few messages, you should see something like this on the screen appearing:

List Displayed with some button and hovering

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>
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }

Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

We can see that we are introducing two new state variables

const [conversationDisplayed, setConversationDisplayed] = useState<Array<MessageType>>([])
const [conversationIsLoading, setConversationIsLoading] = useState<boolean>(false)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

And we should get something like that:

Conversation Displayed

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>}
  </>
)
Enter fullscreen mode Exit fullscreen mode

You should have this on refresh:

User not connected

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)

Collapse
 
esteblock profile image
esteblock

Amazing! Congrats!