Hello, в первой части был подготовлен проект, подключены кошельки и написан backend на Solidity, значит пришло время писать frontend на React.

Проект далёк от продакшена и является простым примером для новичков, предназначенным для демонстрации взаимодействия с смарт-контрактом через веб-приложение.

Возвращаемся в главную папку проекта web3, в которой создаем проект на реакте.

$ npx create-react-app client

Далее нужно поставить две библиотеки: react-router-domиweb3. Первая нужна для перехода между страницами, а вторая для работы со смарт-контрактом.

cd client
npm i react-router-dom, web3

Если используете yarn:

cd client
yarn add web3
yarn add react-router-dom

Видим наши библиотеки в зависимостях, значит можем начинать.

Подготовка клиента

Начнем с удаления лишних файлов.

Удаляем

Переходим в файл index.js и импортируем BrowserRouter из библиотеки react-router-dom , также оборачиваем компонент App в роутер. Удаляем лишние комментарии в коде и чистим импорты.

Далее создаем две папки: config и contexts. В папке config создаем два файла: abi.json (в нем будет abi нашего смарт-контракта) и contract.js (в нем будет адрес нашего смарт-контракта).
P.S: адрес контракта можно посмотреть в 1 блоке в Ganache .

ABI - в контексте смарт-контрактов - это как бы язык общения между различными программами или компонентами программного обеспечения.

В случае с смарт-контрактами, ABI представляет собой набор правил и форматов данных, которые определяют, как внешние программы могут взаимодействовать с этими контрактами. Это включает в себя формат передачи данных, типы данных и структуру функций.

В папке contexts создаем файл ContractContext.js в нем мы опишем подключение к кошельку MetaMask и смарт-контракту, а также возвращаемые переменные, чтобы иметь к ним доступ на любой странице сайта, просто вызвав useContract.

В файле contract.js будет всего одна строчка:

export const contract_address = "адрес вашего смарт-контракта";

Теперь нужно найти ABI нашего контракта, чтобы вставить его в файл abi.json. Для этого переходим в папку contracts с нашим бэкэндом и открываем папку build/contarcts и в ней файл Twitter.json. В нем находим abi и полностью копируем.

Просто вставляем скопированный текст в наш файл abi.json. Ниже abi для контракта из первой части.

Hidden text
[
    {
      "inputs": [],
      "stateMutability": "nonpayable",
      "type": "constructor"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_username",
          "type": "string"
        },
        {
          "internalType": "string",
          "name": "_password",
          "type": "string"
        }
      ],
      "name": "Registration",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_password",
          "type": "string"
        }
      ],
      "name": "Login",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "Logout",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_text",
          "type": "string"
        }
      ],
      "name": "AddTwitt",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "_user",
          "type": "address"
        }
      ],
      "name": "UserTwitts",
      "outputs": [
        {
          "components": [
            {
              "components": [
                {
                  "internalType": "address",
                  "name": "login",
                  "type": "address"
                },
                {
                  "internalType": "string",
                  "name": "password",
                  "type": "string"
                },
                {
                  "internalType": "string",
                  "name": "username",
                  "type": "string"
                },
                {
                  "internalType": "string",
                  "name": "avatar",
                  "type": "string"
                }
              ],
              "internalType": "struct Twitter.User",
              "name": "author",
              "type": "tuple"
            },
            {
              "internalType": "string",
              "name": "text",
              "type": "string"
            },
            {
              "internalType": "uint256",
              "name": "likes",
              "type": "uint256"
            },
            {
              "internalType": "uint256",
              "name": "createdTime",
              "type": "uint256"
            }
          ],
          "internalType": "struct Twitter.Twitt[]",
          "name": "",
          "type": "tuple[]"
        }
      ],
      "stateMutability": "view",
      "type": "function",
      "constant": true
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "_user",
          "type": "address"
        }
      ],
      "name": "CheckRegistration",
      "outputs": [
        {
          "internalType": "bool",
          "name": "",
          "type": "bool"
        }
      ],
      "stateMutability": "view",
      "type": "function",
      "constant": true
    },
    {
      "inputs": [
        {
          "internalType": "address",
          "name": "_user",
          "type": "address"
        }
      ],
      "name": "GetUser",
      "outputs": [
        {
          "components": [
            {
              "internalType": "address",
              "name": "login",
              "type": "address"
            },
            {
              "internalType": "string",
              "name": "password",
              "type": "string"
            },
            {
              "internalType": "string",
              "name": "username",
              "type": "string"
            },
            {
              "internalType": "string",
              "name": "avatar",
              "type": "string"
            }
          ],
          "internalType": "struct Twitter.User",
          "name": "",
          "type": "tuple"
        }
      ],
      "stateMutability": "view",
      "type": "function",
      "constant": true
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "_avatar",
          "type": "string"
        }
      ],
      "name": "UpdateUser",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    }
  ]

Далее пишем ContractProvider , надеюсь вы хотя бы немного знакомы с js и react, но в любом случае код будет простым.

В первую очередь подтягиваем web3 библиотеку и наш адрес контракта вместе с abi.

Далее создаем контекст ContractContext.

Контекст используется для передачи данных вниз по дереву компонентов без явной передачи пропсов.

В самом компоненте ContractProvider объявляем несколько состояний.

  1. contract - содержит экземпляр смарт-контракта.

  2. account - содержит адрес пользователя вошедшего в аккаунт.

  3. accounts - содержит адреса всех пользователей.

  4. balance - содержит баланс пользователя вошедшего в аккаунт.

const [contract, setContract] = useState(null);
const [account, setAccount] = useState(null);
const [accounts, setAccounts] = useState([]);
const [balance, setBalance] = useState();

Переходим к написанию функцию подключения аккаунта пользователя к приложению. В ней мы создаем экземпляр объекта web3 для взаимодействия с Ethereum. Подключение идет через MetaMask или локальный сервер с адресом и портом, которые мы указывали в Ganache.

Если расширение MetaMask установлено, то через window.ethereum.request получаем все аккаунты и первый ([0]) записываем, как адрес пользователя.

С помощью web3.eth.getBalance(accounts[0]) получаем баланс аккаунта пользователя и также записываем в состояние баланса (перед этим переводим из wei в ether) и берем только первые 7 символов.

Ниже создаем экземпляр контракта и также записываем в состояние.

const contractInstance = new web3.eth.Contract(abi, contract_address);
setContract(contractInstance);

Оборачиваем все в try-catch для надежности и вызываем функцию внутри UseEffect, тем самым функция будет выполняться при монтировании компонента.

Hidden text
useEffect(() => {
    const initializeContract = async () => {
      const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:7545');
      if (window.ethereum) {
        try {
          const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
          if (accounts && accounts.length > 0) {
            setAccounts(accounts);
            setAccount(accounts[0]);
  
            const weiBalance = await web3.eth.getBalance(accounts[0]);
            const etherBalance = web3.utils.fromWei(weiBalance, 'ether');
            setBalance(etherBalance.slice(0, 8));

            const contractInstance = new web3.eth.Contract(abi, contract_address);
            setContract(contractInstance);
          } else {
            console.error('No accounts found');
          }
        } catch (error) {
          console.error('Error fetching account balance:', error);
        }
      } else {
        alert('Install MetaMask extension!');
      }
    };

    initializeContract();
  }, []);

Возвращать будем дочерние компоненты обернутые в ContractContext.Provider и наши стейты с аккаунтами, балансом и т.д.

return (
    <ContractContext.Provider value={{ contract, account, balance, accounts }}>
      {children}
    </ContractContext.Provider>
);

Полный код контекста

Hidden text
// ContractContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import Web3 from 'web3';
import { contract_address } from '../config/contract';
import abi from '../config/abi.json';

const ContractContext = createContext();

export const ContractProvider = ({ children }) => {
  const [contract, setContract] = useState(null);
  const [account, setAccount] = useState(null);
  const [accounts, setAccounts] = useState([]);
  const [balance, setBalance] = useState();

  useEffect(() => {
    const initializeContract = async () => {
      const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:7545');
      if (window.ethereum) {
        try {
          const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
          if (accounts && accounts.length > 0) {
            setAccounts(accounts);
            setAccount(accounts[0]);
  
            const weiBalance = await web3.eth.getBalance(accounts[0]);
            const etherBalance = web3.utils.fromWei(weiBalance, 'ether');
            setBalance(etherBalance.slice(0, 8));

            const contractInstance = new web3.eth.Contract(abi, contract_address);
            setContract(contractInstance);
          } else {
            console.error('No accounts found');
          }
        } catch (error) {
          console.error('Error fetching account balance:', error);
        }
      } else {
        alert('Install MetaMask extension!');
      }
    };

    initializeContract();
  }, []);

  return (
    <ContractContext.Provider value={{ contract, account, balance, accounts }}>
      {children}
    </ContractContext.Provider>
  );
};

export const useContract = () => {
  const context = useContext(ContractContext);
  if (!context) {
    throw new Error('useContract must be used within a ContractProvider');
  }
  return context;
};

Не забываем обернуть router и app в наш ContractProvider.

Верстка страниц и создание компонентов

Далее по большому счету будет обычная верстка и создание компонентов, код всех css файлов будет в конце, не вижу смысла акцентировать внимание на дизайне.

В первую очередь создадим свой header, т.к. функционал сильно ограничен, давим на него название приложения и иконку профиля для перехода на другую страницу.

Создаем папку components для наших компонентов и папку с константами, в нее мы добавляем главный цвет приложения.

В CustomHeader просто добавляет заголовок для названия и картинку для кнопки перехода на страницу профиля. Для перехода используем Link из react-router-dom.

export default function CustomHeader() {
  return (
    <div style={{
        height: '10vh', 
        backgroundColor: theme.primaryColor,
        display: 'flex', 
        alignItems: 'center',
        justifyContent: 'space-between',
        paddingInline: '10%'
      }}
      >

      <Link to={"/"}><h1 style={{color: 'white'}}>Twitter dApp</h1></Link>
      <Link to={"/profile"}>
        <img style={{
          width: '6vh', 
          height: '6vh'}} 
          src={process.env.PUBLIC_URL + '/userIcon.png'} 
          alt='user'/>
      </Link>
    </div>
  )
}

Также создадим папку pages и в ней 4 страницы: главная, профиль, профиль другого пользователя и страницу "404". Сейчас в них только div с названием страницы

Переходим в файл App.js и импортируем наш CustomHeader. Внутри функции добавляем наш CustomHeader и routes. В качестве элементов страниц передаем наши страницы из папки pages.

У страницы "404" в качестве пути указываем "*". Теперь если пользователь поменяет путь на несуществующий (любой кроме тех, что мы объявили), ему выдаст страницу с ошибкой.

У страницы другого пользователя добавляем к пути динамический параметр ":userAdr" чтобы в параметрах запроса передавать адрес пользователя и получать его на странице.

function App() {
  return (
    <div className="App">
      <CustomHeader/>

      <Routes>
        <Route path="/" element={<Home/>} />
        <Route path="/profile" element={<Profile/>} />
        <Route path='/user/:userAdr' element={<User/>} />
        <Route path='*' element={<ErrorPage/>} />
      </Routes>
    </div>
  );
}

На странице с ошибкой добавляем базовую надпись и кнопку возвращающую на главную страницу ("/").

return (
    <div className='App-header'>
      <h1 style={{textAlign: 'center'}}>Oops...<br/>Page not found</h1>
      <Link to="/" style={{
        backgroundColor: '#3366FF', 
        padding: '10px',
        paddingInline: '20px', 
        borderRadius: '5px',
        color: 'white'}}>Go home</Link>
    </div>
)

Запускаем Ganache и клиент:

cd client
npm run start

После этого видим наш header и открытое окно MetaMask с подключением аккаунта к приложению. Выбираем первый аккаунт и подключаем.

Также если изменим путь страницы на несуществующий, то увидим страницу с ошибкой. Все работает и осталось добавить компонент твитта и контент для профиля.

В папке components создаем файл Twitt.jsx, в качестве аргумента, он будет принимать сам твитт, который будет передаваться на странице вывода твиттов.

Из нашего контекста импортируем только адрес аккаунта, он пригодится для проверки на автора. Объявляем константу isOwner, ее значение зависит от того, является ли авторизованный пользователь автором твитта. Также переводим дату в понятный для пользователя формат.

const { account } = useContract();
const isOwner = account == twit.author.login.toLowerCase();

const date = new Date(Number(twit.createdTime) * 1000);
const formattedDateTime = date.toLocaleString();

Далее просто выводим в div блоки поля из переданного твитта и заворачиваем аватарку автора и никнейм в Link, чтобы при нажатии делать переход на страницу пользователя.

Если авторизованный пользователь является автором твитта, то переход будет не на страницу автора, а в профиль. При переходе на страницу автора передаем в параметрах его адрес, который хранится в twitt.

P.S: Правильно будет сделать одну страницу профиля и разграничить логику и дизайн для владельца и гостя. Но в рамках данного приложения мы разделили страницу на две.

import React from 'react'
import theme from '../constants/colors'
import './Twitt.css';
import { Link } from 'react-router-dom';
import { useContract } from '../contexts/ContractContext';

export default function Twitt({twit}) {
    const { account } = useContract();
    const isOwner = account == twit.author.login.toLowerCase();
    
    const date = new Date(Number(twit.createdTime) * 1000);
    const formattedDateTime = date.toLocaleString();

    return (
        <div className='Twitt'>
            <Link style={{color: 'black'}} to={isOwner ? '/profile' : `/user/${twit.author.login}`}>
                <div style={{display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '2vh'}}>
                    <img className='twitt-pic' src={twit.author.avatar ? twit.author.avatar : `${process.env.PUBLIC_URL}/profile-pic.png`} alt="Profile Picture"/>
                    <div>
                        <h3>@{twit.author.username}</h3>
                        <h3 style={{color: theme.primaryColor}}>{formattedDateTime}</h3>
                    </div>
                </div>
            </Link>
            <h3 style={{marginTop: '2vh'}}>{twit.text}</h3>
            <div className="likes">
                <img src={process.env.PUBLIC_URL+'/like.png'} className='like'/>
                <h3>{twit.likes.toString()}</h3>
            </div>
        </div>
    )
}

Переходим к странице профиля, в ней сразу объявляем несколько стейтов. Для удобства я не стал выносить формы входа и обновление аватарки в отдельные компоненты или модальные окна, поэтому все будет хранить внутри одной страницы.

Здесь мы импортируем из контракта баланс и адрес пользователя, чтобы отобразить его в профиле, а также сам контракт для работы с ним.

Также объявляем состояние для текстовых полей регистрации, входа и текста твитта и несколько boolean состояний. isOpened нужен для отслеживания нажатия кнопки включения изменения аватарки пользователя (при значении true будет отображаться поле для ввода ссылки на картинку).

const { contract, balance, account } = useContract();
const [user, setUser] = useState();
const [link, setLink] = useState();

const [text, setText] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');

const [isOpened, setIsOpened] = useState(false);
const [isLogged, setIsLogged] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);

const [twitts, setTwitts] = useState([]);

Далее объявляем useEffect, который будет обновляться при изменении одного из трех стейтов (контракт, регистрация или вход). Если контракт инициализирован, то получает состояние авторизации из localStorage, также вызываем функцию проверяющую зарегистрирован пользователь или нет. Если пользователь авторизован, то получаем его данные и твитты. Сами функции будут описаны ниже.

useEffect(() => {
  if (contract) {
    const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
    setIsLogged(isLoggedIn);

    CheckRegistration();
    
    if(isLogged) {
      GetUserData();
      getUserTwitts();
    }
  }
}, [isLogged, contract, isRegistered]);

Создадим универсальную функцию для работы с контрактом, в ней мы будем получать в параметрах название функции в смарт-контракте и необходимые для передачи аргументы.

Также с помощью estimateGas узнаем стоимость транзакции и передаем ее вторым аргументом. В Ganache также можно указать 0-ую цену транзакций и не указывать газ при вызове функций. Все функции будут вызываться с адреса пользователя, поэтому указываем from: account.

const sendTransaction = async (method, args) => {
  try {
    const gasEstimation = await method(...args).estimateGas({ from: account });
    await method(...args).send({ from: account, gas: gasEstimation });
  } catch (err) {
    alert(err);
  }
};

Теперь создаем функции регистрации, авторизации и написания твитта в которых проверяем, что переданные аргументы не пустые и передаем их в функцию sendTransaction, в качестве method, передаем название функции в самом смарт-контракте.

Hidden text
const AddTwitt = async(_text) => {
  if(!_text) return;

  try {
    await sendTransaction(contract.methods.AddTwitt, [_text]);

    setText('');
    getUserTwitts();
  } catch (err) {
    alert(err);
  }
}

const Registration = async(_username, _password) => {
  if(!_username || !_password) return;
  try {
    await sendTransaction(contract.methods.Registration, [_username, _password]);

    setIsRegistered(true);
  } catch (err) {
    alert(err);
  }
}

const Login = async(_password) => {
  if(!_password) return;
  try {
    await sendTransaction(contract.methods.Login, [_password]);

    localStorage.setItem('isLoggedIn', true);
    setIsLogged(true);
  } catch (err) {
    alert(err);
  }
}

В функция с получение данных, мы обращаемся к функция контракта через call, и не платим за их вызов, поэтому не передаем стоимость газа. В качестве аргумента передаем адрес пользователя, т.к. получаем его данные.

const getUserTwitts = async() => {
  try {
    const twitts = await contract.methods.UserTwitts(account).call({ from: account });

    setTwitts(twitts);
  } catch (err) {
    alert(err);
  }
}

const GetUserData = async() => {
  try {
    const user = await contract.methods.GetUser(account).call({ from: account });

    setUser(user);
  } catch (err) {
    alert(err);
  }
}

Далее обычная верстка страницы, рассмотрим основные моменты.

Во первых добавляем условие, что если пользователь не вошел в аккаунт и не зарегистрирован, то выводим ему поля для регистрации, и при успешной регистрации или если пользователь уже имеет аккаунт, у него будет только поле для ввода пароля (ведь логином является адрес аккаунта).
Если пользователь уже в аккаунте, то отображаем кнопку выхода.

<div>
  {
    isLogged 
      ? (<button className='edit-btn' onClick={() => Exit()}>exit</button>)
      : (
          isRegistered
          ? (
            <div className='registration'>
              <input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
              <button className='edit-btn' style={{margin: '0'}} onClick={() => Login(password)}>login</button>
            </div>
            )
          : (
            <div className='registration'>
              <input type='text' placeholder='username' value={username} onChange={e => setUsername(e.target.value)}/>
              <input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
              <button className='edit-btn' style={{margin: '0'}} onClick={() => Registration(username, password)}>registration</button>
            </div>
            )
        )
  }
</div>

Также в боковом меню отображаем кнопку обновления аватарки, при нажатии на которую будет появляться текстовое поле для ввода ссылки и новая кнопка.

<div className='sidebar'>
  {user && <h2 style={{marginBottom: '4vh'}}>
    <span style={{color: 'gray'}}>Username:</span><br/>@{user.username}
  </h2>}
  {isOpened && <input type='text' style={{marginBottom: '1vh'}} placeholder='link to img' value={link} onChange={e => setLink(e.target.value)}/>}
  {isOpened 
    ? <button className='edit-btn' onClick={() => UpdateAvatar(link)}>save</button>
    : <button className='edit-btn' onClick={() => setIsOpened(true)}>update avatar</button>
  }
</div>

Для вывода всех твиттов через map проходим по массиву твиттов и передаем каждый твитт в наш компонент Twitt, в качестве ключа передаем дату создания твитта, также сортируем их по времени, чтобы сначала отображались новые.

Лучше

{
  twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
    return (
      <Twitt key={twit.createdTime} twit={twit} />
    );
  })
}

Полный код страницы профиля

Hidden text
import React from 'react'
import '../App.css';
import { useEffect, useState } from 'react';
import './Profile.css';
import theme from '../constants/colors';
import { useContract } from '../contexts/ContractContext';
import Twitt from '../components/Twitt';

export default function Profile() {
    const { contract, balance, account } = useContract();
    const [user, setUser] = useState();
    const [link, setLink] = useState();

    const [text, setText] = useState('');
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');

    const [isOpened, setIsOpened] = useState(false);
    const [isLogged, setIsLogged] = useState(false);
    const [isRegistered, setIsRegistered] = useState(false);
    
    const [twitts, setTwitts] = useState([]);

    useEffect(() => {
      if (contract) {
        const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
        setIsLogged(isLoggedIn);

        CheckRegistration();
        
        if(isLogged) {
          GetUserData();
          getUserTwitts();
        }
      }
    }, [isLogged, contract, isRegistered]);

    const sendTransaction = async (method, args) => {
      try {
        const gasEstimation = await method(...args).estimateGas({ from: account });
        await method(...args).send({ from: account, gas: gasEstimation });
      } catch (err) {
        alert(err);
      }
    };

    const AddTwitt = async(_text) => {
      if(!_text) return;

      try {
        await sendTransaction(contract.methods.AddTwitt, [_text]);

        setText('');
        getUserTwitts();
      } catch (err) {
        alert(err);
      }
    }

    const Registration = async(_username, _password) => {
      if(!_username || !_password) return;
      try {
        await sendTransaction(contract.methods.Registration, [_username, _password]);

        setIsRegistered(true);
      } catch (err) {
        alert(err);
      }
    }

    const Login = async(_password) => {
      if(!_password) return;
      try {
        await sendTransaction(contract.methods.Login, [_password]);

        localStorage.setItem('isLoggedIn', true);
        setIsLogged(true);
      } catch (err) {
        alert(err);
      }
    }

    const getUserTwitts = async() => {
      try {
        const twitts = await contract.methods.UserTwitts(account).call({ from: account });

        setTwitts(twitts);
      } catch (err) {
        alert(err);
      }
    }

    const GetUserData = async() => {
      try {
        const user = await contract.methods.GetUser(account).call({ from: account });

        setUser(user);
      } catch (err) {
        alert(err);
      }
    }

    const UpdateAvatar = async(link) => {
      if(!link) return;
      try {
        await sendTransaction(contract.methods.UpdateUser, [link]);

        setLink('');
        setIsOpened(false);
        GetUserData();
      } catch (err) {
        alert(err);
      }
    }

    const Exit = async() => {
      try {
        await sendTransaction(contract.methods.Logout, []);

        localStorage.setItem('isLoggedIn', false);
        setIsLogged(false);
        setTwitts([]);
      } catch (err) {
        alert(err);
      }
    }

    const CheckRegistration = async() => {
      try {
        const result = await contract.methods.CheckRegistration(account).call({ from: account });
        setIsRegistered(result);
      } catch (err) {
        alert(err);
      }
    }

    return (
      <div className='main'>
        <div className='container' 
          style={{
            alignItems: 'start', 
            justifyContent: 'flex-start',
            gap: '5vh'}}>
          <div>
            <img src={user && user.avatar ? user.avatar : process.env.PUBLIC_URL + '/basicProfile.png'} alt="avatar" className='avatar' />
          </div>
          <div className='user-info'>
            <div style={{marginTop: '5vh'}}>
              <h2>Account: <span style={{color: theme.primaryColor}}>{account}</span></h2>
              <h2>Balance: {balance} BEBRA</h2>
            </div>
            {
              isLogged 
                ? (<button className='edit-btn' onClick={() => Exit()}>exit</button>)
                : (
                    isRegistered
                    ? (
                      <div className='registration'>
                        <input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
                        <button className='edit-btn' style={{margin: '0'}} onClick={() => Login(password)}>login</button>
                      </div>
                      )
                    : (
                      <div className='registration'>
                        <input type='text' placeholder='username' value={username} onChange={e => setUsername(e.target.value)}/>
                        <input type='text' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
                        <button className='edit-btn' style={{margin: '0'}} onClick={() => Registration(username, password)}>registration</button>
                      </div>
                      )
                  )
            }
          </div>
        </div>
        
        <div className="container" style={{
            alignItems: 'start', 
            justifyContent: 'flex-start', 
            marginBottom: '5vh',
            gap: '5vh'
            }}>
          
          {isLogged && <div className='sidebar'>
            {user && <h2 style={{marginBottom: '4vh'}}>
              <span style={{color: 'gray'}}>Username:</span><br/>@{user.username}
            </h2>}
            {isOpened && <input type='text' style={{marginBottom: '1vh'}} placeholder='link to img' value={link} onChange={e => setLink(e.target.value)}/>}
            {isOpened 
              ? <button className='edit-btn' onClick={() => UpdateAvatar(link)}>save</button>
              : <button className='edit-btn' onClick={() => setIsOpened(true)}>update avatar</button>
            }
          </div>}
          <div style={{display: 'flex', flexDirection: 'column', gap: '5vh', width: '80%'}}>
            {isLogged && <div className='add-twitt'>
              <textarea className='input_twitt' type='text' placeholder='What are you thinking?' value={text} onChange={e => setText(e.target.value)}/>
              <button onClick={() => AddTwitt(text)} disabled={!isLogged} className='add-btn'>Twitt it</button>
            </div>}

            {
              twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
                return (
                  <Twitt key={twit.createdTime} twit={twit} />
                );
              })
            }
          </div>
        </div> 
      </div>
    )
}

Переходим на страницу профиля и видим поля для регистрации. Создадим аккаунт и попробуем войти в него.

После входа в аккаунт мы видим все наши блоки, попробуем обновить фото вставим ссылку на картинку из браузера, а также напишем твитт.

После успешного написания твитта сразу видим его в ленте, также видим нашу новую картинку (также можно поставить гифку).

Главная страница

Теперь дело за малым, осталось сделать главную страницу со всеми твиттами других пользователей. Для этого также создаем стейт для твиттов и импортируем из контекста все аккаунты, вместе с аккаунтом самого пользователя.

Создаем функцию для получения всех твиттов. В ней мы проходим по всем аккаунтам и получаем твитты пользователя, чей адрес находится на текущей итерации. Все твитты заносим в сет, чтобы хранить только уникальные твитты и после завершения цикла заносим твитты в стейт.

Не лучший пример получения данных, и по большому счету стоит написать функцию на сервере, которая вернет все твитты. Но в рамках данного проекта, для простоты освоения оставим такое решение.

const getAllTwitts = async() => {
  try {
    const uniqueTwittsSet = new Set();

    for (const acc of accounts) {
      if(acc != account) {
        const _twitts = await contract.methods.UserTwitts(acc).call({ from: account });
      
        _twitts.forEach((twit) => uniqueTwittsSet.add(twit));
      }
    };

    const uniqueTwittsArray = [...uniqueTwittsSet];

    setTwitts(uniqueTwittsArray);
  } catch (err) {
    alert(err);
  }
}

Далее также через map, выводим твитты.

import React, {useEffect, useState} from 'react'
import { useContract } from '../contexts/ContractContext';
import Twitt from '../components/Twitt';

export default function Home() {
  const { contract, accounts, account } = useContract();
  const [twitts, setTwitts] = useState([]);

  const getAllTwitts = async() => {
    try {
      const uniqueTwittsSet = new Set();

      for (const acc of accounts) {
        if(acc != account) {
          const _twitts = await contract.methods.UserTwitts(acc).call({ from: account });
        
          _twitts.forEach((twit) => uniqueTwittsSet.add(twit));
        }
      };

      const uniqueTwittsArray = [...uniqueTwittsSet];

      setTwitts(uniqueTwittsArray);
    } catch (err) {
      alert(err);
    }
  }

  useEffect(() => {
    if (contract) {
      getAllTwitts();
    }
  }, [contract]);

  return (
    <div className='main'>
      <div className="container" style={{display: 'flex', justifyContent: 'center', marginBottom: '5vh'}}>
        <div style={{display: 'flex', flexDirection: 'column', gap: '5vh', width: '80%'}}>
          {
            twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
              return (
                <Twitt key={twit.createdTime}  twit={twit} />
              );
            })
          }
        </div>
      </div>
    </div>
  )
}

Выйдем из аккаунта в профиле и переключим аккаунт в MetaMask, также пройдем этап регистрации для нового аккаунта и напишем твитт.

Переключаем аккаунт

И теперь на главной странице мы видим твитт первого пользователя, т.к. зашли за аккаунт второго.

Если мы нажмем на никнейм или картинку пользователя, то перейдем на его страницу и видим в ссылке на страницу его адрес, осталось скопировать верстку страницы профиля и убрать все лишнее.

Из всех функций и элементов страницы оставляем только получение данных пользователя и его твитты. Здесь уже в качестве аргумента передаем адрес пользователя полученного из параметров страницы (его мы указывали в маршрутизации как userAdr).

P.S: Повторюсь, что не стоит так делать и правильно будет сделать одну страницу для авторизованного пользователя и других юзеров.

import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom';
import '../App.css';
import './Profile.css';
import theme from '../constants/colors';
import { useContract } from '../contexts/ContractContext';
import Twitt from '../components/Twitt';

export default function User() {
    const { contract, account } = useContract();
    const { userAdr } = useParams();
    const [user, setUser] = useState();
    const [twitts, setTwitts] = useState([]);

    useEffect(() => {
      if (contract) {
          GetUserData();
          getUserTwitts();
      }
    }, [contract]);

    const getUserTwitts = async() => {
      try {
        const twitts = await contract.methods.UserTwitts(userAdr).call({ from: account });

        setTwitts(twitts);
      } catch (err) {
        alert(err);
      }
    }

    const GetUserData = async() => {
      try {
        const _user = await contract.methods.GetUser(userAdr).call({ from: account });

        setUser(_user);
      } catch (err) {
        alert(err);
      }
    }

    return (
      <div className='main'>
        <div className='container' 
          style={{
            alignItems: 'start', 
            justifyContent: 'flex-start',
            gap: '5vh'}}>
          <div>
            <img src={user && user.avatar ? user.avatar : process.env.PUBLIC_URL + '/basicProfile.png'} alt="avatar" className='avatar' />
          </div>
          <div className='user-info'>
            <div style={{marginTop: '5vh'}}>
              <h2>Account: <span style={{color: theme.primaryColor}}>{userAdr}</span></h2>
            </div>
          </div>
        </div>
        
        <div className="container" style={{
            alignItems: 'start', 
            justifyContent: 'flex-start', 
            marginBottom: '5vh',
            gap: '5vh'
            }}>
          
          <div className='sidebar'>
            {user && <h2 style={{marginBottom: '4vh'}}>
              <span style={{color: 'gray'}}>Username:</span><br/>@{user.username}
            </h2>}
          </div>
          <div style={{display: 'flex', flexDirection: 'column', gap: '5vh', width: '80%'}}>
            {
              twitts.sort((a, b) => Number(b.createdTime) - Number(a.createdTime)).map((twit) => {
                return (
                  <Twitt key={twit.createdTime} twit={twit} />
                );
              })
            }
          </div>
        </div> 
      </div>
    )
}
Страница другого пользователя

На этом клиентская часть готова. Ниже будет код для всех css файлов.

Twitt.css

Hidden text
.Twitt {
    width: calc(100%-2vh);
    height: auto;
    padding: 2vh;
    padding-top: 0.5vh;
    border-color: var(--primary-color);
    border-style: groove;
    border-width: 2px;
    border-radius: 2vh;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: start;
}

h3, h2 {
    text-align: left;
    margin: 0;
    padding: 0;
}

.likes {
    margin-top: 4vh;
    display: flex;
    flex-direction: row;
    gap: 1vh;
    align-items: center;
}

.like {
    width: 4vh;
    height: 4vh;
}

.twitt-pic {
    width: 6vh;
    height: 6vh;
}

Profile.css

Hidden text
.avatar {
    height: 30vh;
    width: 30vh;
    border-radius: 3vh;
}

.user-info {
    height: 30vh;
    display: flex;
    text-align: left;
    flex-direction: column;
    justify-content: space-between;
}

.edit-btn {
    cursor: pointer;
    margin-bottom: 5vh;
    min-width: 20vh;
    width: auto;
    max-width: 30vh;
    height: 5vh;
    color: white;
    font-size: 1.2rem;
    background-color: var(--primary-color);
    border-radius: 2vh;
    border-style: none;
}

.registration {
    display: flex;
    align-items: center;
    gap: 1.5vh;
}

.sidebar {
    height: auto;
    width: 30vh; 
    display: flex;
    flex-direction: column; 
    justify-content: left;
}

input {
    padding-inline: 5px;
    outline: none;
    height: 5vh;
    border-style: inset;
    border-color: var(--primary-color);
    border-radius: 2vh;
}

.add-twitt {
    padding: 1vh;
    border-style: groove;
    border-width: 2px;
    border-radius: 10px;
    border-color: black;
    height: 15vh;
    display: flex;
    flex-direction: row;
    justify-content: space-between;
}

.input_twitt {
    outline: none;
    border-style: none;
    width: 90%;
    word-wrap: break-word;
    resize: none;
}

.add-btn {
    place-self: center;
    cursor: pointer;
    width: 15vh;
    height: 5vh;
    color: white;
    font-size: 1.2rem;
    background-color: var(--primary-color);
    border-radius: 1vh;
    border-style: none;
}

App.css

Hidden text
.App {
  text-align: center;
}

.App-logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .App-logo {
    animation: App-logo-spin infinite 20s linear;
  }
}

.App-header {
  background-color: white;
  min-height: 90vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: #282c34;
}

.App-link {
  color: #61dafb;
}

index.css

Hidden text
:root {
  --primary-color: #3366ff;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

a {
  text-decoration: none;
}

h2 {
  margin: 0;
}

.main {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.container {
  width: 80%;
  margin-top: 5vh;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

Спасибо за уделенное время, надеюсь статья была полезна и понятна.