Connect the client
In this section, a connection between the frontend and the blockchain is established using the postchain-client library along with the FT4-wrapper. A ContextProvider is created and custom React hooks are defined to seamlessly integrate blockchain capabilities into the app.
Create a context
To manage blockchain-related data and state efficiently, a context needs to be created within the app. This context will serve as a central hub for handling blockchain interactions. A file named ContextProvider.tsx
is created within the src/components
directory. In this file, the contexts are declared and defined:
"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, a context is defined:
- 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.
The respective states are initialized within this component and the app components are wrapped with these contexts. To utilize this context, the NavBar
and {children}
tags are wrapped within src/app/layout.tsx
:
import { ContextProvider } from '@/components/ContextProvider'
<ContextProvider>
<NavBar />
{children}
</ContextProvider>
Initialize the session
The process of initializing a new session can be visualized using a simple flow diagram:
Here’s a step-by-step explanation of the session initialization process:
- Start the app: The app is initiated by connecting with MetaMask, a popular Ethereum wallet provider.
- Check user existence (within the dapp): It is determined whether the user's Ethereum wallet is already associated with an FT account in the decentralized app (dapp).
- Create a session (user exists): If the user's Ethereum wallet links to an account in the dapp, a new session is created. This session allows seamless interaction with the FT protocol.
- Create an account (user doesn't exist): If the user's Ethereum wallet isn't connected to an account in the dapp, a new account is created. This involves a detailed process explained in Module 1.
- Create a session: After successfully creating a new account, a session is established that enables interaction with the blockchain using the FT4-wrapper.
Now, the initialization flow can be implemented:
// 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", // Add your blockchainRid
});
// 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.If you want to change from
blockchainRid
toblockchainIid
for local development, you can do it like the below code:const client = await createClient({
nodeUrlPool: "http://localhost:7740",
// blockchainRid: "..." // Replace this
blockchainIid: 0 // With this for local dev
}); -
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.