DEV Community

loading...
Cover image for [PART 20] Creating a Twitter clone with GraphQL, Typescript, and React ( Retweet )

[PART 20] Creating a Twitter clone with GraphQL, Typescript, and React ( Retweet )

ipscodingchallenge profile image ips-coding-challenge ・4 min read

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository ( Backend )

Github repository ( Frontend )

Db diagram

Backend

I decided to simplify the way I was going to handle retweets. Retweets will be handled like "likes". So I'm going to use the same principle ;).

src/db/migrations/create_retweets_table.ts

import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
  return knex.schema.createTable('retweets', (t) => {
    t.increments('id')
    t.integer('user_id').unsigned().notNullable()
    t.integer('tweet_id').unsigned().notNullable()

    t.unique(['user_id', 'tweet_id'])

    t.foreign('user_id').references('id').inTable('users').onDelete('CASCADE')
    t.foreign('tweet_id').references('id').inTable('tweets').onDelete('CASCADE')
  })
}

export async function down(knex: Knex): Promise<void> {
  return knex.raw('DROP TABLE retweets CASCADE')
}

Enter fullscreen mode Exit fullscreen mode

src/resolvers/RetweetResolver.ts

import { ApolloError } from 'apollo-server'
import { Arg, Authorized, Ctx, Mutation, Resolver } from 'type-graphql'
import { MyContext } from '../types/types'

@Resolver()
class RetweetResolver {
  @Mutation(() => String)
  @Authorized()
  async toggleRetweet(
    @Arg('tweet_id') tweet_id: number,
    @Ctx() ctx: MyContext
  ) {
    const { db, userId } = ctx

    const [tweet] = await db('tweets').where('id', tweet_id)

    if (!tweet) {
      throw new ApolloError('Tweet not found')
    }

    const data = {
      user_id: userId,
      tweet_id: tweet_id,
    }

    try {
      const [alreadyRetweeted] = await db('retweets').where(data)

      if (alreadyRetweeted) {
        // Delete the retweet and return
        await db('retweets').where(data).del()

        return 'Retweet deleted'
      }

      await db('retweets').insert(data)

      return 'Retweet added'
    } catch (e) {
      throw new ApolloError(e.message)
    }
  }
}
export default RetweetResolver

Enter fullscreen mode Exit fullscreen mode

I need to change the way I retrieve the retweetsCount :

src/utils/utils.ts

export const selectCountsForTweet = (db: Knex) => {
  return [
    db.raw(
      '(SELECT count(tweet_id) from likes where likes.tweet_id = tweets.id) as "likesCount"'
    ),
    db.raw(
      `(SELECT count(t.parent_id) from tweets t where t.parent_id = tweets.id and t.type = 'comment') as "commentsCount"`
    ),
    // What I've changed  
    db.raw(
      `(SELECT count(tweet_id) from retweets where retweets.tweet_id = tweets.id) as "retweetsCount"`
    ),
    'tweets.*',
  ]
}

Enter fullscreen mode Exit fullscreen mode

I also removed the part that handles the case of a retweet in the addTweet function of the TweetResolver.

I then added a property to my Tweet entity to know when a tweet has been retweetted.

src/entities/Tweet.ts

  @Field()
  isRetweeted: boolean
Enter fullscreen mode Exit fullscreen mode

And I handle this with a @FieldResolver in the TweetResolver:

src/resolvers/TweetResolver.ts

@FieldResolver(() => Boolean)
  async isRetweeted(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
    const {
      userId,
      dataloaders: { isRetweetedDataloader },
    } = ctx

    if (!userId) return false

    const isRetweeted = await isRetweetedDataloader.load({
      tweet_id: tweet.id,
      user_id: userId,
    })

    return isRetweeted !== undefined
  }
Enter fullscreen mode Exit fullscreen mode

src/dataloaders/dataloaders.ts

isRetweetedDataloader: new DataLoader<any, any, unknown>(async (keys) => {
    const tweetIds = keys.map((k: any) => k.tweet_id)
    const userId = keys[0].user_id

    const retweets = await db('retweets')
      .whereIn('tweet_id', tweetIds)
      .andWhere('user_id', userId)
    return tweetIds.map((id) => retweets.find((r) => r.tweet_id === id))
  }),
Enter fullscreen mode Exit fullscreen mode

Now let's take care of the front end ;).

Frontend

Since we have the same behavior as for the "like" function, I'm going to refactor the code a bit.

src/components/tweets/actions/TweetActionButton.tsx

import Button from '../../Button'

type TweetActionButton = {
  id: number
  isSth: boolean | undefined
  icon: JSX.Element
  activeIcon?: JSX.Element
  text: string
  activeText: string
  activeClass: string
  onClick:
    | ((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void)
    | undefined
}

const TweetActionButton = ({
  id,
  isSth,
  icon,
  activeIcon,
  text,
  activeText,
  activeClass,
  onClick,
}: TweetActionButton) => {
  return (
    <Button
      text={`${isSth ? activeText : text}`}
      variant={`${isSth ? activeClass : 'default'}`}
      className={`text-lg md:text-sm`}
      onClick={onClick}
      icon={isSth && activeIcon ? activeIcon : icon}
      alignment="left"
      hideTextOnMobile={true}
    />
  )
}

export default TweetActionButton

Enter fullscreen mode Exit fullscreen mode

The LikeButton and RetweetButton buttons look like this:

import { useMutation } from '@apollo/client'
import React, { useEffect } from 'react'
import { MdFavorite, MdFavoriteBorder } from 'react-icons/md'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { TOGGLE_LIKE } from '../../../graphql/tweets/mutations'
import { isLikedState, singleTweetState } from '../../../state/tweetsState'
import Button from '../../Button'
import TweetActionButton from './TweetActionButton'

const LikeButton = ({ id }: { id: number }) => {
  const [isLiked, setIsLiked] = useRecoilState(isLikedState(id))
  const setTweet = useSetRecoilState(singleTweetState(id))

  const [toggleLike, { error }] = useMutation(TOGGLE_LIKE, {
    variables: {
      tweet_id: id,
    },
    update(cache, { data: { toggleLike } }) {
      setIsLiked(toggleLike.includes('added'))
      setTweet((oldTweet) => {
        if (oldTweet) {
          let count = oldTweet.likesCount
          toggleLike.includes('added') ? count++ : count--
          return {
            ...oldTweet,
            likesCount: count,
          }
        }
      })
    },
  })

  useEffect(() => {
    if (error) {
      console.log('Toggle like error', error)
    }
  }, [error])

  return (
    <TweetActionButton
      id={id}
      isSth={isLiked}
      icon={<MdFavoriteBorder />}
      activeIcon={<MdFavorite />}
      onClick={() => toggleLike()}
      text="Like"
      activeText="Liked"
      activeClass="red"
    />
  )
}

export default LikeButton

Enter fullscreen mode Exit fullscreen mode
import { useMutation } from '@apollo/client'
import React, { useEffect } from 'react'
import { MdLoop } from 'react-icons/md'
import { useRecoilState, useSetRecoilState } from 'recoil'
import { TOGGLE_RETWEET } from '../../../graphql/tweets/mutations'
import { isRetweetedState, singleTweetState } from '../../../state/tweetsState'
import TweetActionButton from './TweetActionButton'

const RetweetButton = ({ id }: { id: number }) => {
  const setTweet = useSetRecoilState(singleTweetState(id))
  const [isRetweeted, setIsRetweeted] = useRecoilState(isRetweetedState(id))

  const [toggleRetweet, { error }] = useMutation(TOGGLE_RETWEET, {
    variables: {
      tweet_id: id,
    },
    update(cache, { data: { toggleRetweet } }) {
      setIsRetweeted(toggleRetweet.includes('added'))
      setTweet((oldTweet) => {
        if (oldTweet) {
          let count = oldTweet.retweetsCount
          toggleRetweet.includes('added') ? count++ : count--
          return {
            ...oldTweet,
            retweetsCount: count,
          }
        }
      })
    },
  })

  useEffect(() => {
    if (error) {
      console.log('Toggle retweet error', error)
    }
  }, [error])

  return (
    <TweetActionButton
      id={id}
      isSth={isRetweeted}
      icon={<MdLoop />}
      onClick={() => toggleRetweet()}
      text="Retweet"
      activeText="Retweeted"
      activeClass="green"
    />
  )
}

export default RetweetButton

Enter fullscreen mode Exit fullscreen mode

I've added the update of the counters when you like or retweet. That's why I retrieved my tweet via the hook const setTweet = useSetRecoilState(singleTweetState(id)). This will allow me to update the tweet locally.

I create a new component that will be in charge of rendering the different counters:

src/components/tweets/TweetStats.tsx

import { useRecoilValue } from 'recoil'
import { singleTweetState } from '../../state/tweetsState'
import { pluralize } from '../../utils/utils'

const TweetStats = ({ id }: { id: number }) => {
  const tweet = useRecoilValue(singleTweetState(id))

  return (
    <div className="flex justify-end mt-6">
      <p className="text-gray4 text-xs ml-4">
        {pluralize(tweet!.commentsCount, 'Comment')}
      </p>
      <p className="text-gray4 text-xs ml-4">
        {pluralize(tweet!.retweetsCount, 'Retweet')}
      </p>
      <p className="text-gray4 text-xs ml-4">
        {pluralize(tweet!.likesCount, 'Like')}
      </p>
    </div>
  )
}

export default TweetStats

Enter fullscreen mode Exit fullscreen mode

Here I retrieve my tweet via recoil and as a result, the component will be rerendered each time I like or retweet a tweet ;).

Like and retweet behavior on frontend

That's all for today ;)

Bye and take care ;).

Discussion (0)

pic
Editor guide