Skip to main content

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:

src/components/ContextProvider.tsx
"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:

  1. 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:

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:

  1. Start the app: The app is initiated by connecting with MetaMask, a popular Ethereum wallet provider.
  2. 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).
  3. 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.
  4. 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.
  5. 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:

src/components/ContextProvider.tsx
// 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:

  1. Initialize the client: We initialize the client and connect to the local blockchain node using blockchainIid 0. When you start a test node with chr 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 to blockchainIid 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
    });
  2. Connect with MetaMask: We connect with MetaMask, ensuring that the ethereum object is declared globally on the Window interface to eliminate any compilation warnings.

  3. 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.

  4. 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.

  5. 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 mark register_user as the registration operation, enabling us to pass the user name as an argument.

    We also use the getRandomUserName function defined in src/app/user.tsx to generate a random user name for account creation.

src/app/user.tsx
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:

src/components/ContextProvider.tsx
// 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:

src/app/hooks.tsx
// 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:

src/components/NewsFeed.tsx
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:

src/components/NewsFeed.tsx
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:

src/components/NewsFeed.tsx
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:

src/components/UserList.tsx
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.

src/components/UserItem.tsx
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:

src/components/NewPost.tsx
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.