How To Build a Web3 Chat App

Using Airstack, XMTP, and ThirdWeb

Justin Hunter
14 min readJul 17, 2023

Communication between wallet addresses has long been a dream for many people in the web3 space. Heck, my second startup in the web3 space was entirely built around the concept of segmenting and communicating with users based on their wallet addresses. If only me and my team had the tools that exist today.

Today, we’re going to realize those early web3 dreams by building a web3 chat app that allows you to search for users by their web3 social profile and send messages in real-time. We’ll keep track of past conversations so you can get back to them and pick them up at any time. We’ll do this courtesy of the XMTP protocol API and the GraphQL API built by Airstack.

Airstack builds developer tools that make it easy to look up blockchain-based data. Their API and AI assistant are helpful when building real-world apps that can scale.

The messaging protocol will be powered by XMTP. XMTP bills itself as “The open protocol and network for secure web3 messaging.” The protocol relays messages directly between wallet addresses, enabling real-time chat with anyone around the world.

To help us bootstrap the project, we’ll make use of an example app already created by the XMTP team. This example app gets the wallet authentication (courtesy of Thirdweb) out of the way and creates a basic interface for chatting with a bot. But we want and will do so much more.

Getting Started

The example app we’ll be starting with is here:

We’ll clone it, install dependencies, and get it up and running, but first some housekeeping. In order to follow along with this tutorial, you’ll need the following:

  • Node.js v16 or higher
  • Code editor
  • A free Airstack API key

To get your Airstack API key, sign up for free on their website, then click on your profile name when logged in. From there, choose “view API keys”:

OK, we can get the demo app cloned and install our dependencies. From your terminal/command line switch into the directory where you keep your dev projects, then run the following:

git clone https://github.com/fabriguespe/xmtp-quickstart-nextjs

Now, change into the new project directory:

cd xmtp-quickstart-nextjs

Then, install dependencies and start the app:

npm install && npm run dev

Open up the project in your code editor, and let’s get to work!

Building the app

The basic app is a great example, but we don’t just want to chat with a bot. We want to be able to chat with any number of contacts and search for new contacts. Let’s begin by building that interface.

We can do this by updating the Home.js file to support being able to switch between a list of contacts and the messaging interface. The first thing we’ll do is update the PEER_ADDRESS variable to be something more descriptive for this app. Change the variable and all of its references in the file to BOT_ADDRESS.

Now, let’s add a new state variable to control switching between the Contacts screen and the Chat screen. Towards the top of the body of the component function, add this:

...
const [showContactsList, setShowContactList] = useState(false);

Now, go find the code that shows the Chat component. It looks like this:

{isConnected && isOnNetwork && messages && (
<Chat
client={clientRef.current}
conversation={convRef.current}
messageHistory={messages}
/>
)}

Let’s update the code to show either the Chat component or a new Contacts components like this:

{isConnected && isOnNetwork && messages && !showContactsList ? (
<Chat
client={clientRef.current}
conversation={convRef.current}
messageHistory={messages}
/>
) :
(
<Contacts setShowContactList={setShowContactList} />
)
}

We haven’t built the Contacts component yet, so let’s do that and then let’s make sure we import it into our Home.js file. In your components folder, add a Contacts.js file. Then, add the following:

import React, { useState } from 'react'
import styles from "./Chat.module.css";

const Contacts = (props) => {
const [contacts, setContacts] = useState([]);
const [profileName, setProfileName] = useState("");
const [results, setResults] = useState([]);

const searchForUsers = async function(name) {
console.log(name);
};

const handleInputChange = (e) => {
setProfileName(e.target.value);
}

const SearchResults = () => {
return (
<div>
<div>
<h3>{profileName}</h3>
{
results.map(r => {
return (
<p key={r}>{r}</p>
)
})
}
</div>
</div>
)
}

return (
<div className={styles.Contacts}>
<div className={styles.searchInput}>
<input
type="text"
className={styles.inputField}
onChange={handleInputChange}
value={profileName}
placeholder="Search for new Lens or Farcaster contacts"
/>
<button onClick={searchForUsers}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</button>
</div>
{
results.length > 0 && profileName &&
<SearchResults />
}
<div>
{
contacts?.map(() => {
return (
<div>

</div>
)
})
}
</div>
</div>
)
}

export default Contacts

Let’s walk through this new file. We are reusing the existing Chat.module.css file. We’ll add some classes to that file for our new Contacts components in a minute.

Now, inside the body of the component, we are using two state variables. We also have a search function that is triggered when our search button is clicked.

In the main part of our component, we have a search field and a placeholder for showing all the contacts we’ve selected. There’s not a lot going on yet, but this is a nice foundation.

Let’s add these new classes to the Chat.module.css file:

.backButton {
border-radius: 4px;
padding: 12px;
font-size: 16px;
color: #555;
cursor: pointer;
margin-left: 10px;
outline: none;
}

.Contacts{
background-color: white;
margin: 0;
color:black;
padding: 0;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
width:100%;
margin: 0;
}

.searchInput {
display: flex;
flex-direction: row;
padding-right: 10px;
padding-left: 10px;
margin-top: 10px;
}

.searchInput button {
margin-left: 15px;
}

I think we should wire up the search functionality before we figure out how we’re going to send messages to more than just our bot friend. We’ll use the Airstack SDK to resolve Farcaster and Lens usernames into 0x.. addresses. With that address, we can send messages via XMTP.

In your Contacts.js file, let’s import the Airstack SDK and initialize it like this:

import { init, useLazyQueryWithPagination } from "@airstack/airstack-react";

init("YOUR AIRSTACK API KEY");

Now, let’s set two useQueryWithPagination hooks. We’re setting up two, because we’re going to make a slightly different query depending on if the user being looked up is a Lens user or a Farcaster user. Beneath your state variables in the component body, add the following:

const [fetchLensUser, { data: lensData, loading: lensLoading, pagination: lensPagination }] = useLazyQueryWithPagination(
lensQuery, variables
);

const [fetchFCUser, { data: fcData, loading: fcLoading, pagination: fcPagination }] = useLazyQueryWithPagination(
fcQuery, variables
);

You’ll notice we are passing in a query and variables to both of those fetch functions. We’ll store our variables in state and we’ll hard code our queries. Airstack has some great documentation on how this works with hooks here. Essentially, the variables will be passed into your query at request time automatically.

Make sure to add these new useState hook the these two queries above your two useLazyQueryWithPagination hook.

const [variables, setVariables] = useState({
name: ""
})

const lensQuery = `query LensUser($name: Identity!) {
Wallet(input: {identity: $name, blockchain: ethereum}) {
addresses
}
}`

const fcQuery = `query FarcasterUser($name: Identity!) {
Wallet(input: {identity: $name, blockchain: ethereum}) {
addresses
}
}`

As mentioned, the useState hook will be used to store the variable we’re passing into our query. In this case, it’s the user’s social profile name. The two queries are slightly different because of the differences in how you look up Lens users and Farcaster users.

If you’re interested in how I arrived at these two GraphQL queries, I’ll let you in on a secret. I cheated. I used Airstack’s AI Assistant. We can use the Airstack Explorer and the AI Assistant to help us write our GraphQL query.

In the AI Assistant text box, type your plain text search. Here’s mine along with the resulting query:

This is pretty good, but how does it work if we’re searching for Farcaster users?

Pretty good! For the sake of simplicity, we’re going to have users indicate which social network they’d like to search when trying to find a contact to message by including the .lens or not at the end of the profile name.

We will need to update the variables state based on the search input value, so let’s jump back into the code. In your Contacts.js file, let’s update our handleInputChange function to look like this:

const handleInputChange = (e) => {
setResults([]);
setProfileName(e.target.value);
setVariables({
name: e.target.value.includes(".lens") ? e.target.value : `fc_fname:${e.target.value}`
})
}

We’re checking the text typed in the search box to see if it includes .lens. If it does, we set one the variable for Lens, otherwise we set it for Farcaster.

Now, let’s update our searchForUsers function to look like this:

const searchForUsers = async function() {
let res;
if(profileName.includes(".lens")) {
res = await fetchLensUser(variables);
} else {
res = await fetchFCUser(variables);
}

setResults(res?.data?.Wallet?.addresses || []);
}

We’ll do something with the results, but for now, let’s console.log it to make sure it works. Test the app in your browser by searching for a Lens or Farcaster social profile. If we check the console output, we should see something like this:

Ok, now let’s update our search function to look like this:

const res = await fetchData();
setResults(res?.data?.Wallet?.addresses || []);
}

Now that we are setting search results, our SearchResults component should show up. Let’s try it.

It doesn’t look pretty, but it’s a start. Let’s clean this up a bit. Go back to the SearchResults component and update it to look like this:

const setContactDetails = (contact) => {

}

const SearchResults = () => {
return (
<div className={styles.SearchResults}>
<h3>{profileName}</h3>
{
results.map(r => {
return (
<div key={r}>
<button onClick={() => setContactDetails({profileName, address: r})} key={r}>{r}</button>
</div>
)
})
}
</div>
)
}

We are going to set our contacts and we’re going to need to pass the new contact to our Chat component so we can send messages through XMTP. We’ll get to that, but for now we just want to make the search look better. So open up your Chat.module.css file and add the following:

.SearchResults {
margin-left: 10px;
margin-right: 50px;
padding: 5px;
border: 1px solid #ccc;
}

.SearchResults button {
text-decoration: underline;
cursor: hover;
}

Now, our search results will look slightly better like this:

We’re getting there! We’ve resolved a web3 social profile into available wallet addresses thanks to the Airstack API. Now, we need to pass the selected wallet address to XMTP and start a chat.

While we’re still on the Contacts.js file, let’s find a way to load our existing contacts and a way to save new ones and start/continue a chat. XMTP has a function that will allow you to load all your prior conversations, which is helpful. We’ll use that and combine it with another Airstack query to resolve a peerAddress on XMTP into a social profile (if one exists).

In the Contacts.js file, import useEffect from React at the top of the file, then add the following below your state variables in the main Contacts component body:

useEffect(() => {
resolveContactsAndProfiles();
}, []);

const resolveSocial = async (address) => {
const newQuery = `
query MyQuery {
Wallet(
input: {identity: "${address}", blockchain: ethereum}
) {
socials {
dappName
profileName
}
}
}
`
const response = await fetchQuery(newQuery)
if(response.data.Wallet.socials && response.data.Wallet.socials.length > 0) {
return response?.data?.Wallet?.socials[0].profileName
}
return "No web3 profile"
}

const resolveContactsAndProfiles = async () => {
const results = await props.loadConversations()
let existingContacts = [];
for(const r of results) {
existingContacts.push({
profileName: await resolveSocial(r.peerAddress),
address: r.peerAddress
})
}
setContacts(existingContacts)
}

We’ll need to import the fetchQuery function from the Airstack SDK because we’re not using the same hook function as we use in our search feature. So be sure to update your Airstack SDK import in the Contacts.js file to look like this:

import { init, useLazyQueryWithPagination, fetchQuery } from "@airstack/airstack-react";

Now, let’s take a moment to break down what these new functions are doing. The resolveContactsAndProfiles function fetches conversations from XMTP (we need to create a function in our Home.js file to manage this) and returns the results, including the other party’s peerAddress.

We use that address plus a call to Airstack to check on the address’s social profiles to build up our contact list. Let’s check out how we resolve the social profiles for the peerAddress.

In the resolveSocial function, we’re making a query to the Airstack GraphQL API with the peerAddress from the conversation. If there are social profiles, we return the first one, otherwise, we return a string saying there are no social profiles. It should be noted that we’re returning the first social profile out of simplicity. You could return all social profiles and build the interface to show these in any way you’d like.

Speaking of which, let’s update our contact list interface. We already had some placeholder code for this. Below our SearchResults component in the main function body, we had a mapping for our contacts variable. Let’s update that to look like this now:

<div>
{
contacts?.map((c) => {
return (
<div onClick={() => selectExistingContact(c)} key={c.address}>
<h3>{c.profileName}</h3>
<p>{c.address}</p>
</div>
)
})
}
</div>

We’re displaying the social profile name (if there is one) and the peerAddress. When we click on a contact, we call the selectExistingContact function. But we haven’t written that function yet, so let’s do so now. Add the following function below the existing setContactDetails function:

const selectExistingContact = (contact) => {
props.setSelectedContact(contact);
props.setShowContactList(false);
}

This will set our selected contact, just like we do with our search results. It will also close the contact list window and show us the chat.

The API calls to Airstack in this component are not very efficient. It’s likely that you’re going to be requesting the same info multiple times. As a challenge outside the scope of this tutorial, you can think about ways to cache the results and only make requests for new conversations that the app wasn’t already aware of.

OK, so we already know that we passed in a function called loadConversations from the parent component to Contacts. Let’s create that function now. Open the Home.js file, and add the following function:

const loadConversations = async () => {
const conversations = await clientRef.current.conversations.list()
return conversations
}

We also need to make sure we have our selectedContact state variable set up in this file. Up towards the top, near your other useState hooks, add:

const [selectedContact, setSelectedContact] = useState({
profileName: "Contact Bot", address: BOT_ADDRESS
})

We need to make two more changes to this file before we can see the results of our work. First, let’s use the useEffect hook and listen for changes to the selectedContact state variable. We want to make sure we initialize our XMTP conversation with that contact.

useEffect(() => {
const startConvo = async() => {
const xmtp = await Client.create(signer, { env: "production" });
//Create or load conversation with Gm bot
newConversation(xmtp, selectedContact.address);
// Set the XMTP client in state for later use
setIsOnNetwork(!!xmtp.address);
//Set the client in the ref
clientRef.current = xmtp;
}


if(selectedContact) {
startConvo();
}
}, [selectedContact]);

Here, we’ve wrapped some of the same code we use in the initXmtp function in startConvo function within the useEffecthook. We’re checking for changes to the selectedContact variable and starting a conversation with that contact if detected.

Inside the initXmtp function, we’re going to make a similar change.

const initXmtp = async function () {
const startConvo = async(contactToInit) => {
const xmtp = await Client.create(signer, { env: "production" });
//Create or load conversation with Gm bot
newConversation(xmtp, contactToInit.address);
// Set the XMTP client in state for later use
setIsOnNetwork(!!xmtp.address);
//Set the client in the ref
clientRef.current = xmtp;
}


if(selectedContact) {
startConvo(selectedContact);
} else {
startConvo({address: BOT_ADDRESS})
}
};

Because our new format for selectedContact takes an object with a profile name and an address, we need make some changes that check for a selectedContact or pass in the original bot address to initialize the conversation.

And finally, let’s make sure we’re passing all the correct props to our Contactscomponent and we are going to pass a new prop to the Chat component (you’ll see why soon). Here’s the updated Contacts component:

<Contacts loadConversations={loadConversations} setSelectedContact={setSelectedContact} setShowContactList={setShowContactList} />

And here’s the updated Chat component:

<Chat
client={clientRef.current}
conversation={convRef.current}
messageHistory={messages}
selectedContact={selectedContact}
setShowContactList={setShowContactList}
/>

Right away, we can start using the app to communicate by resolving web3 social profiles to peer addresses within an XMTP-enabled chat interface, but let’s make one more update. The chat interface was designed to always be chatting with a bot. Let’s display either a profile name (if applicable or a 0x address). Open up Chat.js and at the top of the main function be sure to pass in the selectedContact prop like this:

function Chat({ client, messageHistory, conversation, setShowContactList, selectedContact }) {
...
}

Now, find the nested function called MessageList and update it to look like this:

const MessageList = ({ messages, selectedContact }) => {
// Filter messages by unique id
messages = messages.filter(
(v, i, a) => a.findIndex((t) => t.id === v.id) === i,
);

const getUserName = () => {
if(message.senderAddress === address) {
return "You"
} else if(selectedContact && selectedContact.profileName !== "No web3 profile") {
return selectedContact.profileName
} else if(selectedContact && selectedContact.address) {
return selectedContact.address
} else {
return "Bot"
}
}

return (
<ul className="messageList">
{messages.map((message, index) => (
<li
key={message.id}
className="messageItem"
title="Click to log this message to the console">
<strong>
{getUserName()}:
</strong>
<span>{message.content}</span>
<span className="date"> ({message.sent.toLocaleTimeString()})</span>
<span className="eyes" onClick={() => console.log(message)}>
👀
</span>
</li>
))}
</ul>
);
};

Your app should look like this with the chat interface showing either a profile name or a wallet address.

There’s a lot we can improve and before we close out the tutorial, I’ll provide some ideas so you can really make this your own and build an incredible experience. But let’s see the app in action now!

Using Airstack to resolve the 0x address from a web3 social profile for use in an XMTP conversation is powerful. The foundation of some incredible apps can be laid using just the code we’ve written so far.

Let’s recap and then look forward.

Next Steps

In this tutorial, you learned how to connect Airstack and XMTP to build a web3 chat application. The real-time feed of messages is generated by listening to conversations between “peers”. Peers are defined by their wallet addresses.

This isn’t very user-friendly, so we used Airstack’s versatile GraphQL API to build a web3 social profile resolver. This allowed us to look up web3 usernames like polluterofminds or polluterofminds.lens to start an XMTP-based chat.

As powerful as this is, it’s just the beginning. From here, you have the opportunity to bring in profile pictures, save contact lists in a more persistent and UX-friendly way, streamline the initialization of XMTP (hint: you shouldn’t be making people connect multiple times in the same session like our demo app does), and so much more.

We started with a simple example app from XMTP’s docs and built out a fully functional search and chat app with Airstack. Let’s see what you can do when you extend this knowledge to your own apps!

The full source code for the app built in this tutorial can be found here.

--

--