Connect the client
In this section, we establish a connection between our frontend and the blockchain using the postchain-client library along with the FT4-wrapper. Our goal is to create a ContextProvider and define custom React hooks to seamlessly integrate blockchain capabilities into our app.
Create a context
To manage blockchain-related data and state efficiently, we need to create a context within our app. This context will serve as a central hub for handling blockchain interactions. We start by creating a file named ContextProvider.tsx
within the src/components
directory. In this file, we will declare and define our contexts:
"use client";
import {
Session,
createKeyStoreInteractor,
createSingleSigAuthDescriptorRegistration,
createWeb3ProviderEvmKeyStore,
hours,
registerAccount,
registrationStrategy,
ttlLoginRule,
} from "@chromia/ft4";
import { createClient } from "postchain-client";
import { ReactNode, createContext, useContext, useEffect, useState } from "react";
import { getRandomUserName } from "../app/user";
// Create context for Chromia session
const ChromiaContext = createContext<Session | undefined>(undefined);
export function ContextProvider({ children }: { children: ReactNode }) {
// Initialize session and EVM address states
const [session, setSession] = useState<Session | undefined>(undefined);
// Additional state initialization will be defined here
return <ChromiaContext.Provider value={session}>{children}</ChromiaContext.Provider>;
}
In the ContextProvider
component, we define a context:
- ChromiaContext: This context holds a
Session
object, serving as a wrapper for the FT4 client. It manages the state of the current session, including key pairs for signing transactions.
We initialize the respective states within this component and wrap our app components with these contexts. To utilize this context, we wrap the NavBar
and {children}
tags within src/app/layout.tsx
:
import { ContextProvider } from '@/components/ContextProvider'
<ContextProvider>
<NavBar />
{children}
</ContextProvider>
Initialize the session
To visualize the process of initializing a new session, we can use a simple flow diagram:
Here’s a step-by-step explanation of the session initialization process:
- Start the app: We initiate our app by connecting with MetaMask, a popular Ethereum wallet provider.
- Check user existence (within our dapp): We determine whether the user's Ethereum wallet is already associated with an FT account in our decentralized app (dapp).
- Create a session (user exists): If the user's Ethereum wallet links to an account in our dapp, we create a new session. This session allows us to interact seamlessly with the FT protocol.
- Create an account (user doesn't exist): If the user's Ethereum wallet isn't connected to an account in our dapp, we create a new account. This involves a detailed process explained in Module 1.
- Create a session: After successfully creating a new account, we establish a session that enables interaction with the blockchain using the FT4-wrapper.
Now, we can implement the initialization flow:
// 2.
declare global {
interface Window {
ethereum: any;
}
}
export function ContextProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | undefined>(undefined);
useEffect(() => {
const initSession = async () => {
console.log("Initializing Session");
// 1. Initialize Client
const client = await createClient({
nodeUrlPool: "http://localhost:7740",
blockchainRid: "26A69CAACE069D03404D58E17CF9E38B4417274D5E4BEB663E6F329FD56F6D90",
});
// 2. Connect with MetaMask
const evmKeyStore = await createWeb3ProviderEvmKeyStore(window.ethereum);
// 3. Get all accounts associated with evm address
const evmKeyStoreInteractor = createKeyStoreInteractor(client, evmKeyStore);
const accounts = await evmKeyStoreInteractor.getAccounts();
if (accounts.length > 0) {
// 4. Start a new session
const { session } = await evmKeyStoreInteractor.login({
accountId: accounts[0].id,
config: {
rules: ttlLoginRule(hours(2)),
flags: ["MySession"],
},
});
setSession(session);
} else {
// 5. Create a new account by signing a message using metamask
const authDescriptor = createSingleSigAuthDescriptorRegistration(["A", "T"], evmKeyStore.id);
const { session } = await registerAccount(
client,
evmKeyStore,
registrationStrategy.open(authDescriptor, {
config: {
rules: ttlLoginRule(hours(2)),
flags: ["MySession"],
},
}),
{
name: "register_user",
args: [getRandomUserName()],
}
);
setSession(session);
}
console.log("Session initialized");
};
initSession().catch(console.error);
}, []);
return <ChromiaContext.Provider value={session}>{children}</ChromiaContext.Provider>;
}
Let's break this down:
-
Initialize the client: We initialize the client and connect to the local blockchain node using
blockchainIid
0. When you start a test node withchr node start
, you’ll see logs that indicate the port for the REST API connection and both the internal (chain id) and external (blockchain Rid) identifiers for the blockchain.During testing, using the internal Id (
blockchainIid
) is more convenient because it accurately reflects the state of the chain. However, when connecting the client to a blockchain deployed on a network, it’s crucial to use the blockchain's referential Id (blockchainRid
) to ensure you identify and interact with the specific blockchain on the network correctly. -
Connect with MetaMask: We connect with MetaMask, ensuring that the
ethereum
object is declared globally on theWindow
interface to eliminate any compilation warnings. -
Retrieve accounts: We retrieve all accounts associated with the Ethereum wallet address from our dapp. We anticipate having either 0 or 1 account per wallet.
-
Start a new session: If an account exists, we start a new session with the "MySession" flag defined in our dapp's authentication handlers. We set the expiration time for this session to two hours, after which the user will need to sign in again.
-
Create a new account: If no accounts exist, we create a new account by signing a message in MetaMask and sending a transaction to our dapp. This process is described in detail on the Conceptual design page. For the EVM key, we add the flags "A" and "T" to allow signing administrative and transfer operations for this key, along with the "MySession" flag for the session key utilized by the
Session
. We markregister_user
as the registration operation, enabling us to pass the user name as an argument.We also use the
getRandomUserName
function defined insrc/app/user.tsx
to generate a random user name for account creation.
const funnyAnimalNames = [
"SneakyLlama",
"CheekyMonkey",
"LaughingPenguin",
"CrazyKangaroo",
"GigglingHedgehog",
"WackyWalrus",
"DancingDolphin",
"BumblingBee",
"HoppingHare",
"SingingSeagull",
];
export function getRandomUserName() {
const randomIndex = Math.floor(Math.random() * funnyAnimalNames.length);
return funnyAnimalNames[randomIndex];
}
Access context with custom hooks
We streamline the interaction with our backend by adding custom hooks that allow us to call queries and operations easily from our frontend:
// Define hooks for accessing context
export function useSessionContext() {
return useContext(ChromiaContext);
}
Next, we create a new file called src/app/hooks.tsx
, where we introduce a new hook named useQuery
:
// Create a custom hook for queries
import { useSessionContext } from "@/components/ContextProvider";
import { useEffect, useState, useCallback } from "react";
import { RawGtv, DictPair } from "postchain-client";
// Custom hook for queries and operations
export function useQuery<TReturn extends RawGtv, TArgs extends DictPair | undefined = DictPair>(
name: string,
args?: TArgs
) {
const session = useSessionContext();
const [serializedArgs, setSerializedArgs] = useState(JSON.stringify(args));
const [data, setData] = useState<TReturn | undefined>();
// Function to send the query
const sendQuery = useCallback(async () => {
if (!session || !args) return;
const data = await session.query<TReturn>({ name: name, args: args });
setSerializedArgs(JSON.stringify(args));
setData(data!!);
}, [session, name, args]);
// Trigger the query when session, query name, or arguments change
useEffect(() => {
sendQuery().catch(console.error);
}, [session, name, serializedArgs]);
// Return query result and reload function
return {
result: data,
reload: sendQuery,
};
}
This custom hook retrieves the necessary context and initiates a query whenever the session, query name, or arguments change. We serialize the arguments to ensure stability and avoid unintended queries. Additionally, we graciously handle cases where the arguments may still need initialization, postponing the query until they are defined.
Implement the news feed
We will implement the news feed in the NewsFeed.tsx
component. First, we define the Data Transfer Object (DTO) structures that correspond to the return type of the get_posts
query. These structures help us organize the data effectively:
import { useEffect } from "react";
import { useSessionContext } from "@/components/ContextProvider";
import { useQuery } from "@/app/hooks";
export type User = {
name: string;
id: number;
account: number;
};
export type PostDto = {
timestamp: number;
user: User;
content: string;
};
export type GetPostsReturnType = {
pointer: number;
posts: PostDto[];
};
In the NewsFeed
component, we utilize the custom hook useQuery
to fetch data. We retrieve information about the user, followers, those they follow, and the actual news feed posts:
export default function NewsFeed() {
const session = useSessionContext()
const accountId = session?.account.id;
const { result: userName } = useQuery<string>("get_user_name", accountId ? { user_id: accountId } : undefined);
const { result: followersCount } = useQuery<number>("get_followers_count", accountId ? { user_id: accountId } : undefined);
const { result: followingCount } = useQuery<number>("get_following_count", accountId ? { user_id: accountId } : undefined);
const { result: newsFeed, reload: reloadPosts } = useQuery<GetPostsReturnType>("get_posts", accountId ? { user_id: accountId, n_posts: 10, pointer: 0 } : undefined);
// Refresh posts every 10 seconds
useEffect(() => {
const refreshPosts = setInterval(() => {
reloadPosts();
}, 10000);
return () => {
clearInterval(refreshPosts);
}
});
The queries are directed to fetch specific data: get_user_name
, get_followers_count
, get_following_count
, and
get_posts
. We refresh the posts every 10 seconds. To ensure smooth loading, we pass undefined
if evmContext
is not
initialized. Here's how we incorporate these values into our style:
return (
<div className="p-4 md:p-8">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">{userName}</h1>
<div className="flex text-center">
{/* Followers Box */}
<div className="bg-white m-1 p-2 rounded-lg shadow">
<h3 className="text-lg font-semibold">Followers</h3>
<p className="text-3xl font-bold">{followersCount}</p>
</div>
{/* Following Box */}
<div className="bg-white m-1 p-2 rounded-lg shadow">
<h3 className="text-lg font-semibold">Following</h3>
<p className="text-3xl font-bold">{followingCount}</p>
</div>
</div>
</div>
{/* News Feed */}
<div className="bg-white p-4 rounded-lg shadow">
<ul>
{newsFeed ? (
newsFeed.posts.map((post, index) => (
<li key={index} className="mb-4">
<div className="flex">
<div className="font-semibold">{post.user.name}</div>
<div className="text-gray-500 text-sm ml-2">{new Date(post.timestamp).toLocaleString()}</div>
</div>
<div className="mt-2">{post.content}</div>
{/* Add a horizontal line between posts */}
<hr className="my-4 border-t border-gray-300" />
</li>
))
) : (
<p>Loading...</p>
)}
</ul>
</div>
</div>
);
The UserList
component
In the UserList
component, we implement the following code to fetch 100 users for the app:
import { useQuery } from "@/app/hooks";
export default function UsersList() {
const { result: users } = useQuery<GetUsersReturnType>("get_users", { n_users: 100, pointer: 0 });
This code enables the app to retrieve the first 100 users. While we could enhance the get_users
and get_posts
queries to fetch users dynamically while scrolling, we'll keep it simple for this course.
The UserItem
component
To make the follow/unfollow button in the UserItem
component functional, we need to check if we are currently following the user and trigger the appropriate action: either follow_user
or unfollow_user
based on the follow state. We can build upon the code structure we prepared in Lesson 2 by adding a hook to the session and calling the necessary operation.
export default function UserItem({ user }: { user: UsersDto }) {
// Step 1: Initialize state variables
const session = useSessionContext();
const accountId = session?.account.id;
const { result: isFollowing, reload: updateIsFollowing } = useQuery<boolean>("is_following", accountId ? { my_id: accountId, your_id: user.id } : undefined);
...
// Step 2: Handle follow/unfollow click
const handleFollowClick = async (userId: Buffer, follow: boolean) => {
if (!session) return
try {
setIsLoading(true);
// Step 3: Handle follow/unfollow logic
await session.call({
name: follow ? "follow_user" : "unfollow_user",
args: [userId]
});
updateIsFollowing();
First, we replace the isFollowing
state with an actual query that retrieves the current follow status. In the handleFollowClick
function, we then call either the follow_user
or unfollow_user
operation to change the follow status. Finally, we update the isFollowing
state by reloading the query, ensuring that our component reflects the latest following state.
The NewPost
component
To enable users to create new posts, let's implement a pattern similar to the UserItem
. Building on the scaffold from Lesson 2, we will add the following logic:
export default function NewPost() {
// Step 1: Initialize state variables
const session = useSessionContext();
...
// Step 3: Handle form submission
const onSubmit = async (data: string) => {
if (!session) return
try {
if (data.trim() !== '') {
setIsLoading(true);
// Step 4: Content submission
await session.call({
name: "make_post",
args: [data],
})
router.push('/');
With these changes, your dapp now allows users to create and send posts seamlessly.