[PART 19][Frontend] Creating a Twitter clone with GraphQL, Typescript, and React ( link's preview, add tweets )
ips-coding-challenge
Posted on January 26, 2021
Hi everyone ;).
As a reminder, I'm doing this Tweeter challenge
Github repository ( Frontend )
Link's preview ( Frontend )
Let's start with the form to send a tweet.
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
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
}
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}
`
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
}
`
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))
}
),
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
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)
}
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,
}
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
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
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:
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 ;)
Posted on January 26, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.