├── .eslintrc.json
├── app
├── favicon.ico
├── register
│ └── page.tsx
├── api
│ ├── token
│ │ └── route.ts
│ ├── users
│ │ └── route.ts
│ └── register-user
│ │ └── route.ts
├── layout.tsx
├── globals.css
└── page.tsx
├── postcss.config.js
├── model
└── UserObject.ts
├── components
├── ChannelList
│ ├── CategoryItem
│ │ ├── CategoryItem.css
│ │ └── CategoryItem.tsx
│ ├── CustomChannelPreview.tsx
│ ├── TopBar
│ │ ├── ChannelListMenuRow.tsx
│ │ ├── menuItems.tsx
│ │ └── ChannelListTopBar.tsx
│ ├── CustomChannelList.tsx
│ ├── CreateChannelForm
│ │ ├── UserRow.tsx
│ │ └── CreateChannelForm.tsx
│ ├── CallList
│ │ └── CallList.tsx
│ ├── BottomBar
│ │ └── ChannelListBottomBar.tsx
│ └── Icons.tsx
├── MessageList
│ ├── CustomChannelHeader
│ │ └── CustomChannelHeader.tsx
│ ├── CustomReactions
│ │ └── CustomReactionsSelector.tsx
│ ├── MessageComposer
│ │ ├── plusItems.tsx
│ │ └── MessageComposer.tsx
│ ├── CustomDateSeparator
│ │ └── CustomDateSeparator.tsx
│ └── CustomMessage
│ │ ├── MessageOptions.tsx
│ │ └── CustomMessage.tsx
├── MyCall
│ ├── CallLayout.tsx
│ └── MyCall.tsx
├── ServerList
│ ├── UserCard.tsx
│ ├── ServerList.tsx
│ └── CreateServerForm.tsx
└── MyChat.tsx
├── .gitignore
├── public
├── vercel.svg
├── discord.svg
└── next.svg
├── tsconfig.json
├── tailwind.config.ts
├── assets
├── discord-black.svg
└── discord-white.svg
├── next.config.js
├── package.json
├── hooks
├── useClient.ts
└── useVideoClient.ts
├── middleware.ts
├── README.md
└── contexts
└── DiscordContext.tsx
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GetStream/discord-clone-nextjs/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/model/UserObject.ts:
--------------------------------------------------------------------------------
1 | export type UserObject = {
2 | id: string;
3 | name: string;
4 | image?: string;
5 | online?: boolean;
6 | lastOnline?: string;
7 | };
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/assets/discord-black.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/discord.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/discord-white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | setActiveChannel(channel)}
18 | >
19 | #
20 |
21 | {channel.data?.name || 'Channel Preview'}
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default CustomChannelPreview;
29 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
showEmojiReactions((currentValue) => !currentValue)}
14 | >
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
24 | {user.image && (
25 |
32 | )}
33 | {!user.image && }
34 |
35 | {user.name}
36 | {user.lastOnline && (
37 |
38 | Last online: {user.lastOnline.split('T')[0]}
39 |
40 | )}
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
7 | {user.image && (
8 |
15 | )}
16 | {!user.image && (
17 |
25 |
30 |
31 | )}
32 |
33 | {user.name}
34 | {user.lastOnline && (
35 |
36 | Last online: {user.lastOnline.split('T')[0]}
37 |
38 | )}
39 |
40 |
41 | );
42 | };
43 |
44 | export default UserCard;
45 |
--------------------------------------------------------------------------------
/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 |
setMenuOpen((currentValue) => !currentValue)}
20 | >
21 | {serverName}
22 | {menuOpen && }
23 | {!menuOpen && }
24 |
25 |
26 | {menuOpen && (
27 |
28 |
29 | {menuItems.map((option) => (
30 | setMenuOpen(false)}
34 | >
35 |
36 |
37 | ))}
38 |
39 |
40 | )}
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
setIsOpen((currentValue) => !currentValue)}
28 | >
29 |
34 |
35 |
36 |
37 | {category}
38 |
39 |
40 |
44 |
45 |
46 |
47 | {isOpen && (
48 |
49 | {channels.map((channel) => {
50 | return (
51 |
56 | );
57 | })}
58 |
59 | )}
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
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 |
--------------------------------------------------------------------------------
/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 |
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/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 |
setPlusMenuOpen((menuOpen) => !menuOpen)}>
19 |
20 |
21 | {plusMenuOpen && (
22 |
23 |
24 | {plusItems.map((option) => (
25 | setPlusMenuOpen(false)}
29 | >
30 |
31 |
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/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/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 |
setIsOpen((currentValue) => !currentValue)}
37 | >
38 |
43 |
44 |
45 |
46 | Voice Channels
47 |
48 |
49 |
53 |
54 |
55 |
56 | {isOpen && (
57 |
58 | {calls.map((call) => (
59 | {
63 | setCall(call.id);
64 | }}
65 | >
66 |
67 |
70 | {call.state.custom.callName || 'Channel Preview'}
71 |
72 |
73 | ))}
74 |
75 | )}
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/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 |
setMenuOpen((currentValue) => !currentValue)}
21 | >
22 | {client.user?.image && (
23 |
26 |
33 |
34 | )}
35 |
36 |
37 | {client.user?.name}
38 |
39 |
40 | {client.user?.online ? 'Online' : 'Offline'}
41 |
42 |
43 |
44 |
setMicActive((currentValue) => !currentValue)}
49 | >
50 |
51 |
52 |
setAudioActive((currentValue) => !currentValue)}
57 | >
58 |
59 |
60 |
61 |
62 |
63 | {menuOpen && (
64 |
signOut()}
67 | >
68 | }
71 | bottomBorder={false}
72 | red
73 | />
74 |
75 | )}
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
changeServer(undefined, client)}
53 | >
54 |
55 |
56 |
57 | {serverList.map((server) => {
58 | return (
59 | {
65 | changeServer(server, client);
66 | }}
67 | >
68 | {server.image && checkIfUrl(server.image) ? (
69 |
76 | ) : (
77 |
78 | {server.name.charAt(0)}
79 |
80 | )}
81 |
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/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 |
116 |
117 |
118 | Cancel
119 |
120 |
128 | Create Server
129 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
165 |
166 |
167 | Cancel
168 |
169 |
177 | Create Channel
178 |
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/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 |
--------------------------------------------------------------------------------