[PART 20] Create a Twitter clone with GraphQL, Typescript, and React ( Retweet )
Hi everyone ;).
As a reminder, I'm doing this Tweeter challenge
Github repository ( Frontend )
Backend
I decided to simplify the way I was going to handle retweets. Retweets will be handled like "likes". So I'm going to use the same principle ;).
src/db/migrations/create_retweets_table.ts
import * as Knex from 'knex'
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('retweets', (t) => {
t.increments('id')
t.integer('user_id').unsigned().notNullable()
t.integer('tweet_id').unsigned().notNullable()
t.unique(['user_id', 'tweet_id'])
t.foreign('user_id').references('id').inTable('users').onDelete('CASCADE')
t.foreign('tweet_id').references('id').inTable('tweets').onDelete('CASCADE')
})
}
export async function down(knex: Knex): Promise<void> {
return knex.raw('DROP TABLE retweets CASCADE')
}
src/resolvers/RetweetResolver.ts
import { ApolloError } from 'apollo-server'
import { Arg, Authorized, Ctx, Mutation, Resolver } from 'type-graphql'
import { MyContext } from '../types/types'
@Resolver()
class RetweetResolver {
@Mutation(() => String)
@Authorized()
async toggleRetweet(
@Arg('tweet_id') tweet_id: number,
@Ctx() ctx: MyContext
) {
const { db, userId } = ctx
const [tweet] = await db('tweets').where('id', tweet_id)
if (!tweet) {
throw new ApolloError('Tweet not found')
}
const data = {
user_id: userId,
tweet_id: tweet_id,
}
try {
const [alreadyRetweeted] = await db('retweets').where(data)
if (alreadyRetweeted) {
// Delete the retweet and return
await db('retweets').where(data).del()
return 'Retweet deleted'
}
await db('retweets').insert(data)
return 'Retweet added'
} catch (e) {
throw new ApolloError(e.message)
}
}
}
export default RetweetResolver
I need to change the way I retrieve the retweetsCount :
src/utils/utils.ts
export const selectCountsForTweet = (db: Knex) => {
return [
db.raw(
'(SELECT count(tweet_id) from likes where likes.tweet_id = tweets.id) as "likesCount"'
),
db.raw(
`(SELECT count(t.parent_id) from tweets t where t.parent_id = tweets.id and t.type = 'comment') as "commentsCount"`
),
// What I've changed
db.raw(
`(SELECT count(tweet_id) from retweets where retweets.tweet_id = tweets.id) as "retweetsCount"`
),
'tweets.*',
]
}
I also removed the part that handles the case of a retweet in the addTweet function of the TweetResolver.
I then added a property to my Tweet entity to know when a tweet has been retweeted.
src/entities/Tweet.ts
@Field()
isRetweeted: boolean
And I handle this with a @FieldResolver in the TweetResolver:
src/resolvers/TweetResolver.ts
@FieldResolver(() => Boolean)
async isRetweeted(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
const {
userId,
dataloaders: { isRetweetedDataloader },
} = ctx
if (!userId) return false
const isRetweeted = await isRetweetedDataloader.load({
tweet_id: tweet.id,
user_id: userId,
})
return isRetweeted !== undefined
}
src/dataloaders/dataloaders.ts
isRetweetedDataloader: new DataLoader<any, any, unknown>(async (keys) => {
const tweetIds = keys.map((k: any) => k.tweet_id)
const userId = keys[0].user_id
const retweets = await db('retweets')
.whereIn('tweet_id', tweetIds)
.andWhere('user_id', userId)
return tweetIds.map((id) => retweets.find((r) => r.tweet_id === id))
}),
Now let's take care of the front end ;).
Frontend
Since we have the same behavior as for the "like" function, I'm going to refactor the code a bit.
src/components/tweets/actions/TweetActionButton.tsx
import Button from '../../Button'
type TweetActionButton = {
id: number
isSth: boolean | undefined
icon: JSX.Element
activeIcon?: JSX.Element
text: string
activeText: string
activeClass: string
onClick:
| ((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void)
| undefined
}
const TweetActionButton = ({
id,
isSth,
icon,
activeIcon,
text,
activeText,
activeClass,
onClick,
}: TweetActionButton) => {
return (
<Button
text={`${isSth ? activeText : text}`}
variant={`${isSth ? activeClass : 'default'}`}
className={`text-lg md:text-sm`}
onClick={onClick}
icon={isSth && activeIcon ? activeIcon : icon}
alignment="left"
hideTextOnMobile={true}
/>
)
}
export default TweetActionButton
The LikeButton and RetweetButton buttons look like this:
import { useMutation } from '@apollo/client'
import React, { useEffect } from 'react'
import { MdFavorite, MdFavoriteBorder } from 'react-icons/md'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { TOGGLE_LIKE } from '../../../graphql/tweets/mutations'
import { isLikedState, singleTweetState } from '../../../state/tweetsState'
import Button from '../../Button'
import TweetActionButton from './TweetActionButton'
const LikeButton = ({ id }: { id: number }) => {
const [isLiked, setIsLiked] = useRecoilState(isLikedState(id))
const setTweet = useSetRecoilState(singleTweetState(id))
const [toggleLike, { error }] = useMutation(TOGGLE_LIKE, {
variables: {
tweet_id: id,
},
update(cache, { data: { toggleLike } }) {
setIsLiked(toggleLike.includes('added'))
setTweet((oldTweet) => {
if (oldTweet) {
let count = oldTweet.likesCount
toggleLike.includes('added') ? count++ : count--
return {
...oldTweet,
likesCount: count,
}
}
})
},
})
useEffect(() => {
if (error) {
console.log('Toggle like error', error)
}
}, [error])
return (
<TweetActionButton
id={id}
isSth={isLiked}
icon={<MdFavoriteBorder />}
activeIcon={<MdFavorite />}
onClick={() => toggleLike()}
text="Like"
activeText="Liked"
activeClass="red"
/>
)
}
export default LikeButton
import { useMutation } from '@apollo/client'
import React, { useEffect } from 'react'
import { MdLoop } from 'react-icons/md'
import { useRecoilState, useSetRecoilState } from 'recoil'
import { TOGGLE_RETWEET } from '../../../graphql/tweets/mutations'
import { isRetweetedState, singleTweetState } from '../../../state/tweetsState'
import TweetActionButton from './TweetActionButton'
const RetweetButton = ({ id }: { id: number }) => {
const setTweet = useSetRecoilState(singleTweetState(id))
const [isRetweeted, setIsRetweeted] = useRecoilState(isRetweetedState(id))
const [toggleRetweet, { error }] = useMutation(TOGGLE_RETWEET, {
variables: {
tweet_id: id,
},
update(cache, { data: { toggleRetweet } }) {
setIsRetweeted(toggleRetweet.includes('added'))
setTweet((oldTweet) => {
if (oldTweet) {
let count = oldTweet.retweetsCount
toggleRetweet.includes('added') ? count++ : count--
return {
...oldTweet,
retweetsCount: count,
}
}
})
},
})
useEffect(() => {
if (error) {
console.log('Toggle retweet error', error)
}
}, [error])
return (
<TweetActionButton
id={id}
isSth={isRetweeted}
icon={<MdLoop />}
onClick={() => toggleRetweet()}
text="Retweet"
activeText="Retweeted"
activeClass="green"
/>
)
}
export default RetweetButton
I've added the update of the counters when you like or retweet. That's why I retrieved my tweet via the hook const setTweet = useSetRecoilState(singleTweetState(id)). This will allow me to update the tweet locally.
I create a new component that will be in charge of rendering the different counters:
src/components/tweets/TweetStats.tsx
import { useRecoilValue } from 'recoil'
import { singleTweetState } from '../../state/tweetsState'
import { pluralize } from '../../utils/utils'
const TweetStats = ({ id }: { id: number }) => {
const tweet = useRecoilValue(singleTweetState(id))
return (
<div className="flex justify-end mt-6">
<p className="text-gray4 text-xs ml-4">
{pluralize(tweet!.commentsCount, 'Comment')}
</p>
<p className="text-gray4 text-xs ml-4">
{pluralize(tweet!.retweetsCount, 'Retweet')}
</p>
<p className="text-gray4 text-xs ml-4">
{pluralize(tweet!.likesCount, 'Like')}
</p>
</div>
)
}
export default TweetStats
Here I retrieve my tweet via recoil; as a result, the component will be rerendered each time I like or retweet a tweet.
That's all for today ;)
Bye and take care ;).