[PART 19][Frontend] Creating a Twitter clone with GraphQL, Typescript, and React ( link's preview, add tweets )
Tags: webdev, challenge, react, typescript
Published: 2021-01-26 14:41:19

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)

## Link's preview ( Frontend )

Let's start with the form to send a tweet.

![Tweet Form](https://dev-to-uploads.s3.amazonaws.com/i/crthcnpuxh4m790qaufn.png)

*src/components/tweets/TweetForm.tsx*

```tsx
import { ApolloError, useMutation } from '@apollo/client'
import { forwardRef, useEffect, useState } from 'react'
import { MdImage, MdPublic } from 'react-icons/md'
import { useRecoilValue, useSetRecoilState } from 'recoil'
import { ValidationError } from 'yup'
import { ADD_TWEET } from '../../graphql/tweets/mutations'
import { tweetsState } from '../../state/tweetsState'
import { userState } from '../../state/userState'
import { extractMetadata, handleErrors, shortenURLS } from '../../utils/utils'
import { addTweetSchema } from '../../validations/tweets/schema'
import Alert from '../Alert'
import Avatar from '../Avatar'
import Button from '../Button'

const TweetForm = () => {
  // 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
  // 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 {
      // I should not validate hashtags and shortenedURLS as
      // it's an "intern" thing. I let it for now mostly for development purposes.
      await addTweetSchema.validate({
        body,
        hashtags,
        shortenedURLS,
      })
      await addTweetMutation({
        variables: {
          payload: {
            body: newBody ?? body,
            hashtags,
            url: shortenedURLS ? shortenedURLS[0].shorten : null,
          },
        },
      })
    } 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])

  return (
    <div className="mb-4 p-4 w-full rounded-lg shadow bg-white">
      {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>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
              rows={5}
              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="Tweet"
              variant="primary"
              onClick={addTweet}
              disabled={loading}
              loading={loading}
            />
          </div>
        </div>
      </div>
    </div>
  )
}

export default TweetForm

```

There is a lot to see here ;). First of all, a tweet is not just a string. Therefore, I'm going to extract some data. All this could be done in the backend but since I don't have a anything, at least for the moment, allowing me to listen to certain events (pubsub with Redis for example), I decided to do the work on the frontend side. 

For example, I'll have to extract the links and then shorten them. I also extracted the hashtags even if I didn't need to do that on the frontend.

Anyway ;), let's focus on the *addTweet* function.

First thing you can notice is that I'm not using the **loading and error** provided by the apollo client. Since shortening the urls can take some time, I need to set the state to *loading* as soon as the function starts. In the same way, I need to handle errors since I validate the data with the **yup** library.

This is what the *extractMetadata and shortenURLS* functions look like:

```typescript
export const extractMetadata = async (body: string) => {
  let hashtags = body.match(/(#[\w]+)/g)

  const urls = body.match(/https?:\/\/\S+/g)

  // Remove duplicates
  if (hashtags && hashtags?.length > 0) {
    hashtags = Array.from(new Set(hashtags))
  }
  return {
    hashtags,
    urls,
  }
}

export const shortenURLS = async (
  urls: string[]
): Promise<{ original: string; shorten: string }[]> => {
  const tinyURLS = []
  for (let url of urls) {
    const res = await TinyURL.shorten(url)
    tinyURLS.push({
      original: url,
      shorten: res,
    })
  }
  return tinyURLS
}
```

The biggest problem here is the fact that I use an external service to shorten the urls. Since it can take a bit of time, doing this on the Frontend is far from ideal. However, I don't especially want to do my own service to shorten the urls. I guess a better solution would be to use Redis for example to launch the shortening of urls in the background and listen to the task once it's done to update the tweet with the shortened urls. Let's make it as simple as possible for now :D. 

Regarding the ADD_TWEET mutation:

```typescript
export const ADD_TWEET = gql`
  mutation($payload: AddTweetPayload!) {
    addTweet(payload: $payload) {
      ...tweetFragment
    }
  }
  ${TWEET_FRAGMENT}
`

```

As you can see and since I don't like to repeat myself, we can use graphql's fragments. Here is the fragment:

*src/graphql/tweets/fragments.ts*

```typescript
import { gql } from '@apollo/client'

export const TWEET_FRAGMENT = gql`
  fragment tweetFragment on Tweet {
    id
    body
    visibility
    likesCount
    retweetsCount
    commentsCount
    parent {
      id
      body
      user {
        id
        username
        display_name
        avatar
      }
    }
    preview {
      id
      title
      description
      url
      image
    }
    isLiked
    type
    visibility
    user {
      id
      username
      display_name
      avatar
    }
    created_at
  }
`

```

I don't think I mentioned the "preview" part. Let's take a quick tour through the backend to see what I've modified ;)

## Preview Dataloader ( Backend  )

To display the link preview, we will have to fetch it. We'll use a dataloader for that:

*src/dataloaders.ts*

```typescript
previewLinkDataloader: new DataLoader<number, unknown, unknown>(
    async (ids) => {
      const previews = await db('previews as p')
        .innerJoin('previews_tweets as pt', 'pt.preview_id', '=', 'p.id')
        .whereIn('pt.tweet_id', ids)
        .select(['p.*', 'pt.tweet_id'])

      return ids.map((id) => previews.find((p) => p.tweet_id === id))
    }
  ),
```

We are starting to get used to it now ;).

I also added a **Preview** entity

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

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

  @Field()
  url: string

  @Field()
  title: string

  @Field({ nullable: true })
  description?: string

  @Field({ nullable: true })
  image?: string
}

export default Preview

```

And a **@FieldResolver**.

*src/resolvers/tweetsResolvers.ts*

```typescript
@FieldResolver(() => Preview)
  async preview(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
    const {
      dataloaders: { previewLinkDataloader },
    } = ctx

    return await previewLinkDataloader.load(tweet.id)
  }
```

Also to avoid some issues, on the **addTweet** function of the **TweetResolver** I added the different when returning the inserted tweet:

```typescript
return {
        ...tweet,
        likesCount: 0,
        commentsCount: 0,
        retweetsCount: 0,
      }
```

Finally, after inserting the link's preview, we are going to clean the cache of the dataloader we've just created:

*src/events/scrapPreviewEmitter.ts*

```typescript
import { EventEmitter } from 'events'
import { scrap } from '../utils/utils'
import knex from '../db/connection'
import { dataloaders } from '../dataloaders/dataloaders'

const scrapPreviewEmitter = new EventEmitter()

scrapPreviewEmitter.on('scrap', async (url: string, tweet_id: number) => {
  try {
    const result = await scrap(url)
    const previewsIds = await knex('previews')
      .insert({
        ...result,
        url,
      })
      .onConflict('url')
      .ignore()
      .returning('id')

    const toInsert = previewsIds.map((id) => {
      return {
        preview_id: id,
        tweet_id: tweet_id,
      }
    })

    await knex('previews_tweets').insert(toInsert)
    dataloaders.previewLinkDataloader.clear(tweet_id)
  } catch (e) {
    console.log('e', e)
  }
})

export default scrapPreviewEmitter

```

By the way, I've changed a little bit what I did before. And notably the fact that I insert the shortened url and not the url I was getting by scrapping ;). Otherwise I wouldn't have a match in the frontend and so I couldn't display the preview ;).

## Preview Component

Let's go back to the Frontend side to finish the job by adding the **Preview** component.

*src/components/tweets/Preview.tsx*

```tsx
const Preview = ({ preview }: any) => {
  return (
    <a
      href={preview.url}
      className="rounded shadow block p-3 hover:bg-gray3 transition-colors duration-300"
    >
      {preview.image && (
        <img
          className="rounded object-cover w-full"
          src={preview.image}
          alt={preview.title}
        />
      )}
      <h4 className="font-semibold my-2">{preview.title}</h4>
      {preview.description && <p>{preview.description}</p>}
    </a>
  )
}

export default Preview

```

Nothing very complicated here. Nevertheless, I will have to pay attention to the LazyLoad of the images. I added an issue on Github so I don't forget ;).

Here is a small preview of the result:

![Link Preview](https://dev-to-uploads.s3.amazonaws.com/i/g94u1am5hr2dw2drpdf9.png)

I think I've more or less said what I wanted to say about that part. Remember to check out the [Github Repo](https://github.com/ips-coding-challenge/challenge_twitter_front) if I forgot to mention something ;). Otherwise, feel free to contact me and leave comments ;).

Bye and Take care ;)