Decentralized applications (dApps) are one of the most promising applications of blockchain technology. They open up new possibilities for consumer and business-focused products with never-before-seen capabilities.
It's fascinating to see how powerful decentralized applications may be built to supplement the commercial environment.
This post will teach us how to Build a Decentralized News Feed using Reactjs, TailwindCSS, Etherjs, IPFS, and Solidity.
It will be a platform where anyone on the internet can read, share, and post news, with the data being stored on the Polygon network's blockchain using smart contracts.
A smart contract is code stored on the blockchain and can be read and written from; we'll get into more detail later.
We'll build and deploy the smart contract and a website that allows people to connect their wallets and interact with our smart contract.
š GitHub Repositories
Prerequisite
Let us ensure we have Node/NPM installed on our PC. If we don't have it installed, head over here for a guide.
Project Setup and Installation
Let's navigate to the terminal. We'll need to cd
into any directory of our choice and then run the following commands:
mkdir newsfeed-be
cd newsfeed-be
npm init -y
npm install --save-dev hardhat
Let's get a sample project by running the command below:
npx hardhat
We'll go with the following options:
- A sample project.
- Accept all other requests.
Installing hardhat-waffle
and hardhat-ethers
is required for the sample project.
Just in case it didn't install automatically, we will install this other requirement with the following command:
npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers @openzeppelin/contracts
Next, we will install @openzeppelin/contracts
for the counter we will use later in this tutorial.
npm i @openzeppelin/contracts
To make sure everything is working, let us run the command below.
npx hardhat test
We will see a passed test result in our console.
It is now possible for us to delete sample-test.js
from the test folder and delete sample-script.js
from the scripts
directory. After that, go to contracts and delete Greeter.sol.
The folders themselves should not be deleted!
We'll create a NewsFeed.sol
file inside the contracts directory. When using Hardhat, file layout is crucial, so pay attention! We're going to start with the basic structure of every contract.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "hardhat/console.sol";
contract NewsFeed {
constructor() {
console.log("NewsFeed deployed");
}
}
To build and deploy our smart contract, we will navigate to the scripts
folder, create a new run.js
file, and update it with the following code snippet:
const main = async () => {
// This will actually compile our contract and generate the necessary files we need to work with our contract under the artifacts directory.
const newsFeedContractFactory = await hre.ethers.getContractFactory(
"NewsFeed"
);
const newsFeedContract = await newsFeedContractFactory.deploy();
await newsFeedContract.deployed(); // We'll wait until our contract is officially deployed to our local blockchain! Our constructor runs when we deploy.
console.log("NewsFeed Contract deployed to: ", newsFeedContract.address);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.log(error);
process.exit(1);
}
};
runMain();
Let's run it with the following command.
npx hardhat run scripts/run.js
You should see something similar to what we have below:
We have a working smart contract š„³ Let us deploy it to a local network.
Under the scripts
folder, we will create a deploy.js
file. Add the following code snippet.
const main = async () => {
const [deployer] = await hre.ethers.getSigners();
const accountBalance = await deployer.getBalance();
console.log("Deploying contracts with account: ", deployer.address);
console.log("Account balance: ", accountBalance.toString());
const Token = await hre.ethers.getContractFactory("NewsFeed");
const portal = await Token.deploy();
await portal.deployed();
console.log("NewsFeed address: ", portal.address);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
};
runMain();
Before deploying, let us ensure we have our local node up and running in a separate terminal with the following command.
npx hardhat node
Next, we will deploy our smart contract.
npx hardhat run scripts/deploy.js --network localhost
We should have something like this.
Building and Deploying NewsFeed Smart Contract to Blockchain
Everything, including the test script and the deploy.js
file, is in place. We'll update the smart contract, run.js,
and deploy.js
files with the following code snippet:
Updating the contracts/NewsFeed.sol
file.
//SPDX-License-Identifier: Unlicense | |
pragma solidity ^0.8.0; | |
import "hardhat/console.sol"; | |
import "@openzeppelin/contracts/utils/Counters.sol"; | |
contract NewsFeed { | |
uint256 totalFeeds; | |
using Counters for Counters.Counter; | |
Counters.Counter private _feedIds; | |
constructor() { | |
console.log("NewsFeed deployed"); | |
} | |
/* | |
* I created a struct here named Feed. | |
* A struct is a custom datatype where we can customize what we want to hold inside it. | |
*/ | |
struct Feed { | |
uint256 id; | |
string title; | |
string description; | |
string location; | |
string category; | |
string coverImageHash; | |
string date; | |
address author; | |
} | |
/* | |
* A little magic is known as an event in Solidity! | |
*/ | |
event FeedCreated( | |
uint256 id, | |
string title, | |
string description, | |
string location, | |
string category, | |
string coverImageHash, | |
string date, | |
address author | |
); | |
/* | |
* I declare variable feeds that let me store an array of structs. | |
* This is what holds all the feeds anyone ever created. | |
*/ | |
Feed[] feeds; | |
/* | |
* This function will be used to get all the feeds. | |
*/ | |
function getAllFeeds() public view returns (Feed[] memory) { | |
/* | |
* This is a function that will return the length of the array. | |
* This is how we know how many feeds are in the array. | |
*/ | |
return feeds; | |
} | |
/* | |
* This function will be used to get the number of feeds. | |
*/ | |
function getTotalFeeds() public view returns (uint256) { | |
return totalFeeds; | |
} | |
/* | |
* This is a function that will be used to get a feed. | |
* It will take in the following parameters: | |
* - _id: The id of the feed | |
*/ | |
function getFeed(uint256 _id) public view returns (Feed memory) { | |
/* | |
* We are using the mapping function to get the feed from the mapping. | |
* We are using the _id variable to get the feed from the mapping. | |
*/ | |
return feeds[_id]; | |
} | |
/* | |
* This function will be used to create a news feed. | |
* It will take in the following parameters: | |
* - _title: The title of the feed | |
* - _description: The description of the feed | |
* - _location: The location of the feed | |
* - _category: The category of the feed | |
* - _coverImageHash: The hash of the cover image of the feed | |
* - _date: The date of the feed | |
*/ | |
function createFeed( | |
string memory _title, | |
string memory _description, | |
string memory _location, | |
string memory _category, | |
string memory _coverImageHash, | |
string memory _date | |
) public { | |
// Validation | |
require(bytes(_coverImageHash).length > 0); | |
require(bytes(_title).length > 0); | |
require(bytes(_description).length > 0); | |
require(bytes(_location).length > 0); | |
require(bytes(_category).length > 0); | |
require(msg.sender != address(0)); | |
totalFeeds++; | |
/* Increment the counter */ | |
_feedIds.increment(); | |
/* | |
* We are using the struct Feed to create a news feed. | |
To create a news feed* We use the id, title, description, location, category, coverImageHash, date, and author variables. | |
*/ | |
feeds.push( | |
Feed( | |
_feedIds.current(), | |
_title, | |
_description, | |
_location, | |
_category, | |
_coverImageHash, | |
_date, | |
msg.sender | |
) | |
); | |
/* | |
* We are using the event FeedCreated to emit an event. | |
To emit an event*, We use the id, title, description, location, category, coverImageHash, date, and author variables. | |
*/ | |
emit FeedCreated( | |
_feedIds.current(), | |
_title, | |
_description, | |
_location, | |
_category, | |
_coverImageHash, | |
_date, | |
msg.sender | |
); | |
} | |
} |
Update scripts/run.js
const main = async () => {
// This will actually compile our contract and generate the necessary files we need to work with our contract under the artifacts directory.
const newsFeedContractFactory = await hre.ethers.getContractFactory(
"NewsFeed"
);
const newsFeedContract = await newsFeedContractFactory.deploy();
await newsFeedContract.deployed(); // We'll wait until our contract is officially deployed to our local blockchain! Our constructor runs when we deploy.
console.log("NewsFeed Contract deployed to: ", newsFeedContract.address);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.log(error);
process.exit(1);
}
};
runMain();
scripts/deploy.js
const main = async () => {
const [deployer] = await hre.ethers.getSigners();
const accountBalance = await deployer.getBalance();
console.log("Deploying contracts with account: ", deployer.address);
console.log("Account balance: ", accountBalance.toString());
const Token = await hre.ethers.getContractFactory("NewsFeed");
const portal = await Token.deploy();
await portal.deployed();
console.log("NewsFeed address: ", portal.address);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
};
runMain();
It's finally time to get down to business and deploy to the blockchain.
Before deploying to the blockchain, we'll need to create an Alchemy account.
Alchemy enables us to broadcast our contract creation transaction so that miners can pick it up as quickly as feasible. Once mined, the transaction is published as a valid transaction to the blockchain. After that, everyone's blockchain copy is updated.
After you sign up, we'll create an app like the one below. Remember to switch the network to Mumbai, where we'll be deploying.
We will need to grab our keys, as shown below, and store them for later use:
We'll need some MATIC tokens in our testnet account, and we'll have to request some from the network. Polygon Mumbai can get some phony MATIC by using a faucet. This fake MATIC can only be used on this testnet.
We can grab some MATIC token here
Let us update the hardhat.config.js
file in the root project directory.
require("@nomiclabs/hardhat-waffle");
require("dotenv").config();
// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
networks: {
mumbai: {
url: process.env.STAGING_ALCHEMY_KEY,
accounts: [process.env.PRIVATE_KEY],
},
},
};
If we look at the code snippet above, we can see that some keys were read from the .env
file, as well as the import at the top of require("dotenv").config(),
which implies we'll need to install the dotenv
package and also create a .env
file using the command below:
npm install -D dotenv
touch .env
Inside the .env
file, we will add the following keys:
STAGING_ALCHEMY_KEY= // Add the key we copied from the Alchemy dashboard here
PRIVATE_KEY= // Add your account private key here
Getting our private account key is easy. Check out this post.
Next, let's write a basic test to test out the most critical functions we'll be using.
To do so, open we will create a feed-test.js
file inside the test directory and update it with the following code:
const { expect } = require("chai"); | |
const { ethers } = require("hardhat"); | |
describe("NewsFeed", function () { | |
this.timeout(0); | |
let NewsFeed; | |
let newsFeedContract; | |
before(async () => { | |
NewsFeed = await ethers.getContractFactory("NewsFeed"); | |
newsFeedContract = await NewsFeed.deploy(); | |
}); | |
it("should deploy", async () => { | |
expect(newsFeedContract.address).to.not.be.null; | |
}); | |
it("should have a default value of 0", async () => { | |
const value = await newsFeedContract.getTotalFeeds(); | |
expect(value.toString()).to.equal("0"); | |
}); | |
it("should be able to create feed", async () => { | |
const tx = await newsFeedContract.createFeed( | |
"Hello World", | |
"New York world", | |
"New York", | |
"Sports", | |
"0x123", | |
"2022-05-05" | |
); | |
expect(tx.hash).to.not.be.null; | |
}); | |
it("should be able to get feeds", async () => { | |
const tx = await newsFeedContract.createFeed( | |
"Hello World", | |
"New York world", | |
"New York", | |
"Sports", | |
"0x123", | |
"2022-05-05" | |
); | |
// get feeds | |
const feeds = await newsFeedContract.getAllFeeds(); | |
expect(feeds.length).to.equal(2); | |
}); | |
it("should be able to get feed count", async () => { | |
const tx = await newsFeedContract.createFeed( | |
"Hello World", | |
"New York world", | |
"New York", | |
"Sports", | |
"0x123", | |
"2022-05-05" | |
); | |
const newsCount = await newsFeedContract.getTotalFeeds(); | |
expect(newsCount.toString()).to.equal("3"); | |
}); | |
it("should be able to get feed by id", async () => { | |
const tx = await newsFeedContract.createFeed( | |
"Hello World", | |
"New York world", | |
"New York", | |
"Sports", | |
"0x123", | |
"2022-05-05" | |
); | |
const news = await newsFeedContract.getFeed(2); | |
expect(news.title).to.equal("Hello World"); | |
}); | |
}); |
Next, we will run the test with the following command:
npx hardhat test
Now we can run the command to deploy our contract to a real blockchain network.
npx hardhat run scripts/deploy.js --network mumbai
Our output should look like what we have below.
We just deployed our contract. š„³š„³š„³
Building Frontend React Client
To quickly get started with the project setup and installation, we will clone this project on GitHub and ensure we are on the project-setup
branch.
Next, we will launch the project locally after cloning it using the following command on our terminal.
cd newsfeed-fe && yarn && yarn start
Or
cd newsfeed-fe && npm install && npm start
After cloning and installing the project, we should have something similar to what we have below:
We want to get all the news feeds from the smart contract we just launched without requiring users to connect their wallets. This implies that anyone can use our app to browse information without linking their wallets and only connect wallets when they wish to create a news feed.
Let us update the getContract.js
file inside the utilities folder with the following code snippet.
import ContractAbi from "./newsFeed.json";
import { ethers } from "ethers";
export default function getContract() {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner(
"0x2C08B4B909F02EA5D8A0E44986720D76BAC8224B" // Random (fake) wallet address
);
let contract = new ethers.Contract(
"0x545ed82953b300ae5a8b21339c942788599Cd239", // Our contract adress
ContractAbi.abi,
signer
);
return contract;
}
In the code snippet above, we get our contract and include a random wallet address in the getSigner
function. This is because we want everyone that visits our site to reads news without having to connect their wallets.
Ensure passing a valid ethereum wallet address when creating/saving a record on the blockchain
We also added a contract address displayed in our terminal when we deployed our contract to the blockchain.
Let's go back to the smart contract project we worked on before, then navigate to artifacts/contracts/NewsFeed.json
and copy the entire content inside it. We will update the newsfeed.json file in the utilities
folder with what we copied.
Building the FeedList Component
In this section, we will create a FeedList.js
file inside the component
folder and update it with the following code snippet.
import React from "react"; | |
import { BiCheck } from "react-icons/bi"; | |
export default function FeedList({ horizontal, feed }) { | |
return ( | |
<div | |
className={`${ | |
horizontal | |
? "flex flex-row mx-5 mb-5 item-center justify-center" | |
: "flex flex-col m-5" | |
} `} | |
> | |
<img | |
className={ | |
horizontal | |
? "object-cover rounded-lg w-60 h-40" | |
: "object-cover rounded-lg w-full h-40" | |
} | |
src={`https://ipfs.infura.io/ipfs/${feed.coverImageHash}`} | |
alt="cover" | |
/> | |
<div className={horizontal && "ml-3 w-80"}> | |
<h4 className="text-md font-bold dark:text-white mt-3 text-black"> | |
{feed.title} | |
</h4> | |
{horizontal && ( | |
<p className="text-sm flex items-center text-textSubTitle mt-1"> | |
{feed.category} | |
</p> | |
)} | |
{horizontal && ( | |
<p className="text-sm flex items-center text-textSubTitle mt-1"> | |
{feed.description.slice(0, 30)}... | |
</p> | |
)} | |
<p className="text-sm flex items-center text-textSubTitle mt-1"> | |
{horizontal ? null : feed.category + " ā¢ "} | |
{feed?.author?.slice(0, 12)}...{" "} | |
<BiCheck size="20px" color="green" className="ml-1" /> | |
</p> | |
</div> | |
</div> | |
); | |
} |
Next, we will import the FeedList
component, the toast response, and ToastContainer
by updating the HomePage.js
file with the following code snippet.
import React, { useState, useEffect } from "react"; | |
import { Header } from "./components/Header"; | |
import FeedList from "./components/FeedList"; | |
import { Link } from "react-router-dom"; | |
import { success, error, warn } from "./utilities/response"; | |
import "react-toastify/dist/ReactToastify.css"; | |
export default function Main() { | |
//... | |
// Create a state variable to store the feeds in the blockchain | |
const [feeds, setFeeds] = useState([]); | |
return ( | |
<div className="w-full flex flex-row"> | |
<div className="flex-1 flex flex-col"> | |
<Header /> | |
<div className="flex-1 flex flex-row flex-wrap"> | |
{feeds.map((feed, index) => { | |
return ( | |
<Link to={`/feed?id=${feed.id}`} key={index}> | |
<div className="w-80 h-80 m-2"> | |
<FeedList feed={feed} /> | |
</div> | |
</Link> | |
); | |
})} | |
{loading && ( | |
<div className="flex-1 flex flex-row flex-wrap"> | |
{Array(loadingArray) | |
.fill(0) | |
.map((_, index) => ( | |
<div key={index} className="w-80"> | |
<Loader /> | |
</div> | |
))} | |
</div> | |
)} | |
</div> | |
</div> | |
</div> | |
); | |
} | |
const Loader = () => { | |
return ( | |
<div className="flex flex-col m-5 animate-pulse"> | |
<div className="w-full bg-gray-300 dark:bg-borderGray h-40 rounded-lg "></div> | |
<div className="w-50 mt-3 bg-gray-300 dark:bg-borderGray h-6 rounded-md "></div> | |
<div className="w-24 bg-gray-300 h-3 dark:bg-borderGray mt-3 rounded-md "></div> | |
</div> | |
); | |
}; |
Because no record has yet been recorded on the blockchain, and we are yet to create the function that retrieves all the feeds made, we should have something similar to what is displayed above, which appears empty.
Building User's Connect Wallet Functionality
This section will build the functionality that allows users to contact their wallets on our platform to create a feed.
Let's update the HomePage.js
with the following code snippet.
import React, { useState, useEffect } from "react"; | |
import { Header } from "./components/Header"; | |
import { ToastContainer } from "react-toastify"; | |
import FeedList from "./components/FeedList"; | |
import { Link } from "react-router-dom"; | |
import getContract from "./utilities/getContract"; | |
import { success, error, warn } from "./utilities/response"; | |
import "react-toastify/dist/ReactToastify.css"; | |
export default function Main() { | |
const [loading, setLoading] = useState(false); | |
const [loadingArray] = useState(15); | |
// Create a state variable to store the feeds in the blockchain | |
const [feeds, setFeeds] = useState([]); | |
/* | |
* A state variable we use to store our user's public wallet. | |
*/ | |
const [currentAccount, setCurrentAccount] = useState(""); | |
/* | |
* A function to check if a user wallet is connected. | |
*/ | |
const checkIfWalletIsConnected = async () => { | |
try { | |
const { ethereum } = window; | |
/* | |
* Check if we're authorized to access the user's wallet | |
*/ | |
const accounts = await ethereum.request({ method: "eth_accounts" }); | |
if (accounts.length !== 0) { | |
const account = accounts[0]; | |
setCurrentAccount(account); | |
success("š¦ Wallet is Connected!"); | |
} else { | |
success("Welcome š "); | |
warn("To create a feed, Ensure your wallet Connected!"); | |
} | |
} catch (err) { | |
error(`${err.message}`); | |
} | |
}; | |
/** | |
* Implement your connectWallet method here | |
*/ | |
const connectWallet = async () => { | |
try { | |
const { ethereum } = window; | |
if (!ethereum) { | |
warn("Make sure you have MetaMask Connected"); | |
return; | |
} | |
const accounts = await ethereum.request({ | |
method: "eth_requestAccounts", | |
}); | |
setCurrentAccount(accounts[0]); | |
} catch (err) { | |
error(`${err.message}`); | |
} | |
}; | |
/* | |
* This runs our function when the page loads. | |
*/ | |
useEffect(() => { | |
checkIfWalletIsConnected(); | |
/* | |
* This is a hack to make sure we only run the function once. | |
* We need to do this because we're using the useEffect hook. | |
* We can't use the useEffect hook more than once. | |
* https://reactjs.org/docs/hooks-effect.html | |
* https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-the-effects-api | |
* https://reactjs.org/docs/hooks-faq.html#how-do-i-optimize-the-effects-of-a-component | |
*/ | |
const onFeedCreated = async ( | |
id, | |
title, | |
description, | |
location, | |
category, | |
coverImageHash, | |
date, | |
author | |
) => { | |
setFeeds((prevState) => [ | |
...prevState, | |
{ | |
id, | |
title, | |
description, | |
location, | |
category, | |
coverImageHash, | |
date, | |
author, | |
}, | |
]); | |
}; | |
let contract; | |
if (window.ethereum) { | |
contract = getContract(); | |
contract.on("FeedCreated", onFeedCreated); | |
} | |
return () => { | |
if (contract) { | |
contract.off("FeedCreated", onFeedCreated); | |
} | |
}; | |
}, []); | |
return ( | |
<div className="w-full flex flex-row"> | |
<div className="flex-1 flex flex-col"> | |
<Header | |
currentAccount={currentAccount} | |
connectWallet={connectWallet} | |
ToastContainer={ToastContainer} | |
/> | |
<div className="flex-1 flex flex-row flex-wrap"> | |
//.. | |
</div> | |
</div> | |
</div> | |
); | |
} | |
const Loader = () => { | |
//... | |
}; |
Next, we will update the Header.js
file.
import React from "react"; | |
import { Link } from "react-router-dom"; | |
export const Header = ({ currentAccount, connectWallet, ToastContainer }) => { | |
return ( | |
<header className="w-full flex justify-between h-20 items-center border-b p-4 border-borderWhiteGray dark:border-borderGray"> | |
<div className=" w-1/3"> | |
<Link to="/" className="flex items-center"> | |
<h1 className="text-2xl font-bold text-green-700">Home</h1> | |
</Link> | |
</div> | |
<div className=" w-1/3 flex justify-center items-center"> | |
<h1 className="text-2xl font-bold text-green-500 dark:text-green-400"> | |
News Feed! | |
</h1> | |
</div> | |
{currentAccount ? ( | |
<div className="w-1/3 flex justify-end items-center"> | |
<Link to="/upload"> | |
<button className="items-center bg-green-600 rounded-full font-medium p-2 shadow-lg color-blue-500 hover:bg-green-500 focus:outline-none focus:shadow-outline text-white"> | |
<span className="">Create a New Feed</span> | |
</button> | |
</Link> | |
</div> | |
) : ( | |
<div className=" w-1/3 flex justify-end"> | |
<button | |
className="items-center bg-green-700 rounded-full font-medium p-3 shadow-lg color-blue-500 hover:bg-green-500 focus:outline-none focus:shadow-outline text-white" | |
onClick={() => { | |
connectWallet(); | |
}} | |
> | |
<span className="">Connect your wallet</span> | |
</button> | |
</div> | |
)} | |
<ToastContainer | |
position="top-center" | |
autoClose={5000} | |
hideProgressBar={false} | |
newestOnTop={false} | |
closeOnClick | |
rtl={false} | |
pauseOnFocusLoss | |
draggable | |
pauseOnHover | |
/> | |
</header> | |
); | |
}; |
Clicking on the Connect your Wallet
button, we will get a metamask login popup.
After connecting, we will be redirected back to our application where the button showing Connect your wallet
earlier now shows Create a Feed
as shown below.
Building Upload News Feed Page
We will build a page where users can enter new feed details and upload them to the blockchain. Let us create UploadPage.js
inside the src
directory and update it with the following code snippet.
import React, { useState, useRef } from "react"; | |
import { create } from "ipfs-http-client"; | |
import { BiCloud, BiPlus } from "react-icons/bi"; | |
import getContract from "./utilities/getContract"; | |
import { ToastContainer } from "react-toastify"; | |
import { success, error, defaultToast } from "./utilities/response"; | |
export default function Upload() { | |
/* | |
* A state variable we use to store new feed input. | |
*/ | |
const [title, setTitle] = useState(""); | |
const [description, setDescription] = useState(""); | |
const [category, setCategory] = useState(""); | |
const [location, setLocation] = useState(""); | |
const [coverImage, setCoverImage] = useState(""); | |
/* | |
* Create an IPFS node | |
*/ | |
const client = create("https://ipfs.infura.io:5001/api/v0"); | |
const coverImageRef = useRef(); | |
/* | |
* A function to handle validation of uploading a new feed. | |
*/ | |
const handleSubmit = async () => { | |
if ( | |
title === "" || | |
description === "" || | |
category === "" || | |
location === "" || | |
coverImage === "" | |
) { | |
error("Please, all the fields are required!"); | |
return; | |
} | |
/* | |
* Upload the cover image to IPFS | |
*/ | |
uploadCoverImage(coverImage); | |
}; | |
/* | |
* A function to upload a cover image to IPFS | |
*/ | |
const uploadCoverImage = async (coverImage) => { | |
defaultToast("Uploading Cover Image..."); | |
try { | |
const image = await client.add(coverImage); | |
/* | |
* Save the new feed to the blockchain | |
*/ | |
await saveFeed(image.path); | |
} catch (err) { | |
error("Error Uploading Cover Image"); | |
} | |
}; | |
/* | |
* A function to save a new feed to the blockchain | |
*/ | |
const saveFeed = async (coverImage) => { | |
defaultToast("Saving Feed..."); | |
console.log(title, description, category, location, coverImage); | |
try { | |
const contract = await getContract(); | |
const UploadedDate = String(new Date()); | |
/* | |
* Save the new feed to the blockchain | |
*/ | |
await contract.createFeed( | |
title, | |
description, | |
location, | |
category, | |
coverImage, | |
UploadedDate | |
); | |
success("Feed Saved Successfully"); | |
// reset form | |
setTitle(""); | |
setDescription(""); | |
setCategory(""); | |
setLocation(""); | |
setCoverImage(""); | |
// Redirect to Home Page | |
window.location.href = "/"; | |
} catch (err) { | |
error("Error Saving Feed"); | |
} | |
}; | |
// Handles redirect to Home Page or previous page | |
const goBack = () => { | |
window.history.back(); | |
}; | |
return ( | |
<div className="w-full h-screen flex flex-row"> | |
<div className="flex-1 flex flex-col"> | |
<div className="mt-5 mr-10 flex justify-end"> | |
<div className="flex items-center"> | |
<button | |
className="bg-transparent dark:text-[#9CA3AF] py-2 px-6 border rounded-lg border-gray-600 mr-6" | |
onClick={() => { | |
goBack(); | |
}} | |
> | |
Discard | |
</button> | |
<button | |
onClick={() => { | |
handleSubmit(); | |
}} | |
className="bg-blue-500 hover:bg-blue-700 text-white py-2 rounded-lg flex px-4 justify-between flex-row items-center" | |
> | |
<BiCloud /> | |
<p className="ml-2">Upload</p> | |
</button> | |
</div> | |
</div> | |
<div className="flex flex-col m-10 mt-5 lg:flex-row lg:justify-center"> | |
<div className="flex lg:w-3/4 flex-col "> | |
<label className="text-gray-600 dark:text-[#9CA3AF] text-md font-bold mb-2"> | |
Title | |
</label> | |
<input | |
value={title} | |
onChange={(e) => setTitle(e.target.value)} | |
placeholder="Web3 is taking over the world!" | |
className="w-[60%] dark:text-white dark:placeholder:text-gray-600 rounded-xl mt-2 h-12 p-2 border border-borderWhiteGray bg-white dark:bg-backgroundBlack dark:border-[#444752] focus:outline-none" | |
/> | |
<label className="text-gray-600 dark:text-[#9CA3AF] mt-10 text-md font-bold"> | |
Body | |
</label> | |
<textarea | |
value={description} | |
onChange={(e) => setDescription(e.target.value)} | |
placeholder="Web3 is all about decentralization ā it aims to give users more control over their data." | |
className="w-[60%] dark:text-white dark:placeholder:text-gray-600 rounded-xl mt-2 h-32 p-2 border border-borderWhiteGray bg-white dark:bg-backgroundBlack dark:border-[#444752] focus:outline-none" | |
/> | |
<div className="flex flex-row mt-10 w-[60%] justify-between"> | |
<div className="flex flex-col w-2/5 "> | |
<label className="text-gray-600 dark:text-[#9CA3AF] text-md font-bold"> | |
Location | |
</label> | |
<input | |
value={location} | |
onChange={(e) => setLocation(e.target.value)} | |
type="text" | |
placeholder="Lagos - Nigeria" | |
className="rounded-md dark:text-white mt-2 dark:placeholder:text-gray-600 h-12 p-2 border border-borderWhiteGray bg-white dark:bg-backgroundBlack dark:border-[#444752] focus:outline-none" | |
/> | |
</div> | |
<div className="flex flex-col w-2/5"> | |
<label className="text-gray-600 dark:text-[#9CA3AF] text-md font-bold"> | |
Category | |
</label> | |
<select | |
value={category} | |
onChange={(e) => setCategory(e.target.value)} | |
className="dark:text-white mt-2 h-12 p-2 dark:border-gray-600 border rounded-xl border-borderWhiteGray bg-white dark:bg-backgroundBlack dark:text-[#9CA3AF] focus:outline-none" | |
> | |
<option>Music</option> | |
<option>Sports</option> | |
<option>Gaming</option> | |
<option>News</option> | |
<option>Entertainment</option> | |
<option>Education</option> | |
<option>Technology</option> | |
<option>Travel</option> | |
</select> | |
</div> | |
</div> | |
<label className="text-gray-600 dark:text-[#9CA3AF] mt-10 text-md font-bold"> | |
Cover Image | |
</label> | |
<div | |
onClick={() => { | |
coverImageRef.current.click(); | |
}} | |
className="border-2 w-64 dark:border-gray-600 border-dashed border-borderWhiteGray rounded-md mt-2 p-2 h-46 items-center justify-center flex flex-row" | |
> | |
{coverImage ? ( | |
<img | |
onClick={() => { | |
coverImageRef.current.click(); | |
}} | |
src={URL.createObjectURL(coverImage)} | |
alt="coverImage" | |
className="h-full rounded-md w-full" | |
/> | |
) : ( | |
<BiPlus size={70} color="gray" /> | |
)} | |
</div> | |
<input | |
type="file" | |
className="hidden" | |
ref={coverImageRef} | |
onChange={(e) => { | |
setCoverImage(e.target.files[0]); | |
}} | |
/> | |
</div> | |
</div> | |
</div> | |
<ToastContainer | |
position="top-center" | |
autoClose={5000} | |
hideProgressBar={false} | |
newestOnTop={false} | |
closeOnClick | |
rtl={false} | |
pauseOnFocusLoss | |
draggable | |
pauseOnHover | |
/> | |
</div> | |
); | |
} |
Next, we will update the App.js
file by importing the new page we just created with the following code snippet.
//...
function App() {
return (
<Routes>
//...
<Route path="/upload" element={<Upload />} />
</Routes>
);
}
export default App;
Clicking on the Create a New Feed
button on the homepage will redirect us to the upload page, as shown below.
After entering all the required details for upload, we can proceed to submit the feed.
We were redirected to the homepage, and nothing happened :(.
We will create a getFeeds
function on the homepage to retrieve all the feeds.
HomePage.js
//...
export default function Main() {
//...
/*
* Get Feeds
*/
const getFeeds = async () => {
try {
setLoading(true);
const contract = await getContract();
const AllFeeds = await contract.getAllFeeds();
/*
* We only need a title, category, coverImageHash, and author
* pick those out
*/
const formattedFeed = AllFeeds.map((feed) => {
return {
id: feed.id,
title: feed.title,
category: feed.category,
coverImageHash: feed.coverImageHash,
author: feed.author,
date: new Date(feed.date * 1000),
};
});
setFeeds(formattedFeed);
setLoading(false);
} catch (err) {
error(`${err.message}`);
}
};
/*
* This runs our function when the page loads.
*/
useEffect(() => {
getFeeds();
//...
}, []);
return (
//...
);
}
const Loader = () => {
//...
};
Let's wait for the transaction to confirm. It takes a few seconds, and we should see it appear in real-time.
Building the Feed Page
Start by creating the Feed.js
file inside the components folder and updating it with the following code snippet.
import React from "react";
import { BiCheck } from "react-icons/bi";
import {
AiFillTwitterCircle,
AiFillLinkedin,
AiFillRedditCircle,
} from "react-icons/ai";
export default function Feed({ feed }) {
return (
<div>
<img
className=" rounded-lg w-full bg-contain h-80"
src={`https://ipfs.infura.io/ipfs/${feed.coverImageHash}`}
alt="cover"
/>
<div className="flex justify-between flex-row py-4 border-borderWhiteGray dark:border-borderGray border-b-2">
<div>
<h3 className="text-2xl dark:text-white">{feed.title}</h3>
<p className="text-gray-500 mt-4">
{feed.category} ā¢ {feed.date}
</p>
</div>
<div className="flex flex-row items-center">
<a
className="bg-transparent dark:text-[#9CA3AF] py-2 px-6 border rounded-lg border-blue-600 mr-6 text-blue-600 hover:bg-blue-600 hover:text-white"
href={`https://twitter.com/intent/tweet?text=${feed.title}&url=https://ipfs.infura.io/ipfs/${feed.coverImageHash}`}
target="_blank"
rel="noopener noreferrer"
>
<AiFillTwitterCircle />
</a>
<a
className="bg-transparent dark:text-[#9CA3AF] py-2 px-6 border rounded-lg border-blue-600 mr-6 text-blue-500 hover:bg-blue-600 hover:text-white"
href={`https://www.linkedin.com/shareArticle?mini=true&url=https://ipfs.infura.io/ipfs/${feed.coverImageHash}&title=${feed.title}&summary=${feed.description}&source=https://ipfs.infura.io/ipfs/${feed.coverImageHash}`}
target="_blank"
rel="noopener noreferrer"
>
<AiFillLinkedin />
</a>
<a
className="bg-transparent dark:text-[#9CA3AF] py-2 px-6 border rounded-lg border-red-600 mr-6 text-red-600 hover:bg-red-600 hover:text-white"
href={`https://www.reddit.com/submit?url=https://ipfs.infura.io/ipfs/${feed.coverImageHash}&title=${feed.title}`}
target="_blank"
rel="noopener noreferrer"
>
<AiFillRedditCircle />
</a>
</div>
</div>
<div className="flex mt-5 flex-row items-center ">
<div className="flex items-center text-textSubTitle mt-1">
Author: {feed?.author?.slice(0, 12)}...
<BiCheck size="20px" color="green" className="ml-1" />
</div>
</div>
<p className="text-sm text-black mt-4">{feed.description}</p>
</div>
);
}
Next, we will create the FeedPage.js
file inside the src
directory and update it with the code snippet below.
import React, { useEffect, useState } from "react"; | |
import getContract from "./utilities/getContract"; | |
import { Link } from "react-router-dom"; | |
import FeedList from "./components/FeedList"; | |
import Feed from "./components/Feed"; | |
export default function FeedPage() { | |
const [relatedFeeds, setRelatedFeeds] = useState([]); | |
// state variable to store the current feed | |
const [feed, setFeed] = useState([]); | |
// Function to get the feed id from the url | |
const getUrlValue = () => { | |
let vars = {}; | |
window.location.href.replace( | |
/[?&]+([^=&]+)=([^&]*)/gi, | |
function (m, key, value) { | |
vars[key] = value; | |
} | |
); | |
return vars; | |
}; | |
/* | |
* Get Feed | |
*/ | |
const getFeed = async () => { | |
try { | |
const contract = await getContract(); | |
let feedId = getUrlValue()["id"]; | |
const singleFeed = await contract.getFeed(feedId); | |
// Format feed | |
const formattedFeed = { | |
id: singleFeed[0], | |
title: singleFeed[1], | |
description: singleFeed[2], | |
location: singleFeed[3], | |
category: singleFeed[4], | |
coverImageHash: singleFeed[5], | |
date: singleFeed[6], | |
author: singleFeed[7], | |
}; | |
setFeed(formattedFeed); | |
} catch (error) { | |
console.log(error); | |
} | |
}; | |
/* | |
* Get Related Feeds | |
*/ | |
const getRelatedFeeds = async () => { | |
try { | |
const contract = await getContract(); | |
let feedId = getUrlValue()["id"]; | |
// Get all feeds and return feeds and filter only the one in the same category as the feed | |
const allFeeds = await contract.getAllFeeds(); | |
const singleFeed = await contract.getFeed(feedId); | |
// Format feed | |
const formattedSingleFeed = { | |
id: singleFeed[0], | |
title: singleFeed[1], | |
description: singleFeed[2], | |
location: singleFeed[3], | |
category: singleFeed[4], | |
coverImageHash: singleFeed[5], | |
date: singleFeed[6], | |
author: singleFeed[7], | |
}; | |
const relatedFeeds = allFeeds.filter( | |
(feed) => feed.category === formattedSingleFeed.category | |
); | |
const formattedFeeds = relatedFeeds.map((feed) => { | |
return { | |
id: feed.id, | |
title: feed.title, | |
description: feed.description, | |
location: feed.location, | |
category: feed.category, | |
coverImageHash: feed.coverImageHash, | |
author: feed.author, | |
date: feed.date, | |
}; | |
}); | |
setRelatedFeeds(formattedFeeds); | |
} catch (error) { | |
console.log(error); | |
} | |
}; | |
useEffect(() => { | |
getFeed(); | |
getRelatedFeeds(); | |
}, []); | |
return ( | |
<div className="w-full flex flex-row"> | |
<div className="flex-1 flex flex-col"> | |
<div className="flex flex-col m-10 justify-between lg:flex-row"> | |
<div className="lg:w-4/6 w-6/6">{feed && <Feed feed={feed} />}</div> | |
<div className="w-2/6"> | |
<h4 className="text-xl font-bold dark:text-white ml-5 mb-3 text-black"> | |
Related Feeds | |
<Link to="/"> | |
<button className="bg-red-600 hover:bg-red-800 text-white font-bold px-2 rounded ml-10"> | |
Go Back | |
</button> | |
</Link> | |
</h4> | |
{relatedFeeds.map((f) => { | |
return ( | |
<Link | |
onClick={() => { | |
setFeed(f); | |
}} | |
to={`/feed?id=${f.id}`} | |
> | |
<FeedList feed={f} horizontal={true} /> | |
</Link> | |
); | |
})} | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} |
In the snippet above, we retrieve a single feed and get related feeds in the feed category.
Next, we will update App.js
with the following code snippet.
//...
import Feed from "./FeedPage";
function App() {
return (
<Routes>
//...
<Route path="/feed" element={<Feed />} />
</Routes>
);
}
export default App;
Testing our Application š„³
I have created several posts to test the application, as shown below.
Single Feed Page
Social Share
After upload, we might experience some delay or glitch when the feed appears on the homepage. We need to wait for some extra minutes, and then everything will become stable ;)
Conclusion
This article teaches us to build a decentralized News Feed using Reactjs, TailwindCSS, Etherjs, IPFS & Solidity on Polygon Network.
References
- Polygon
- IPFS
- Cover Image
- Design/Layout inspired by @Suhail Kakar š
I'd love to connect with you at Twitter | LinkedIn | GitHub | Portfolio
See you in my next blog article. Take care!!!
Top comments (0)