[PART 21] Creating a Twitter clone with GraphQL, Typescript, and React ( comments, trending hashtags )
Tags: webdev, react, graphql, typescript
Published: 2021-02-01 14:11:42

Hi everyone ;).

As a reminder, I'm doing this [Tweeter challenge](https://devchallenges.io/challenges/rleoQc34THclWx1cFFKH)

[Github repository ( Backend )](https://github.com/ips-coding-challenge/challenge_twitter)

[Github repository ( Frontend )](https://github.com/ips-coding-challenge/challenge_twitter_front)

[Db diagram](https://dbdiagram.io/d/5f75c0243a78976d7b75f7be)

## Bookmarks

For the favorites, I'll let you take a look at the [Github Repository](https://github.com/ips-coding-challenge) 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**.

```tsx

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.

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

*src/components/tweets/Tweet.tsx*

```tsx
const [showCommentForm, setShowCommentForm] = useState(false)
```

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

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

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

```tsx
<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**.

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

That's what it looks like:

![Add comment](https://dev-to-uploads.s3.amazonaws.com/i/78kp8o6o4sqyz92l3kuz.gif)

## Trending Hashtags [Backend]

I start with the **Hashtag** entity

*src/entities/Hashtag.ts*

```typescript
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*

```typescript
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.

I don't forget to add the resolver to the server.

*src/server.ts*

```typescript
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](https://dev-to-uploads.s3.amazonaws.com/i/vsey8qnmr8l7gv9teyn8.png)

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*

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

```typescript
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*

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

![Trending hashtags sidebar](https://dev-to-uploads.s3.amazonaws.com/i/v4njwfxzlyxq6tadynin.png)

That's all for today ;).



Bye and take care ;)