DEV Community

Dillion Megida
Dillion Megida

Posted on • Originally published at getstream.io

Twitter Clone Part 1: Connecting Users to Stream Feeds and Creating a Tweet

Build a Twitter Clone Part 1 Feature Image

In this article, the first part of the Build a Twitter Clone series, you will be creating a Twitter clone, which connects and authenticates a selected user with your Stream app. For this tutorial, you will make the layout and add support for creating tweets in the clone using Stream activity feeds.

Let us get started.

Developer Setup

To start building the clone, you need to install dependencies from the npm library. To install dependencies, you need to have Node installed on your system. Alternatively, you can use yarn, but I will be using npm in this article.

I used node version 16.13.1 for this tutorial, so I recommend you use the same to code along.

Setting Up your Stream Dashboard

A Stream Feeds App handles all the backend services for managing feeds, such as creating activities, adding reactions to activities, following and unfollowing activities, etc.

Create A Feeds App on Your Stream Dashboard

To create a feeds app on Stream, you need a Stream account. If you do not have one, head over to the register page for a free trial account or register for a Maker Account for free indefinite access to Stream.

To create a Stream app for feeds:

  1. Go to your Stream dashboard after logging in
  2. Select Create App
  3. Enter a name for the app (for this tutorial, I will use streamer, the fancy name for the clone)
  4. Set your Server Locations
  5. Set the Environment to Development
  6. And finally, select Create App.

After creating the app, select Activity Feeds from the sidebar:

Selecting activity feeds from the left sidebar

Here is the overview of the Feeds dashboard:

Overview of feeds dashboard

You have the App ID, API Key, and API Secret. You will need these values later when you want to connect to Stream from your client app.

Create Feed Groups

Currently, there are no feed groups in your app:

Empty feed groups

A feed group is used for grouping similar activities together. For example, in this tutorial, you will have a:

  • "timeline" feed group for activities made by users that a user follows
  • "user" feed group for activities made by a user
  • "notification" feed group for notification activities originating from follow or reaction actions

Adding a feed group

For the timeline and user group, use a flat feed type, and a notification group with a notification feed type.

With Stream set up, you can now create the client application.

Create Your Twitter Clone Application

We will use create-react-app (CRA) to create the React application. On your terminal, run:

npx create-react-app streamer
cd streamer
Enter fullscreen mode Exit fullscreen mode

This command will create a starter React project. Now, you need to install the required dependencies. These dependencies are broken into two categories.

Stream's dependencies

  • getstream: official JavaScript client for Stream Feeds
  • react-activity-feed: built on the getstream library for providing React components to integrate activity feeds into your application.

Other dependencies

  • react-router-dom: for adding routes for different pages in the application. You will use it to add pages for the starter login page, home page, profile page, and notifications page
  • classnames: utility library for dynamically combining classes
  • date-fns: for formatting dates in a readable manner
  • styled-components: for CSS-in-JS styles
  • nanoid: for generating unique IDs. You will use this to generate IDs for tweets

Install the dependencies by running:

npm install getstream react-activity-feed react-router-dom classnames date-fns styled-components nanoid
Enter fullscreen mode Exit fullscreen mode

If you come across a dependency resolution error for react and react-activity-feed similar to this:

20. Dependency error

You can add the --force flag to the npm install command. This will ignore the resolution error. The error above occurs because CRA installs the latest versions of react and react-dom (which is version 18, released recently), but Stream’s dependencies haven’t been updated to support React v18 yet. In this project, we won’t be using specific React v18 features.

Folder Structure of the Application

To keep your code organized and so you can follow this tutorial correctly, you should use the following folder structure for this application.

After starting the project with CRA, you should get this:

├── README.md
├── package-lock.json
├── package.json
├── node_modules
├── public
| ├── favicon.ico
| ├── index.html
| ├── logo192.png
| ├── logo512.png
| ├── manifest.json
| └── robots.txt
└── src
├── App.css
├── App.js
├── App.test.js
├── index.css
├── index.js
├── logo.svg
├── reportWebVitals.js
└── setupTests.js

You will need new folders to improve the structure. Create the following folders:

  • src/components: where the components--the building blocks in your application--will be created
  • src/pages: where the page components (profile, notifications, etc.) will be created
  • src/hooks: where the custom hooks you create in this tutorial will live
  • src/utils: where the utilities will live

With these folders created, you should have the following structure:

├── README.md
├── package-lock.json
├── package.json
├── public
| ├── favicon.ico
| ├── index.html
| ├── logo192.png
| ├── logo512.png
| ├── manifest.json
| └── robots.txt
└── src
├── App.css
├── App.js
├── App.test.js
├── components/
├── hooks/
├── index.css
├── index.js
├── logo.svg
├── reportWebVitals.js
├── setupTests.js
├── utils/
└── pages/

Create Starter Page for Selecting Users

The starter page for this application shows different demo users that a user can select from to use Streamer:

Starter page that user will build

Ideally, there should be a login form that sends requests to a backend server, which authenticates the user's credentials with the database. For demonstration purposes, we will stick with demo users.

Add Demo Users

Create a new file called src/users.js and paste the following code:

const users = [
  {
    id: 'iamdillion',
    name: 'Dillion',
    image: 'https://dillionmegida.com/img/deee.jpg',
    bio: 'Just here, doing my thing. Developer advocate at @getstream_io',
    token: 'ENTER TOKEN FOR iamdillion',
  },
  {
    id: 'getstream_io',
    name: 'Stream',
    image: 'https://avatars.githubusercontent.com/u/8597527?s=200&v=4',
    bio: 'Deploy activity feeds and chat at scale with Stream – an API driven platform powering over a billion end users. Get started at http://getstream.io.',
    token: 'ENTER TOKEN FOR getstream_io',
  },
  {
    id: 'jake',
    name: 'Jake',
    image: 'https://picsum.photos/300/300',
    bio: 'Just Jake, nothing much',
    token: 'ENTER TOKEN FOR jake',
  },
  {
    id: 'joe',
    name: 'Joe',
    image: 'https://picsum.photos/200/200',
    bio: 'How are you?',
    token: 'ENTER TOKEN FOR joe',
  },
  {
    id: 'mike',
    name: 'Mike',
    image: 'https://picsum.photos/400/400',
    bio: 'I am mike here. I do things on #react and #javascript',
    token: 'ENTER TOKEN FOR mike',
  },
]

export default users
Enter fullscreen mode Exit fullscreen mode

This is an array of users. Each user object has an id which is a required property to connect the user to Stream feeds. This id will also be used as the Streamer username of each user. Each object also has a name, image, and bio property.

In a live application, the token should also be generated from the backend server using the API Key and Secret Key of your Stream app, but for tutorial purposes, you can manually generate tokens on generator.getstream.io using the user's id and your application's API Key and Secret Key. When you generate a token for a user, replace it in the users.js file.

Stream uses User Tokens to authenticate users--to confirm that users have access to your Stream application.

Create a Storage Utility

Next, create a storage utility in src/utils/storage.js. This utility handles storing and retrieving data from local storage. Add the following code to this file:

export const saveToStorage = (key, value) =>
  window.localStorage.setItem(key, value)

export const getFromStorage = (key) => window.localStorage.getItem(key)
Enter fullscreen mode Exit fullscreen mode

You will use this utility to save the selected user id from the start page. This way, the user will not have to choose a user on every refresh.

Add Global Default Styles

You need to add global default styles for buttons, links, and other elements. Replace the content of src/index.css with the following:

:root {
  --theme-color: #f91680;
  --faded-theme-color: #f916803c;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background-color: black;
  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;
}

button {
  border: none;
  background: none;
  cursor: pointer;
  text-align: left;
}

button:disabled {
  cursor: not-allowed;
}

h1,
h2,
h3,
h4,
h5,
h6,
p {
  margin: 0;
}

input,
textarea {
  font-family: inherit;
}

span {
  display: block;
}

a {
  text-decoration: none;
}
Enter fullscreen mode Exit fullscreen mode

The --theme-color variable will be used in many parts of the application.

Create the StartPage Component

Create a new file src/views/StartPage.jsfor the start page, and paste the following. Start from the imports and styles:

import styled from 'styled-components'

import users from '../users'
import { saveToStorage } from '../utils/storage'

const Main = styled.main`
  background-color: black;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100vh;
  flex-direction: column;

  h1 {
    text-align: center;
    color: white;
    font-size: 20px;
    margin-bottom: 20px;
  }

  .users {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 300px;
    margin: 0 auto;

    &__user {
      display: flex;
      flex-direction: column;
      img {
        width: 50px;
        height: 50px;
        border-radius: 50%;
        margin-bottom: 5px;
      }
      .name {
        margin: 10px auto;
        color: white;
        text-align: center;
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

And next, the component:

export default function Startpage() {
  const onClickUser = (id) => {
    saveToStorage('user', id)
    window.location.href = '/home'
  }

  return (
    <Main>
      <h1>Select a user</h1>
      <div className="users">
        {users.map((u) => (
          <button
            onClick={() => onClickUser(u.id)}
            className="users__user"
            key={u.id}
          >
            <img src={u.image} alt="" />
            <span className="name">{u.name}</span>
          </button>
        ))}
      </div>
    </Main>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the StartPage component, you loop through the users on the page, and on clicking a user, you save the user's id to local storage and navigate to the /home path.

Note: For some of the colors in this application, I randomly selected a close color code, but for some notable colors (like a black background, pink highlights, etc.), I used the Eye Dropper Chrome extension to pick the color from the Twitter page.

Next, you have to configure React Router to show the start page on the index path.

Configure Route for the StartPage Component

Replace the content of src/App.js with the following:

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'

import StartPage from './pages/StartPage'

export default function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<StartPage />} />
      </Routes>
    </Router>
  )
}
Enter fullscreen mode Exit fullscreen mode

Start the development server by running the npm run start command on your terminal. On http://localhost:3000, you will get the users on the screen.

When you click on a user on this page, the browser navigates to /home, which should show the home page of the logged-in user.

Create the User Homepage

In this section, you will create a homepage for the user.

Here is what the result of this section will look like:

Home page user will build

Home page with Create Tweet Modal

Add Icon Components

A lot of icons are used throughout this project. I got the icons from remixicon and made them reusable React components. You can find all the icons in this archived file in the repo. Create a new folder src/components/Icons and save all the icons from the archive there.

All icons have a size and color property that you can use to customize the icon's look. Some icons also have the fill property, which specifies if the icon should be in stroke form or fill form.

Connect a User to Stream Feeds in App.js

The next step is to connect the selected user from the start page to the Feeds App on your Stream dashboard. To connect a user to Stream, you first need to create an instance of your Stream app in your React application. To do this, you use the StreamClient constructor from the getstream library. After creating the instance, then you can connect the user to Stream. And with the StreamApp component from the React SDK, you can provide feed methods and data to other components.

In your App.js file, add the following imports to the existing imports:

import { useEffect, useState } from 'react'
import { StreamClient } from 'getstream'
import { StreamApp } from 'react-activity-feed'
import users from './users'
import { getFromStorage } from './utils/storage'
Enter fullscreen mode Exit fullscreen mode

Using getFromStorage, you will get the user's id, and find that user in the users array. If such a user exists, then you connect them to Stream. This approach is our own method of authentication for development 😁

To connect to your feeds application, you need your App ID and API Key. You can get these from your dashboard, as shown in the screenshot below:

Get app id and api key

Assign these values to variables in App.js like this:

const APP_ID = '1183905'
const API_KEY = 'mx8gc4kmvpec'
Enter fullscreen mode Exit fullscreen mode

Before the return statement in the App component, add these lines of code:

function App() {
  const userId = getFromStorage('user')

  const user = users.find((u) => u.id === userId) || users[0]

  const [client, setClient] = useState(null)

  useEffect(() => {
    async function init() {
      const client = new StreamClient(API_KEY, user.token, APP_ID)

      await client.user(user.id).getOrCreate({ ...user, token: '' })

      setClient(client)
    }

    init()
  }, [])

  if (!client) return <></>

  return (
    // ...
  )
}
Enter fullscreen mode Exit fullscreen mode

First, you get the user's id. Next, you find the user from the users array. If the user does not exist, you set the user variable as the first user in the array.

You also keep track of the client state you will use in a second.

When the component mounts, you connect the user to Stream. The component must mount first because connecting a user to Stream creates a WebSocket connection on the browser. The useEffect hook with an empty dependency array runs when the component mounts.

In the useEffect hook, you create the app instance using your API_KEY, the user's token, and your APP_ID. Using the instance, you can define a user by their id, and add the user to the Stream database if they do not exist already using the getOrCreate method. As the name implies, this method retrieves the user's info from the database, and if the user does not exist, it adds the user to the database. You can find the user feeds in your dashboard explorer:

Users in the feeds database

After connecting the user, you update the client state. Now, you can use the client object. In the App component, wrap the elements with the StreamApp component like this:

function App() {
  // ...

  return (
    <StreamApp token={user.token} appId={APP_ID} apiKey={API_KEY}>
      <Router>
        <Routes>
          <Route path="/" element={<StartPage />} />
        </Routes>
      </Router>
    </StreamApp>
  )
}
Enter fullscreen mode Exit fullscreen mode

The StreamApp component provides feed methods and context data to the children components to trigger feed functionalities.

With the code so far, when you click on a user from the start page, the browser navigates to the /home route, and the selected user is connected to Stream. Now, you will create a layout for the home page.

Create Common Shareable Components

This project has some common components that you will reuse in many other components. Creating these components separately makes the code more manageable.

Create a Loading Indicator Component

Before creating the layouts, you need to create a loading indicator component that you will use in other components. Create a new file src/components/LoadingIndicator. In that file, paste the following:

import styled from 'styled-components'

const Container = styled.div`
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  padding-top: 100px;
  background-color: black;

  .circle {
    border: 2px solid #333;
    border-radius: 50%;
    position: relative;
    width: 25px;
    height: 25px;

    &::after {
      content: '';
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      border-top: 2px solid var(--theme-color);
      border-radius: 50%;
      animation: spin 500ms infinite linear;

      @keyframes spin {
        from {
          transform: rotate(0deg);
        }
        to {
          transform: rotate(360deg);
        }
      }
    }
  }
`

export default function LoadingIndicator() {
  return (
    <Container>
      <div className="circle"></div>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

This creates an infinite rotating circle.

Create a Modal Component

The Modal component serves as a modal dialog for different elements such as the tweet form, comment form, etc.

Modal component user will build

Create a new file src/components/Modal.js and paste the imports and styles:

import classNames from 'classnames'
import styled from 'styled-components'

import Close from './Icons/Close'

const Container = styled.div`
  position: fixed;
  z-index: 6;
  width: 100%;
  height: 100vh;
  display: flex;
  justify-content: center;
  padding: 30px 0;
  left: 0;
  top: 0;

  .modal {
    z-index: 2;
    position: relative;

    background-color: black;
    border-radius: 20px;

    .close-btn {
      position: relative;
      left: -10px;
    }
  }
`

const Backdrop = styled.div`
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  background-color: rgba(255, 255, 255, 0.2);
`
Enter fullscreen mode Exit fullscreen mode

Next, the component:

export default function Modal({ className, children, onClickOutside }) {
  return (
    <Container>
      <Backdrop onClick={() => onClickOutside()} />
      <div className={classNames('modal', className)}>
        <button onClick={onClickOutside} className="close-btn">
          <Close color="white" size={24} />
        </button>
        {children}
      </div>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

On clicking the Backdrop styled component or the close button, the onClickOutside function is called, which is expected to hide the modal conditionally.

Create a ScrollToTop Component

When you navigate to a new page in React using the Link component from react-router-dom, the scroll position would usually retain its position. This component you are about to build will help resolve that by automatically scrolling to the top of the page on every route change.

Create a new file src/components/ScrollToTop.js with the following code:

import { useEffect } from 'react'
import { useLocation } from 'react-router'

const ScrollToTop = (props) => {
  const location = useLocation()

  useEffect(() => {
    window.scrollTo(0, 0)
  }, [location])

  return <>{props.children}</>
}

export default ScrollToTop
Enter fullscreen mode Exit fullscreen mode

When the location object changes, the useEffect hook triggers the scroll to top expression.

Next, you will add this component in App.js.

// other imports
import ScrollToTop from './components/ScrollToTop'
Enter fullscreen mode Exit fullscreen mode
export default function App() {
  // ...
  return (
    <StreamApp token={user.token} appId={APP_ID} apiKey={API_KEY}>
      <Router>
        <ScrollToTop />
        // routes
      </Router>
    </StreamApp>
  )
}
Enter fullscreen mode Exit fullscreen mode

Create FollowBtn Component

Follow button component

The follow button is used for following and unfollowing users. Create a new file src/components/FollowBtn. Add the imports and the styles:

import classNames from 'classnames'
import styled from 'styled-components'
import { useState } from 'react'
Enter fullscreen mode Exit fullscreen mode

Next, the UI of the button:

export default function FollowBtn({ userId }) {
  const [following, setFollowing] = useState(false)

  return (
    <Container>
      <button
        className={classNames(following ? 'following' : 'not-following')}
        onClick={() => setFollowing(!following)}
      >
        {following ? (
          <div className="follow-text">
            <span className="follow-text__following">Following</span>
            <span className="follow-text__unfollow">Unfollow</span>
          </div>
        ) : (
          'Follow'
        )}
      </button>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

This component is not fully functional as that is not the scope of this part. Part 3 adds more to the code. For now, the component receives the userId prop (which it doesn't use yet) and toggles the following state when clicked.

Create a TweetForm component

Tweet form with some text and buttons

The TweetForm component is a shareable form component with the tweet input and a submit button. Create a new file src/components/Tweet/TweetForm.js. Import some libraries:

import classNames from 'classnames'
import { useEffect, useRef, useState } from 'react'
import { useStreamContext } from 'react-activity-feed'
import styled from 'styled-components'

import Calendar from '../Icons/Calendar'
import Emoji from '../Icons/Emoji'
import Gif from '../Icons/Gif'
import Image from '../Icons/Image'
import Location from '../Icons/Location'
import Poll from '../Icons/Poll'
import ProgressRing from '../Icons/ProgressRing'
Enter fullscreen mode Exit fullscreen mode

The ProgressRing component indicates the text length and shows when the text exceeds the maximum available length.

Next, the styles:

const Container = styled.div`
  width: 100%;

  .reply-to {
    font-size: 14px;
    color: #888;
    display: flex;
    margin-left: 55px;
    margin-bottom: 10px;

    &--name {
      margin-left: 4px;
      color: var(--theme-color);
    }
  }
`

const Form = styled.form`
  width: 100%;
  display: flex;
  align-items: ${({ inline }) => (inline ? 'center' : 'initial')};

  .user {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    overflow: hidden;
    margin-right: 15px;

    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  }

  .input-section {
    width: 100%;
    display: flex;
    flex: 1;
    flex-direction: ${({ inline }) => (inline ? 'row' : 'column')};
    align-items: ${({ inline }) => (inline ? 'center' : 'initial')};
    height: ${({ inline, minHeight }) => (inline ? '40px' : minHeight)};

    textarea {
      padding-top: 10px;
      background: none;
      border: none;
      padding-bottom: 0;
      font-size: 18px;
      width: 100%;
      flex: 1;
      resize: none;
      outline: none;
      color: white;
    }

    .actions {
      margin-top: ${({ inline }) => (inline ? '0' : 'auto')};
      display: flex;
      height: 50px;
      align-items: center;

      button {
        &:disabled {
          opacity: 0.5;
        }
      }

      .right {
        margin-left: auto;
        display: flex;
        align-items: center;
      }

      .tweet-length {
        position: relative;

        svg {
          position: relative;
          top: 2px;
        }

        &__text {
          position: absolute;
          color: #888;
          font-size: 14px;
          top: 0;
          bottom: 0;
          left: 0;
          right: 0;
          margin: auto;
          height: max-content;
          width: max-content;

          &.red {
            color: red;
          }
        }
      }

      .divider {
        height: 30px;
        width: 2px;
        border: none;
        background-color: #444;
        margin: 0 18px;
      }

      .submit-btn {
        background-color: var(--theme-color);
        padding: 10px 20px;
        color: white;
        border-radius: 30px;
        margin-left: auto;
        font-weight: bold;
        font-size: 16px;

        &:disabled {
          opacity: 0.6;
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

The action buttons, though non-functional:

const actions = [
  {
    id: 'image',
    Icon: Image,
    alt: 'Image',
  },
  {
    id: 'gif',
    Icon: Gif,
    alt: 'GIF',
  },
  {
    id: 'poll',
    Icon: Poll,
    alt: 'Poll',
  },
  {
    id: 'emoji',
    Icon: Emoji,
    alt: 'Emoji',
  },
  {
    id: 'schedule',
    Icon: Calendar,
    alt: 'Schedule',
  },
  {
    id: 'location',
    Icon: Location,
    alt: 'Location',
  },
]
Enter fullscreen mode Exit fullscreen mode

And for the component, paste this:

export default function TweetForm({
  submitText = 'Tweet',
  onSubmit,
  className,
  placeholder,
  collapsedOnMount = false,
  minHeight = 120,
  shouldFocus = false,
  replyingTo = null,
}) {
  const inputRef = useRef(null)

  const { client } = useStreamContext()

  const [expanded, setExpanded] = useState(!collapsedOnMount)
  const [text, setText] = useState('')

  useEffect(() => {
    if (shouldFocus && inputRef.current) inputRef.current.focus()
  }, [])

  const user = client.currentUser.data

  const MAX_CHARS = 280

  const percentage =
    text.length >= MAX_CHARS ? 100 : (text.length / MAX_CHARS) * 100

  const submit = async (e) => {
    e.preventDefault()

    if (exceededMax)
      return alert('Tweet cannot exceed ' + MAX_CHARS + ' characters')

    await onSubmit(text)

    setText('')
  }

  const onClick = () => {
    setExpanded(true)
  }

  const isInputEmpty = !Boolean(text)

  const charsLeft = MAX_CHARS - text.length
  const maxAlmostReached = charsLeft <= 20
  const exceededMax = charsLeft < 0

  const isReplying = Boolean(replyingTo)
}
Enter fullscreen mode Exit fullscreen mode

The component receives eight props:

  • submitText: The text on the submit button, which by default is "Tweet"
  • onSubmit: The function called when the submit button is called. This function will be called with the text argument from the input
  • className: For custom class names passed to this component
  • placeholder: Placeholder for the input
  • collapsedOnMount: A boolean to specify if the form is collapsed on mount.
  • minHeight: For the minimum height of the form
  • shouldFocus: A boolean to specify if the input should be focused on mount
  • replyingTo: If the form is a reply to a user, then the user's id will be passed here.

The percentage variable calculates how many characters the user has typed. This value works with the ProgressRing component to indicate how much has been typed and how many characters are left based on the maximum amount.

When the form is submitted, and the input exceeds the maximum length, it throws an alert warning.

Next, the UI of the form:

export default function TweetForm() {
  //
  return (
    <Container>
      {isReplying && expanded && (
        <span className="reply-to">
          Replying to <span className="reply-to--name">@{replyingTo}</span>
        </span>
      )}
      <Form
        minHeight={minHeight + 'px'}
        inline={!expanded}
        className={className}
        onSubmit={submit}
      >
        <div className="user">
          <img src={user.image} alt="" />
        </div>
        <div className="input-section">
          <textarea
            ref={inputRef}
            onChange={(e) => setText(e.target.value)}
            placeholder={placeholder}
            value={text}
            onClick={onClick}
          />
          <div className="actions">
            {expanded &&
              actions.map((action) => {
                return (
                  <button
                    type="button"
                    disabled={action.id === 'location' && 'disabled'}
                    key={action.id}
                  >
                    <action.Icon size={19} color="var(--theme-color)" />
                  </button>
                )
              })}
            <div className="right">
              {!isInputEmpty && (
                <div className="tweet-length">
                  <ProgressRing
                    stroke={2.2}
                    color={
                      exceededMax
                        ? 'red'
                        : maxAlmostReached
                        ? '#ffd400'
                        : 'var(--theme-color)'
                    }
                    radius={maxAlmostReached ? 19 : 14}
                    progress={percentage}
                  />
                  {maxAlmostReached && (
                    <span
                      className={classNames(
                        'tweet-length__text',
                        exceededMax && 'red'
                      )}
                    >
                      {charsLeft}
                    </span>
                  )}
                </div>
              )}
              {!isInputEmpty && <hr className="divider" />}
              <button
                type="submit"
                className="submit-btn"
                disabled={isInputEmpty}
              >
                {submitText}
              </button>
            </div>
          </div>
        </div>
      </Form>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Create the Left Section

Left side section

The left section shows the different navigation links, the "Tweet" button, and the user icon at the bottom.

Create a new file called src/components/LeftSide.js. Add the following imports:

import classNames from 'classnames'
import { useEffect, useState } from 'react'
import { useStreamContext } from 'react-activity-feed'
import { Link, useLocation } from 'react-router-dom'
import styled from 'styled-components'

import LoadingIndicator from './LoadingIndicator'
import Bell from './Icons/Bell'
import Group from './Icons/Group'
import Home from './Icons/Home'
import Hashtag from './Icons/Hashtag'
import Mail from './Icons/Mail'
import Bookmark from './Icons/Bookmark'
import User from './Icons/User'
import More from './Icons/More'
import Twitter from './Icons/Twitter'
Enter fullscreen mode Exit fullscreen mode

useStreamContext is an exported custom hook from the react-activity-feed library, which exposes context data from the StreamApp component you added in App.js. From this hook, you can get the logged-in user details.

You will use the useLocation hook to get information about the URL path, which can be useful for getting the active link.

Next, the styles:

const Container = styled.div`
  display: flex;
  flex-direction: column;
  padding: 0 30px;
  height: 100%;

  .header {
    padding: 15px;
  }

  .buttons {
    margin-top: 5px;
    max-width: 200px;

    a,
    button {
      display: block;
      margin-bottom: 12px;
      color: white;
      padding: 10px 15px;
      display: flex;
      align-items: center;
      border-radius: 30px;
      font-size: 18px;
      padding-right: 25px;
      text-decoration: none;
      --icon-size: 25px;

      .btn--icon {
        margin-right: 15px;
        height: var(--icon-size);
        width: var(--icon-size);

        position: relative;
        .notifications-count {
          position: absolute;
          font-size: 11px;
          /* min-width: 14px; */
          background-color: var(--theme-color);
          top: -5px;
          padding: 1px 5px;
          border-radius: 10px;
          left: 0;
          right: 0;
          margin: 0 auto;
          width: max-content;
        }
      }

      &.active {
        font-weight: bold;

        img {
          --size: 27px;
        }
      }

      &:hover {
        background-color: #333;
      }

      &.btn--home {
        position: relative;
        &.new-tweets::after {
          content: '';
          position: absolute;
          width: 5px;
          height: 5px;
          left: 35px;
          top: 7px;
          border-radius: 50%;
          background-color: var(--theme-color);
        }
      }

      &.btn--more {
        svg {
          border: 1px solid #fff;
          border-radius: 50%;
          display: flex;
          align-items: center;
          justify-content: center;
        }
      }
    }
  }

  .tweet-btn {
    background-color: var(--theme-color);
    margin-top: 10px;
    border-radius: 30px;
    color: white;
    text-align: center;
    padding: 15px 0;
    font-size: 16px;
  }

  .profile-section {
    margin-top: auto;
    margin-bottom: 20px;
    padding: 10px;
    display: flex;
    text-align: left;
    align-items: center;
    justify-content: space-between;
    border-radius: 30px;

    &:hover {
      background-color: #333;
    }

    .details {
      display: flex;
      align-items: center;
      &__img {
        margin-right: 10px;
        width: 40px;
        border-radius: 50%;
        height: 40px;
        overflow: hidden;

        img {
          width: 100%;
          height: 100%;
        }
      }

      &__text {
        span {
          display: block;
        }

        &__name {
          color: white;
          font-size: 16px;
          font-weight: bold;
        }

        &__id {
          font-size: 14px;
          margin-top: 2px;
          color: #aaa;
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Add the following to the LeftSide.js file:

export default function LeftSide({ onClickTweet }) {
  const location = useLocation()
  const { userData } = useStreamContext()

  const [newNotifications, setNewNotifications] = useState(0)

  if (!userData)
    return (
      <Container>
        <LoadingIndicator />
      </Container>
    )

  const menus = [
    {
      id: 'home',
      label: 'Home',
      Icon: Home,
      link: '/home',
    },
    {
      id: 'explore',
      label: 'Explore',
      Icon: Hashtag,
    },
    {
      id: 'communities',
      label: 'Communities',
      Icon: Group,
    },
    {
      id: 'notifications',
      label: 'Notifications',
      Icon: Bell,
      link: '/notifications',
      value: newNotifications,
    },
    {
      id: 'messages',
      label: 'Messages',
      Icon: Mail,
    },
    {
      id: 'bookmarks',
      label: 'Bookmarks',
      Icon: Bookmark,
    },
    {
      id: 'profile',
      label: 'Profile',
      Icon: User,
      link: `/${userData.id}`,
    },
  ]
}
Enter fullscreen mode Exit fullscreen mode

The component receives an onClickTweet method prop which is called when the "Tweet" button is clicked.

First, you get the user object from useStreamContext. Also, you keep track of the notifications state.

Note:Part 3 of this series focuses on implementing notifications.

You also show the LoadingIndicator component if the userData object is undefined.

And you have the menu list. Now, for the UI:

function App({ onClickTweet }) {
  // ...

  return (
    <Container>
      <Link to="/" className="header">
        <Twitter color="white" size={25} />
      </Link>
      <div className="buttons">
        {menus.map((m) => {
          const isActiveLink =
            location.pathname === `/${m.id}` ||
            (m.id === 'profile' && location.pathname === `/${userData.id}`)

          return (
            <Link
              to={m.link ?? '#'}
              className={classNames(
                `btn--${m.id} new-tweets`,
                isActiveLink && 'active'
              )}
              key={m.id}
              onClick={m.onClick}
            >
              <div className="btn--icon">
                {newNotifications && m.id === 'notifications' ? (
                  <span className="notifications-count">
                    {newNotifications}
                  </span>
                ) : null}
                <m.Icon fill={isActiveLink} color="white" size={25} />
              </div>
              <span>{m.label}</span>
            </Link>
          )
        })}
        <button className="btn--more">
          <div className="btn--icon">
            <More color="white" size={20} />
          </div>
          <span>More</span>
        </button>
      </div>
      <button onClick={onClickTweet} className="tweet-btn">
        Tweet
      </button>
      <button className="profile-section">
        <div className="details">
          <div className="details__img">
            <img src={userData.image} alt="" />
          </div>
          <div className="details__text">
            <span className="details__text__name">{userData.name}</span>
            <span className="details__text__id">@{userData.id}</span>
          </div>
        </div>
        <div>
          <More color="white" />
        </div>
      </button>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

For the link, you determine if it is active if the id of the item in the menu is the same as the pathname of the URL. For the profile, you check if the pathname of the URL is the same as the id of the logged-in user.

With the left side done, you can proceed to the right side of the layout. The right side has a "Follow" button, so first, create a shareable follow button component.

Create the Right Section

Right side section

The right section shows the search input, the "Trends for you" block, and the "Who to follow" block.

Create a new file src/components/RightSide.js. Add the following imports:

import classNames from 'classnames'
import { useState } from 'react'
import { useStreamContext } from 'react-activity-feed'
import { Link } from 'react-router-dom'
import styled from 'styled-components'

import users from '../users'
import FollowBtn from './FollowBtn'
import More from './Icons/More'
import Search from './Icons/Search'
Enter fullscreen mode Exit fullscreen mode

Next, you have the trends demo data:

const trends = [
  {
    title: 'iPhone 12',
    tweetsCount: '11.6k',
    category: 'Technology',
  },
  {
    title: 'LinkedIn',
    tweetsCount: '51.1K',
    category: 'Business & finance',
  },
  {
    title: 'John Cena',
    tweetsCount: '1,200',
    category: 'Sports',
  },
  {
    title: '#Microsoft',
    tweetsCount: '3,022',
    category: 'Business & finance',
  },
  {
    title: '#DataSciencve',
    tweetsCount: '18.6k',
    category: 'Technology',
  },
]
Enter fullscreen mode Exit fullscreen mode

Now for the component:

export default function RightSide() {
  const [searchText, setSearchText] = useState('')

  const { client } = useStreamContext()

  const whoToFollow = users.filter((u) => {
    // filter out currently logged in user
    return u.id !== client.userId
  })
}
Enter fullscreen mode Exit fullscreen mode

You keep track of the searchText state and also have the whoToFollow array, which is the users array with the currently logged-in user filtered out.

For the UI, paste the following:

export default function RightSide() {
  // ...

  return (
    <Container>
      <div className="search-container">
        <form className="search-form">
          <div className="search-icon">
            <Search color="rgba(85,85,85,1)" />
          </div>
          <input
            onChange={(e) => setSearchText(e.target.value)}
            value={searchText}
          />
          <button
            className={classNames(!Boolean(searchText) && 'hide', 'submit-btn')}
            type="button"
            onClick={() => setSearchText('')}
          >
            X
          </button>
        </form>
      </div>

      <div className="trends">
        <h2>Trends for you</h2>
        <div className="trends-list">
          {trends.map((trend, i) => {
            return (
              <div className="trend" key={trend.title + '-' + i}>
                <div className="trend__details">
                  <div className="trend__details__category">
                    {trend.category}
                    <span className="trend__details__category--label">
                      Trending
                    </span>
                  </div>
                  <span className="trend__details__title">{trend.title}</span>
                  <span className="trend__details__tweets-count">
                    {trend.tweetsCount} Tweets
                  </span>
                </div>
                <button className="more-btn">
                  <More color="white" />
                </button>
              </div>
            )
          })}
        </div>
      </div>

      <div className="follows">
        <h2>Who to follow</h2>
        <div className="follows-list">
          {whoToFollow.map((user) => {
            return (
              <div className="user" key={user.id}>
                <Link to={`/${user.id}`} className="user__details">
                  <div className="user__img">
                    <img src={user.image} alt="" />
                  </div>
                  <div className="user__info">
                    <span className="user__name">{user.name}</span>
                    <span className="user__id">@{user.id}</span>
                  </div>
                </Link>
                <FollowBtn userId={user.id} />
              </div>
            )
          })}
        </div>
        <span className="show-more-text">Show more</span>
      </div>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

The UI shows the search input and loops through the trends and whoToFollow array and displays them on the UI.

Create the Layout Component

The Layout component shows the create tweet modal, so before the layout, create this component.

Create a CreateTweetDialog Component

Create a new file src/components/Tweet/CreateTweetDialog.js. Start with the import and styles:

import styled from 'styled-components'

import Modal from '../Modal'
import TweetForm from './TweetForm'

const Container = styled.div`
  .modal-block {
    margin-top: 20px;
    padding: 15px;
    width: 600px;
    height: max-content;
    z-index: 10;
  }

  .tweet-form {
    margin-top: 20px;
  }
`
Enter fullscreen mode Exit fullscreen mode

The shareable TweetForm component will be used in this component. Next, the UI:

export default function CreateTweetDialog({ onClickOutside }) {
  const onSubmit = async (text) => {
    // create tweet

    onClickOutside()
  }

  return (
    <Container>
      <Modal onClickOutside={onClickOutside} className="modal-block">
        <TweetForm
          onSubmit={onSubmit}
          shouldFocus={true}
          minHeight={240}
          className="tweet-form"
          placeholder="What's happening"
        />
      </Modal>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

The create tweet function itself will be created in a few sections below; this is just the layout.

Compose With the Layout Component

With the LeftSide, RightSide, and tweet modal components ready, you can create the Layout component.

Create a new file src/components/Layout.js. Add the imports:

import { useState } from 'react'
import { useStreamContext } from 'react-activity-feed'
import styled from 'styled-components'

import LeftSide from './LeftSide'
import CreateTweetDialog from './Tweet/CreateTweetDialog'
import RightSide from './RightSide'
import LoadingIndicator from './LoadingIndicator'
Enter fullscreen mode Exit fullscreen mode

The styles:

const Container = styled.div`
  min-height: 100vh;
  background: black;
  --left: 300px;
  --right: 400px;
  --middle: calc(100% - var(--left) - var(--right));

  .content {
    max-width: 1300px;
    margin: 0 auto;
    width: 100%;
    display: flex;
  }

  .left-side-bar {
    height: 100vh;
    width: var(--left);
    position: sticky;
    top: 0;
  }

  .main-content {
    position: relative;
    width: var(--middle);
    border-left: 1px solid #333;
    border-right: 1px solid #333;
    min-height: 100vh;
  }

  .right-side-bar {
    width: var(--right);
  }
`
Enter fullscreen mode Exit fullscreen mode

The Container styled component has three style variables: --left of 300px, --right of 400px, and --middle, which is calculated by subtracting the left and right from 100%. The left section uses the left variable, and so for the right and the middle content.

For the component:

export default function Layout({ children }) {
  const { user } = useStreamContext()

  const [createDialogOpened, setCreateDialogOpened] = useState(false)

  if (!user) return <LoadingIndicator />

  return (
    <>
      {createDialogOpened && (
        <CreateTweetDialog
          onClickOutside={() => setCreateDialogOpened(false)}
        />
      )}
      <Container>
        <div className="content">
          <div className="left-side-bar">
            <LeftSide onClickTweet={() => setCreateDialogOpened(true)} />
          </div>
          <main className="main-content">
            {!user ? <LoadingIndicator /> : children}
          </main>
          <div className="right-side-bar">
            <RightSide />
          </div>
          <div />
        </div>
      </Container>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

The Layout component manages a createDialogOpened state which is updated to true when the "Tweet" button in the LeftSide component is clicked.

Create the HomeContent Component

Home content component

This component will show the logged-in user's timeline. Their timeline shows the tweets of people they follow.

The HomeContent component houses the top header, the tweet form below the header, and the timeline feed. Let us start from the header.

Create the Home Top Header component

Create a new file src/components/Home/MainHeader.js with the following code:

import styled from 'styled-components'

import Star from '../Icons/Star'

const Header = styled.header`
  display: flex;
  align-items: center;
  padding: 15px;
  color: white;
  width: 100%;
  font-weight: bold;
  justify-content: space-between;
  backdrop-filter: blur(2px);
  background-color: rgba(0, 0, 0, 0.5);

  h1 {
    font-size: 20px;
  }
`

export default function MainHeader() {
  return (
    <Header>
      <h1>Home</h1>
      <Star color="white" />
    </Header>
  )
}
Enter fullscreen mode Exit fullscreen mode

Create the CreateTweetTop component

The CreateTweetTop component shows the tweet form below the header. This component will also use the shareable TweetForm component.

Create a new file, src/components/Home/CreateTweetTop.js with the following code:

import styled from 'styled-components'

import TweetForm from '../Tweet/TweetForm'

const Container = styled.div`
  padding: 15px;
`

export default function CreateTweetTop() {
  const onSubmit = async (text) => {
    // create tweet here
  }

  return (
    <Container>
      <TweetForm placeholder="What's happening?" onSubmit={onSubmit} />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

The onSubmit method does nothing for you. Later on in this article, you will add the functionality to create a tweet. For now, let us focus on the layout.

For the remaining part of the HomeContent component, you also need a tweet block that shows a tweet's information, actor details, and reactions.

Create the TweetBlock component

The TweetBlock component is broken down into three elements: TweetActorName, the tweet's content, and a CommentDialog modal component.

Tweet block with actor name, content and actions

Tweet Comment Dialog

Create the TweetActorName component

The TweetActorName is a shared component that shows the name and id of an actor. It also shows the time (hours difference or date) that the tweet was made. Create a new file called src/components/Tweet/TweetActorName.js.

Add the imports and styles:

import { format } from 'date-fns'
import { Link } from 'react-router-dom'
import styled from 'styled-components'

const TextBlock = styled(Link)`
  display: flex;

  &:hover .user--name {
    text-decoration: underline;
  }

  .user {
    &--name {
      color: white;
      font-weight: bold;
    }
    &--id {
      margin-left: 5px;
      color: #777;
    }
  }
  .tweet-date {
    margin-left: 15px;
    color: #777;
    position: relative;

    &::after {
      content: '';
      width: 2px;
      height: 2px;
      background-color: #777;
      position: absolute;
      left: -8px;
      top: 0;
      bottom: 0;
      margin: auto 0;
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

And the component:

export default function TweetActorName({ time, name, id }) {
  const timeDiff = Date.now() - new Date(time).getTime()

  // convert ms to hours
  const hoursBetweenDates = timeDiff / (60 * 60 * 1000)

  const lessThan24hrs = hoursBetweenDates < 24

  const lessThan1hr = hoursBetweenDates < 1

  const timeText = lessThan1hr
    ? format(timeDiff, 'm') + 'm'
    : lessThan24hrs
    ? format(timeDiff, 'H') + 'h'
    : format(new Date(time), 'MMM d')

  return (
    <TextBlock to={`/${id}`}>
      <span className="user--name">{name}</span>
      <span className="user--id">@{id}</span>
      <span className="tweet-date">{timeText}</span>
    </TextBlock>
  )
}
Enter fullscreen mode Exit fullscreen mode

The time is interpreted in three ways. If it is less than one hour, it shows as "[X]m". If it is less than twenty-four hours, it shows as "[X]h". And if it is none of these conditions, it is displayed as "Month Date".

Create a Tweet Link Generator Utility

Tweet links usually exist in this format: /{username}/status/{tweet-id}/. You will create a reusable function that generates a link like this.

Create a new file src/utils/links.js with the following code:

export function generateTweetLink(actorId, tweetActivityId) {
  return `/${actorId}/status/${tweetActivityId}`
}
Enter fullscreen mode Exit fullscreen mode
Create a Text Formatter Utility for Links

Because texts can contain links, hashtags, and mentions, you will create a utility for formatting such texts and replacing some of the texts with anchor tags.

Create a new file src/utils/string.js. And add the following function:

export function formatStringWithLink(text, linkClass, noLink = false) {
  // regex to match links, hashtags and mentions
  const regex = /((https?:\/\/\S*)|(#\S*))|(@\S*)/gi

  const modifiedText = text.replace(regex, (match) => {
    let url, label

    if (match.startsWith('#')) {
      // it is a hashtag
      url = match
      label = match
    } else if (match.startsWith('@')) {
      // it is a mention
      url = `/${match.replace('@', '')}`
      label = match
    } else {
      // it is a link
      url = match
      label = url.replace('https://', '')
    }

    const tag = noLink ? 'span' : 'a'

    return `<${tag} class="${
      noLink ? '' : linkClass
    }" href="${url}">${label}</${tag}>`
  })

  return modifiedText
}
Enter fullscreen mode Exit fullscreen mode

This utility returns an HTML string that can be embedded into an element.

Create the CommentDialog Component

The CommentDialog modal popups up when the comment icon is clicked on a tweet block:

This dialog will be used to add a comment to a tweet. Create a new file src/components/Tweet/CommentDialog. Let us start with the imports and styles:

import styled from 'styled-components'

import { formatStringWithLink } from '../../utils/string'
import Modal from '../Modal'
import TweetActorName from './TweetActorName'
import TweetForm from './TweetForm'

const Container = styled.div`
  .modal-block {
    padding: 15px;
    width: 600px;
    height: max-content;
  }
`

const BlockContent = styled.div`
  .tweet {
    margin-top: 30px;
    display: flex;
    position: relative;

    &::after {
      content: '';
      background-color: #444;
      width: 2px;
      height: calc(100% - 35px);
      position: absolute;
      left: 20px;
      z-index: 0;
      top: 45px;
    }

    .img {
      width: 40px;
      height: 40px;
      border-radius: 50%;
      margin-right: 15px;
      border-radius: 50%;
      overflow: hidden;

      img {
        width: 100%;
        height: 100%;
        object-fit: cover;
      }
    }

    .details {
      .actor-name {
        font-size: 15px;
        &--name {
          color: white;
          font-weight: bold;
        }

        &--id {
          color: #888;
        }
      }

      .tweet-text {
        color: white;
        margin-top: 3px;
        font-size: 14px;
      }

      .replying-info {
        color: #555;
        display: flex;
        margin-top: 20px;
        font-size: 14px;

        &--actor {
          margin-left: 5px;
          color: var(--theme-color);
        }
      }
    }
  }

  .comment {
    display: flex;
    margin-top: 20px;

    .img {
      width: 35px;
      height: 35px;
      margin-left: 3px;
      border-radius: 50%;
      margin-right: 15px;
      border-radius: 50%;
      overflow: hidden;

      img {
        width: 100%;
        height: 100%;
        object-fit: cover;
      }
    }

    .comment-form {
      flex: 1;
      height: 120px;
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

This component uses the shareable TweetForm and TweetActorName components.

Next, the component:

export default function CommentDialog({
  activity,
  onPostComment,
  onClickOutside,
}) {
  const {
    object: { data: tweet },
  } = activity

  const tweetActor = activity.actor

  const onSubmit = async (text) => {
    await onPostComment(text)

    onClickOutside()
  }
}
Enter fullscreen mode Exit fullscreen mode

This component receives three props:

  • activity: The active activity that the comment should be added to
  • onPostComment: A function called with the text argument when the submit button from the TweetForm component is called
  • onClickOutside: A function called when the backdrop of the modal is called

Now, for the UI:

export default function CommentDialog(
  {
    // ...
  }
) {
  // ...

  return (
    <Container>
      <Modal onClickOutside={onClickOutside} className="modal-block">
        <BlockContent>
          <div className="tweet">
            <div className="img">
              <img src={tweetActor.data.image} alt="" />
            </div>
            <div className="details">
              <TweetActorName
                time={activity.time}
                name={tweetActor.data.name}
                id={tweetActor.data.id}
              />
              <p
                className="tweet-text"
                dangerouslySetInnerHTML={{
                  __html: formatStringWithLink(
                    tweet.text,
                    'tweet__text--link',
                    true
                  ).replace(/\n/g, '<br/>'),
                }}
              />
              <div className="replying-info">
                Replying to{' '}
                <span className="replying-info--actor">@{tweetActor.id}</span>
              </div>
            </div>
          </div>
          <div className="comment">
            <TweetForm
              className="comment-form"
              submitText="Reply"
              placeholder="Tweet your reply"
              onSubmit={onSubmit}
              shouldFocus={true}
            />
          </div>
        </BlockContent>
      </Modal>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode
Composing the TweetBlock Component

With the required components created, you can now compose this component.

Create a new file, src/components/Tweet/TweetBlock.js. Start with the imports:

import classNames from 'classnames'
import { useState } from 'react'
import { useStreamContext } from 'react-activity-feed'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'

import { formatStringWithLink } from '../../utils/string'
import CommentDialog from './CommentDialog'
import Comment from '../Icons/Comment'
import Heart from '../Icons/Heart'
import Retweet from '../Icons/Retweet'
import Upload from '../Icons/Upload'
import More from '../Icons/More'
import TweetActorName from './TweetActorName'
import { generateTweetLink } from '../../utils/links'
Enter fullscreen mode Exit fullscreen mode

Next, paste the styles:

const Block = styled.div`
  display: flex;
  border-bottom: 1px solid #333;
  padding: 15px;

  .user-image {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    overflow: hidden;
    margin-right: 10px;

    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  }

  .tweet {
    flex: 1;
    .link {
      display: block;
      padding-bottom: 5px;
      text-decoration: none;
      width: 100%;
    }

    &__text {
      color: white;
      font-size: 15px;
      line-height: 20px;
      margin-top: 3px;
      width: 100%;

      &--link {
        color: var(--theme-color);
        text-decoration: none;
      }
    }

    &__actions {
      display: flex;
      justify-content: space-between;
      margin-top: 5px;

      button {
        display: flex;
        align-items: center;
      }

      &__value {
        margin-left: 10px;
        color: #666;

        &.colored {
          color: var(--theme-color);
        }
      }
    }

    &__image {
      margin-top: 20px;
      border-radius: 20px;
      border: 1px solid #333;
      overflow: hidden;
      width: calc(100% + 20px);

      width: 100%;
      height: 100%;
      object-fit: cover;
      object-position: center;
    }
  }

  .more {
    width: 40px;
    height: 40px;
    display: flex;
  }
`
Enter fullscreen mode Exit fullscreen mode

Then the component:

export default function TweetBlock({ activity }) {
  const { user } = useStreamContext()
  const navigate = useNavigate()
  const [commentDialogOpened, setCommentDialogOpened] = useState(false)

  const actor = activity.actor

  let hasLikedTweet = false

  const tweet = activity.object.data

  // check if current logged in user has liked tweet
  if (activity?.own_reactions?.like) {
    const myReaction = activity.own_reactions.like.find(
      (l) => l.user.id === user.id
    )
    hasLikedTweet = Boolean(myReaction)
  }

  const onToggleLike = () => {
    // toggle like reaction
  }

  const actions = [
    {
      id: 'comment',
      Icon: Comment,
      alt: 'Comment',
      value: activity?.reaction_counts?.comment || 0,
      onClick: () => setCommentDialogOpened(true),
    },
    {
      id: 'retweet',
      Icon: Retweet,
      alt: 'Retweet',
      value: 0,
    },
    {
      id: 'heart',
      Icon: Heart,
      alt: 'Heart',
      value: activity?.reaction_counts?.like || 0,
      onClick: onToggleLike
    },
    {
      id: 'upload',
      Icon: Upload,
      alt: 'Upload',
    },
  ]

  const tweetLink = activity.id ? generateTweetLink(actor.id, activity.id) : '#'

  const onPostComment = async (text) => {
    // create comment
  }
}
Enter fullscreen mode Exit fullscreen mode

The hasLikedTweet variable is a boolean that indicates if the currently logged-in user has liked the current tweet. To find this information, you check the like object of the own_reactions object of the activity. The like object holds an array of objects which contains information about users that have added a like reaction to an activity.

The onToggleLike and onPostComment functions do nothing just yet. Part 3 covers adding reactions.

Next for this component is the UI:

export default function TweetBlock({ activity }) {
  //...

  return (
    <>
      <Block>
        <div className="user-image">
          <img src={actor.data.image} alt="" />
        </div>
        <div className="tweet">
          <button onClick={() => navigate(tweetLink)} className="link">
            <TweetActorName
              name={actor.data.name}
              id={actor.id}
              time={activity.time}
            />
            <div className="tweet__details">
              <p
                className="tweet__text"
                dangerouslySetInnerHTML={{
                  __html: formatStringWithLink(
                    tweet.text,
                    'tweet__text--link'
                  ).replace(/\n/g, '<br/>'),
                }}
              />
            </div>
          </button>

          <div className="tweet__actions">
            {actions.map((action) => {
              return (
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    action.onClick?.()
                  }}
                  key={action.id}
                  type="button"
                >
                  <action.Icon
                    color={
                      action.id === 'heart' && hasLikedTweet
                        ? 'var(--theme-color)'
                        : '#777'
                    }
                    size={17}
                    fill={action.id === 'heart' && hasLikedTweet && true}
                  />
                  <span
                    className={classNames('tweet__actions__value', {
                      colored: action.id === 'heart' && hasLikedTweet,
                    })}
                  >
                    {action.value}
                  </span>
                </button>
              )
            })}
          </div>
        </div>
        <button className="more">
          <More color="#777" size={20} />
        </button>
      </Block>
      {activity.id && commentDialogOpened && (
        <CommentDialog
          onPostComment={onPostComment}
          shouldOpen={commentDialogOpened}
          onClickOutside={() => setCommentDialogOpened(false)}
          activity={activity}
        />
      )}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

This UI shows the tweet block with the action buttons (comment, like) and the comment dialog when it is active. On submitting the tweet form in the comment dialog, nothing happens for now. You will add this functionality in Part 3.

Creating the Timeline Component

The Timeline component shows the tweets made by the users the currently logged-in user follows:

Since we haven't added the follow feature yet, you will create this component to show the tweets made by the currently logged-in user.

Create a new file src/components/Home/Timeline.js with the following code:

import { FlatFeed, useStreamContext } from 'react-activity-feed'

import TweetBlock from '../Tweet/TweetBlock'

export default function Timeline() {
  const { user } = useStreamContext()

  return (
    <div>
      <FlatFeed Activity={TweetBlock} userId={user.id} feedGroup="user" />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The FlatFeed component allows you to pass a custom Activity component using the Activity prop.

Also, in the FlatFeed component, you can use the "timeline" or "user" feedGroup. The "timeline" shows a feed of activities made by the users a user follows. While the "user", similar to a user's profile page, shows a feed of activities made by a particular user (the logged-in user in our case). For now, we will leave this as "user". You will change this to "timeline" when you add the follow feature.

Composing the HomeContent Component

You can now compose the HomeContent component with the dialog, utilities, timeline, and other components created.

Create a new file src/components/Home/HomeContent.js. Add the import and styles:

import styled from 'styled-components'
import { Feed, useStreamContext } from 'react-activity-feed'

import CreateTweetTop from './CreateTweetTop'
import MainHeader from './MainHeader'
import Timeline from '../Home/Timeline'
import LoadingIndicator from '../LoadingIndicator'

const Container = styled.div`
  .header {
    position: sticky;
    top: 0;
    z-index: 1;
  }

  .create-tweet-top {
    border-bottom: 1px solid #333;
  }

  .new-tweets-info {
    border-bottom: 1px solid #333;
    padding: 20px;
    text-align: center;
    color: var(--theme-color);
    display: block;
    width: 100%;
    font-size: 16px;

    &:hover {
      background: #111;
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

The Feed component does not add anything to the UI. It provides feed data and methods such that the children of these components can create tweets in the user's feed.

Next, the component:

export default function HomeContent() {
  const { client } = useStreamContext()

  const user = client.currentUser.data

  if (!user)
    return (
      <Container>
        <LoadingIndicator />
      </Container>
    )

  return (
    <Container>
      <div className="header">
        <MainHeader />
      </div>
      <Feed feedGroup="user">
        <div className="create-tweet-top">
          <CreateTweetTop />
        </div>
        <Timeline />
      </Feed>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Create a Page Component for the Homepage

With the layout and home content components ready, you can now create a page for the home content.

Create a new file src/pages/HomePage.js with the following code:

import Layout from '../components/Layout'
import HomeContent from '../components/Home/HomeContent'

export default function Home() {
  return (
    <Layout>
      <HomeContent />
    </Layout>
  )
}
Enter fullscreen mode Exit fullscreen mode

In App.js, add a route for the home page like this:

// other imports
import HomePage from './pages/HomePage'
Enter fullscreen mode Exit fullscreen mode
// other routes
<Route element={<HomePage />} path="/home" />
Enter fullscreen mode Exit fullscreen mode

With your development server on, when you go to localhost:3000/home, you will see the Homepage result.

When you click the "Tweet" button on the left section, you can also see the create tweet modal.

For now, you cannot see the comment dialog as the tweet block is not in use. Next, I will walk you through adding the create tweet feature so you can see the other components at work.

Add a Create Tweet Feature

In this section, you add the create tweet feature that allows users to create tweets. After adding this feature and using it, you can see the TweetBlock components in the Timeline component.

Create a Custom useTweet Hook

The tweet feature can be triggered from the CreateTweetDialog and the CreateTweetTop components. Creating a custom hook for this feature makes things manageable.

Create a new file src/hooks/useTweet.js with the following code:

import { nanoid } from 'nanoid'
import { useStreamContext } from 'react-activity-feed'

export default function useTweet() {
  const { client } = useStreamContext()

  const user = client.feed('user', client.userId)

  const createTweet = async (text) => {
    const collection = await client.collections.add('tweet', nanoid(), { text })

    await user.addActivity({
      verb: 'tweet',
      object: `SO:tweet:${collection.id}`,
    })
  }

  return {
    createTweet,
  }
}
Enter fullscreen mode Exit fullscreen mode

In this hook, you retrieve the client object from useStreamContext. With the client object, you can instantiate the user's feed.

The createTweet function receives a text argument, and in that function, you create a tweet collection with the text data in an object. Then, you create an activity on the user's feed, with the collection id passed to the object property. This property receives a reference to a collection, which you have specified as a tweet reference, and the collection's id.

Now you can use the createTweet function in other components.

Add the useTweet Hook to the CreateTweetDialog Component

In the src/components/Tweet/CreateTweetDialog.js component file, import the hook:

// other imports
import useTweet from '../../hooks/useTweet'
Enter fullscreen mode Exit fullscreen mode

Then, use the hook in the component:

export default function CreateTweetDialog({ onClickOutside }) {
  const { createTweet } = useTweet()

  const onSubmit = async (text) => {
    createTweet(text)

    onClickOutside()
  }

  return // the UI
}
Enter fullscreen mode Exit fullscreen mode

Add the useTweet Hook to the CreateTweetTop Component

In the src/components/Home/CreateTweetTop.js component file, import the hook:

// other imports
import useTweet from '../../hooks/useTweet'
Enter fullscreen mode Exit fullscreen mode

Then, use the hook in the component:

export default function CreateTweetTop() {
  const { createTweet } = useTweet()

  const onSubmit = async (text) => {
    createTweet(text)
  }

  return // the UI
}
Enter fullscreen mode Exit fullscreen mode

And now, you can create tweets. Click on "Tweet" in the left section of the screen, and create your first tweet in the modal.

Create tweet modal

On submitting and refreshing, you will see the homepage showing the new tweet.

Conclusion

In this tutorial, you have successfully created a Twitter clone using the React Activity Feed SDK. This clone allows a user to select a profile and authenticate them with the feeds application in your Stream dashboard. This clone currently includes the Twitter layout, reusable components, and the create tweet feature.

Stay tuned for part 2 and part 3 where we add the follow-users functionality, reactions and notifications

Top comments (0)