[PART 19][Frontend] Creating a Twitter clone with GraphQL, Typescript, and React ( link's preview, add tweets )

ipscodingchallenge

ips-coding-challenge

Posted on January 26, 2021

[PART 19][Frontend] Creating a Twitter clone with GraphQL, Typescript, and React ( link's preview, add tweets )

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository ( Backend )

Github repository ( Frontend )

Db diagram

Link's preview ( Frontend )

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

Tweet Form

src/components/tweets/TweetForm.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

Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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

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

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

I also added a Preview entity

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

Enter fullscreen mode Exit fullscreen mode

And a @FieldResolver.

src/resolvers/tweetsResolvers.ts

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

    return await previewLinkDataloader.load(tweet.id)
  }
Enter fullscreen mode Exit fullscreen mode

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

return {
        ...tweet,
        likesCount: 0,
        commentsCount: 0,
        retweetsCount: 0,
      }
Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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

I think I've more or less said what I wanted to say about that part. Remember to check out the Github Repo if I forgot to mention something ;). Otherwise, feel free to contact me and leave comments ;).

Bye and Take care ;)

💖 💪 🙅 🚩
ipscodingchallenge
ips-coding-challenge

Posted on January 26, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related