├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── app │ ├── (auth) │ │ └── login │ │ │ └── page.tsx │ ├── (dashboard) │ │ └── dashboard │ │ │ ├── add │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ │ ├── chat │ │ │ └── [chatId] │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── requests │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── api │ │ ├── friends │ │ │ ├── accept │ │ │ │ └── route.ts │ │ │ ├── add │ │ │ │ └── route.ts │ │ │ └── deny │ │ │ │ └── route.ts │ │ └── message │ │ │ └── send │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── AddFriendButton.tsx │ ├── ChatInput.tsx │ ├── FriendRequestSidebarOptions.tsx │ ├── FriendRequests.tsx │ ├── Icons.tsx │ ├── Messages.tsx │ ├── MobileChatLayout.tsx │ ├── Providers.tsx │ ├── SidebarChatList.tsx │ ├── SignOutButton.tsx │ ├── UnseenChatToast.tsx │ └── ui │ │ └── Button.tsx ├── helpers │ ├── get-friends-by-user-id.ts │ └── redis.ts ├── lib │ ├── auth.ts │ ├── db.ts │ ├── pusher.ts │ ├── utils.ts │ └── validations │ │ ├── add-friend.ts │ │ └── message.ts ├── middleware.ts ├── pages │ └── api │ │ └── auth │ │ └── [...nextauth].ts └── types │ ├── db.d.ts │ ├── next-auth.d.ts │ ├── pusher.d.ts │ └── typings.d.ts ├── tailwind.config.js └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FriendZone - A full-stack realtime messaging chat application 2 | 3 | A project to learn modern full-stack development made by Josh. 4 | 5 | ## Features 6 | 7 | - Realtime messaging 8 | - Adding friends and sending friend requests via email 9 | - Performant database queries with Redis 10 | - Responsive UI built with TailwindCSS 11 | - Protection of sensitive routes 12 | - Google authentication 13 | 14 | - Built with TypeScript 15 | - TailwindCSS 16 | - Icons from Lucide 17 | 18 | - Class merging with tailwind-merge 19 | - Conditional classes with clsx 20 | - Variants with class-variance-authority 21 | 22 | ## Things I forgot in the video 23 | - [Adding metadata to some pages](https://github.com/joschan21/nextjs-realtime-chat/blob/master/src/app/(dashboard)/dashboard/chat/%5BchatId%5D/page.tsx) 24 | - [Adding a favicon](https://github.com/joschan21/nextjs-realtime-chat/blob/master/public/favicon.ico) 25 | 26 | ## Acknowledgements 27 | 28 | - [Awesome Button UI Component](https://ui.shadcn.com/) 29 | 30 | ## Feedback 31 | 32 | If you have any feedback, please reach out to me at admin@wordful.ai 33 | 34 | ## License 35 | 36 | [MIT](https://choosealicense.com/licenses/mit/) 37 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | images: { 7 | domains: ['lh3.googleusercontent.com'] 8 | } 9 | } 10 | 11 | module.exports = nextConfig 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "realtime-app-youtube", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.7.13", 13 | "@hookform/resolvers": "^3.0.0", 14 | "@next-auth/upstash-redis-adapter": "^3.0.4", 15 | "@tailwindcss/forms": "^0.5.3", 16 | "@types/node": "18.15.11", 17 | "@types/react": "18.0.31", 18 | "@types/react-dom": "18.0.11", 19 | "@upstash/redis": "^1.20.2", 20 | "axios": "^1.3.4", 21 | "class-variance-authority": "^0.4.0", 22 | "clsx": "^1.2.1", 23 | "date-fns": "^2.29.3", 24 | "encoding": "^0.1.13", 25 | "eslint": "8.37.0", 26 | "eslint-config-next": "13.2.4", 27 | "lucide-react": "^0.129.0", 28 | "nanoid": "^4.0.2", 29 | "next": "13.2.4", 30 | "next-auth": "^4.20.1", 31 | "pusher": "^5.1.2", 32 | "pusher-js": "^8.0.2", 33 | "react": "18.2.0", 34 | "react-dom": "18.2.0", 35 | "react-hook-form": "^7.43.9", 36 | "react-hot-toast": "^2.4.0", 37 | "react-loading-skeleton": "^3.2.0", 38 | "react-textarea-autosize": "^8.4.1", 39 | "tailwind-merge": "^1.11.0", 40 | "typescript": "4.9.5", 41 | "zod": "^3.21.4" 42 | }, 43 | "devDependencies": { 44 | "autoprefixer": "^10.4.14", 45 | "postcss": "^8.4.21", 46 | "tailwindcss": "^3.3.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joschan21/nextjs-realtime-chat/78057985c9cb588afb3745fa52ece5c435dcd075/public/favicon.ico -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Button from '@/components/ui/Button' 4 | import { FC, useState } from 'react' 5 | import { signIn } from 'next-auth/react' 6 | import { toast } from 'react-hot-toast' 7 | 8 | const Page: FC = () => { 9 | const [isLoading, setIsLoading] = useState(false) 10 | 11 | async function loginWithGoogle() { 12 | setIsLoading(true) 13 | try { 14 | await signIn('google') 15 | } catch (error) { 16 | // display error message to user 17 | toast.error('Something went wrong with your login.') 18 | } finally { 19 | setIsLoading(false) 20 | } 21 | } 22 | 23 | return ( 24 | <> 25 |
26 |
27 |
28 | logo 29 |

30 | Sign in to your account 31 |

32 |
33 | 34 | 70 |
71 |
72 | 73 | ) 74 | } 75 | 76 | export default Page 77 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/add/loading.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import Skeleton from 'react-loading-skeleton' 3 | import 'react-loading-skeleton/dist/skeleton.css' 4 | 5 | interface loadingProps {} 6 | 7 | const loading: FC = ({}) => { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ) 15 | } 16 | 17 | export default loading 18 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/add/page.tsx: -------------------------------------------------------------------------------- 1 | import AddFriendButton from '@/components/AddFriendButton' 2 | import { FC } from 'react' 3 | 4 | const page: FC = () => { 5 | return ( 6 |
7 |

Add a friend

8 | 9 |
10 | ) 11 | } 12 | 13 | export default page 14 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/chat/[chatId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import Skeleton from "react-loading-skeleton" 3 | 4 | interface loadingProps {} 5 | 6 | const loading: FC = ({}) => { 7 | return ( 8 |
9 | 10 | {/* chat messages */} 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 |
32 |
33 | 34 |
35 |
36 |
37 | 38 | {/* my messages */} 39 |
40 |
41 |
42 | 43 |
44 |
45 | 46 |
47 |
48 |
49 |
50 |
51 |
52 | 53 |
54 |
55 | 56 |
57 |
58 |
59 |
60 |
61 |
62 | 63 |
64 |
65 | 66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | 76 | {/* chat input */} 77 | 78 | {/* */} 83 |
84 | ) 85 | } 86 | 87 | export default loading 88 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/chat/[chatId]/page.tsx: -------------------------------------------------------------------------------- 1 | import ChatInput from '@/components/ChatInput' 2 | import Messages from '@/components/Messages' 3 | import { fetchRedis } from '@/helpers/redis' 4 | import { authOptions } from '@/lib/auth' 5 | import { messageArrayValidator } from '@/lib/validations/message' 6 | import { getServerSession } from 'next-auth' 7 | import Image from 'next/image' 8 | import { notFound } from 'next/navigation' 9 | 10 | // The following generateMetadata functiion was written after the video and is purely optional 11 | export async function generateMetadata({ 12 | params, 13 | }: { 14 | params: { chatId: string } 15 | }) { 16 | const session = await getServerSession(authOptions) 17 | if (!session) notFound() 18 | const [userId1, userId2] = params.chatId.split('--') 19 | const { user } = session 20 | 21 | const chatPartnerId = user.id === userId1 ? userId2 : userId1 22 | const chatPartnerRaw = (await fetchRedis( 23 | 'get', 24 | `user:${chatPartnerId}` 25 | )) as string 26 | const chatPartner = JSON.parse(chatPartnerRaw) as User 27 | 28 | return { title: `FriendZone | ${chatPartner.name} chat` } 29 | } 30 | 31 | interface PageProps { 32 | params: { 33 | chatId: string 34 | } 35 | } 36 | 37 | async function getChatMessages(chatId: string) { 38 | try { 39 | const results: string[] = await fetchRedis( 40 | 'zrange', 41 | `chat:${chatId}:messages`, 42 | 0, 43 | -1 44 | ) 45 | 46 | const dbMessages = results.map((message) => JSON.parse(message) as Message) 47 | 48 | const reversedDbMessages = dbMessages.reverse() 49 | 50 | const messages = messageArrayValidator.parse(reversedDbMessages) 51 | 52 | return messages 53 | } catch (error) { 54 | notFound() 55 | } 56 | } 57 | 58 | const page = async ({ params }: PageProps) => { 59 | const { chatId } = params 60 | const session = await getServerSession(authOptions) 61 | if (!session) notFound() 62 | 63 | const { user } = session 64 | 65 | const [userId1, userId2] = chatId.split('--') 66 | 67 | if (user.id !== userId1 && user.id !== userId2) { 68 | notFound() 69 | } 70 | 71 | const chatPartnerId = user.id === userId1 ? userId2 : userId1 72 | // new 73 | 74 | const chatPartnerRaw = (await fetchRedis( 75 | 'get', 76 | `user:${chatPartnerId}` 77 | )) as string 78 | const chatPartner = JSON.parse(chatPartnerRaw) as User 79 | const initialMessages = await getChatMessages(chatId) 80 | 81 | return ( 82 |
83 |
84 |
85 |
86 |
87 | {`${chatPartner.name} 94 |
95 |
96 | 97 |
98 |
99 | 100 | {chatPartner.name} 101 | 102 |
103 | 104 | {chatPartner.email} 105 |
106 |
107 |
108 | 109 | 116 | 117 |
118 | ) 119 | } 120 | 121 | export default page 122 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Icons } from '@/components/Icons' 2 | import SignOutButton from '@/components/SignOutButton' 3 | import { authOptions } from '@/lib/auth' 4 | import { getServerSession } from 'next-auth' 5 | import Image from 'next/image' 6 | import Link from 'next/link' 7 | import { notFound } from 'next/navigation' 8 | import { FC, ReactNode } from 'react' 9 | import FriendRequestSidebarOptions from '@/components/FriendRequestSidebarOptions' 10 | import { fetchRedis } from '@/helpers/redis' 11 | import { getFriendsByUserId } from '@/helpers/get-friends-by-user-id' 12 | import SidebarChatList from '@/components/SidebarChatList' 13 | import MobileChatLayout from '@/components/MobileChatLayout' 14 | import { SidebarOption } from '@/types/typings' 15 | 16 | interface LayoutProps { 17 | children: ReactNode 18 | } 19 | 20 | // Done after the video and optional: add page metadata 21 | export const metadata = { 22 | title: 'FriendZone | Dashboard', 23 | description: 'Your dashboard', 24 | } 25 | 26 | const sidebarOptions: SidebarOption[] = [ 27 | { 28 | id: 1, 29 | name: 'Add friend', 30 | href: '/dashboard/add', 31 | Icon: 'UserPlus', 32 | }, 33 | ] 34 | 35 | const Layout = async ({ children }: LayoutProps) => { 36 | const session = await getServerSession(authOptions) 37 | if (!session) notFound() 38 | 39 | const friends = await getFriendsByUserId(session.user.id) 40 | console.log('friends', friends) 41 | 42 | const unseenRequestCount = ( 43 | (await fetchRedis( 44 | 'smembers', 45 | `user:${session.user.id}:incoming_friend_requests` 46 | )) as User[] 47 | ).length 48 | 49 | return ( 50 |
51 |
52 | 58 |
59 | 60 |
61 | 62 | 63 | 64 | 65 | {friends.length > 0 ? ( 66 |
67 | Your chats 68 |
69 | ) : null} 70 | 71 | 133 |
134 | 135 | 138 |
139 | ) 140 | } 141 | 142 | export default Layout 143 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { getFriendsByUserId } from '@/helpers/get-friends-by-user-id' 2 | import { fetchRedis } from '@/helpers/redis' 3 | import { authOptions } from '@/lib/auth' 4 | import { chatHrefConstructor } from '@/lib/utils' 5 | import { ChevronRight } from 'lucide-react' 6 | import { getServerSession } from 'next-auth' 7 | import Image from 'next/image' 8 | import Link from 'next/link' 9 | import { notFound } from 'next/navigation' 10 | 11 | const page = async ({}) => { 12 | const session = await getServerSession(authOptions) 13 | if (!session) notFound() 14 | 15 | const friends = await getFriendsByUserId(session.user.id) 16 | 17 | const friendsWithLastMessage = await Promise.all( 18 | friends.map(async (friend) => { 19 | const [lastMessageRaw] = (await fetchRedis( 20 | 'zrange', 21 | `chat:${chatHrefConstructor(session.user.id, friend.id)}:messages`, 22 | -1, 23 | -1 24 | )) as string[] 25 | 26 | const lastMessage = JSON.parse(lastMessageRaw) as Message 27 | 28 | return { 29 | ...friend, 30 | lastMessage, 31 | } 32 | }) 33 | ) 34 | 35 | return ( 36 |
37 |

Recent chats

38 | {friendsWithLastMessage.length === 0 ? ( 39 |

Nothing to show here...

40 | ) : ( 41 | friendsWithLastMessage.map((friend) => ( 42 |
45 |
46 | 47 |
48 | 49 | 55 |
56 |
57 | {`${friend.name} 64 |
65 |
66 | 67 |
68 |

{friend.name}

69 |

70 | 71 | {friend.lastMessage.senderId === session.user.id 72 | ? 'You: ' 73 | : ''} 74 | 75 | {friend.lastMessage.text} 76 |

77 |
78 | 79 |
80 | )) 81 | )} 82 |
83 | ) 84 | } 85 | 86 | export default page 87 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/requests/loading.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import Skeleton from 'react-loading-skeleton' 3 | import 'react-loading-skeleton/dist/skeleton.css' 4 | 5 | interface loadingProps {} 6 | 7 | const loading: FC = ({}) => { 8 | return ( 9 |
10 | 11 | 12 | 13 | 14 |
15 | ) 16 | } 17 | 18 | export default loading 19 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/requests/page.tsx: -------------------------------------------------------------------------------- 1 | import FriendRequests from '@/components/FriendRequests' 2 | import { fetchRedis } from '@/helpers/redis' 3 | import { authOptions } from '@/lib/auth' 4 | import { getServerSession } from 'next-auth' 5 | import { notFound } from 'next/navigation' 6 | import { FC } from 'react' 7 | 8 | const page = async () => { 9 | const session = await getServerSession(authOptions) 10 | if (!session) notFound() 11 | 12 | // ids of people who sent current logged in user a friend requests 13 | const incomingSenderIds = (await fetchRedis( 14 | 'smembers', 15 | `user:${session.user.id}:incoming_friend_requests` 16 | )) as string[] 17 | 18 | const incomingFriendRequests = await Promise.all( 19 | incomingSenderIds.map(async (senderId) => { 20 | const sender = (await fetchRedis('get', `user:${senderId}`)) as string 21 | const senderParsed = JSON.parse(sender) as User 22 | 23 | return { 24 | senderId, 25 | senderEmail: senderParsed.email, 26 | } 27 | }) 28 | ) 29 | 30 | return ( 31 |
32 |

Add a friend

33 |
34 | 38 |
39 |
40 | ) 41 | } 42 | 43 | export default page 44 | -------------------------------------------------------------------------------- /src/app/api/friends/accept/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRedis } from '@/helpers/redis' 2 | import { authOptions } from '@/lib/auth' 3 | import { db } from '@/lib/db' 4 | import { pusherServer } from '@/lib/pusher' 5 | import { toPusherKey } from '@/lib/utils' 6 | import { getServerSession } from 'next-auth' 7 | import { z } from 'zod' 8 | 9 | export async function POST(req: Request) { 10 | try { 11 | const body = await req.json() 12 | 13 | const { id: idToAdd } = z.object({ id: z.string() }).parse(body) 14 | 15 | const session = await getServerSession(authOptions) 16 | 17 | if (!session) { 18 | return new Response('Unauthorized', { status: 401 }) 19 | } 20 | 21 | // verify both users are not already friends 22 | const isAlreadyFriends = await fetchRedis( 23 | 'sismember', 24 | `user:${session.user.id}:friends`, 25 | idToAdd 26 | ) 27 | 28 | if (isAlreadyFriends) { 29 | return new Response('Already friends', { status: 400 }) 30 | } 31 | 32 | const hasFriendRequest = await fetchRedis( 33 | 'sismember', 34 | `user:${session.user.id}:incoming_friend_requests`, 35 | idToAdd 36 | ) 37 | 38 | if (!hasFriendRequest) { 39 | return new Response('No friend request', { status: 400 }) 40 | } 41 | 42 | const [userRaw, friendRaw] = (await Promise.all([ 43 | fetchRedis('get', `user:${session.user.id}`), 44 | fetchRedis('get', `user:${idToAdd}`), 45 | ])) as [string, string] 46 | 47 | const user = JSON.parse(userRaw) as User 48 | const friend = JSON.parse(friendRaw) as User 49 | 50 | // notify added user 51 | 52 | await Promise.all([ 53 | pusherServer.trigger( 54 | toPusherKey(`user:${idToAdd}:friends`), 55 | 'new_friend', 56 | user 57 | ), 58 | pusherServer.trigger( 59 | toPusherKey(`user:${session.user.id}:friends`), 60 | 'new_friend', 61 | friend 62 | ), 63 | db.sadd(`user:${session.user.id}:friends`, idToAdd), 64 | db.sadd(`user:${idToAdd}:friends`, session.user.id), 65 | db.srem(`user:${session.user.id}:incoming_friend_requests`, idToAdd), 66 | ]) 67 | 68 | return new Response('OK') 69 | } catch (error) { 70 | console.log(error) 71 | 72 | if (error instanceof z.ZodError) { 73 | return new Response('Invalid request payload', { status: 422 }) 74 | } 75 | 76 | return new Response('Invalid request', { status: 400 }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/app/api/friends/add/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRedis } from '@/helpers/redis' 2 | import { authOptions } from '@/lib/auth' 3 | import { db } from '@/lib/db' 4 | import { pusherServer } from '@/lib/pusher' 5 | import { toPusherKey } from '@/lib/utils' 6 | import { addFriendValidator } from '@/lib/validations/add-friend' 7 | import { getServerSession } from 'next-auth' 8 | import { z } from 'zod' 9 | 10 | export async function POST(req: Request) { 11 | try { 12 | const body = await req.json() 13 | 14 | const { email: emailToAdd } = addFriendValidator.parse(body.email) 15 | 16 | const idToAdd = (await fetchRedis( 17 | 'get', 18 | `user:email:${emailToAdd}` 19 | )) as string 20 | 21 | if (!idToAdd) { 22 | return new Response('This person does not exist.', { status: 400 }) 23 | } 24 | 25 | const session = await getServerSession(authOptions) 26 | 27 | if (!session) { 28 | return new Response('Unauthorized', { status: 401 }) 29 | } 30 | 31 | if (idToAdd === session.user.id) { 32 | return new Response('You cannot add yourself as a friend', { 33 | status: 400, 34 | }) 35 | } 36 | 37 | // check if user is already added 38 | const isAlreadyAdded = (await fetchRedis( 39 | 'sismember', 40 | `user:${idToAdd}:incoming_friend_requests`, 41 | session.user.id 42 | )) as 0 | 1 43 | 44 | if (isAlreadyAdded) { 45 | return new Response('Already added this user', { status: 400 }) 46 | } 47 | 48 | // check if user is already added 49 | const isAlreadyFriends = (await fetchRedis( 50 | 'sismember', 51 | `user:${session.user.id}:friends`, 52 | idToAdd 53 | )) as 0 | 1 54 | 55 | if (isAlreadyFriends) { 56 | return new Response('Already friends with this user', { status: 400 }) 57 | } 58 | 59 | // valid request, send friend request 60 | 61 | await pusherServer.trigger( 62 | toPusherKey(`user:${idToAdd}:incoming_friend_requests`), 63 | 'incoming_friend_requests', 64 | { 65 | senderId: session.user.id, 66 | senderEmail: session.user.email, 67 | } 68 | ) 69 | 70 | await db.sadd(`user:${idToAdd}:incoming_friend_requests`, session.user.id) 71 | 72 | return new Response('OK') 73 | } catch (error) { 74 | if (error instanceof z.ZodError) { 75 | return new Response('Invalid request payload', { status: 422 }) 76 | } 77 | 78 | return new Response('Invalid request', { status: 400 }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/app/api/friends/deny/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from '@/lib/auth' 2 | import { db } from '@/lib/db' 3 | import { getServerSession } from 'next-auth' 4 | import { z } from 'zod' 5 | 6 | export async function POST(req: Request) { 7 | try { 8 | const body = await req.json() 9 | const session = await getServerSession(authOptions) 10 | 11 | if (!session) { 12 | return new Response('Unauthorized', { status: 401 }) 13 | } 14 | 15 | const { id: idToDeny } = z.object({ id: z.string() }).parse(body) 16 | 17 | await db.srem(`user:${session.user.id}:incoming_friend_requests`, idToDeny) 18 | 19 | return new Response('OK') 20 | } catch (error) { 21 | console.log(error) 22 | 23 | if (error instanceof z.ZodError) { 24 | return new Response('Invalid request payload', { status: 422 }) 25 | } 26 | 27 | return new Response('Invalid request', { status: 400 }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/api/message/send/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRedis } from '@/helpers/redis' 2 | import { authOptions } from '@/lib/auth' 3 | import { db } from '@/lib/db' 4 | import { pusherServer } from '@/lib/pusher' 5 | import { toPusherKey } from '@/lib/utils' 6 | import { Message, messageValidator } from '@/lib/validations/message' 7 | import { nanoid } from 'nanoid' 8 | import { getServerSession } from 'next-auth' 9 | 10 | export async function POST(req: Request) { 11 | try { 12 | const { text, chatId }: { text: string; chatId: string } = await req.json() 13 | const session = await getServerSession(authOptions) 14 | 15 | if (!session) return new Response('Unauthorized', { status: 401 }) 16 | 17 | const [userId1, userId2] = chatId.split('--') 18 | 19 | if (session.user.id !== userId1 && session.user.id !== userId2) { 20 | return new Response('Unauthorized', { status: 401 }) 21 | } 22 | 23 | const friendId = session.user.id === userId1 ? userId2 : userId1 24 | 25 | const friendList = (await fetchRedis( 26 | 'smembers', 27 | `user:${session.user.id}:friends` 28 | )) as string[] 29 | const isFriend = friendList.includes(friendId) 30 | 31 | if (!isFriend) { 32 | return new Response('Unauthorized', { status: 401 }) 33 | } 34 | 35 | const rawSender = (await fetchRedis( 36 | 'get', 37 | `user:${session.user.id}` 38 | )) as string 39 | const sender = JSON.parse(rawSender) as User 40 | 41 | const timestamp = Date.now() 42 | 43 | const messageData: Message = { 44 | id: nanoid(), 45 | senderId: session.user.id, 46 | text, 47 | timestamp, 48 | } 49 | 50 | const message = messageValidator.parse(messageData) 51 | 52 | // notify all connected chat room clients 53 | await pusherServer.trigger(toPusherKey(`chat:${chatId}`), 'incoming-message', message) 54 | 55 | await pusherServer.trigger(toPusherKey(`user:${friendId}:chats`), 'new_message', { 56 | ...message, 57 | senderImg: sender.image, 58 | senderName: sender.name 59 | }) 60 | 61 | // all valid, send the message 62 | await db.zadd(`chat:${chatId}:messages`, { 63 | score: timestamp, 64 | member: JSON.stringify(message), 65 | }) 66 | 67 | return new Response('OK') 68 | } catch (error) { 69 | if (error instanceof Error) { 70 | return new Response(error.message, { status: 500 }) 71 | } 72 | 73 | return new Response('Internal Server Error', { status: 500 }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | container { 7 | @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 8 | } 9 | } 10 | 11 | .scrollbar-w-2::-webkit-scrollbar { 12 | width: 0.25rem; 13 | height: 0.25rem; 14 | } 15 | 16 | .scrollbar-track-blue-lighter::-webkit-scrollbar-track { 17 | --bg-opacity: 1; 18 | background-color: #f7fafc; 19 | background-color: rgba(247, 250, 252, var(--bg-opacity)); 20 | } 21 | 22 | .scrollbar-thumb-blue::-webkit-scrollbar-thumb { 23 | --bg-opacity: 1; 24 | background-color: #edf2f7; 25 | background-color: rgba(237, 242, 247, var(--bg-opacity)); 26 | } 27 | 28 | .scrollbar-thumb-rounded::-webkit-scrollbar-thumb { 29 | border-radius: 0.25rem; 30 | } 31 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Providers from '@/components/Providers' 2 | import './globals.css' 3 | 4 | // Done after the video and optional: add page metadata 5 | export const metadata = { 6 | title: 'FriendZone | Home', 7 | description: 'Welcome to the FriendZone', 8 | } 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: { 13 | children: React.ReactNode 14 | }) { 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Button from '@/components/ui/Button' 4 | import { signOut } from 'next-auth/react' 5 | 6 | export default function Home() { 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /src/components/AddFriendButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { addFriendValidator } from '@/lib/validations/add-friend' 4 | import axios, { AxiosError } from 'axios' 5 | import { FC, useState } from 'react' 6 | import Button from './ui/Button' 7 | import { z } from 'zod' 8 | import { useForm } from 'react-hook-form' 9 | import { zodResolver } from '@hookform/resolvers/zod' 10 | 11 | interface AddFriendButtonProps {} 12 | 13 | type FormData = z.infer 14 | 15 | const AddFriendButton: FC = ({}) => { 16 | const [showSuccessState, setShowSuccessState] = useState(false) 17 | 18 | const { 19 | register, 20 | handleSubmit, 21 | setError, 22 | formState: { errors }, 23 | } = useForm({ 24 | resolver: zodResolver(addFriendValidator), 25 | }) 26 | 27 | const addFriend = async (email: string) => { 28 | try { 29 | const validatedEmail = addFriendValidator.parse({ email }) 30 | 31 | await axios.post('/api/friends/add', { 32 | email: validatedEmail, 33 | }) 34 | 35 | setShowSuccessState(true) 36 | } catch (error) { 37 | if (error instanceof z.ZodError) { 38 | setError('email', { message: error.message }) 39 | return 40 | } 41 | 42 | if (error instanceof AxiosError) { 43 | setError('email', { message: error.response?.data }) 44 | return 45 | } 46 | 47 | setError('email', { message: 'Something went wrong.' }) 48 | } 49 | } 50 | 51 | const onSubmit = (data: FormData) => { 52 | addFriend(data.email) 53 | } 54 | 55 | return ( 56 |
57 | 62 | 63 |
64 | 70 | 71 |
72 |

{errors.email?.message}

73 | {showSuccessState ? ( 74 |

Friend request sent!

75 | ) : null} 76 |
77 | ) 78 | } 79 | 80 | export default AddFriendButton 81 | -------------------------------------------------------------------------------- /src/components/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import axios from 'axios' 4 | import { FC, useRef, useState } from 'react' 5 | import { toast } from 'react-hot-toast' 6 | import TextareaAutosize from 'react-textarea-autosize' 7 | import Button from './ui/Button' 8 | 9 | interface ChatInputProps { 10 | chatPartner: User 11 | chatId: string 12 | } 13 | 14 | const ChatInput: FC = ({ chatPartner, chatId }) => { 15 | const textareaRef = useRef(null) 16 | const [isLoading, setIsLoading] = useState(false) 17 | const [input, setInput] = useState('') 18 | 19 | const sendMessage = async () => { 20 | if(!input) return 21 | setIsLoading(true) 22 | 23 | try { 24 | await axios.post('/api/message/send', { text: input, chatId }) 25 | setInput('') 26 | textareaRef.current?.focus() 27 | } catch { 28 | toast.error('Something went wrong. Please try again later.') 29 | } finally { 30 | setIsLoading(false) 31 | } 32 | } 33 | 34 | return ( 35 |
36 |
37 | { 40 | if (e.key === 'Enter' && !e.shiftKey) { 41 | e.preventDefault() 42 | sendMessage() 43 | } 44 | }} 45 | rows={1} 46 | value={input} 47 | onChange={(e) => setInput(e.target.value)} 48 | placeholder={`Message ${chatPartner.name}`} 49 | className='block w-full resize-none border-0 bg-transparent text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:py-1.5 sm:text-sm sm:leading-6' 50 | /> 51 | 52 |
textareaRef.current?.focus()} 54 | className='py-2' 55 | aria-hidden='true'> 56 |
57 |
58 |
59 |
60 | 61 |
62 |
63 | 66 |
67 |
68 |
69 |
70 | ) 71 | } 72 | 73 | export default ChatInput 74 | -------------------------------------------------------------------------------- /src/components/FriendRequestSidebarOptions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { pusherClient } from '@/lib/pusher' 4 | import { toPusherKey } from '@/lib/utils' 5 | import { User } from 'lucide-react' 6 | import Link from 'next/link' 7 | import { FC, useEffect, useState } from 'react' 8 | 9 | interface FriendRequestSidebarOptionsProps { 10 | sessionId: string 11 | initialUnseenRequestCount: number 12 | } 13 | 14 | const FriendRequestSidebarOptions: FC = ({ 15 | sessionId, 16 | initialUnseenRequestCount, 17 | }) => { 18 | const [unseenRequestCount, setUnseenRequestCount] = useState( 19 | initialUnseenRequestCount 20 | ) 21 | 22 | useEffect(() => { 23 | pusherClient.subscribe( 24 | toPusherKey(`user:${sessionId}:incoming_friend_requests`) 25 | ) 26 | pusherClient.subscribe(toPusherKey(`user:${sessionId}:friends`)) 27 | 28 | const friendRequestHandler = () => { 29 | setUnseenRequestCount((prev) => prev + 1) 30 | } 31 | 32 | const addedFriendHandler = () => { 33 | setUnseenRequestCount((prev) => prev - 1) 34 | } 35 | 36 | pusherClient.bind('incoming_friend_requests', friendRequestHandler) 37 | pusherClient.bind('new_friend', addedFriendHandler) 38 | 39 | return () => { 40 | pusherClient.unsubscribe( 41 | toPusherKey(`user:${sessionId}:incoming_friend_requests`) 42 | ) 43 | pusherClient.unsubscribe(toPusherKey(`user:${sessionId}:friends`)) 44 | 45 | pusherClient.unbind('new_friend', addedFriendHandler) 46 | pusherClient.unbind('incoming_friend_requests', friendRequestHandler) 47 | } 48 | }, [sessionId]) 49 | 50 | return ( 51 | 54 |
55 | 56 |
57 |

Friend requests

58 | 59 | {unseenRequestCount > 0 ? ( 60 |
61 | {unseenRequestCount} 62 |
63 | ) : null} 64 | 65 | ) 66 | } 67 | 68 | export default FriendRequestSidebarOptions 69 | -------------------------------------------------------------------------------- /src/components/FriendRequests.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { pusherClient } from '@/lib/pusher' 4 | import { toPusherKey } from '@/lib/utils' 5 | import axios from 'axios' 6 | import { Check, UserPlus, X } from 'lucide-react' 7 | import { useRouter } from 'next/navigation' 8 | import { FC, useEffect, useState } from 'react' 9 | 10 | interface FriendRequestsProps { 11 | incomingFriendRequests: IncomingFriendRequest[] 12 | sessionId: string 13 | } 14 | 15 | const FriendRequests: FC = ({ 16 | incomingFriendRequests, 17 | sessionId, 18 | }) => { 19 | const router = useRouter() 20 | const [friendRequests, setFriendRequests] = useState( 21 | incomingFriendRequests 22 | ) 23 | 24 | useEffect(() => { 25 | pusherClient.subscribe( 26 | toPusherKey(`user:${sessionId}:incoming_friend_requests`) 27 | ) 28 | console.log("listening to ", `user:${sessionId}:incoming_friend_requests`) 29 | 30 | const friendRequestHandler = ({ 31 | senderId, 32 | senderEmail, 33 | }: IncomingFriendRequest) => { 34 | console.log("function got called") 35 | setFriendRequests((prev) => [...prev, { senderId, senderEmail }]) 36 | } 37 | 38 | pusherClient.bind('incoming_friend_requests', friendRequestHandler) 39 | 40 | return () => { 41 | pusherClient.unsubscribe( 42 | toPusherKey(`user:${sessionId}:incoming_friend_requests`) 43 | ) 44 | pusherClient.unbind('incoming_friend_requests', friendRequestHandler) 45 | } 46 | }, [sessionId]) 47 | 48 | const acceptFriend = async (senderId: string) => { 49 | await axios.post('/api/friends/accept', { id: senderId }) 50 | 51 | setFriendRequests((prev) => 52 | prev.filter((request) => request.senderId !== senderId) 53 | ) 54 | 55 | router.refresh() 56 | } 57 | 58 | const denyFriend = async (senderId: string) => { 59 | await axios.post('/api/friends/deny', { id: senderId }) 60 | 61 | setFriendRequests((prev) => 62 | prev.filter((request) => request.senderId !== senderId) 63 | ) 64 | 65 | router.refresh() 66 | } 67 | 68 | return ( 69 | <> 70 | {friendRequests.length === 0 ? ( 71 |

Nothing to show here...

72 | ) : ( 73 | friendRequests.map((request) => ( 74 |
75 | 76 |

{request.senderEmail}

77 | 83 | 84 | 90 |
91 | )) 92 | )} 93 | 94 | ) 95 | } 96 | 97 | export default FriendRequests 98 | -------------------------------------------------------------------------------- /src/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import { LucideProps, UserPlus } from 'lucide-react' 2 | 3 | export const Icons = { 4 | Logo: (props: LucideProps) => ( 5 | 6 | 10 | 11 | ), 12 | UserPlus 13 | } 14 | 15 | export type Icon = keyof typeof Icons 16 | -------------------------------------------------------------------------------- /src/components/Messages.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { pusherClient } from '@/lib/pusher' 4 | import { cn, toPusherKey } from '@/lib/utils' 5 | import { Message } from '@/lib/validations/message' 6 | import { format } from 'date-fns' 7 | import Image from 'next/image' 8 | import { FC, useEffect, useRef, useState } from 'react' 9 | 10 | interface MessagesProps { 11 | initialMessages: Message[] 12 | sessionId: string 13 | chatId: string 14 | sessionImg: string | null | undefined 15 | chatPartner: User 16 | } 17 | 18 | const Messages: FC = ({ 19 | initialMessages, 20 | sessionId, 21 | chatId, 22 | chatPartner, 23 | sessionImg, 24 | }) => { 25 | const [messages, setMessages] = useState(initialMessages) 26 | 27 | useEffect(() => { 28 | pusherClient.subscribe( 29 | toPusherKey(`chat:${chatId}`) 30 | ) 31 | 32 | const messageHandler = (message: Message) => { 33 | setMessages((prev) => [message, ...prev]) 34 | } 35 | 36 | pusherClient.bind('incoming-message', messageHandler) 37 | 38 | return () => { 39 | pusherClient.unsubscribe( 40 | toPusherKey(`chat:${chatId}`) 41 | ) 42 | pusherClient.unbind('incoming-message', messageHandler) 43 | } 44 | }, [chatId]) 45 | 46 | const scrollDownRef = useRef(null) 47 | 48 | const formatTimestamp = (timestamp: number) => { 49 | return format(timestamp, 'HH:mm') 50 | } 51 | 52 | return ( 53 |
56 |
57 | 58 | {messages.map((message, index) => { 59 | const isCurrentUser = message.senderId === sessionId 60 | 61 | const hasNextMessageFromSameUser = 62 | messages[index - 1]?.senderId === messages[index].senderId 63 | 64 | return ( 65 |
68 |
72 |
80 | 89 | {message.text}{' '} 90 | 91 | {formatTimestamp(message.timestamp)} 92 | 93 | 94 |
95 | 96 |
102 | Profile picture 111 |
112 |
113 |
114 | ) 115 | })} 116 |
117 | ) 118 | } 119 | 120 | export default Messages 121 | -------------------------------------------------------------------------------- /src/components/MobileChatLayout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Transition, Dialog } from '@headlessui/react' 4 | import { Menu, X } from 'lucide-react' 5 | import Image from 'next/image' 6 | import Link from 'next/link' 7 | import { FC, Fragment, useEffect, useState } from 'react' 8 | import { Icons } from './Icons' 9 | import SignOutButton from './SignOutButton' 10 | import Button, { buttonVariants } from './ui/Button' 11 | import FriendRequestSidebarOptions from './FriendRequestSidebarOptions' 12 | import SidebarChatList from './SidebarChatList' 13 | import { Session } from 'next-auth' 14 | import { SidebarOption } from '@/types/typings' 15 | import { usePathname } from 'next/navigation' 16 | 17 | interface MobileChatLayoutProps { 18 | friends: User[] 19 | session: Session 20 | sidebarOptions: SidebarOption[] 21 | unseenRequestCount: number 22 | } 23 | 24 | const MobileChatLayout: FC = ({ friends, session, sidebarOptions, unseenRequestCount }) => { 25 | const [open, setOpen] = useState(false) 26 | 27 | const pathname = usePathname() 28 | 29 | useEffect(() => { 30 | setOpen(false) 31 | }, [pathname]) 32 | 33 | return ( 34 |
35 |
36 | 39 | 40 | 41 | 44 |
45 | 46 | 47 |
48 | 49 |
50 |
51 |
52 | 60 | 61 |
62 |
63 |
64 | 65 | Dashboard 66 | 67 |
68 | 75 |
76 |
77 |
78 |
79 | {/* Content */} 80 | 81 | {friends.length > 0 ? ( 82 |
83 | Your chats 84 |
85 | ) : null} 86 | 87 | 161 | 162 | {/* content end */} 163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 | ) 174 | } 175 | 176 | export default MobileChatLayout 177 | -------------------------------------------------------------------------------- /src/components/Providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FC, ReactNode } from 'react' 4 | import { Toaster } from 'react-hot-toast' 5 | 6 | interface ProvidersProps { 7 | children: ReactNode 8 | } 9 | 10 | const Providers: FC = ({ children }) => { 11 | return ( 12 | <> 13 | 14 | {children} 15 | 16 | ) 17 | } 18 | 19 | export default Providers 20 | -------------------------------------------------------------------------------- /src/components/SidebarChatList.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { pusherClient } from '@/lib/pusher' 4 | import { chatHrefConstructor, toPusherKey } from '@/lib/utils' 5 | import { usePathname, useRouter } from 'next/navigation' 6 | import { FC, useEffect, useState } from 'react' 7 | import { toast } from 'react-hot-toast' 8 | import UnseenChatToast from './UnseenChatToast' 9 | 10 | interface SidebarChatListProps { 11 | friends: User[] 12 | sessionId: string 13 | } 14 | 15 | interface ExtendedMessage extends Message { 16 | senderImg: string 17 | senderName: string 18 | } 19 | 20 | const SidebarChatList: FC = ({ friends, sessionId }) => { 21 | const router = useRouter() 22 | const pathname = usePathname() 23 | const [unseenMessages, setUnseenMessages] = useState([]) 24 | const [activeChats, setActiveChats] = useState(friends) 25 | 26 | useEffect(() => { 27 | pusherClient.subscribe(toPusherKey(`user:${sessionId}:chats`)) 28 | pusherClient.subscribe(toPusherKey(`user:${sessionId}:friends`)) 29 | 30 | const newFriendHandler = (newFriend: User) => { 31 | console.log("received new user", newFriend) 32 | setActiveChats((prev) => [...prev, newFriend]) 33 | } 34 | 35 | const chatHandler = (message: ExtendedMessage) => { 36 | const shouldNotify = 37 | pathname !== 38 | `/dashboard/chat/${chatHrefConstructor(sessionId, message.senderId)}` 39 | 40 | if (!shouldNotify) return 41 | 42 | // should be notified 43 | toast.custom((t) => ( 44 | 52 | )) 53 | 54 | setUnseenMessages((prev) => [...prev, message]) 55 | } 56 | 57 | pusherClient.bind('new_message', chatHandler) 58 | pusherClient.bind('new_friend', newFriendHandler) 59 | 60 | return () => { 61 | pusherClient.unsubscribe(toPusherKey(`user:${sessionId}:chats`)) 62 | pusherClient.unsubscribe(toPusherKey(`user:${sessionId}:friends`)) 63 | 64 | pusherClient.unbind('new_message', chatHandler) 65 | pusherClient.unbind('new_friend', newFriendHandler) 66 | } 67 | }, [pathname, sessionId, router]) 68 | 69 | useEffect(() => { 70 | if (pathname?.includes('chat')) { 71 | setUnseenMessages((prev) => { 72 | return prev.filter((msg) => !pathname.includes(msg.senderId)) 73 | }) 74 | } 75 | }, [pathname]) 76 | 77 | return ( 78 | 103 | ) 104 | } 105 | 106 | export default SidebarChatList 107 | -------------------------------------------------------------------------------- /src/components/SignOutButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Loader2, LogOut } from 'lucide-react' 4 | import { signOut } from 'next-auth/react' 5 | import { ButtonHTMLAttributes, FC, useState } from 'react' 6 | import { toast } from 'react-hot-toast' 7 | import Button from './ui/Button' 8 | 9 | interface SignOutButtonProps extends ButtonHTMLAttributes {} 10 | 11 | const SignOutButton: FC = ({ ...props }) => { 12 | const [isSigningOut, setIsSigningOut] = useState(false) 13 | return ( 14 | 33 | ) 34 | } 35 | 36 | export default SignOutButton 37 | -------------------------------------------------------------------------------- /src/components/UnseenChatToast.tsx: -------------------------------------------------------------------------------- 1 | import { chatHrefConstructor, cn } from '@/lib/utils' 2 | import Image from 'next/image' 3 | import { FC } from 'react' 4 | import { toast, type Toast } from 'react-hot-toast' 5 | 6 | interface UnseenChatToastProps { 7 | t: Toast 8 | sessionId: string 9 | senderId: string 10 | senderImg: string 11 | senderName: string 12 | senderMessage: string 13 | } 14 | 15 | const UnseenChatToast: FC = ({ 16 | t, 17 | senderId, 18 | sessionId, 19 | senderImg, 20 | senderName, 21 | senderMessage, 22 | }) => { 23 | return ( 24 |
29 | toast.dismiss(t.id)} 31 | href={`/dashboard/chat/${chatHrefConstructor(sessionId, senderId)}`} 32 | className='flex-1 w-0 p-4'> 33 |
34 |
35 |
36 | {`${senderName} 43 |
44 |
45 | 46 |
47 |

{senderName}

48 |

{senderMessage}

49 |
50 |
51 |
52 | 53 |
54 | 59 |
60 |
61 | ) 62 | } 63 | 64 | export default UnseenChatToast 65 | -------------------------------------------------------------------------------- /src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | import { cva, VariantProps } from 'class-variance-authority' 3 | import { Loader2 } from 'lucide-react' 4 | import { ButtonHTMLAttributes, FC } from 'react' 5 | 6 | export const buttonVariants = cva( 7 | 'active:scale-95 inline-flex items-center justify-center rounded-md text-sm font-medium transition-color focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-slate-900 text-white hover:bg-slate-800', 12 | ghost: 'bg-transparent hover:text-slate-900 hover:bg-slate-200', 13 | }, 14 | size: { 15 | default: 'h-10 py-2 px-4', 16 | sm: 'h-9 px-2', 17 | lg: 'h-11 px-8', 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: 'default', 22 | size: 'default', 23 | }, 24 | } 25 | ) 26 | 27 | export interface ButtonProps 28 | extends ButtonHTMLAttributes, 29 | VariantProps { 30 | isLoading?: boolean 31 | } 32 | 33 | const Button: FC = ({ 34 | className, 35 | children, 36 | variant, 37 | isLoading, 38 | size, 39 | ...props 40 | }) => { 41 | return ( 42 | 49 | ) 50 | } 51 | 52 | export default Button 53 | 54 | 55 | 56 | 57 | 58 | 59 | interface PersonInterface { 60 | age: number 61 | name: string 62 | job?: boolean 63 | } 64 | 65 | const Person: PersonInterface = { 66 | age: 14, 67 | name: 'John' 68 | } -------------------------------------------------------------------------------- /src/helpers/get-friends-by-user-id.ts: -------------------------------------------------------------------------------- 1 | import { fetchRedis } from './redis' 2 | 3 | export const getFriendsByUserId = async (userId: string) => { 4 | // retrieve friends for current user 5 | console.log("userid", userId) 6 | const friendIds = (await fetchRedis( 7 | 'smembers', 8 | `user:${userId}:friends` 9 | )) as string[] 10 | console.log("friend ids", friendIds) 11 | 12 | const friends = await Promise.all( 13 | friendIds.map(async (friendId) => { 14 | const friend = await fetchRedis('get', `user:${friendId}`) as string 15 | const parsedFriend = JSON.parse(friend) as User 16 | return parsedFriend 17 | }) 18 | ) 19 | 20 | return friends 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/redis.ts: -------------------------------------------------------------------------------- 1 | const upstashRedisRestUrl = process.env.UPSTASH_REDIS_REST_URL 2 | const authToken = process.env.UPSTASH_REDIS_REST_TOKEN 3 | 4 | type Command = 'zrange' | 'sismember' | 'get' | 'smembers' 5 | 6 | export async function fetchRedis( 7 | command: Command, 8 | ...args: (string | number)[] 9 | ) { 10 | const commandUrl = `${upstashRedisRestUrl}/${command}/${args.join('/')}` 11 | 12 | const response = await fetch(commandUrl, { 13 | headers: { 14 | Authorization: `Bearer ${authToken}`, 15 | }, 16 | cache: 'no-store', 17 | }) 18 | 19 | if (!response.ok) { 20 | throw new Error(`Error executing Redis command: ${response.statusText}`) 21 | } 22 | 23 | const data = await response.json() 24 | return data.result 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { NextAuthOptions } from 'next-auth' 2 | import { UpstashRedisAdapter } from '@next-auth/upstash-redis-adapter' 3 | import { db } from './db' 4 | import GoogleProvider from 'next-auth/providers/google' 5 | import { fetchRedis } from '@/helpers/redis' 6 | 7 | function getGoogleCredentials() { 8 | const clientId = process.env.GOOGLE_CLIENT_ID 9 | const clientSecret = process.env.GOOGLE_CLIENT_SECRET 10 | 11 | if (!clientId || clientId.length === 0) { 12 | throw new Error('Missing GOOGLE_CLIENT_ID') 13 | } 14 | 15 | if (!clientSecret || clientSecret.length === 0) { 16 | throw new Error('Missing GOOGLE_CLIENT_SECRET') 17 | } 18 | 19 | return { clientId, clientSecret } 20 | } 21 | 22 | export const authOptions: NextAuthOptions = { 23 | adapter: UpstashRedisAdapter(db), 24 | session: { 25 | strategy: 'jwt', 26 | }, 27 | 28 | pages: { 29 | signIn: '/login', 30 | }, 31 | providers: [ 32 | GoogleProvider({ 33 | clientId: getGoogleCredentials().clientId, 34 | clientSecret: getGoogleCredentials().clientSecret, 35 | }), 36 | ], 37 | callbacks: { 38 | async jwt({ token, user }) { 39 | const dbUserResult = (await fetchRedis('get', `user:${token.id}`)) as 40 | | string 41 | | null 42 | 43 | if (!dbUserResult) { 44 | if (user) { 45 | token.id = user!.id 46 | } 47 | 48 | return token 49 | } 50 | 51 | const dbUser = JSON.parse(dbUserResult) as User 52 | 53 | return { 54 | id: dbUser.id, 55 | name: dbUser.name, 56 | email: dbUser.email, 57 | picture: dbUser.image, 58 | } 59 | }, 60 | async session({ session, token }) { 61 | if (token) { 62 | session.user.id = token.id 63 | session.user.name = token.name 64 | session.user.email = token.email 65 | session.user.image = token.picture 66 | } 67 | 68 | return session 69 | }, 70 | redirect() { 71 | return '/dashboard' 72 | }, 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from '@upstash/redis' 2 | 3 | export const db = new Redis({ 4 | url: process.env.UPSTASH_REDIS_REST_URL, 5 | token: process.env.UPSTASH_REDIS_REST_TOKEN, 6 | }) 7 | -------------------------------------------------------------------------------- /src/lib/pusher.ts: -------------------------------------------------------------------------------- 1 | import PusherServer from 'pusher' 2 | import PusherClient from 'pusher-js' 3 | 4 | export const pusherServer = new PusherServer({ 5 | appId: process.env.PUSHER_APP_ID!, 6 | key: process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, 7 | secret: process.env.PUSHER_APP_SECRET!, 8 | cluster: 'eu', 9 | useTLS: true, 10 | }) 11 | 12 | export const pusherClient = new PusherClient( 13 | process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, 14 | { 15 | cluster: 'eu', 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export function toPusherKey(key: string) { 9 | return key.replace(/:/g, '__') 10 | } 11 | 12 | export function chatHrefConstructor(id1: string, id2: string) { 13 | const sortedIds = [id1, id2].sort() 14 | return `${sortedIds[0]}--${sortedIds[1]}` 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/validations/add-friend.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const addFriendValidator = z.object({ 4 | email: z.string().email(), 5 | }) 6 | -------------------------------------------------------------------------------- /src/lib/validations/message.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const messageValidator = z.object({ 4 | id: z.string(), 5 | senderId: z.string(), 6 | text: z.string(), 7 | timestamp: z.number(), 8 | }) 9 | 10 | export const messageArrayValidator = z.array(messageValidator) 11 | 12 | export type Message = z.infer 13 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from 'next-auth/jwt' 2 | import { withAuth } from 'next-auth/middleware' 3 | import { NextResponse } from 'next/server' 4 | 5 | export default withAuth( 6 | async function middleware(req) { 7 | const pathname = req.nextUrl.pathname 8 | 9 | // Manage route protection 10 | const isAuth = await getToken({ req }) 11 | const isLoginPage = pathname.startsWith('/login') 12 | 13 | const sensitiveRoutes = ['/dashboard'] 14 | const isAccessingSensitiveRoute = sensitiveRoutes.some((route) => 15 | pathname.startsWith(route) 16 | ) 17 | 18 | if (isLoginPage) { 19 | if (isAuth) { 20 | return NextResponse.redirect(new URL('/dashboard', req.url)) 21 | } 22 | 23 | return NextResponse.next() 24 | } 25 | 26 | if (!isAuth && isAccessingSensitiveRoute) { 27 | return NextResponse.redirect(new URL('/login', req.url)) 28 | } 29 | 30 | if (pathname === '/') { 31 | return NextResponse.redirect(new URL('/dashboard', req.url)) 32 | } 33 | }, 34 | { 35 | callbacks: { 36 | async authorized() { 37 | return true 38 | }, 39 | }, 40 | } 41 | ) 42 | 43 | export const config = { 44 | matchter: ['/', '/login', '/dashboard/:path*'], 45 | } 46 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/lib/auth"; 2 | import NextAuth from "next-auth/next"; 3 | 4 | export default NextAuth(authOptions) -------------------------------------------------------------------------------- /src/types/db.d.ts: -------------------------------------------------------------------------------- 1 | interface User { 2 | name: string 3 | email: string 4 | image: string 5 | id: string 6 | } 7 | 8 | interface Chat { 9 | id: string 10 | messages: Message[] 11 | } 12 | 13 | interface Message { 14 | id: string 15 | senderId: string 16 | receiverId: string 17 | text: string 18 | timestamp: number 19 | } 20 | 21 | interface FriendRequest { 22 | id: string 23 | senderId: string 24 | receiverId: string 25 | } 26 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import type { Session, User } from 'next-auth' 2 | import type { JWT } from 'next-auth/jwt' 3 | 4 | type UserId = string 5 | 6 | declare module 'next-auth/jwt' { 7 | interface JWT { 8 | id: UserId 9 | } 10 | } 11 | 12 | declare module 'next-auth' { 13 | interface Session { 14 | user: User & { 15 | id: UserId 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/types/pusher.d.ts: -------------------------------------------------------------------------------- 1 | interface IncomingFriendRequest { 2 | senderId: string 3 | senderEmail: string | null | undefined 4 | } 5 | -------------------------------------------------------------------------------- /src/types/typings.d.ts: -------------------------------------------------------------------------------- 1 | import { Icon } from "@/components/Icons" 2 | 3 | interface SidebarOption { 4 | id: number 5 | name: string 6 | href: string 7 | Icon: Icon 8 | } 9 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./app/**/*.{js,ts,jsx,tsx}", 5 | "./pages/**/*.{js,ts,jsx,tsx}", 6 | "./components/**/*.{js,ts,jsx,tsx}", 7 | 8 | // Or if using `src` directory: 9 | "./src/**/*.{js,ts,jsx,tsx}", 10 | ], 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: '1.5rem', 15 | screens: { 16 | '2xl': '1360px' 17 | }, 18 | }, 19 | extend: {}, 20 | }, 21 | plugins: [require('@tailwindcss/forms')], 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------