├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── api │ ├── register-user │ │ └── route.ts │ ├── token │ │ └── route.ts │ └── users │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── page.tsx └── register │ └── page.tsx ├── assets ├── discord-black.svg └── discord-white.svg ├── components ├── ChannelList │ ├── BottomBar │ │ └── ChannelListBottomBar.tsx │ ├── CallList │ │ └── CallList.tsx │ ├── CategoryItem │ │ ├── CategoryItem.css │ │ └── CategoryItem.tsx │ ├── CreateChannelForm │ │ ├── CreateChannelForm.tsx │ │ └── UserRow.tsx │ ├── CustomChannelList.tsx │ ├── CustomChannelPreview.tsx │ ├── Icons.tsx │ └── TopBar │ │ ├── ChannelListMenuRow.tsx │ │ ├── ChannelListTopBar.tsx │ │ └── menuItems.tsx ├── MessageList │ ├── CustomChannelHeader │ │ └── CustomChannelHeader.tsx │ ├── CustomDateSeparator │ │ └── CustomDateSeparator.tsx │ ├── CustomMessage │ │ ├── CustomMessage.tsx │ │ └── MessageOptions.tsx │ ├── CustomReactions │ │ └── CustomReactionsSelector.tsx │ └── MessageComposer │ │ ├── MessageComposer.tsx │ │ └── plusItems.tsx ├── MyCall │ ├── CallLayout.tsx │ └── MyCall.tsx ├── MyChat.tsx └── ServerList │ ├── CreateServerForm.tsx │ ├── ServerList.tsx │ └── UserCard.tsx ├── contexts └── DiscordContext.tsx ├── hooks ├── useClient.ts └── useVideoClient.ts ├── middleware.ts ├── model └── UserObject.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── discord.svg ├── next.svg └── vercel.svg ├── tailwind.config.ts ├── tsconfig.json └── yarn.lock /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Discord-Clone-Part1-2400x1350px](https://github.com/GetStream/discord-clone-nextjs/assets/12433593/3c15c77d-7c7c-45fc-b7ba-a26ae20a2842) 2 | 3 | # Discord Clone using NextJS, TailwindCSS, and Stream 4 | 5 | This repository accompanies the series of blog posts published on the Stream Blog about creating a Discord clone using [NextJS](https://nextjs.org), [TailwindCSS](https://tailwindcss.com), and the Stream [Chat](https://getstream.io/chat/docs/) and [Video](https://getstream.io/video/docs/) SDKs. 6 | 7 | The series will have five posts at the end (we'll have the links updated once they are published): 8 | 9 | - [Part 1: Setup Project](https://getstream.io/blog/discord-clone-project-setup/) 10 | - [Part 2: General Layout and Server List](https://getstream.io/blog/discord-clone-server-list/) 11 | - Part 3: Channel List UI (coming soon) 12 | - Part 4: Message List UI 13 | - Part 5: Adding video and audio calling 14 | 15 | --- 16 | 17 | ## Running the project 18 | 19 | ### Prerequisites 20 | 21 | First, a machine running [Node.js](https://nodejs.org/en) and the option to clone the repository. The rest of the setup is explained in [Part 1](https://getstream.io/blog/discord-clone-project-setup/). 22 | 23 | Second, an account with Stream. We have a [free tier](https://getstream.io/pricing/#chat), and we can create an account for free using [this link](https://http://getstream.io/try-for-free/). 24 | 25 | ### Running locally 26 | 27 | The first thing to do is install dependencies: 28 | 29 | ```bash 30 | npm install 31 | # or 32 | yarn 33 | ``` 34 | 35 | Then we can run the project on our machine in development mode: 36 | 37 | ```bash 38 | npm run dev 39 | # or 40 | yarn dev 41 | ``` 42 | 43 | ## Use the Stream SDKs yourself 44 | 45 | You can get started with the Stream SDKs today [for free](https://http://getstream.io/try-for-free/). 46 | 47 | Find our React documentation here: 48 | 49 | - [Chat SDK](https://getstream.io/chat/sdk/react/) 50 | - [Video and Audio SDK](https://getstream.io/video/docs/react/) 51 | -------------------------------------------------------------------------------- /app/api/register-user/route.ts: -------------------------------------------------------------------------------- 1 | import { clerkClient } from '@clerk/nextjs'; 2 | import { StreamChat } from 'stream-chat'; 3 | 4 | export async function POST(request: Request) { 5 | const serverClient = StreamChat.getInstance( 6 | '7cu55d72xtjs', 7 | process.env.STREAM_CHAT_SECRET 8 | ); 9 | const body = await request.json(); 10 | console.log('[/api/register-user] Body:', body); 11 | 12 | const userId = body?.userId; 13 | const mail = body?.email; 14 | 15 | if (!userId || !mail) { 16 | return Response.error(); 17 | } 18 | 19 | const user = await serverClient.upsertUser({ 20 | id: userId, 21 | role: 'user', 22 | name: mail, 23 | imageUrl: `https://getstream.io/random_png/?id=${userId}&name=${mail}`, 24 | }); 25 | 26 | const params = { 27 | publicMetadata: { 28 | streamRegistered: true, 29 | }, 30 | }; 31 | const updatedUser = await clerkClient.users.updateUser(userId, params); 32 | 33 | console.log('[/api/register-user] User:', updatedUser); 34 | const response = { 35 | userId: userId, 36 | userName: mail, 37 | }; 38 | 39 | return Response.json(response); 40 | } 41 | -------------------------------------------------------------------------------- /app/api/token/route.ts: -------------------------------------------------------------------------------- 1 | import { StreamChat } from 'stream-chat'; 2 | 3 | export async function POST(request: Request) { 4 | const serverClient = StreamChat.getInstance( 5 | '7cu55d72xtjs', 6 | process.env.STREAM_CHAT_SECRET 7 | ); 8 | const body = await request.json(); 9 | console.log('[/api/token] Body:', body); 10 | 11 | const userId = body?.userId; 12 | 13 | if (!userId) { 14 | return Response.error(); 15 | } 16 | 17 | const token = serverClient.createToken(userId); 18 | 19 | const response = { 20 | userId: userId, 21 | token: token, 22 | }; 23 | 24 | return Response.json(response); 25 | } 26 | -------------------------------------------------------------------------------- /app/api/users/route.ts: -------------------------------------------------------------------------------- 1 | import { UserObject } from '@/model/UserObject'; 2 | import { StreamChat } from 'stream-chat'; 3 | 4 | export async function GET() { 5 | const serverClient = StreamChat.getInstance( 6 | '7cu55d72xtjs', 7 | process.env.STREAM_CHAT_SECRET 8 | ); 9 | const response = await serverClient.queryUsers({}); 10 | const data: UserObject[] = response.users 11 | .filter((user) => user.role !== 'admin') 12 | .map((user) => { 13 | return { 14 | id: user.id, 15 | name: user.name ?? user.id, 16 | image: user.image as string, 17 | online: user.online, 18 | lastOnline: user.last_active, 19 | }; 20 | }); 21 | 22 | return Response.json({ data }); 23 | } 24 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/discord-clone-nextjs/6571506e61968b3492a65cd5a7045f5d168bb5ec/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import '~stream-chat-react/dist/css/v2/index.css'; 6 | 7 | :root { 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | --discord-purple: #7289da; 12 | --dark-discord: #4752c4; 13 | 14 | --gray-normal: #313338; 15 | } 16 | 17 | @media (prefers-color-scheme: dark) { 18 | :root { 19 | --foreground-rgb: 255, 255, 255; 20 | --background-start-rgb: 0, 0, 0; 21 | --background-end-rgb: 0, 0, 0; 22 | } 23 | } 24 | 25 | .str-chat { 26 | --str-chat__message-list-background-color: white; 27 | --str-chat__spacing-10: 0; 28 | } 29 | 30 | body { 31 | color: rgb(var(--foreground-rgb)); 32 | background: linear-gradient( 33 | to bottom, 34 | transparent, 35 | rgb(var(--background-end-rgb)) 36 | ) 37 | rgb(var(--background-start-rgb)); 38 | } 39 | 40 | .layout { 41 | display: grid; 42 | grid-template-columns: 5rem auto 1fr; 43 | } 44 | 45 | ::backdrop { 46 | background-image: linear-gradient(-45deg, #7289da, rebeccapurple); 47 | opacity: 0.5; 48 | } 49 | 50 | .labelTitle { 51 | @apply uppercase text-sm font-bold text-gray-600; 52 | } 53 | 54 | input, 55 | select { 56 | @apply w-full p-2 rounded; 57 | } 58 | 59 | input[type='text'] { 60 | @apply bg-transparent outline-transparent; 61 | } 62 | 63 | input[type='text']:focus { 64 | outline: none; 65 | } 66 | 67 | input[type='radio'] { 68 | @apply w-8 h-8 mb-0; 69 | accent-color: black; 70 | } 71 | 72 | .rounded-icon { 73 | @apply transition-all ease-in-out duration-200 aspect-square object-cover; 74 | border-radius: 50%; 75 | } 76 | 77 | .rounded-icon:hover { 78 | border-radius: 1rem; 79 | } 80 | 81 | .sidebar-icon { 82 | @apply flex items-center justify-center w-full relative transition-all ease-in-out duration-200; 83 | } 84 | 85 | .sidebar-icon::before { 86 | @apply transition-all duration-200 ease-in-out; 87 | --content-height: 0rem; 88 | --content-width: 0rem; 89 | --offset: -0.4rem; 90 | content: ''; 91 | display: block; 92 | height: var(--content-height); 93 | width: var(--content-width); 94 | background: black; 95 | position: absolute; 96 | border-radius: 3px; 97 | left: var(--offset); 98 | } 99 | 100 | .sidebar-icon:hover::before { 101 | --content-height: 1.25rem; 102 | --content-width: 0.5rem; 103 | --offset: -0.15rem; 104 | } 105 | 106 | .selected-icon::before { 107 | --content-height: 2rem; 108 | --content-width: 0.5rem; 109 | --offset: -0.15rem; 110 | } 111 | 112 | .discord-icon { 113 | @apply bg-white p-3 w-full h-full; 114 | content: ''; 115 | 116 | background: url('../assets/discord-black.svg') no-repeat center center, white; 117 | background-origin: content-box; 118 | } 119 | 120 | .discord-icon:hover { 121 | background: url('../assets/discord-white.svg') no-repeat center center, 122 | var(--discord-purple); 123 | background-origin: content-box; 124 | --offset: 1.5rem; 125 | } 126 | 127 | .online-icon::after { 128 | @apply block absolute h-4 w-4 bg-green-600 bottom-0 right-0 rounded-full border-2 border-gray-200; 129 | content: ''; 130 | } 131 | 132 | .inactive-icon::after { 133 | @apply block absolute h-full w-0.5 bg-red-400 rotate-45 rounded-xl m-2; 134 | content: ''; 135 | } 136 | 137 | .channel-container { 138 | @apply relative; 139 | } 140 | 141 | .channel-container::before { 142 | @apply block absolute h-2 w-3 -left-4 bg-gray-700 rounded-xl; 143 | content: ''; 144 | } 145 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import './globals.css'; 4 | import { DiscordContextProvider } from '@/contexts/DiscordContext'; 5 | import { ClerkProvider } from '@clerk/nextjs'; 6 | 7 | const inter = Inter({ subsets: ['latin'] }); 8 | 9 | export const metadata: Metadata = { 10 | title: 'Discord Clone', 11 | description: 'Powered by Stream Chat', 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { User } from 'stream-chat'; 4 | import { LoadingIndicator } from 'stream-chat-react'; 5 | 6 | import { useClerk } from '@clerk/nextjs'; 7 | import { useCallback, useEffect, useState } from 'react'; 8 | import MyChat from '@/components/MyChat'; 9 | 10 | // const userId = '7cd445eb-9af2-4505-80a9-aa8543c3343f'; 11 | // const userName = 'Harry Potter'; 12 | 13 | const apiKey = '7cu55d72xtjs'; 14 | // const userToken = 15 | // 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiN2NkNDQ1ZWItOWFmMi00NTA1LTgwYTktYWE4NTQzYzMzNDNmIn0.TtrCA5VoRB2KofI3O6lYjYZd2pHdQT408u7ryeWO4Qg'; 16 | 17 | export type DiscordServer = { 18 | name: string; 19 | image: string | undefined; 20 | }; 21 | 22 | export type Homestate = { 23 | apiKey: string; 24 | user: User; 25 | token: string; 26 | }; 27 | 28 | export default function Home() { 29 | const [myState, setMyState] = useState(undefined); 30 | 31 | const { user: myUser } = useClerk(); 32 | 33 | const registerUser = useCallback( 34 | async function registerUser() { 35 | // register user on Stream backend 36 | console.log('[registerUser] myUser:', myUser); 37 | const userId = myUser?.id; 38 | const mail = myUser?.primaryEmailAddress?.emailAddress; 39 | if (userId && mail) { 40 | const streamResponse = await fetch('/api/register-user', { 41 | method: 'POST', 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | }, 45 | body: JSON.stringify({ 46 | userId: userId, 47 | email: mail, 48 | }), 49 | }); 50 | const responseBody = await streamResponse.json(); 51 | console.log('[registerUser] Stream response:', responseBody); 52 | return responseBody; 53 | } 54 | }, 55 | [myUser] 56 | ); 57 | 58 | useEffect(() => { 59 | if ( 60 | myUser?.id && 61 | myUser?.primaryEmailAddress?.emailAddress && 62 | !myUser?.publicMetadata.streamRegistered 63 | ) { 64 | console.log('[Page - useEffect] Registering user on Stream backend'); 65 | registerUser().then((result) => { 66 | console.log('[Page - useEffect] Result: ', result); 67 | getUserToken( 68 | myUser.id, 69 | myUser?.primaryEmailAddress?.emailAddress || 'Unknown' 70 | ); 71 | }); 72 | } else { 73 | // take user and get token 74 | if (myUser?.id) { 75 | console.log( 76 | '[Page - useEffect] User already registered on Stream backend: ', 77 | myUser?.id 78 | ); 79 | getUserToken( 80 | myUser?.id || 'Unknown', 81 | myUser?.primaryEmailAddress?.emailAddress || 'Unknown' 82 | ); 83 | } 84 | } 85 | }, [registerUser, myUser]); 86 | 87 | if (!myState) { 88 | return ; 89 | } 90 | 91 | return ; 92 | 93 | async function getUserToken(userId: string, userName: string) { 94 | const response = await fetch('/api/token', { 95 | method: 'POST', 96 | headers: { 97 | 'Content-Type': 'application/json', 98 | }, 99 | body: JSON.stringify({ 100 | userId: userId, 101 | }), 102 | }); 103 | const responseBody = await response.json(); 104 | const token = responseBody.token; 105 | 106 | if (!token) { 107 | console.error("Couldn't retrieve token."); 108 | return; 109 | } 110 | 111 | const user: User = { 112 | id: userId, 113 | name: userName, 114 | image: `https://getstream.io/random_png/?id=${userId}&name=${userName}`, 115 | }; 116 | setMyState({ 117 | apiKey: apiKey, 118 | user: user, 119 | token: token, 120 | }); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function Home() { 4 | useEffect(() => {}, []); 5 | 6 | return ( 7 |
8 |

Register

9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /assets/discord-black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/discord-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ChannelList/BottomBar/ChannelListBottomBar.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { useState } from 'react'; 3 | import { Gear, LeaveServer, Mic, Speaker } from '../Icons'; 4 | import { useChatContext } from 'stream-chat-react'; 5 | import { useClerk } from '@clerk/nextjs'; 6 | import ChannelListMenuRow from '../TopBar/ChannelListMenuRow'; 7 | 8 | export default function ChannelListBottomBar(): JSX.Element { 9 | const { client } = useChatContext(); 10 | const [micActive, setMicActive] = useState(false); 11 | const [audioActive, setAudioActive] = useState(false); 12 | const [menuOpen, setMenuOpen] = useState(false); 13 | 14 | const { signOut } = useClerk(); 15 | 16 | return ( 17 |
18 | 44 | 52 | 60 | 63 | {menuOpen && ( 64 | 75 | )} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /components/ChannelList/CallList/CallList.tsx: -------------------------------------------------------------------------------- 1 | import { useDiscordContext } from '@/contexts/DiscordContext'; 2 | import { Call, useStreamVideoClient } from '@stream-io/video-react-sdk'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | import { ChevronRight, PlusIcon, Speaker } from '../Icons'; 5 | import Link from 'next/link'; 6 | 7 | export default function CallList(): JSX.Element { 8 | const { server, callId, setCall } = useDiscordContext(); 9 | const client = useStreamVideoClient(); 10 | 11 | const [isOpen, setIsOpen] = useState(true); 12 | const [calls, setCalls] = useState([]); 13 | 14 | const loadAudioChannels = useCallback(async () => { 15 | const callsRequest = await client?.queryCalls({ 16 | filter_conditions: { 17 | 'custom.serverName': server?.name || 'Test Server', 18 | }, 19 | sort: [{ field: 'created_at', direction: 1 }], 20 | watch: true, 21 | }); 22 | if (callsRequest?.calls) { 23 | setCalls(callsRequest?.calls); 24 | } 25 | }, [client, server]); 26 | 27 | useEffect(() => { 28 | loadAudioChannels(); 29 | }, [loadAudioChannels]); 30 | 31 | return ( 32 |
33 |
34 | 49 | 53 | 54 | 55 |
56 | {isOpen && ( 57 |
58 | {calls.map((call) => ( 59 | 73 | ))} 74 |
75 | )} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /components/ChannelList/CategoryItem/CategoryItem.css: -------------------------------------------------------------------------------- 1 | .create-button { 2 | position: relative; 3 | } 4 | 5 | .create-button:hover::before { 6 | @apply absolute p-2 bg-white shadow-md rounded-md text-sm font-medium text-gray-500 z-10 overflow-visible; 7 | content: 'Create Channel'; 8 | top: -44px; 9 | left: -100px; 10 | width: 120px; 11 | } 12 | -------------------------------------------------------------------------------- /components/ChannelList/CategoryItem/CategoryItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { Channel } from 'stream-chat'; 3 | import CustomChannelPreview from '../CustomChannelPreview'; 4 | import { useState } from 'react'; 5 | import { ChevronDown, PlusIcon } from '../Icons'; 6 | 7 | import './CategoryItem.css'; 8 | import { DefaultStreamChatGenerics } from 'stream-chat-react'; 9 | 10 | type CategoryItemProps = { 11 | category: string; 12 | channels: Channel[]; 13 | serverName: string; 14 | }; 15 | 16 | export default function CategoryItem({ 17 | category, 18 | serverName, 19 | channels, 20 | }: CategoryItemProps): JSX.Element { 21 | const [isOpen, setIsOpen] = useState(true); 22 | return ( 23 |
24 |
25 | 40 | 44 | 45 | 46 |
47 | {isOpen && ( 48 |
49 | {channels.map((channel) => { 50 | return ( 51 | 56 | ); 57 | })} 58 |
59 | )} 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /components/ChannelList/CreateChannelForm/CreateChannelForm.tsx: -------------------------------------------------------------------------------- 1 | import { UserObject } from '@/model/UserObject'; 2 | import { useDiscordContext } from '@/contexts/DiscordContext'; 3 | import { useSearchParams } from 'next/navigation'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useCallback, useEffect, useRef, useState } from 'react'; 6 | import { useChatContext } from 'stream-chat-react'; 7 | import Link from 'next/link'; 8 | import { CloseMark, Speaker } from '../Icons'; 9 | import UserRow from './UserRow'; 10 | import { useStreamVideoClient } from '@stream-io/video-react-sdk'; 11 | 12 | type FormState = { 13 | channelType: 'text' | 'voice'; 14 | channelName: string; 15 | category: string; 16 | users: UserObject[]; 17 | }; 18 | 19 | export default function CreateChannelForm(): JSX.Element { 20 | const params = useSearchParams(); 21 | const showCreateChannelForm = params.get('createChannel'); 22 | 23 | const dialogRef = useRef(null); 24 | const router = useRouter(); 25 | 26 | const { client } = useChatContext(); 27 | const videoClient = useStreamVideoClient(); 28 | const { server, createChannel, createCall } = useDiscordContext(); 29 | const initialState: FormState = { 30 | channelType: 'text', 31 | channelName: '', 32 | category: '', 33 | users: [], 34 | }; 35 | const [formData, setFormData] = useState(initialState); 36 | const [users, setUsers] = useState([]); 37 | 38 | const loadUsers = useCallback(async () => { 39 | const response = await fetch('/api/users'); 40 | const data = (await response.json())?.data as UserObject[]; 41 | if (data) setUsers(data); 42 | }, []); 43 | 44 | useEffect(() => { 45 | loadUsers(); 46 | }, [loadUsers]); 47 | 48 | useEffect(() => { 49 | const category = params.get('category'); 50 | const isVoice = params.get('isVoice'); 51 | setFormData({ 52 | channelType: isVoice ? 'voice' : 'text', 53 | channelName: '', 54 | category: category ?? '', 55 | users: [], 56 | }); 57 | }, [setFormData, params]); 58 | 59 | useEffect(() => { 60 | if (showCreateChannelForm && dialogRef.current) { 61 | dialogRef.current.showModal(); 62 | } else { 63 | dialogRef.current?.close(); 64 | } 65 | }, [showCreateChannelForm]); 66 | 67 | return ( 68 | 69 |
70 |

Create Channel

71 | 72 | 73 | 74 |
75 |
76 |
77 |

Channel Type

78 |
79 | 91 | setFormData({ ...formData, channelType: 'text' })} 98 | /> 99 |
100 |
101 | 113 | 120 | setFormData({ ...formData, channelType: 'voice' }) 121 | } 122 | /> 123 |
124 |
125 | 128 |
129 | # 130 | 136 | setFormData({ ...formData, channelName: e.target.value }) 137 | } 138 | /> 139 |
140 | 146 |
147 | # 148 | 154 | setFormData({ ...formData, category: e.target.value }) 155 | } 156 | /> 157 |
158 |

Add Users

159 |
160 | {users.map((user) => ( 161 | 162 | ))} 163 |
164 |
165 |
166 | 167 | Cancel 168 | 169 | 179 |
180 |
181 | ); 182 | 183 | function buttonDisabled(): boolean { 184 | return ( 185 | !formData.channelName || !formData.category || formData.users.length <= 1 186 | ); 187 | } 188 | 189 | function userChanged(user: UserObject, checked: boolean) { 190 | if (checked) { 191 | setFormData({ 192 | ...formData, 193 | users: [...formData.users, user], 194 | }); 195 | } else { 196 | setFormData({ 197 | ...formData, 198 | users: formData.users.filter((thisUser) => thisUser.id !== user.id), 199 | }); 200 | } 201 | } 202 | 203 | function createClicked() { 204 | switch (formData.channelType) { 205 | case 'text': 206 | createChannel( 207 | client, 208 | formData.channelName, 209 | formData.category, 210 | formData.users.map((user) => user.id) 211 | ); 212 | case 'voice': 213 | if (videoClient && server) { 214 | createCall( 215 | videoClient, 216 | server, 217 | formData.channelName, 218 | formData.users.map((user) => user.id) 219 | ); 220 | } 221 | } 222 | setFormData(initialState); 223 | router.replace('/'); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /components/ChannelList/CreateChannelForm/UserRow.tsx: -------------------------------------------------------------------------------- 1 | import { UserObject } from '@/model/UserObject'; 2 | import Image from 'next/image'; 3 | import { PersonIcon } from '../Icons'; 4 | 5 | export default function UserRow({ 6 | user, 7 | userChanged, 8 | }: { 9 | user: UserObject; 10 | userChanged: (user: UserObject, checked: boolean) => void; 11 | }): JSX.Element { 12 | return ( 13 |
14 | { 20 | userChanged(user, event.target.checked); 21 | }} 22 | > 23 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /components/ChannelList/CustomChannelList.tsx: -------------------------------------------------------------------------------- 1 | import { ChannelListMessengerProps } from 'stream-chat-react'; 2 | 3 | import { useDiscordContext } from '@/contexts/DiscordContext'; 4 | import CreateChannelForm from './CreateChannelForm/CreateChannelForm'; 5 | import UserBar from './BottomBar/ChannelListBottomBar'; 6 | import ChannelListTopBar from './TopBar/ChannelListTopBar'; 7 | import CategoryItem from './CategoryItem/CategoryItem'; 8 | import CallList from './CallList/CallList'; 9 | 10 | const CustomChannelList: React.FC = () => { 11 | const { server, channelsByCategories } = useDiscordContext(); 12 | 13 | return ( 14 |
15 | 16 | 17 |
18 | {Array.from(channelsByCategories.keys()).map((category, index) => ( 19 | 25 | ))} 26 |
27 | 28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | export default CustomChannelList; 35 | -------------------------------------------------------------------------------- /components/ChannelList/CustomChannelPreview.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChannelPreviewUIComponentProps, 3 | useChatContext, 4 | } from 'stream-chat-react'; 5 | 6 | const CustomChannelPreview = (props: ChannelPreviewUIComponentProps) => { 7 | const { channel } = props; 8 | const { setActiveChannel } = useChatContext(); 9 | return ( 10 |
0 ? 'channel-container' : '' 13 | }`} 14 | > 15 | 24 |
25 | ); 26 | }; 27 | 28 | export default CustomChannelPreview; 29 | -------------------------------------------------------------------------------- /components/ChannelList/Icons.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from 'next/dist/lib/metadata/types/metadata-types'; 2 | 3 | type IconProps = { 4 | className?: string; 5 | }; 6 | 7 | export function PlusIcon({ 8 | className = 'h-5 w-5 text-gray-500', 9 | }: IconProps): JSX.Element { 10 | return ( 11 | 19 | 24 | 25 | ); 26 | } 27 | 28 | export function CloseIcon({ 29 | className = 'h-5 w-5 text-gray-500', 30 | }: IconProps): JSX.Element { 31 | return ( 32 | 40 | 45 | 46 | ); 47 | } 48 | 49 | export function CloseCircle({ 50 | className = 'w-8 h-8 text-gray-500 hover:text-black hover:font-bold', 51 | }): JSX.Element { 52 | return ( 53 | 61 | 66 | 67 | ); 68 | } 69 | 70 | export function PersonIcon({ className = 'h-8 w-8' }: IconProps): JSX.Element { 71 | return ( 72 | 80 | 85 | 86 | ); 87 | } 88 | 89 | export function ChevronRight({ 90 | className = 'h-5 w-5 text-gray-500', 91 | }: IconProps): JSX.Element { 92 | return ( 93 | 101 | 106 | 107 | ); 108 | } 109 | 110 | export function ChevronDown({ 111 | className = 'h-5 w-5 text-gray-500', 112 | }: IconProps): JSX.Element { 113 | return ( 114 | 122 | 127 | 128 | ); 129 | } 130 | 131 | export function Bell({ className = 'h-5 w-5' }: IconProps): JSX.Element { 132 | return ( 133 | 139 | 144 | 145 | ); 146 | } 147 | 148 | export function Boost({ className = 'h-5 w-5' }: IconProps): JSX.Element { 149 | return ( 150 | 156 | 161 | 162 | ); 163 | } 164 | 165 | export function FaceSmile({ className = 'h-5 w-5' }: IconProps): JSX.Element { 166 | return ( 167 | 173 | 178 | 179 | ); 180 | } 181 | 182 | export function FolderPlus({ className = 'h-5 w-5' }: IconProps): JSX.Element { 183 | return ( 184 | 190 | 195 | 196 | ); 197 | } 198 | 199 | export function Gear({ className = 'h-5 w-5' }: IconProps): JSX.Element { 200 | return ( 201 | 207 | 212 | 213 | ); 214 | } 215 | 216 | export function LeaveServer({ className = 'h-5 w-5' }: IconProps): JSX.Element { 217 | return ( 218 | 224 | 229 | 230 | ); 231 | } 232 | 233 | export function Pen({ className = 'h-5 w-5' }: IconProps): JSX.Element { 234 | return ( 235 | 241 | 242 | 243 | ); 244 | } 245 | 246 | export function PersonAdd({ className = 'h-5 w-5' }: IconProps): JSX.Element { 247 | return ( 248 | 254 | 255 | 256 | ); 257 | } 258 | 259 | export function PlusCircle({ className = 'h-5 w-5' }: IconProps): JSX.Element { 260 | return ( 261 | 267 | 272 | 273 | ); 274 | } 275 | 276 | export function Shield({ className = 'h-5 w-5' }: IconProps): JSX.Element { 277 | return ( 278 | 284 | 289 | 290 | ); 291 | } 292 | 293 | export function SpeakerMuted({ 294 | className = 'h-5 w-5', 295 | }: IconProps): JSX.Element { 296 | return ( 297 | 303 | 304 | 305 | ); 306 | } 307 | 308 | export function Mic({ className = 'w-full h-full' }: IconProps): JSX.Element { 309 | return ( 310 | 316 | 317 | 318 | 319 | ); 320 | } 321 | 322 | export function Speaker({ 323 | className = 'w-full h-full', 324 | }: IconProps): JSX.Element { 325 | return ( 326 | 332 | 333 | 334 | 335 | ); 336 | } 337 | 338 | export function Present({ 339 | className = 'w-full h-full', 340 | }: IconProps): JSX.Element { 341 | return ( 342 | 348 | 349 | 350 | ); 351 | } 352 | 353 | export function GIF({ className = 'w-full h-full' }: IconProps): JSX.Element { 354 | return ( 355 | 361 | 366 | 367 | ); 368 | } 369 | 370 | export function Emoji({ className = 'w-full h-full' }: IconProps): JSX.Element { 371 | return ( 372 | 378 | 383 | 384 | ); 385 | } 386 | 387 | export function Thread({ className = 'w-5 h-5' }: IconProps): JSX.Element { 388 | return ( 389 | 395 | 400 | 401 | ); 402 | } 403 | 404 | export function Apps({ className = 'w-5 h-5' }: IconProps): JSX.Element { 405 | return ( 406 | 412 | 413 | 414 | ); 415 | } 416 | 417 | export function ArrowUturnLeft({ 418 | className = 'w-5 h-5', 419 | }: IconProps): JSX.Element { 420 | return ( 421 | 429 | 434 | 435 | ); 436 | } 437 | 438 | export function CloseMark({ className = 'w-5 h-5' }: IconProps): JSX.Element { 439 | return ( 440 | 448 | 453 | 454 | ); 455 | } 456 | -------------------------------------------------------------------------------- /components/ChannelList/TopBar/ChannelListMenuRow.tsx: -------------------------------------------------------------------------------- 1 | import { ListRowElement } from './menuItems'; 2 | 3 | export default function ChannelListMenuRow({ 4 | name, 5 | icon, 6 | bottomBorder = true, 7 | purple = false, 8 | red = false, 9 | reverseOrder = false, 10 | }: ListRowElement): JSX.Element { 11 | return ( 12 | <> 13 |

22 | {name} 23 | {icon} 24 |

25 | {bottomBorder &&
} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/ChannelList/TopBar/ChannelListTopBar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { ChevronDown, CloseIcon } from '../Icons'; 3 | import ChannelListMenuRow from './ChannelListMenuRow'; 4 | import { menuItems } from './menuItems'; 5 | 6 | export default function ChannelListTopBar({ 7 | serverName, 8 | }: { 9 | serverName: string; 10 | }): JSX.Element { 11 | const [menuOpen, setMenuOpen] = useState(false); 12 | 13 | return ( 14 |
15 | 25 | 26 | {menuOpen && ( 27 |
28 |
29 | {menuItems.map((option) => ( 30 | 37 | ))} 38 |
39 |
40 | )} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/ChannelList/TopBar/menuItems.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Bell, 3 | Boost, 4 | FaceSmile, 5 | FolderPlus, 6 | Gear, 7 | LeaveServer, 8 | Pen, 9 | PersonAdd, 10 | PlusCircle, 11 | Shield, 12 | SpeakerMuted, 13 | } from '../Icons'; 14 | 15 | export type ListRowElement = { 16 | name: string; 17 | icon: JSX.Element; 18 | bottomBorder?: boolean; 19 | purple?: boolean; 20 | red?: boolean; 21 | reverseOrder?: boolean; 22 | }; 23 | 24 | export const menuItems: ListRowElement[] = [ 25 | { name: 'Server Boost', icon: , bottomBorder: true }, 26 | { 27 | name: 'Invite People', 28 | icon: , 29 | bottomBorder: false, 30 | purple: true, 31 | }, 32 | { name: 'Server Settings', icon: , bottomBorder: false }, 33 | { name: 'Create Channel', icon: , bottomBorder: false }, 34 | { name: 'Create Category', icon: , bottomBorder: false }, 35 | { name: 'App Directory', icon: , bottomBorder: true }, 36 | { name: 'Notification Settings', icon: , bottomBorder: false }, 37 | { name: 'Privacy Settings', icon: , bottomBorder: true }, 38 | { name: 'Edit Server Profile', icon: , bottomBorder: false }, 39 | { name: 'Hide Muted Channels', icon: , bottomBorder: true }, 40 | { 41 | name: 'Leave Server', 42 | icon: , 43 | bottomBorder: false, 44 | red: true, 45 | }, 46 | ]; 47 | -------------------------------------------------------------------------------- /components/MessageList/CustomChannelHeader/CustomChannelHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useChannelStateContext } from 'stream-chat-react'; 2 | 3 | export default function CustomChannelHeader(): JSX.Element { 4 | const { channel } = useChannelStateContext(); 5 | const { name } = channel?.data || {}; 6 | return ( 7 |
8 | # 9 | {name} 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /components/MessageList/CustomDateSeparator/CustomDateSeparator.tsx: -------------------------------------------------------------------------------- 1 | import { DateSeparatorProps } from 'stream-chat-react'; 2 | 3 | export default function CustomDateSeparator( 4 | props: DateSeparatorProps 5 | ): JSX.Element { 6 | const { date } = props; 7 | 8 | function formatDate(date: Date): string { 9 | return `${date.toLocaleDateString('en-US', { dateStyle: 'long' })}`; 10 | } 11 | 12 | return ( 13 |
14 | 15 | {formatDate(date)} 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components/MessageList/CustomMessage/CustomMessage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ReactionSelector, 3 | ReactionsList, 4 | useMessageContext, 5 | } from 'stream-chat-react'; 6 | import Image from 'next/image'; 7 | import { useState } from 'react'; 8 | import MessageOptions from './MessageOptions'; 9 | 10 | export default function CustomMessage(): JSX.Element { 11 | const { message } = useMessageContext(); 12 | const [showOptions, setShowOptions] = useState(false); 13 | const [showReactions, setShowReactions] = useState(false); 14 | return ( 15 |
setShowOptions(true)} 17 | onMouseLeave={() => setShowOptions(false)} 18 | className='flex relative space-x-2 p-2 rounded-md transition-colors ease-in-out duration-200 hover:bg-gray-100' 19 | > 20 | User avatar 27 |
28 | {showOptions && ( 29 | 30 | )} 31 | {showReactions && ( 32 |
33 | 34 |
35 | )} 36 |
37 | 38 | {message.user?.name} 39 | 40 | {message.updated_at && ( 41 | 42 | {formatDate(message.updated_at)} 43 | 44 | )} 45 |
46 |

{message.text}

47 | 48 |
49 |
50 | ); 51 | 52 | function formatDate(date: Date | string): string { 53 | if (typeof date === 'string') { 54 | return date; 55 | } 56 | return `${date.toLocaleString('en-US', { 57 | dateStyle: 'medium', 58 | timeStyle: 'short', 59 | })}`; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /components/MessageList/CustomMessage/MessageOptions.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowUturnLeft, Emoji, Thread } from '@/components/ChannelList/Icons'; 2 | import { Dispatch, SetStateAction } from 'react'; 3 | 4 | export default function MessageOptions({ 5 | showEmojiReactions, 6 | }: { 7 | showEmojiReactions: Dispatch>; 8 | }): JSX.Element { 9 | return ( 10 |
11 | 17 | 20 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/MessageList/CustomReactions/CustomReactionsSelector.tsx: -------------------------------------------------------------------------------- 1 | export const customReactionOptions = [ 2 | { 3 | type: 'runner', 4 | Component: () => <>🏃🏼, 5 | name: 'Runner', 6 | }, 7 | { 8 | type: 'sun', 9 | Component: () => <>🌞, 10 | name: 'Sun', 11 | }, 12 | { 13 | type: 'star', 14 | Component: () => <>🤩, 15 | name: 'Star', 16 | }, 17 | { 18 | type: 'confetti', 19 | Component: () => <>🎉, 20 | name: 'Confetti', 21 | }, 22 | { 23 | type: 'howdy', 24 | Component: () => <>🤠, 25 | name: 'Howdy', 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /components/MessageList/MessageComposer/MessageComposer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Emoji, 3 | GIF, 4 | PlusCircle, 5 | Present, 6 | } from '@/components/ChannelList/Icons'; 7 | import { useState } from 'react'; 8 | import { SendButton, useChatContext } from 'stream-chat-react'; 9 | import { plusItems } from './plusItems'; 10 | import ChannelListMenuRow from '@/components/ChannelList/TopBar/ChannelListMenuRow'; 11 | 12 | export default function MessageComposer(): JSX.Element { 13 | const [plusMenuOpen, setPlusMenuOpen] = useState(false); 14 | const { channel } = useChatContext(); 15 | const [message, setMessage] = useState(''); 16 | return ( 17 |
18 | 21 | {plusMenuOpen && ( 22 |
23 |
24 | {plusItems.map((option) => ( 25 | 32 | ))} 33 |
34 |
35 | )} 36 | setMessage(e.target.value)} 41 | placeholder='Message #general' 42 | /> 43 | 44 | 45 | 46 | { 48 | channel?.sendMessage({ text: message }); 49 | setMessage(''); 50 | }} 51 | /> 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/MessageList/MessageComposer/plusItems.tsx: -------------------------------------------------------------------------------- 1 | import { Apps, FolderPlus, Thread } from '@/components/ChannelList/Icons'; 2 | import { ListRowElement } from '@/components/ChannelList/TopBar/menuItems'; 3 | 4 | export const plusItems: ListRowElement[] = [ 5 | { 6 | name: 'Upload a File', 7 | icon: , 8 | bottomBorder: false, 9 | reverseOrder: true, 10 | }, 11 | { 12 | name: 'Create Thread', 13 | icon: , 14 | bottomBorder: false, 15 | reverseOrder: true, 16 | }, 17 | { name: 'Use Apps', icon: , bottomBorder: false, reverseOrder: true }, 18 | ]; 19 | -------------------------------------------------------------------------------- /components/MyCall/CallLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useDiscordContext } from '@/contexts/DiscordContext'; 2 | import { CallingState } from '@stream-io/video-client'; 3 | import { 4 | useCallStateHooks, 5 | StreamTheme, 6 | SpeakerLayout, 7 | CallControls, 8 | } from '@stream-io/video-react-sdk'; 9 | import '@stream-io/video-react-sdk/dist/css/styles.css'; 10 | 11 | export default function CallLayout(): JSX.Element { 12 | const { setCall } = useDiscordContext(); 13 | const { useCallCallingState, useParticipantCount } = useCallStateHooks(); 14 | const participantCount = useParticipantCount(); 15 | const callingState = useCallCallingState(); 16 | 17 | if (callingState !== CallingState.JOINED) { 18 | return
Loading...
; 19 | } 20 | 21 | return ( 22 | 23 |

Participants: {participantCount}

24 | 25 | { 27 | setCall(undefined); 28 | }} 29 | /> 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /components/MyCall/MyCall.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Call, 3 | StreamCall, 4 | useStreamVideoClient, 5 | } from '@stream-io/video-react-sdk'; 6 | import { useCallback, useEffect, useState } from 'react'; 7 | import CallLayout from './CallLayout'; 8 | 9 | export default function MyCall({ callId }: { callId: string }): JSX.Element { 10 | const [call, setCall] = useState(undefined); 11 | const client = useStreamVideoClient(); 12 | 13 | const [joining, setJoining] = useState(false); 14 | 15 | const createCall = useCallback(async () => { 16 | const callToCreate = client?.call('default', callId); 17 | await callToCreate?.camera.disable(); 18 | await callToCreate?.join({ create: true }); 19 | setCall(callToCreate); 20 | setJoining(false); 21 | }, [client, callId]); 22 | 23 | useEffect(() => { 24 | if (!client) { 25 | console.error('No client in MyCall component'); 26 | return; 27 | } 28 | 29 | if (!call) { 30 | if (joining) { 31 | createCall(); 32 | } else { 33 | setJoining(true); 34 | } 35 | } 36 | }, [call, client, createCall, joining]); 37 | 38 | if (!call) { 39 | return ( 40 |
41 | Joining call ... 42 |
43 | ); 44 | } 45 | 46 | return ( 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /components/MyChat.tsx: -------------------------------------------------------------------------------- 1 | import { useClient } from '@/hooks/useClient'; 2 | import { User } from 'stream-chat'; 3 | import { 4 | Chat, 5 | Channel, 6 | ChannelList, 7 | ChannelHeader, 8 | MessageList, 9 | MessageInput, 10 | Thread, 11 | Window, 12 | } from 'stream-chat-react'; 13 | 14 | import CustomChannelList from '@/components/ChannelList/CustomChannelList'; 15 | import ServerList from '@/components/ServerList/ServerList'; 16 | import MessageComposer from '@/components/MessageList/MessageComposer/MessageComposer'; 17 | import CustomDateSeparator from '@/components/MessageList/CustomDateSeparator/CustomDateSeparator'; 18 | import CustomMessage from '@/components/MessageList/CustomMessage/CustomMessage'; 19 | import { customReactionOptions } from '@/components/MessageList/CustomReactions/CustomReactionsSelector'; 20 | import { useVideoClient } from '@/hooks/useVideoClient'; 21 | import { StreamVideo } from '@stream-io/video-react-sdk'; 22 | import { useDiscordContext } from '@/contexts/DiscordContext'; 23 | import MyCall from '@/components/MyCall/MyCall'; 24 | import CustomChannelHeader from './MessageList/CustomChannelHeader/CustomChannelHeader'; 25 | 26 | export default function MyChat({ 27 | apiKey, 28 | user, 29 | token, 30 | }: { 31 | apiKey: string; 32 | user: User; 33 | token: string; 34 | }) { 35 | const chatClient = useClient({ 36 | apiKey, 37 | user, 38 | tokenOrProvider: token, 39 | }); 40 | const videoClient = useVideoClient({ 41 | apiKey, 42 | user, 43 | tokenOrProvider: token, 44 | }); 45 | const { callId } = useDiscordContext(); 46 | 47 | if (!chatClient) { 48 | return
Error, please try again later.
; 49 | } 50 | 51 | if (!videoClient) { 52 | return
Video Error, please try again later.
; 53 | } 54 | 55 | return ( 56 | 57 | 58 |
59 | 60 | 61 | {callId && } 62 | {!callId && ( 63 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | )} 77 |
78 |
79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /components/ServerList/CreateServerForm.tsx: -------------------------------------------------------------------------------- 1 | import { useDiscordContext } from '@/contexts/DiscordContext'; 2 | import { UserObject } from '@/model/UserObject'; 3 | import Link from 'next/link'; 4 | import { useSearchParams } from 'next/navigation'; 5 | import { useRouter } from 'next/navigation'; 6 | import { useCallback, useEffect, useRef, useState } from 'react'; 7 | import { useChatContext } from 'stream-chat-react'; 8 | import { useStreamVideoClient } from '@stream-io/video-react-sdk'; 9 | import { CloseMark } from '../ChannelList/Icons'; 10 | import UserRow from '../ChannelList/CreateChannelForm/UserRow'; 11 | 12 | type FormState = { 13 | serverName: string; 14 | serverImage: string; 15 | users: UserObject[]; 16 | }; 17 | 18 | const CreateServerForm = () => { 19 | // Check if we are shown 20 | const params = useSearchParams(); 21 | const showCreateServerForm = params.get('createServer'); 22 | const dialogRef = useRef(null); 23 | const router = useRouter(); 24 | 25 | // Data 26 | const { client } = useChatContext(); 27 | const videoClient = useStreamVideoClient(); 28 | const { createServer } = useDiscordContext(); 29 | const initialState: FormState = { 30 | serverName: '', 31 | serverImage: '', 32 | users: [], 33 | }; 34 | 35 | const [formData, setFormData] = useState(initialState); 36 | const [users, setUsers] = useState([]); 37 | 38 | const loadUsers = useCallback(async () => { 39 | const response = await client.queryUsers({}); 40 | const users: UserObject[] = response.users 41 | .filter((user) => user.role !== 'admin') 42 | .map((user) => { 43 | return { 44 | id: user.id, 45 | name: user.name ?? user.id, 46 | image: user.image as string, 47 | online: user.online, 48 | lastOnline: user.last_active, 49 | }; 50 | }); 51 | if (users) setUsers(users); 52 | }, [client]); 53 | 54 | useEffect(() => { 55 | if (showCreateServerForm && dialogRef.current) { 56 | dialogRef.current.showModal(); 57 | } else { 58 | dialogRef.current?.close(); 59 | } 60 | }, [showCreateServerForm]); 61 | 62 | useEffect(() => { 63 | loadUsers(); 64 | }, [loadUsers]); 65 | 66 | return ( 67 | 68 |
69 |

70 | Create new server 71 |

72 | 73 | 74 | 75 |
76 |
77 | 80 |
81 | # 82 | 88 | setFormData({ ...formData, serverName: e.target.value }) 89 | } 90 | required 91 | /> 92 |
93 | 96 |
97 | # 98 | 104 | setFormData({ ...formData, serverImage: e.target.value }) 105 | } 106 | required 107 | /> 108 |
109 |

Add Users

110 |
111 | {users.map((user) => ( 112 | 113 | ))} 114 |
115 |
116 |
117 | 118 | Cancel 119 | 120 | 130 |
131 |
132 | ); 133 | 134 | function buttonDisabled(): boolean { 135 | return ( 136 | !formData.serverName || 137 | !formData.serverImage || 138 | formData.users.length <= 1 139 | ); 140 | } 141 | 142 | function userChanged(user: UserObject, checked: boolean) { 143 | if (checked) { 144 | setFormData({ 145 | ...formData, 146 | users: [...formData.users, user], 147 | }); 148 | } else { 149 | setFormData({ 150 | ...formData, 151 | users: formData.users.filter((thisUser) => thisUser.id !== user.id), 152 | }); 153 | } 154 | } 155 | 156 | function createClicked() { 157 | if (!videoClient) { 158 | console.log('[CreateServerForm] Video client not available'); 159 | return; 160 | } 161 | createServer( 162 | client, 163 | videoClient, 164 | formData.serverName, 165 | formData.serverImage, 166 | formData.users.map((user) => user.id) 167 | ); 168 | setFormData(initialState); 169 | router.replace('/'); 170 | } 171 | }; 172 | export default CreateServerForm; 173 | -------------------------------------------------------------------------------- /components/ServerList/ServerList.tsx: -------------------------------------------------------------------------------- 1 | import { useChatContext } from 'stream-chat-react'; 2 | import Image from 'next/image'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | import { DiscordServer } from '@/app/page'; 5 | import { useDiscordContext } from '@/contexts/DiscordContext'; 6 | import CreateServerForm from './CreateServerForm'; 7 | import Link from 'next/link'; 8 | import { Channel } from 'stream-chat'; 9 | 10 | const ServerList = () => { 11 | const { client } = useChatContext(); 12 | const { server: activeServer, changeServer } = useDiscordContext(); 13 | const [serverList, setServerList] = useState([]); 14 | 15 | const loadServerList = useCallback(async (): Promise => { 16 | const channels = await client.queryChannels({ 17 | type: 'messaging', 18 | members: { $in: [client.userID as string] }, 19 | }); 20 | const serverSet: Set = new Set( 21 | channels 22 | .map((channel: Channel) => { 23 | return { 24 | name: (channel.data?.data?.server as string) ?? 'Unknown', 25 | image: channel.data?.data?.image, 26 | }; 27 | }) 28 | .filter((server: DiscordServer) => server.name !== 'Unknown') 29 | .filter( 30 | (server: DiscordServer, index, self) => 31 | index === 32 | self.findIndex((serverObject) => serverObject.name == server.name) 33 | ) 34 | ); 35 | const serverArray = Array.from(serverSet.values()); 36 | setServerList(serverArray); 37 | if (serverArray.length > 0) { 38 | changeServer(serverArray[0], client); 39 | } 40 | }, [client, changeServer]); 41 | 42 | useEffect(() => { 43 | loadServerList(); 44 | }, [loadServerList]); 45 | 46 | return ( 47 |
48 | 56 |
57 | {serverList.map((server) => { 58 | return ( 59 | 82 | ); 83 | })} 84 |
85 | 89 | + 90 | 91 | 92 |
93 | ); 94 | 95 | function checkIfUrl(path: string): Boolean { 96 | try { 97 | const _ = new URL(path); 98 | return true; 99 | } catch (_) { 100 | return false; 101 | } 102 | } 103 | }; 104 | 105 | export default ServerList; 106 | -------------------------------------------------------------------------------- /components/ServerList/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { UserObject } from '@/model/UserObject'; 3 | 4 | const UserCard = ({ user }: { user: UserObject }) => { 5 | return ( 6 | 41 | ); 42 | }; 43 | 44 | export default UserCard; 45 | -------------------------------------------------------------------------------- /contexts/DiscordContext.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { DiscordServer } from '@/app/page'; 4 | import { MemberRequest, StreamVideoClient } from '@stream-io/video-client'; 5 | import { createContext, useCallback, useContext, useState } from 'react'; 6 | import { Channel, ChannelFilters, StreamChat } from 'stream-chat'; 7 | import { DefaultStreamChatGenerics } from 'stream-chat-react'; 8 | import { v4 as uuid } from 'uuid'; 9 | 10 | type DiscordState = { 11 | server?: DiscordServer; 12 | callId: string | undefined; 13 | channelsByCategories: Map>>; 14 | changeServer: (server: DiscordServer | undefined, client: StreamChat) => void; 15 | createServer: ( 16 | client: StreamChat, 17 | videoClient: StreamVideoClient, 18 | name: string, 19 | imageUrl: string, 20 | userIds: string[] 21 | ) => void; 22 | createChannel: ( 23 | client: StreamChat, 24 | name: string, 25 | category: string, 26 | userIds: string[] 27 | ) => void; 28 | createCall: ( 29 | client: StreamVideoClient, 30 | server: DiscordServer, 31 | channelName: string, 32 | userIds: string[] 33 | ) => Promise; 34 | setCall: (callId: string | undefined) => void; 35 | }; 36 | 37 | const initialValue: DiscordState = { 38 | server: undefined, 39 | callId: undefined, 40 | channelsByCategories: new Map(), 41 | changeServer: () => {}, 42 | createServer: () => {}, 43 | createChannel: () => {}, 44 | createCall: async () => {}, 45 | setCall: () => {}, 46 | }; 47 | 48 | const DiscordContext = createContext(initialValue); 49 | 50 | export const DiscordContextProvider: any = ({ 51 | children, 52 | }: { 53 | children: React.ReactNode; 54 | }) => { 55 | const [myState, setMyState] = useState(initialValue); 56 | 57 | const changeServer = useCallback( 58 | async (server: DiscordServer | undefined, client: StreamChat) => { 59 | let filters: ChannelFilters = { 60 | type: 'messaging', 61 | members: { $in: [client.userID as string] }, 62 | }; 63 | if (!server) { 64 | filters.member_count = 2; 65 | } 66 | 67 | console.log( 68 | '[DiscordContext - loadServerList] Querying channels for ', 69 | client.userID 70 | ); 71 | const channels = await client.queryChannels(filters); 72 | const channelsByCategories = new Map< 73 | string, 74 | Array> 75 | >(); 76 | if (server) { 77 | const categories = new Set( 78 | channels 79 | .filter((channel) => { 80 | return channel.data?.data?.server === server.name; 81 | }) 82 | .map((channel) => { 83 | return channel.data?.data?.category; 84 | }) 85 | ); 86 | 87 | for (const category of Array.from(categories)) { 88 | channelsByCategories.set( 89 | category, 90 | channels.filter((channel) => { 91 | return ( 92 | channel.data?.data?.server === server.name && 93 | channel.data?.data?.category === category 94 | ); 95 | }) 96 | ); 97 | } 98 | } else { 99 | channelsByCategories.set('Direct Messages', channels); 100 | } 101 | setMyState((myState) => { 102 | return { ...myState, server, channelsByCategories }; 103 | }); 104 | }, 105 | [setMyState] 106 | ); 107 | 108 | const createCall = useCallback( 109 | async ( 110 | client: StreamVideoClient, 111 | server: DiscordServer, 112 | channelName: string, 113 | userIds: string[] 114 | ) => { 115 | const callId = uuid(); 116 | const audioCall = client.call('default', callId); 117 | const audioChannelMembers: MemberRequest[] = userIds.map((userId) => { 118 | return { 119 | user_id: userId, 120 | }; 121 | }); 122 | try { 123 | const createdAudioCall = await audioCall.create({ 124 | data: { 125 | custom: { 126 | // serverId: server?.id, 127 | serverName: server?.name, 128 | callName: channelName, 129 | }, 130 | members: audioChannelMembers, 131 | }, 132 | }); 133 | console.log( 134 | `[DiscordContext] Created Call with id: ${createdAudioCall.call.id}` 135 | ); 136 | } catch (err) { 137 | console.log(err); 138 | } 139 | }, 140 | [] 141 | ); 142 | 143 | const createServer = useCallback( 144 | async ( 145 | client: StreamChat, 146 | videoClient: StreamVideoClient, 147 | name: string, 148 | imageUrl: string, 149 | userIds: string[] 150 | ) => { 151 | const messagingChannel = client.channel('messaging', uuid(), { 152 | name: 'Welcome', 153 | members: userIds, 154 | data: { 155 | image: imageUrl, 156 | server: name, 157 | category: 'Text Channels', 158 | }, 159 | }); 160 | 161 | try { 162 | const response = await messagingChannel.create(); 163 | console.log('[DiscordContext - createServer] Response: ', response); 164 | if (myState.server) { 165 | await createCall( 166 | videoClient, 167 | myState.server, 168 | 'General Voice Channel', 169 | userIds 170 | ); 171 | } 172 | changeServer({ name, image: imageUrl }, client); 173 | } catch (err) { 174 | console.error(err); 175 | } 176 | }, 177 | [changeServer, createCall, myState.server] 178 | ); 179 | 180 | const createChannel = useCallback( 181 | async ( 182 | client: StreamChat, 183 | name: string, 184 | category: string, 185 | userIds: string[] 186 | ) => { 187 | if (client.userID) { 188 | const channel = client.channel('messaging', { 189 | name: name, 190 | members: userIds, 191 | data: { 192 | server: myState.server?.name, 193 | category: category, 194 | }, 195 | }); 196 | try { 197 | const response = await channel.create(); 198 | } catch (err) { 199 | console.log(err); 200 | } 201 | } 202 | }, 203 | [myState.server?.name] 204 | ); 205 | 206 | const setCall = useCallback( 207 | (callId: string | undefined) => { 208 | setMyState((myState) => { 209 | return { ...myState, callId }; 210 | }); 211 | }, 212 | [setMyState] 213 | ); 214 | 215 | const store: DiscordState = { 216 | server: myState.server, 217 | callId: myState.callId, 218 | channelsByCategories: myState.channelsByCategories, 219 | changeServer: changeServer, 220 | createServer: createServer, 221 | createChannel: createChannel, 222 | createCall: createCall, 223 | setCall: setCall, 224 | }; 225 | 226 | return ( 227 | {children} 228 | ); 229 | }; 230 | 231 | export const useDiscordContext = () => useContext(DiscordContext); 232 | -------------------------------------------------------------------------------- /hooks/useClient.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { StreamChat, TokenOrProvider, User } from 'stream-chat'; 3 | 4 | export type UseClientOptions = { 5 | apiKey: string; 6 | user: User; 7 | tokenOrProvider: TokenOrProvider; 8 | }; 9 | 10 | export const useClient = ({ 11 | apiKey, 12 | user, 13 | tokenOrProvider, 14 | }: UseClientOptions): StreamChat | undefined => { 15 | const [chatClient, setChatClient] = useState(); 16 | 17 | useEffect(() => { 18 | const client = new StreamChat(apiKey); 19 | // prevents application from setting stale client (user changed, for example) 20 | let didUserConnectInterrupt = false; 21 | 22 | const connectionPromise = client 23 | .connectUser(user, tokenOrProvider) 24 | .then(() => { 25 | if (!didUserConnectInterrupt) { 26 | setChatClient(client); 27 | } 28 | }); 29 | 30 | return () => { 31 | didUserConnectInterrupt = true; 32 | setChatClient(undefined); 33 | // wait for connection to finish before initiating closing sequence 34 | connectionPromise 35 | .then(() => client.disconnectUser()) 36 | .then(() => { 37 | console.log('connection closed'); 38 | }); 39 | }; 40 | // eslint-disable-next-line react-hooks/exhaustive-deps -- should re-run only if user.id changes 41 | }, [apiKey, user.id, tokenOrProvider]); 42 | 43 | return chatClient; 44 | }; 45 | -------------------------------------------------------------------------------- /hooks/useVideoClient.ts: -------------------------------------------------------------------------------- 1 | import { StreamVideoClient } from '@stream-io/video-client'; 2 | import { useEffect, useState } from 'react'; 3 | import { UseClientOptions } from './useClient'; 4 | 5 | export const useVideoClient = ({ 6 | apiKey, 7 | user, 8 | tokenOrProvider, 9 | }: UseClientOptions): StreamVideoClient | undefined => { 10 | const [videoClient, setVideoClient] = useState(); 11 | 12 | useEffect(() => { 13 | const streamVideoClient = new StreamVideoClient({ apiKey }); 14 | // prevents application from setting stale client (user changed, for example) 15 | let didUserConnectInterrupt = false; 16 | 17 | const videoConnectionPromise = streamVideoClient 18 | .connectUser(user, tokenOrProvider) 19 | .then(() => { 20 | if (!didUserConnectInterrupt) { 21 | setVideoClient(streamVideoClient); 22 | } 23 | }); 24 | 25 | return () => { 26 | didUserConnectInterrupt = true; 27 | setVideoClient(undefined); 28 | // wait for connection to finish before initiating closing sequence 29 | videoConnectionPromise 30 | .then(() => streamVideoClient.disconnectUser()) 31 | .then(() => { 32 | console.log('video connection closed'); 33 | }); 34 | }; 35 | // eslint-disable-next-line react-hooks/exhaustive-deps -- should re-run only if user.id changes 36 | }, [apiKey, user.id, tokenOrProvider]); 37 | 38 | return videoClient; 39 | }; 40 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware, clerkClient, redirectToSignIn } from '@clerk/nextjs'; 2 | import { redirect } from 'next/navigation'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | // See https://clerk.com/docs/references/nextjs/auth-middleware 6 | // for more information about configuring your Middleware 7 | 8 | export default authMiddleware({ 9 | // Allow signed out users to access the specified routes: 10 | // publicRoutes: ['/anyone-can-visit-this-route'], 11 | // Prevent the specified routes from accessing 12 | // authentication information: 13 | // ignoredRoutes: ['/no-auth-in-this-route'], 14 | 15 | async afterAuth(auth, request) { 16 | // if users are not authenticated 17 | if (!auth.userId && !auth.isPublicRoute) { 18 | return redirectToSignIn({ returnBackUrl: request.url }); 19 | } 20 | 21 | // If user has signed up, register on Stream backend 22 | if (auth.userId && !auth.user?.privateMetadata?.streamRegistered) { 23 | // return redirect('/register'); 24 | } else { 25 | console.log( 26 | '[Middleware] User already registered on Stream backend: ', 27 | auth.userId 28 | ); 29 | } 30 | 31 | return NextResponse.next(); 32 | }, 33 | }); 34 | 35 | export const config = { 36 | matcher: [ 37 | // Exclude files with a "." followed by an extension, which are typically static files. 38 | // Exclude files in the _next directory, which are Next.js internals. 39 | 40 | '/((?!.+\\.[\\w]+$|_next).*)', 41 | // Re-include any files in the api or trpc folders that might have an extension 42 | '/(api|trpc)(.*)', 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /model/UserObject.ts: -------------------------------------------------------------------------------- 1 | export type UserObject = { 2 | id: string; 3 | name: string; 4 | image?: string; 5 | online?: boolean; 6 | lastOnline?: string; 7 | }; 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'source.unsplash.com', 8 | }, 9 | { 10 | protocol: 'https', 11 | hostname: 'images.unsplash.com', 12 | }, 13 | { 14 | protocol: 'https', 15 | hostname: 'getstream.io', 16 | }, 17 | { 18 | protocol: 'https', 19 | hostname: 'thispersondoesnotexist.com', 20 | }, 21 | { 22 | protocol: 'https', 23 | hostname: 'cdn.discordapp.com', 24 | }, 25 | { 26 | protocol: 'https', 27 | hostname: 'static.wikia.nocookie.net', 28 | }, 29 | { 30 | protocol: 'https', 31 | hostname: 'starwars.fandom.com', 32 | }, 33 | ], 34 | }, 35 | }; 36 | 37 | module.exports = nextConfig; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-clone", 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 | "@clerk/backend": "^0.38.3", 13 | "@clerk/nextjs": "^4.29.9", 14 | "@stream-io/video-react-sdk": "^0.5.5", 15 | "@types/uuid": "^9.0.8", 16 | "next": "14.0.3", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "stream-chat": "^8.19.1", 20 | "stream-chat-react": "^11.11.0", 21 | "uuid": "^9.0.1" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20.11.24", 25 | "@types/react": "^18.2.63", 26 | "@types/react-dom": "^18.2.19", 27 | "autoprefixer": "^10.4.18", 28 | "eslint": "^8.57.0", 29 | "eslint-config-next": "14.0.3", 30 | "postcss": "^8.4.35", 31 | "tailwindcss": "^3.4.1", 32 | "typescript": "^5.3.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | discord: '#7289da', 13 | 'dark-discord': '#4752c4', 14 | 'dark-gray': '#dfe1e4', 15 | 'medium-gray': '#f0f1f3', 16 | 'light-gray': '#e8eaec', 17 | 'hover-gray': '#cdcfd3', 18 | 'composer-gray': 'hsl(210 calc( 1 * 11.1%) 92.9% / 1);', 19 | 'gray-normal': '#313338', 20 | }, 21 | }, 22 | }, 23 | plugins: [], 24 | }; 25 | export default config; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------