[PART 21] Create a Twitter clone with GraphQL, Typescript, and React ( comments, trending hashtags )

[PART 21] Create a Twitter clone with GraphQL, Typescript, and React ( comments, trending hashtags )

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository ( Backend )

Github repository ( Frontend )

Db diagram

Bookmarks

For the favorites, I'll let you take a look at the Github Repository since it's exactly the same logic as for the "likes".

Comments

For the comments, there's not going to be much to do either. We already have the logic on the backend side. All that remains is to modify a little bit our TweetForm.


type TweetFormProps = {
  tweet_id?: number
  type?: TweetTypeEnum
  onSuccess?: Function
}

export enum TweetTypeEnum {
  TWEET = 'tweet',
  COMMENT = 'comment',
}

const TweetForm = ({ tweet_id, type, onSuccess }: TweetFormProps) => {
  // Global state
  const user = useRecoilValue(userState)
  const setTweets = useSetRecoilState(tweetsState)

  // Local state
  const [body, setBody] = useState('')
  const [addTweetMutation, { data }] = useMutation(ADD_TWEET)
  // I create a local state for loading instead of using the apollo loading
  // It's because of the urlShortener function.
  const [loading, setLoading] = useState(false)
  const [errors, setErrors] = useState<ValidationError | null>(null)
  const [serverErrors, setServerErrors] = useState<any[]>([])

  const addTweet = async () => {
    setErrors(null)
    setServerErrors([])
    setLoading(true)
    // extract info from the tweet body ( urls, hashtags for now)
    const { hashtags, urls } = await extractMetadata(body)

    // Shorten the urls
    let shortenedURLS: any
    let newBody = body.slice() /* make a copy of the body */
    if (urls && urls.length > 0) {
      // Shorten the url via tinyURL
      // Not ideal but ok for now as I didn't create my own service to shorten the url
      // and I don't think I will create one ;)
      shortenedURLS = await shortenURLS(urls)
      shortenedURLS.forEach((el: any) => {
        // Need to escape characters for the regex to work
        const pattern = el.original.replace(/[^a-zA-Z0-9]/g, '\\$&')
        newBody = newBody.replace(new RegExp(pattern), el.shorten)
      })
    }

    try {
      // Honestly, I should not validate against hashtags and shortenedURLS as
      // it's an "intern" thing. I let it for now mostly for development purposes.
      await addTweetSchema.validate({
        body,
        hashtags,
        shortenedURLS,
      })

      const payload: any = {
        body: newBody ?? body,
        hashtags,
        url: shortenedURLS ? shortenedURLS[0].shorten : null,
      }

      if (type) {
        payload.type = type
      }
      if (tweet_id) {
        payload.parent_id = tweet_id
      }

      await addTweetMutation({
        variables: {
          payload,
        },
      })
      if (onSuccess) {
        onSuccess()
      }
    } catch (e) {
      if (e instanceof ValidationError) {
        setErrors(e)
      } else if (e instanceof ApolloError) {
        setServerErrors(handleErrors(e))
      }

      console.log('e', e)
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    if (data) {
      setTweets((old) => {
        return [data.addTweet].concat(old)
      })
      setBody('')
    }
  }, [data])

  const commentHeader = () => {
    return (
      <>
        <span>In response to </span>
        <Link to="/" className="text-primary hover:text-primary_hover">
          @{user!.username}
        </Link>
      </>
    )
  }

  return (
    <div
      className={`mb-4 p-4 w-full rounded-lg shadow bg-white ${
        type === TweetTypeEnum.COMMENT ? 'mt-4 border border-primary' : ''
      }`}
    >
      {serverErrors.length > 0 && (
        <div className="mb-4">
          {serverErrors.map((e: any, index: number) => {
            return (
              <Alert
                key={index}
                variant="danger"
                message={Array.isArray(e) ? e[0].message : e.message}
              />
            )
          })}
        </div>
      )}

      <h3 className={type === TweetTypeEnum.COMMENT ? 'text-sm' : ''}>
        {type === TweetTypeEnum.COMMENT ? commentHeader() : 'Tweet something'}
      </h3>
      <hr className="my-2" />
      <div className="flex w-full">
        <Avatar className="mr-2" display_name={user!.display_name} />
        <div className="w-full">
          <div className="w-full mb-2">
            <textarea
              value={body}
              onChange={(e) => setBody(e.target.value)}
              className="w-full placeholder-gray4 p-2 "
              placeholder="What's happening"
            ></textarea>
            {errors && errors.path === 'body' && (
              <span className="text-red-500 text-sm">{errors.message}</span>
            )}
          </div>

          {/* Actions */}
          <div className="flex justify-between">
            <div className="flex items-center">
              <MdImage className="text-primary mr-2" />
              <div className="text-primary inline-flex items-center">
                <MdPublic className="mr-1" />
                <span className="text-xs">Everyone can reply</span>
              </div>
            </div>
            <Button
              text={type === TweetTypeEnum.COMMENT ? 'Comment' : 'Tweet'}
              variant="primary"
              onClick={addTweet}
              disabled={loading}
              loading={loading}
            />
          </div>
        </div>
      </div>
    </div>
  )
}

export default TweetForm

To add a comment, I need the id of the parent tweet, the type and I also pass a function that will let me know when the addition is finished. I could, with this function, hide the form for example.

In my Tweet component, I will show/hide the TweetForm in case of a comment.

src/components/tweets/Tweet.tsx

const [showCommentForm, setShowCommentForm] = useState(false)

I create a local state and also a function to toggle the status of the form:

const toggleCommentForm = (e: any) => {
    setShowCommentForm((old) => (old = !old))
  }

I just have to use this function on my comment button:

<Button
    text="Comment"
    variant="default"
    className="text-lg md:text-sm"
    icon={<MdModeComment />}
    alignment="left"
    hideTextOnMobile={true}
    onClick={toggleCommentForm}
    />

And just below, I display the TweetForm.

{showCommentForm && (
    <TweetForm
        type={TweetTypeEnum.COMMENT}
        tweet_id={tweet.id}
        onSuccess={() => setShowCommentForm(false)}
        />
)}

That's what it looks like:

Add comment

I start with the Hashtag entity

src/entities/Hashtag.ts

import { Field, ObjectType } from 'type-graphql'

@ObjectType()
class Hashtag {
  @Field()
  id: number

  @Field()
  hashtag: string

  @Field({ nullable: true })
  tweetsCount?: number
}

export default Hashtag

And then I create the resolver to fetch the hashtags

src/resolvers/HashtagResolver.ts

import { Ctx, Query, Resolver } from 'type-graphql'
import Hashtag from '../entities/Hashtag'
import { MyContext } from '../types/types'

@Resolver()
class HashtagResolver {
  @Query(() => [Hashtag])
  async trendingHashtags(@Ctx() ctx: MyContext) {
    const { db } = ctx

    const hashtags = await db({ h: 'hashtags' })
      .distinct('h.hashtag', 'h.id')
      .select(
        db.raw(
          '(SELECT count(hashtags_tweets.hashtag_id) from hashtags_tweets WHERE hashtags_tweets.hashtag_id = h.id) as "tweetsCount"'
        )
      )
      .innerJoin('hashtags_tweets as ht', 'h.id', '=', 'ht.hashtag_id')
      .whereRaw(`ht.created_at > NOW() -  interval '7 days'`)
      .groupBy('h.id', 'ht.created_at')
      .orderBy('tweetsCount', 'desc')
      .limit(10)

    return hashtags
  }
}

export default HashtagResolver

I retrieve the most popular hashtags over the last 7 days.

Don't forget to add the resolver to the server.

src/server.ts

export const schema = async () => {
  return await buildSchema({
    resolvers: [
      AuthResolver,
      TweetResolver,
      LikeResolver,
      FollowerResolver,
      RetweetResolver,
      BookmarkResolver,
      HashtagResolver,
    ],
    authChecker: authChecker,
  })
}

And this is what I get when I launch my request:

Hashtags query

I now have everything I need to make the sidebar on the front end.

I start by creating the component Hashtags.tsx in sub-directory sidebars.

src/components/sidebars/Hashtags.tsx

import { useQuery } from '@apollo/client'
import { Link } from 'react-router-dom'
import { HASHTAGS } from '../../graphql/hashtags/queries'
import { HashtagType } from '../../types/types'
import { pluralize } from '../../utils/utils'
import BasicLoader from '../loaders/BasicLoader'

const Hashtags = () => {
  const { data, loading, error } = useQuery(HASHTAGS)

  if (loading) return <BasicLoader />
  if (error) return <div>Error loading the hashtags</div>
  return (
    <div className="rounded-lg shadow bg-white p-4">
      <h3 className="mb-1 font-semibold text-gray5">Trends</h3>
      <hr />
      {data && data.trendingHashtags ? (
        <ul className="mt-4">
          {data.trendingHashtags.map((h: HashtagType) => {
            return (
              <li className="mb-4 text-noto">
                <Link
                  to={`/hashtags/${h.hashtag.replace('#', '')}`}
                  className="font-semibold text-gray8 mb-3 hover:text-gray-500 transition-colors duration-300"
                >
                  {h.hashtag}
                </Link>
                <p className="text-gray7 text-xs">
                  {pluralize(h.tweetsCount!, 'Tweet')}
                </p>
              </li>
            )
          })}
        </ul>
      ) : null}
    </div>
  )
}

export default Hashtags

Nothing special here. I do my graphql query and once I have the data, I make a loop and display the hashtags.

src/graphql/hashtags/queries.ts

import { gql } from '@apollo/client'

export const HASHTAGS = gql`
  query {
    trendingHashtags {
      id
      hashtag
      tweetsCount
    }
  }
`

And in my Home page I replace the placeholder:

src/pages/Home.tsx

{/* Hashtags */}
<div className="hidden md:block w-sidebarWidth flex-none">
    <Hashtags />
</div>

Trending hashtags sidebar

That's all for today ;).

Bye and take care ;)