DEV Community

Dillion Megida
Dillion Megida

Posted on • Originally published at getstream.io

Twitter Clone Part 3: Adding Tweet Reactions and Showing Notifications

In this article, the third part of the Build a Twitter Clone series, you will add support for tweet reactions (likes and comments), threads, and a notifications page.

Part 1 focuses on creating the Twitter layout, authenticating users with Stream, adding the create tweet feature, and displaying the home page activity feeds. Part 2 focuses on creating a profile page for users and adding the follow-users feature. Please check out these parts, if you haven't, before continuing with this part.

Add Tweet Reactions

From the previous steps, I have walked you through building the Twitter layout and the TweetBlock component:

block component preview

This component shows four actions: comment, retweet, like, and share. For the scope of this tutorial, we will only focus on the comment and like actions which are currently not functional. So, let's make them functional.

Add a Like Reaction

You will create a custom hook for the like reaction functionality to easily manage it. In Part 1, we concluded with src/components/Tweet/TweetBlock.js having an onToggleLike function in the TweetBlock component, which currently does nothing:

const onToggleLike = () => {
  // toggle like reaction
}
Enter fullscreen mode Exit fullscreen mode

To make this function work, firstly, let's create the hook. Create a new file src/hooks/useLike.js with the following code:

import { useFeedContext } from 'react-activity-feed'

export default function useLike() {
  const feed = useFeedContext()

  const toggleLike = async (activity, hasLikedTweet) => {
    await feed.onToggleReaction('like', activity)
  }

  return { toggleLike }
}
Enter fullscreen mode Exit fullscreen mode

Thefeed object from the useFeedContext hook has different methods that can be applied to the activities in the feed the TweetBlock is used. This feed can be the timeline feed for the homepage or the user feed for the profile page.

The toggleLike function from the hook receives two arguments: the activity to be liked/unliked and a hasLikedTweet boolean, which is true if the logged-in user has liked the tweet already. You will use the hasLikedTweet argument later when you add notifications.

The onToggleReaction method on the feed object takes a type of reaction (in this case, like) and the activity it should be applied to (the current activity the TweetBlock component is used for), and it toggles between liking and unliking for a logged-in user.

To add the like reaction functionality, import this hook to the TweetBlock component:

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

Then update the onToggleLike function to this:

const onToggleLike = async () => {
  await toggleLike(activity, hasLikedTweet)
}
Enter fullscreen mode Exit fullscreen mode

To test this, go to a tweet in your application either made by the logged-in user or a different user and click on the heart icon. You should have this when you click:

Twitter like reaction

The toggle happens when you click it again.

In Part 1, we applied styles for the heart icon to be red when clicked, just in case you're wondering 😅.

You can also test this by logging in with a different user and liking the same tweet. You will see the like count incremented:

Twitter like reaction other user

Add a Comment Reaction

The current state of the comment functionality is that when a user clicks the comment icon on a tweet block, the comment dialog shows, and the user can type a comment, but on submitting, nothing happens. In previous parts, we concluded with src/components/Tweet/TweetBlock.js having the CommentDialog component attached to an onPostComment function that does nothing:

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

To add the comment reaction, we will make this a custom hook. This functionality will be used in the TweetBlock component and the Thread component (for when a tweet is expanded to show comments).

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

import { useFeedContext } from 'react-activity-feed'

export default function useComment() {
  const feed = useFeedContext()

  const createComment = async (text, activity) => {
    await feed.onAddReaction('comment', activity, {
      text,
    })
  }

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

With the onAddReaction method of the feed object, you can add the comment reaction to an activity and pass the comment text.

To use this hook in src/components/Tweet/TweetBlock.js, first import it:

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

Then, get the createComment function in the TweetBlock component:

const { createComment } = useComment()
Enter fullscreen mode Exit fullscreen mode

And finally, update the onPostComment function to this:

const onPostComment = async (text) => {
  await createComment(text, activity)
}
Enter fullscreen mode Exit fullscreen mode

With this addition, when you enter a comment, you will see the comment reactions incremented.

So far, we have added the like and comment reactions, but we haven't added threads yet. A thread view will show a tweet expanded, showing the comments in a tweet. So, let's add that next.

Add a Tweet Thread page

The thread page shows a single tweet, the tweet action buttons, a comment form, and the comments made on the tweet:

Twitter thread page

This thread view is broken into sections, so we'll build it section by section.

Create the ThreadHeader Component

The ThreadHeader component shows the back button and the tweet text.

Create a new file src/components/Thread/ThreadHeader.js, and paste the following:

import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'

import ArrowLeft from '../Icons/ArrowLeft'

const Header = styled.header`
  display: flex;
  align-items: center;
  padding: 15px;

  button {
    width: 25px;
    height: 20px;
    margin-right: 40px;
  }

  span {
    font-size: 20px;
    color: white;
    font-weight: bold;
  }
`

export default function ThreadHeader() {
  const navigate = useNavigate()

  const navigateBack = () => {
    navigate(-1)
  }

  return (
    <Header>
      <button onClick={navigateBack}>
        <ArrowLeft size={20} color="white" />
      </button>
      <span>Tweet</span>
    </Header>
  )
}
Enter fullscreen mode Exit fullscreen mode

Using useNavigate from react-router-dom, you can navigate the user to the previous page they were on in the history session.

Create the TweetContent Component

This component shows the tweet information, the tweet action buttons, a tweet form to add a comment, and tweet blocks for comments.

The tweet blocks in this component are a little different from the normal tweet blocks we created in Part 1. As you will notice, this block does not have reactions. To avoid so much conditional rendering going on in the TweetBlock component, you will create another tweet block component--TweetCommentBlock.

Create a TweetCommentBlock Component

Create a new file src/components/Thread/TweetCommentBlock.js. Start with imports and styles:

import styled from 'styled-components'

import { formatStringWithLink } from '../../utils/string'
import More from '../Icons/More'
import TweetActorName from '../Tweet/TweetActorName'

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

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

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

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

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

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

  .more {
    width: 30px;
    height: 20px;
    display: flex;
    opacity: 0.6;
  }
`
Enter fullscreen mode Exit fullscreen mode

And for the component:

export default function TweetCommentBlock({ comment }) {
  const { user, data: tweetComment } = comment

  return (
    <Block to="/">
      <div className="user-image">
        <img src={user.data.image} alt="" />
      </div>
      <div className="comment-tweet">
        <div>
          <TweetActorName
            name={user.data.name}
            id={user.id}
            time={comment.created_at}
          />
          <div className="tweet__details">
            <p
              className="comment-tweet__text"
              dangerouslySetInnerHTML={{
                __html: formatStringWithLink(
                  tweetComment.text,
                  'tweet__text--link'
                ).replace(/\n/g, '<br/>'),
              }}
            />
          </div>
        </div>
      </div>
      <button className="more">
        <More size={18} color="white" />
      </button>
    </Block>
  )
}
Enter fullscreen mode Exit fullscreen mode

The TweetCommentBlock receives the comment prop, a comment activity object. From the comment object, you can get the user and the data object (which you have assigned to the tweetComment variable).

Composing the TweetContent Component

Create a new file src/components/Thread/TweetContent.js. Add the imports for the component:

import { format } from 'date-fns'
import { useFeedContext, useStreamContext } from 'react-activity-feed'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import { useState } from 'react'

import { formatStringWithLink } from '../../utils/string'
import BarChart from '../Icons/BarChart'
import Comment from '../Icons/Comment'
import Retweet from '../Icons/Retweet'
import Heart from '../Icons/Heart'
import Upload from '../Icons/Upload'
import TweetForm from '../Tweet/TweetForm'
import TweetCommentBlock from './TweetCommentBlock'
import CommentDialog from '../Tweet/CommentDialog'
import More from '../Icons/More'
import useComment from '../../hooks/useComment'
import useLike from '../../hooks/useLike'
Enter fullscreen mode Exit fullscreen mode

There are many icons here for the actions for the tweet. Also, you will use the useComment hook here for the comment form.

Next, the styles:

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

  .user {
    display: flex;
    text-decoration: none;

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

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

    &__name {
      &--name {
        color: white;
        font-weight: bold;
      }
      &--id {
        color: #52575b;
        font-size: 14px;
      }
    }

    &__option {
      margin-left: auto;
    }
  }

  .tweet {
    margin-top: 20px;

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

    &__text {
      color: white;
      font-size: 20px;
    }

    &__time,
    &__analytics,
    &__reactions,
    &__reactors {
      height: 50px;
      display: flex;
      align-items: center;
      border-bottom: 1px solid #555;
      font-size: 15px;
      color: #888;
    }

    &__time {
      &--date {
        margin-left: 12px;
        position: relative;

        &::after {
          position: absolute;
          content: '';
          width: 2px;
          height: 2px;
          background-color: #777;
          border-radius: 50%;
          top: 0;
          bottom: 0;
          left: -7px;
          margin: auto 0;
        }
      }
    }

    &__analytics {
      &__text {
        margin-left: 7px;
      }
    }

    &__reactions {
      &__likes {
        display: flex;

        .reaction-count {
          color: white;
          font-weight: bold;
        }

        .reaction-label {
          margin-left: 4px;
        }
      }
    }

    &__reactors {
      justify-content: space-between;
      padding: 0 50px;
    }
  }

  .write-reply {
    align-items: center;
    padding: 15px 0;
    border-bottom: 1px solid #555;
  }
`
Enter fullscreen mode Exit fullscreen mode

Next, the component:

export default function TweetContent({ activity }) {
  const feed = useFeedContext()
  const { client } = useStreamContext()

  const { createComment } = useComment()
  const { toggleLike } = useLike()

  const time = format(new Date(activity.time), 'p')
  const date = format(new Date(activity.time), 'PP')

  const tweet = activity.object.data
  const tweetActor = activity.actor.data

  const [commentDialogOpened, setCommentDialogOpened] = useState(false)

  let hasLikedTweet = false

  if (activity?.own_reactions?.like) {
    const myReaction = activity.own_reactions.like.find(
      (l) => l.user.id === client.userId
    )
    hasLikedTweet = Boolean(myReaction)
  }

  const onToggleLike = async () => {
    await toggleLike(activity, hasLikedTweet)
    feed.refresh()
  }

  const reactors = [
    {
      id: 'comment',
      Icon: Comment,
      onClick: () => setCommentDialogOpened(true),
    },
    { id: 'retweet', Icon: Retweet },
    {
      id: 'heart',
      Icon: Heart,
      onClick: onToggleLike,
    },
    { id: 'upload', Icon: Upload },
  ]

  const onPostComment = async (text) => {
    await createComment(text, activity)

    feed.refresh()
  }
}
Enter fullscreen mode Exit fullscreen mode

Just like I showed you in Part 1, the hasLikedTweet variable is initialized and updated to hold a boolean value if the logged-in user has liked this tweet or not.

Similar to the like reaction functionality you created earlier, the onToggleLike function here uses the onToggleReaction method on the feed object. Also, the refresh method on the feed object is used to refresh the feed. This part is relevant because, unlike the FlatFeed component, which automatically refreshes upon reactions, the Feed component, which you will soon use, does not.

Also, the onPostComment function uses the createComment function from the useComment hook and refreshes the feed after a successful comment.

Next, the UI:

export default function TweetContent() {
  //

  return (
    <>
      {commentDialogOpened && (
        <CommentDialog
          activity={activity}
          onPostComment={onPostComment}
          onClickOutside={() => setCommentDialogOpened(false)}
        />
      )}
      <Container>
        <Link to={`/${tweetActor.id}`} className="user">
          <div className="user__image">
            <img src={tweetActor.image} alt="" />
          </div>
          <div className="user__name">
            <span className="user__name--name">{tweetActor.name}</span>
            <span className="user__name--id">@{tweetActor.id}</span>
          </div>
          <div className="user__option">
            <More color="#777" size={20} />
          </div>
        </Link>
        <div className="tweet">
          <p
            className="tweet__text"
            dangerouslySetInnerHTML={{
              __html: formatStringWithLink(
                tweet.text,
                'tweet__text--link'
              ).replace(/\n/g, '<br/>'),
            }}
          />
          <div className="tweet__time">
            <span className="tweet__time--time">{time}</span>
            <span className="tweet__time--date">{date}</span>
          </div>

          <div className="tweet__analytics">
            <BarChart color="#888" />
            <span className="tweet__analytics__text">View Tweet Analytics</span>
          </div>

          <div className="tweet__reactions">
            <div className="tweet__reactions__likes">
              <span className="reaction-count">
                {activity.reaction_counts.like || '0'}
              </span>
              <span className="reaction-label">Likes</span>
            </div>
          </div>

          <div className="tweet__reactors">
            {reactors.map((action, i) => (
              <button onClick={action.onClick} key={`reactor-${i}`}>
                <action.Icon
                  color={
                    action.id === 'heart' && hasLikedTweet
                      ? 'var(--theme-color)'
                      : '#888'
                  }
                  fill={action.id === 'heart' && hasLikedTweet && true}
                  size={20}
                />
              </button>
            ))}
          </div>
        </div>

        <div className="write-reply">
          <TweetForm
            onSubmit={onPostComment}
            submitText="Reply"
            collapsedOnMount={true}
            placeholder="Tweet your reply"
            replyingTo={tweetActor.id}
          />
        </div>
        {activity.latest_reactions?.comment?.map((comment) => (
          <TweetCommentBlock key={comment.id} comment={comment} />
        ))}
      </Container>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

There are two ways to make comments in the UI. First, there's the comment form where users can type a comment and submit. The second way is by clicking the comment icon, which opens the CommentDialog component for typing a comment.

On the activity object, you loop through the latest_reactions.comment array to display the comments with the TweetCommentBlock component.

Create the ThreadContent Component

This component is made up of the ThreadHeader and TweetContent components. Create a new file called src/components/Thread/ThreadContent.js. Start with the imports:

import { useEffect, useState } from 'react'
import { useFeedContext, useStreamContext } from 'react-activity-feed'
import { useParams } from 'react-router-dom'

import LoadingIndicator from '../LoadingIndicator'
import TweetContent from './TweetContent'
import ThreadHeader from './ThreadHeader'
Enter fullscreen mode Exit fullscreen mode

With useParams, you will get the id of the tweet from the URL. Tweet links exist in this format: /[actorId]/status/[tweetActivityId].

Next, the component:

export default function ThreadContent() {
  const { client } = useStreamContext()
  const { id } = useParams()

  const feed = useFeedContext()

  const [activity, setActivity] = useState(null)

  useEffect(() => {
    if (feed.refreshing || !feed.hasDoneRequest) return

    const activityPaths = feed.feedManager.getActivityPaths(id) || []

    if (activityPaths.length) {
      const targetActivity = feed.feedManager.state.activities
        .getIn([...activityPaths[0]])
        .toJS()

      setActivity(targetActivity)
    }
  }, [feed.refreshing])

  if (!client || !activity) return <LoadingIndicator />

  return (
    <div>
      <ThreadHeader />
      <TweetContent activity={activity} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

feedManager.getActivityPaths of the feed object returns an array with an id for the current tweet link. This line is essential to ensure that the activity exists. If it returns an empty array, then the tweet link does not exist.

feed.feedManager.state.activities is an immutable Map (created with Immutabe.js), so you get the activity object using getIn and toJS methods.

With the activity obtained, you pass it to the TweetContent component.

Create the Thread Page

Create a new file called src/pages/Thread.js and paste the following:

import { Feed, useStreamContext } from 'react-activity-feed'
import { useParams } from 'react-router-dom'

import Layout from '../components/Layout'
import ThreadContent from '../components/Thread/ThreadContent'

const FEED_ENRICH_OPTIONS = {
  withRecentReactions: true,
  withOwnReactions: true,
  withReactionCounts: true,
  withOwnChildren: true,
}

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

  const { user_id } = useParams()

  return (
    <Layout>
      <Feed
        feedGroup={user.id === user_id ? 'user' : 'timeline'}
        options={FEED_ENRICH_OPTIONS}
        notify
      >
        <ThreadContent />
      </Feed>
    </Layout>
  )
}
Enter fullscreen mode Exit fullscreen mode

For the feedGroup, you check if the currently logged-in user made the tweet, of which you use "user", and if it's a different user, you use "timeline". This is because a tweet exists in one of these feeds, not on both.

The FEED_ENRICH_OPTIONS is relevant so you can get the reactions with each activity. Without this, you will have to make a separate API request to get the comments in the TweetContent component.

Lastly, you need to create a route for this component. Go to src/components/App.js. Import the thread page:

// other imports
import Thread from './pages/Thread'
Enter fullscreen mode Exit fullscreen mode

And add a route for this component:

<Route element={<Thread />} path="/:user_id/status/:id" />
Enter fullscreen mode Exit fullscreen mode

With all these plugged in correctly, when you click a tweet block, you will find the thread view. This view also shows the comment reactions made to a tweet.

You can make more comments using the comment dialog or the comment form:

Adding comments

Thread showing comments

Add the Notifications Page

The notifications page will show new follows, likes, and comment notifications:

Notifications page

The idea with the notifications implementation is to create activities in the notification feed (created in Part 1 when creating feed groups when actions occur). This implies that when you trigger a "like" action, you create an activity in the notification feed with the "like" verb and a reference to the tweet you liked. Similarly, you'll do the same for comments and follow actions.

Before creating a Notifications page, let's start by creating these activities upon these actions we want notifications for.

Create a useNotification hook

Since notifications will be used for different things, making the functionality a hook would be easier to manage. Create a new file src/hooks/useNotification.js with the following code:

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

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

  const createNotification = async (userId, verb, data, reference = {}) => {
    const userNotificationFeed = client.feed('notification', userId)

    const newActivity = {
      verb,
      object: reference,
      ...data,
    }

    await userNotificationFeed.addActivity(newActivity)
  }

  return { createNotification }
}
Enter fullscreen mode Exit fullscreen mode

The returned createNotification function from the hook receives four arguments:

  • userId: id of the user you want to add the notification for
  • verb: the label for the activity
  • data: for other properties to add to the activity, for example, the text of a comment
  • reference: this is optional, but it can be used for referencing a collection, like a tweet, for example

Create Notifications on Reactions and Follows

In this section, you will use this hook on reactions and follow actions.

Create Notifications on Like Reactions

Go to src/hooks/useLike.js to add the hook. First, import the hook:

// other imports
import useNotification from './useNotification'
import { useStreamContext } from 'react-activity-feed'
Enter fullscreen mode Exit fullscreen mode

You will need the user object from the useStreamContext hook, as you will see soon.

Import the createNotification function and the user object:

// ...
const { createNotification } = useNotification()
const { user } = useStreamContext()
Enter fullscreen mode Exit fullscreen mode

Then, update the toggleLike function to create a notification on liking a tweet:

const toggleLike = async (activity, hasLikedTweet) => {
  const actor = activity.actor

  await feed.onToggleReaction('like', activity)

  if (!hasLikedTweet && actor.id !== user.id) {
    // then it is not the logged in user liking their own tweet
    createNotification(actor.id, 'like', {}, `SO:tweet:${activity.object.id}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

The toggleLike function first checks if the tweet has not been liked and the actor of the tweet is not the same as the logged-in user. This check is necessary to ensure that the user does not get a notification upon liking their tweet.

In the last argument, the reference passed to the createNotification function refers to the tweet collection.

When you like a tweet, a new activity is added to the notification feed. You can try this by going to a different user's account and liking one of @getstream_io's tweets. On the Feeds Explorer on your dashboard, you will see the notification:getstream_io created:

Feeds Explorer new notifications

And when you browse the activities in this feed, you will find the new like activity you created:

Feeds explorer like activity notifications feed

Because you created a notification feed group (in Part 1), you can see the is_read and is_seen property. Also, the activities are grouped if they are similar.

Create Notifications on Comment Reactions

Similar to what you did in the previous step, go to src/hooks/useComment.js and import the required hooks:

import { useStreamContext } from 'react-activity-feed'
import useNotification from './useNotification'
Enter fullscreen mode Exit fullscreen mode

Next, get the createNotification function and user object in the useComment hook:

// ...
const { createNotification } = useNotification()
const { user } = useStreamContext()
Enter fullscreen mode Exit fullscreen mode

And finally, update the createComment function:

const createComment = async (text, activity) => {
  const actor = activity.actor

  await feed.onAddReaction('comment', activity, {
    text,
  })

  if (actor.id !== user.id) {
    // then it is not the logged in user commenting on their own tweet

    createNotification(
      actor.id,
      'comment',
      {
        text,
      },
      `SO:tweet:${activity.object.id}`
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

The createComment function also ensures that there are no notifications sent if the same actor of tweet comments on the tweet.

You can test this notification by commenting on a tweet and checking your Feed's explorer.

Create Notifications on Follow Actions

One more notification you want to add is for follow actions. In the useFollow hook in src/hooks/useFollow.js, import the notification hook:

// other imports
import useNotification from './useNotification'
Enter fullscreen mode Exit fullscreen mode

Then, update the toggleFollow function to this:

const { createNotification } = useNotification()

const toggleFollow = async () => {
  const action = isFollowing ? 'unfollow' : 'follow'

  if (action === 'follow') {
    await createNotification(userId, 'follow')
  }

  const timelineFeed = client.feed('timeline', client.userId)
  await timelineFeed[action]('user', userId)

  setIsFollowing((isFollowing) => !isFollowing)
}
Enter fullscreen mode Exit fullscreen mode

In this function, you check if the action is follow and create a follow activity in the notification feed.

You can also test this by following a user and checking your Feeds dashboard.

With these notifications created, now you want to display them.

Create a NotificationContent Component

This component houses the notification header and the notifications for different actions.

To display the different activities in the notifications feed, you will use the NotificationFeed. This component displays the notifications in groups. But you will provide a custom component to handle this grouping.

Creating Grouping Components for Notifications

There are three forms of notifications: like, comment, and follow notifications. The structure of the group is like this:

{
  activities: [...activities created on like action],
  activity_count: NUMBER OF ACTIVITIES,
  actor_count: NUMBER OF ACTORS IN THE ACTIVITIES,
  created_at: ...,
  group: GROUP ID BASED ON VERB AND DATE,
  id: ...,
  is_read: ...,
  is_seen: ...,
  verb: VERB OF GROUPED ACTIVITIES,
}
Enter fullscreen mode Exit fullscreen mode

Let's create grouping components for them.

Create a LikeNotification Group Component

Create a new file src/components/Notification/LikeNotification.js. Add the imports and styles:

import { useStreamContext } from 'react-activity-feed'
import { Link, useNavigate } from 'react-router-dom'
import styled from 'styled-components'

import Heart from '../Icons/Heart'

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

  a {
    color: white;
  }

  span {
    display: inline-block;
  }

  .right {
    margin-left: 20px;
    flex: 1;
  }

  .liked-actors__images {
    display: flex;

    &__image {
      width: 35px;
      height: 35px;
      border-radius: 50%;
      overflow: hidden;
      margin-right: 10px;

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

  .liked-actors__text {
    margin-top: 10px;
    color: white;
    font-size: 15px;

    .liked-actor__name {
      font-weight: bold;

      &:hover {
        text-decoration: underline;
      }
    }
  }

  .tweet-text {
    display: block;
    color: #888;
    margin-top: 10px;
  }
`
Enter fullscreen mode Exit fullscreen mode

With the useNavigate hook, you will navigate to the tweet that was liked when a user clicks on the notification.

Next, for the component:

export default function LikeNotification({ likedActivities }) {
  const likedGroup = {}
  const navigate = useNavigate()

  const { user } = useStreamContext()

  likedActivities.forEach((act) => {
    if (act.object.id in likedGroup) {
      likedGroup[act.object.id].push(act)
    } else likedGroup[act.object.id] = [act]
  })
}
Enter fullscreen mode Exit fullscreen mode

This component receives the activities array from the like group.

You create a likedGroup object that groups activities by the tweet they were made on. The grouping from the notification feeds contains different like activities on tweets.

The next step is to loop over the likedGroup to display the like notifications:

export default function LikeNotification({ likedActivities }) {
  // ...

  return (
    <>
      {Object.keys(likedGroup).map((groupKey) => {
        const activities = likedGroup[groupKey]

        const lastActivity = activities[0]

        const tweetLink = `/${user.id}/status/${lastActivity.object.id}`

        return (
          <Block
            className="active"
            onClick={() => navigate(tweetLink)}
            key={groupKey}
          >
            <Heart color="var(--theme-color)" size={25} fill={true} />
            <div className="right">
              <div className="liked-actors__images">
                {activities.map((act) => (
                  <Link
                    to={`/${act.actor.id}`}
                    key={act.id}
                    className="liked-actors__images__image"
                  >
                    <img src={act.actor.data.image} alt="" />
                  </Link>
                ))}
              </div>
              <span className="liked-actors__text">
                <Link
                  className="liked-actor__name"
                  to={`/${lastActivity.actor.id}`}
                >
                  {lastActivity.actor.data.name}
                </Link>{' '}
                <span to={tweetLink}>
                  {activities.length > 1 &&
                    `and ${activities.length - 1} others`}{' '}
                  liked your Tweet
                </span>
              </span>

              <p className="tweet-text">{lastActivity.object.data.text}</p>
            </div>
          </Block>
        )
      })}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

You loop over each tweet in the likedGroup and also loop over the like activities in the tweet to display the author's information.

Create a CommentNotification Group Component

Create a new file src/components/Notification/CommentNotification.js. Add the imports and styles:

import { Link, useNavigate } from 'react-router-dom'
import { useStreamContext } from 'react-activity-feed'
import styled from 'styled-components'

import { generateTweetLink } from '../../utils/links'
import TweetActorName from '../Tweet/TweetActorName'

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

  a {
    color: white;
  }

  .user__image {
    width: 35px;
    height: 35px;
    overflow: hidden;
    border-radius: 50%;

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

  .user__details {
    margin-left: 20px;
    flex: 1;
  }

  .user__reply-to {
    color: #555;
    font-size: 15px;
    margin-top: 3px;

    a {
      color: var(--theme-color);
      &:hover {
        text-decoration: underline;
      }
    }
  }

  .user__text {
    display: block;
    color: white;
    margin-top: 10px;
  }
`
Enter fullscreen mode Exit fullscreen mode

Next, the component:

export default function CommentNotification({ commentActivities }) {
  const navigate = useNavigate()
  const { user } = useStreamContext()

  return (
    <>
      {commentActivities.map((cAct) => {
        const actor = cAct.actor

        const tweetLink = generateTweetLink(cAct.replyTo, cAct.object.id)

        return (
          <Block key={cAct.id} onClick={() => navigate(tweetLink)}>
            <Link to={`/${actor.id}`} className="user__image">
              <img src={actor.data.image} alt="" />
            </Link>
            <div className="user__details">
              <TweetActorName
                id={actor.id}
                name={actor.data.name}
                time={cAct.time}
              />
              <span className="user__reply-to">
                Replying to <Link to={`/${user.id}`}>@{user.id}</Link>
                <p className="user__text">{cAct.text}</p>
              </span>
            </div>
          </Block>
        )
      })}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

This component receives the commentActivities prop, which is the activities array from the comment group. In this component, you loop through the comments and display the user information and the comment text.

Create a FollowNotification Group Component

Create a new file src/components/Notification/FollowNotification.js. Add the imports and styles:

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

import User from '../Icons/User'

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

  a {
    color: white;
  }

  .right {
    margin-left: 20px;
    flex: 1;
  }

  .actors__images {
    display: flex;

    &__image {
      width: 35px;
      height: 35px;
      border-radius: 50%;
      overflow: hidden;
      margin-right: 10px;

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

  .actors__text {
    margin-top: 10px;
    color: white;
    font-size: 15px;

    span {
      display: inline-block;
    }

    .actors__name {
      font-weight: bold;

      &:hover {
        text-decoration: underline;
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Next, the component:

export default function FollowNotification({ followActivities }) {
  const firstActivity = followActivities[0]

  return (
    <Block>
      <User color="#1c9bef" size={25} />
      <div className="right">
        <div className="actors__images">
          {followActivities.map((follow) => {
            return (
              <Link
                to={`/${follow.actor.id}`}
                className="actors__images__image"
                key={follow.id}
              >
                <img src={follow.actor.data.image} alt="" />
              </Link>
            )
          })}
        </div>
        <p className="actors__text">
          <Link className="actors__name" to={`/${firstActivity.actor.id}`}>
            {firstActivity.actor.data.name}
          </Link>{' '}
          <span>
            {followActivities.length > 1 &&
              `and ${followActivities.length - 1} others`}{' '}
            followed you
          </span>
        </p>
      </div>
    </Block>
  )
}
Enter fullscreen mode Exit fullscreen mode

This component receives the followActivities prop, which is the activities array of the follow group. In this component, you get the first activity from the array so that you can display, "Person A and 5 others followed you".

With these group components created, you can put them together to form a NotificationGroup component.

Create a NotificationGroup Component

Create a new file src/components/Notification/NotificationGroup.js file. Add imports and styles:

import { useEffect, useRef } from 'react'
import { useFeedContext, useStreamContext } from 'react-activity-feed'
import styled from 'styled-components'

import CommentNotification from './CommentNotification'
import FollowNotification from './FollowNotification'
import LikeNotification from './LikeNotification'

const Container = styled.div`
  button {
    width: 100%;
  }
`
Enter fullscreen mode Exit fullscreen mode

Next, the component:

export default function NotificationGroup({ activityGroup }) {
  const feed = useFeedContext()
  const notificationContainerRef = useRef()

  const activities = activityGroup.activities

  const { user, client } = useStreamContext()

  useEffect(() => {
    // stop event propagation on links
    if (!notificationContainerRef.current) return

    const anchorTags = notificationContainerRef.current.querySelectorAll('a')

    anchorTags.forEach((element) => {
      element.addEventListener('click', (e) => e.stopPropagation())
    })

    return () =>
      anchorTags.forEach((element) => {
        element.addEventListener('click', (e) => e.stopPropagation())
      })
  }, [])

  useEffect(() => {
    const notifFeed = client.feed('notification', user.id)

    notifFeed.subscribe((data) => {
      if (data.new.length) {
        feed.refresh()
      }
    })

    return () => notifFeed.unsubscribe()
  }, [])
}
Enter fullscreen mode Exit fullscreen mode

In the first useEffect expression, you stop event propagation on all links in the container ref. The relevance of this is that when you click on a user's name in a like notification block, you don't want the notification block to also navigate to the tweet that was liked.

In the second useEffect expression, you subscribe to the notification feed of the logged-in user. On new notifications, you call the refresh method on the feed object so that the new notifications are displayed.

Finally, for this component, the UI:

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

  return (
    <Container ref={notificationContainerRef}>
      {activityGroup.verb === 'like' && (
        <LikeNotification likedActivities={activities} />
      )}
      {activityGroup.verb === 'follow' && (
        <FollowNotification followActivities={activities} />
      )}
      {activityGroup.verb === 'comment' && (
        <CommentNotification commentActivities={activities} />
      )}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the UI, you check the verb of the group and render the group notification accordingly.

Composing the NotificationContent Component

Create a new file src/components/Notification/NotificationContent.js. Add the imports and styles:

import classNames from 'classnames'
import { useState } from 'react'
import { NotificationFeed } from 'react-activity-feed'
import styled from 'styled-components'

import NotificationGroup from './NotificationGroup'

const Container = styled.div`
  h1 {
    padding: 15px;
    font-size: 16px;
    color: white;
  }

  .tab-list {
    margin-top: 10px;
    border-bottom: 1px solid #333;
    display: grid;
    grid-template-columns: 1fr 1fr;

    .tab {
      color: #777;
      padding: 0 35px;
      width: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-weight: bold;
      font-size: 15px;

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

      &__label {
        position: relative;
        padding: 20px 30px;

        &.active {
          color: white;

          &::after {
            content: '';
            height: 3px;
            width: 100%;
            background-color: var(--theme-color);
            border-radius: 40px;
            position: absolute;
            bottom: 0;
            left: 0;
          }
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Next, the component:

const tabList = [
  {
    id: 'all',
    label: 'All',
  },
  {
    id: 'mentions',
    label: 'Mentions',
  },
]

export default function NotificationContent() {
  const [activeTab, setActiveTab] = useState(tabList[0].id)

  return (
    <Container>
      <h1>Notifications</h1>
      <div className="tab-list">
        {tabList.map((tab) => (
          <button
            onClick={() => setActiveTab(tab.id)}
            className="tab"
            key={tab.id}
          >
            <span
              className={classNames(
                'tab__label',
                activeTab === tab.id && 'active'
              )}
            >
              {tab.label}
            </span>
          </button>
        ))}
      </div>
      <NotificationFeed Group={NotificationGroup} />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Although the tab list is not functional, it's nice to have. In this component, you use the NotificationFeed and pass the NotificationGroup component to the Group prop.

Creating the Notifications Page

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

import Layout from '../components/Layout'
import NotificationContent from '../components/Notification/NotificationContent'

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

And also, add a route in App.js for this page:

// other imports

import Notifications from './pages/Notifications'
Enter fullscreen mode Exit fullscreen mode
<Route element={<Notifications />} path="/notifications" />
Enter fullscreen mode Exit fullscreen mode

Show a Notification Counter

When a user has unread notifications, you will show the count of those notifications in a badge on the Notifications link:

Notifications count badge

This notification link exists in the LeftSide component. Go to src/components/LeftSide.js and import useEffect:

// other imports
import { useEffect } from 'react'
Enter fullscreen mode Exit fullscreen mode

When this component mounts, you will query the notification feed of the logged-in user, get the notifications that haven't been seen (the is_seen property will be false), and display the count. In the LeftSide component, add the following:

export default function LeftSide({ onClickTweet }) {
  // ...other things

  const { client, userData } = useStreamContext()

  useEffect(() => {
    if (!userData || location.pathname === `/notifications`) return

    let notifFeed

    async function init() {
      notifFeed = client.feed('notification', userData.id)
      const notifications = await notifFeed.get()

      const unread = notifications.results.filter(
        (notification) => !notification.is_seen
      )

      setNewNotifications(unread.length)

      notifFeed.subscribe((data) => {
        setNewNotifications(newNotifications + data.new.length)
      })
    }

    init()

    return () => notifFeed?.unsubscribe()
  }, [userData])

  // other things
}
Enter fullscreen mode Exit fullscreen mode

When the component mounts, you create an init function and evoke it. In this function, you get all the activities in the notification feed; then, you filter out the notifications that have been seen to find the unread ones. Next, you update the newNotifications state with the length of the unread array.

Also, you subscribe to the notification feed so that when a new activity is added to the notification feed, you update the newNotifications state.

Remember earlier you triggered some notifications on getstream_io's account by liking, commenting on their tweet, and following them. Now when you log into getstream_io's account and click the notifications link on the left sidebar, you will see the notification activities made on their feed like this:

Notifications page

And there you have it, your Twitter clone!

Conclusion

There are more features that can be added to this clone project, but we have focused on some functionalities that allow you to understand activity feeds and how Stream feeds provides solutions for feed-based applications.

Find the complete source code of the clone in this repository.

Please give the react-activity-feed repository a star if you enjoyed this tutorial.

As a recap:

  • in Part 1, we built most of the layout and shared components and also added the create-tweet feature
  • in Part 2, we added a profile page for users and also created the follow-user functionality
  • in this part, we added support for like and comment reactions and created notifications for each action.

Overall in this Twitter clone, you should now understand the concept of:

  • activity feeds (tweets or notification activities)
  • subscribing to a feed (following a user)

There are many more ways you apply feeds. You can use them in forums (where a user can subscribe to a topic or discussion), e-commerce platforms (where users can follow a product feed and get updated when new related products are added), and social media platforms.

We have other feeds SDKs to allow you to integrate feeds in different languages and platforms. Do check it out.

Discussion (0)