DEV Community

loading...
Cover image for [PART 21] Creating a Twitter clone with GraphQL, Typescript, and React ( comments, trending hashtags )

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

ips-coding-challenge
On a journey to become a developer ;).
・5 min read

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

Enter fullscreen mode Exit fullscreen mode

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.

It is in my Tweet component that I will show/hide the TweetForm in the case of a comment.

src/components/tweets/Tweet.tsx

const [showCommentForm, setShowCommentForm] = useState(false)
Enter fullscreen mode Exit fullscreen mode

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

const toggleCommentForm = (e: any) => {
    setShowCommentForm((old) => (old = !old))
  }
Enter fullscreen mode Exit fullscreen mode

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}
    />
Enter fullscreen mode Exit fullscreen mode

And just below, I display the TweetForm.

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

That's what it looks like:

Add comment

Trending Hashtags [Backend]

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

I 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,
  })
}

Enter fullscreen mode Exit fullscreen mode

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.

Trending Hashtags [Frontend]

I start by creating the component Hashtags.tsx in a 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

Enter fullscreen mode Exit fullscreen mode

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
    }
  }
`

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Trending hashtags sidebar

That's all for today ;).

Bye and take care ;)

Discussion (0)