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 |
25 | );
26 | }
27 |
28 | export function CloseIcon({
29 | className = 'h-5 w-5 text-gray-500',
30 | }: IconProps): JSX.Element {
31 | return (
32 |
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 |
67 | );
68 | }
69 |
70 | export function PersonIcon({ className = 'h-8 w-8' }: IconProps): JSX.Element {
71 | return (
72 |
86 | );
87 | }
88 |
89 | export function ChevronRight({
90 | className = 'h-5 w-5 text-gray-500',
91 | }: IconProps): JSX.Element {
92 | return (
93 |
107 | );
108 | }
109 |
110 | export function ChevronDown({
111 | className = 'h-5 w-5 text-gray-500',
112 | }: IconProps): JSX.Element {
113 | return (
114 |
128 | );
129 | }
130 |
131 | export function Bell({ className = 'h-5 w-5' }: IconProps): JSX.Element {
132 | return (
133 |
145 | );
146 | }
147 |
148 | export function Boost({ className = 'h-5 w-5' }: IconProps): JSX.Element {
149 | return (
150 |
162 | );
163 | }
164 |
165 | export function FaceSmile({ className = 'h-5 w-5' }: IconProps): JSX.Element {
166 | return (
167 |
179 | );
180 | }
181 |
182 | export function FolderPlus({ className = 'h-5 w-5' }: IconProps): JSX.Element {
183 | return (
184 |
196 | );
197 | }
198 |
199 | export function Gear({ className = 'h-5 w-5' }: IconProps): JSX.Element {
200 | return (
201 |
213 | );
214 | }
215 |
216 | export function LeaveServer({ className = 'h-5 w-5' }: IconProps): JSX.Element {
217 | return (
218 |
230 | );
231 | }
232 |
233 | export function Pen({ className = 'h-5 w-5' }: IconProps): JSX.Element {
234 | return (
235 |
243 | );
244 | }
245 |
246 | export function PersonAdd({ className = 'h-5 w-5' }: IconProps): JSX.Element {
247 | return (
248 |
256 | );
257 | }
258 |
259 | export function PlusCircle({ className = 'h-5 w-5' }: IconProps): JSX.Element {
260 | return (
261 |
273 | );
274 | }
275 |
276 | export function Shield({ className = 'h-5 w-5' }: IconProps): JSX.Element {
277 | return (
278 |
290 | );
291 | }
292 |
293 | export function SpeakerMuted({
294 | className = 'h-5 w-5',
295 | }: IconProps): JSX.Element {
296 | return (
297 |
305 | );
306 | }
307 |
308 | export function Mic({ className = 'w-full h-full' }: IconProps): JSX.Element {
309 | return (
310 |
319 | );
320 | }
321 |
322 | export function Speaker({
323 | className = 'w-full h-full',
324 | }: IconProps): JSX.Element {
325 | return (
326 |
335 | );
336 | }
337 |
338 | export function Present({
339 | className = 'w-full h-full',
340 | }: IconProps): JSX.Element {
341 | return (
342 |
350 | );
351 | }
352 |
353 | export function GIF({ className = 'w-full h-full' }: IconProps): JSX.Element {
354 | return (
355 |
367 | );
368 | }
369 |
370 | export function Emoji({ className = 'w-full h-full' }: IconProps): JSX.Element {
371 | return (
372 |
384 | );
385 | }
386 |
387 | export function Thread({ className = 'w-5 h-5' }: IconProps): JSX.Element {
388 | return (
389 |
401 | );
402 | }
403 |
404 | export function Apps({ className = 'w-5 h-5' }: IconProps): JSX.Element {
405 | return (
406 |
414 | );
415 | }
416 |
417 | export function ArrowUturnLeft({
418 | className = 'w-5 h-5',
419 | }: IconProps): JSX.Element {
420 | return (
421 |
435 | );
436 | }
437 |
438 | export function CloseMark({ className = 'w-5 h-5' }: IconProps): JSX.Element {
439 | return (
440 |
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 |
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 |
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 |
--------------------------------------------------------------------------------