Build a Discord Clone Using Next.js and Tailwind: Channel List — Part Three

25 min read
Stefan B.
Stefan B.
Published February 2, 2024

Welcome to our series about building a Discord clone using Next.js and TailwindCSS. In the previous posts, we covered setting up the project and adding the server list. This one will tackle the channel list that will look like this:

We already mentioned different customization options for the Stream Chat SDK. In the previous piece about the server list, we needed to customize ourselves.

While building the channel list, we can use some of the built-in components to make it easier. Specifically, we’ll customize the ChannelList component using the List parameter.
This will automatically inject the component we hand to the List parameter and handle the properties we need to render the channels list.

We’ll still need to filter the channels by categories, but we will get the rest for free. We can then customize how we render the channels in our custom component.

Building the Channel List With Categories and Channels

First, we create a new folder called ChannelList, where we store all files for this article. Then, we create a new file in that directory called CustomChannelList.tsx. We add an empty component here for now to have a look at what the parameters for it look like:

tsx
1
2
3
4
5
const CustomChannelList: React.FC<ChannelListMessengerProps> = ( props: PropsWithChildren<ChannelListMessengerProps> ) => { return <div>List</div> };

We can see that we get handed a props parameter for the ChannelListMessengerProps. This is a powerful way to access all necessary data without using a custom context. The props type has several properties, including a loading state, an error variable, and more (the full list can be found here).

For our use case, we will use the loadedChannels property (which requires us to set a parameter later on, so keep that in mind). We can take this, combined with the server information we can retrieve from the DiscordContext, and split the channels into their respective categories.

Let’s first create a helper function to do that:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function splitChannelsIntoCategories( channels: Channel<DefaultStreamChatGenerics>[], server: DiscordServer | undefined ): Map<string, Array<Channel<DefaultStreamChatGenerics>>> { const channelsByCategories = new Map< string, Array<Channel<DefaultStreamChatGenerics>> >(); if (server) { const categories = new Set( channels .filter((channel) => { return channel.data?.data?.server === server.name; }) .map((channel) => { return channel.data?.data?.category; }) ); for (const category of Array.from(categories)) { channelsByCategories.set( category, channels.filter((channel) => { return ( channel.data?.data?.server === server.name && channel.data?.data?.category === category ); }) ); } } else { channelsByCategories.set('Direct Messages', channels); } return channelsByCategories; }

We create a Map with the category name as a key. If a server is set, we filter the channels by the category name. If not, we only want to show the direct messages and these channels.

For rendering the UI, there’s a semantic element in HTML for showing different categories with a disclosure indicator: the details element (MDN docs here) combined with the summary element (MDN docs here). However, the summary element limits customization options so we will opt for a custom-built solution.

We’ll iterate over all keys (being the category names) of the channelsByCategories element we created and want to show each channel. To keep the code clean, we’ll create a custom component for this in a second. But first, we think about the properties we need to hand to that component:

  1. category: the name of the category to render out and show as a title
  2. channels: each channel will be shown in its line
  3. serverName: while we don’t render this out, we need to know which server we’re on because when a user wants to add a new channel (which happens inside our component), we need that information

The code to render the list looks like this (we create the CategoryItem in the next step) and can be placed inside the CustomChannelList:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { server } = useDiscordContext(); const channelsByCategories = splitChannelsIntoCategories( props.loadedChannels || [], server ); return ( <div className='w-64 bg-medium-bg-gray h-full flex flex-col items-start'> <div className='w-full'> {Array.from(channelsByCategories.keys()).map((category, index) => ( <CategoryItem key={`${category}-${index}`} category={category} serverName={server?.name || 'Direct Messages'} channels={channelsByCategories.get(category) || []} /> ))} </div> </div> );

We use a custom color for the container (bg-medium-gray), so we must extend our theme in the tailwind.config.ts file to contain that color. While we’re at it, we’ll also add the other colors we need for this article.

This is how the file should look like in the end:

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { Config } from 'tailwindcss'; const config: Config = { content: [ './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: { colors: { discord: '#7289da', 'dark-discord': '#4752c4', 'dark-bg-gray': '#dfe1e4', 'medium-gray': '#f0f1f3', 'light-gray': '#e8eaec', 'hover-gray': '#cdcfd3', }, }, }, plugins: [], }; export default config;

Now, we can finally implement the CategoryItem. We create a new folder inside the ChannelList directory called CategoryItem and add a file called CategoryItem.tsx. The first thing we define is a type for the properties that will be handed to the component:

jsx
1
2
3
4
5
type CategoryItemProps = { category: string; channels: Channel<DefaultStreamChatGenerics>[]; serverName: string; };

We want to track whether a category is toggled open or closed. Using a state property for each CategoryItem instance works perfectly. So, we define an isOpen property using a useState hook.

Let’s first define the barebone of the component like this:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function CategoryItem({ category, serverName, channels, }: CategoryItemProps): JSX.Element { const [isOpen, setIsOpen] = useState(true); return ( <> <div className='flex items-center text-gray-500 mb-2 p-2'> {/* Toggle will go here */} </div> </> ); }

Now, two things are remaining:

  1. Add a UI element that toggles the visibility of the list of channels and has a plus icon to add a new channel to this category on the given server.
  2. Render the list of channels that are handed to the component.

We start with the toggle button. We want icons for this that we get from heroicons. Let’s create a new file in the ChannelList folder called Icons.tsx and paste the code for the icons here to have a solid separation:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type IconProps = { size?: number; color?: string; }; export function PlusIcon({ size = 5, color }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' strokeWidth={2} stroke='currentColor' className={`h-${size} w-${size} ${color ? color : ''}`} > <path strokeLinecap='round' strokeLinejoin='round' d='M12 4.5v15m7.5-7.5h-15' /> </svg> ); } export function ChevronRight({ size = 5, color }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' strokeWidth={2} stroke='currentColor' className={`h-${size} w-${size} ${color ? color : ''}`} > <path strokeLinecap='round' strokeLinejoin='round' d='m8.25 4.5 7.5 7.5-7.5 7.5' /> </svg> ); }

Now we can add the code for the toggle mechanism inside of CategoryItem (where we previously had the placeholder comment):

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<button className='flex w-full items-center justify-start' onClick={() => setIsOpen((currentValue) => !currentValue)} > <div className={`${ isOpen ? 'rotate-90' : '' } transition-all ease-in-out duration-200`} > <ChevronRight size={4} /> </div> <span className='inline-block uppercase text-sm font-bold px-2'> {category} </span> </button>

On the button click, we toggle the isOpen state property and rotate the icon to indicate that it’s open. We animate this to give it a nice touch.

To finish this row, we add the code to open the form to create a new channel on the server below the button:

tsx
1
2
3
4
5
6
<Link className='inline-block create-button' href={`/?createChannel=true&serverName=${serverName}&category=${category}`} > <PlusIcon size={4} /> </Link>

We will implement the form in the next chapter and focus on finishing the CategoryItem first. For that, we need to render the channels if the isOpen property is set to true.

After the div that contains the button and the link (but inside the `` fragment), add the following code:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
{isOpen && ( <div> {channels.map((channel) => { return ( <CustomChannelPreview key={channel.id} channel={channel} className='w-full' /> ); })} </div> )}

Note: We have not yet created the CustomChannelPreview element. We will do that in the next chapter, but I want to discuss this more, so stay tuned.

With that, the UI for the CustomChannelList is done. We still need to inject it into the built-in Stream component. We can do this in one line of code.

We head over to page.tsx, and inside our Home component, we currently use the ChannelList that we get for free from the Stream SDK. We can customize it using the List parameter, so we do just that. In addition, we also need to set the sendChannelsToList property since otherwise, the loadedChannels wouldn’t be handed to the CustomChannelList component.

Here is the code:

tsx
1
<ChannelList List={CustomChannelList} sendChannelsToList={true} />

With that, we can now switch between the servers and toggle the channels in the different categories on each server.

However, the preview for the channel in the list is not yet customized. Let’s explore how we can do this in the next chapter.

Building the Channel List Preview

Using the List property, we just added our CustomChannelList property to the ChannelList property. We could use another property on the ChannelList called Preview to show a custom preview with the channel's name and other options.

This is a perfectly viable option for many applications. It works by setting the Preview property like this: Preview={CustomChannelPreview}. Inside our CustomChannelList, we access a children array in the props, which we can render inside the DOM like this: {children}.

While this would generally work, we are not doing this here. The reason is that we want to render the channels list for each category separately. So, instead, we will create a custom component and hand the channel to it inside our CustomChannelList.

Let’s create a new file inside the Channel List folder and call it CustomChannelPreview.tsx.

The UI part is simple. We show a # and then the name of the channel. We add a little hover effect. We'll wrap it in a button since we want to react to clicks.

What is supposed to happen when the user taps a channel? We want to render the selected channel on the right side of our 3-pane-layout. Luckily, there’s another hook we can use for this from the Stream Chat SDK called useChatContext (documentation). From that, we can get a function called setActiveChannel that does that.

This is where the power of the Stream components comes into play. On our page.tsx we render the currently active channel using the Channel component. Using the setActiveChannel function from the ChatContext automatically updates this with the one the user just clicked.

We use a custom channel preview component that we need to create. Inside the ChannelList folder, add a new file called CustomChannelPreview.tsx and insert the following code:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const CustomChannelPreview = (props: ChannelPreviewUIComponentProps) => { const { channel } = props; const { setActiveChannel } = useChatContext(); return ( <div className={`flex items-center mx-2 ${ props.channel.countUnread() > 0 ? 'channel-container' : '' }`} > <button className='w-full flex items-center px-2 hover:bg-gray-200 rounded-md' onClick={() => setActiveChannel(channel)} > <span className='italic text-xl mr-2 text-gray-500'>#</span> <span className='text-sm'> {channel.data?.name || 'Channel Preview'} </span> </button> </div> ); }; export default CustomChannelPreview;

What are we doing here?

  • We have a container div that shows a small dot at the left in case there are unread messages (using the props.channel.countUnread() function from the SDK).
  • The button allows the user to click on the channel, and the setActiveChannel function from the chat context allows us to set this one as active in the message list (the right pane in our three-pane-layout).
  • We then show the channel name prefixed with a # sign.

For the unread indicator to work, we add the channel-container CSS class to the globals.css file:

css
1
2
3
4
5
6
7
8
.channel-container { @apply relative; } .channel-container::before { @apply block absolute h-2 w-3 -left-4 bg-gray-700 rounded-xl; content: ''; }

With that, the UI for showing the channel lists is done, and we can already try it and see it working with the selection of channels and rendering the different channels when we click on them.

Create a Form to Add Channels to a Server

There are many different ways we can use forms in React in general and Next.js specifically. We went over this process already in part two of this series, so we will only briefly touch upon the general principle and focus on the implementation itself.

The Link element we added in the CategoryItem will put the following properties into the URL:

  • createChannel: is set to true and will trigger the form to be rendered using a dialog element
  • serverName: is set to the server that is currently active, because we want to create the channel on the current server
  • category: while the user can change this, we want to pre-fill the category name in the form to the one where the create channel button was clicked

We can create a new folder inside the ChannelList directory and call it CreateChannelForm. We add a file called CreateChannelForm.tsx and can start with adding the code to extract the search parameters we just mentioned from the URL:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default function CreateChannelForm(): JSX.Element { // Extract the search params const params = useSearchParams(); const showCreateChannelForm = params.get('createChannel'); const category = params.get('category'); const dialogRef = useRef<HTMLDialogElement>(null); const router = useRouter(); useEffect(() => { if (showCreateChannelForm && dialogRef.current) { dialogRef.current.showModal(); } else { dialogRef.current?.close(); } }, [showCreateChannelForm]); return ( <dialog className='absolute py-16 px-20 z-10 space-y-8 rounded-xl serverDialog' ref={dialogRef} > <Link href='/' className='absolute right-8 top-8'> <CloseCircle /> </Link> <h2 className='text-3xl font-bold text-gray-600'>Create new server</h2> </dialog> );

We already added the functionality to show the dialog using a reference to it with the useRef hook.

Next, we add the form code itself. We will do that in a two-step process. We can easily ask for the channel name and allow the user to change the category name. However, selecting the users requires more work, so we’ll do that in the second step.

The form code itself is not complicated, but for it to work, we must first define the data format. Add this definition to the top of the file:

tsx
1
2
3
4
5
type FormState = { channelName: string; category: string; users: UserObject[]; };

Inside the CreateChannelForm component, we can create an initialState property and initialize a formData state variable. We’ll also already define an empty array of users (we’ll load those in the next step) that can be added to the channel:

tsx
1
2
3
4
5
6
7
const initialState: FormState = { channelName: '', category: category ?? '', users: [], }; const [formData, setFormData] = useState<FormState>(initialState); const [users, setUsers] = useState<UserObject[]>([]);

We have the data available, so let’s define the UI. Add the code for the form below the h2 we already created:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<form method='dialog' className='flex flex-col'> <label className='labelTitle' htmlFor='channelName'> Channel Name </label> <input type='text' id='channelName' name='channelName' value={formData.channelName} onChange={(e) => setFormData({ ...formData, channelName: e.target.value }) } /> <label className='labelTitle flex items-center justify-between' htmlFor='category' > Category </label> <input type='text' id='category' name='category' value={formData.category} onChange={(e) => setFormData({ ...formData, category: e.target.value }) } /> <button type='submit' disabled={buttonDisabled()} className={`bg-discord rounded p-3 text-white font-bold uppercase ${ buttonDisabled() ? 'opacity-50 cursor-not-allowed' : '' }`} onClick={createClicked} > Create </button> </form>

If you look closely, we use two functions we haven’t defined yet. The first one is the buttonDisabled() call. We only want to allow the create button to be clickable when we have the full data to create a new channel. That is a name, a category, and at least two users.

Add this function to the bottom of the component:

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
tsx
1
2
3
4
5
function buttonDisabled(): boolean { return ( !formData.channelName || !formData.category || formData.users.length <= 1 ); }

The second one we need to add is the createClicked function. Luckily, we prepared the createChannel function in the DiscordContext (see part 1), so it’s a simple API call. After that, we set the form data back to its initial state and replace the current route with the empty route (/), which automatically closes the dialog.

Add this function to the file:

tsx
1
2
3
4
5
6
7
8
9
10
function createClicked() { createChannel( client, formData.channelName, category || 'Category', formData.users.map((user) => user.id) ); setFormData(initialState); router.replace('/'); }

We won’t be able to click that since we don’t have users loaded yet.

Let’s take a step back and think about that for a second. We want users to see a list of other users to add to the channel. As this requires access to other users’ profiles, we don’t want to handle that client-side.

Luckily, both Next.js and Stream offer the perfect mechanisms for that. Using Route Handlers in Next.js, we can ensure the code is executed on the server with the right access. This is then automatically scoped to the server; nothing can escape client-side. The Stream Node SDK allows us to query users and return these in the API call.

Read the documentation on Router Handlers for more details. We’ll only show the implementation here. Inside the app folder, we add a folder api and another folder called users. Here, we will add a file called route.ts.

This works because we can define a GET request that will be executed server-side. We can access this route in our frontend component with a fetch request to /api/users. Here’s the code for our request:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export async function GET() { const serverClient = StreamChat.getInstance( '7cu55d72xtjs', process.env.STREAM_CHAT_SECRET ); const response = await serverClient.queryUsers({}); const data: UserObject[] = response.users .filter((user) => user.role !== 'admin') .map((user) => { return { id: user.id, name: user.name ?? user.id, image: user.image as string, online: user.online, lastOnline: user.last_active, }; }); return Response.json({ data }); }

This requires us to add the secret from the Stream dashboard into an environment file (.env.local). We can grab the secret inside of the dashboard and copy it to the file:

Now, we can return to the front end and make the call. Inside our CreateChannelForm component, we first define a function to load the users from the API we just defined (and wrap it in a useCallback to avoid unnecessary re-renders). Then, we call this when the component is mounted (using a useEffect hook).

Add this code inside the component:

tsx
1
2
3
4
5
6
7
8
9
const loadUsers = useCallback(async () => { const response = await fetch('/api/users'); const data = (await response.json())?.data as UserObject[]; if (data) setUsers(data); }, []); useEffect(() => { loadUsers(); }, [loadUsers]);

The last step is to show the users in our form. We want to add a checkbox for each and the information about them. Let’s create a separate component for this. Add a new file inside the CreateChannelForm folder, call it UserRow.tsx and add the following code to it:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
export default function UserRow({ user, userChanged, }: { user: UserObject; userChanged: (user: UserObject, checked: boolean) => void; }): JSX.Element { return ( <div className='flex items-center justify-start w-full space-x-4 my-2'> <input id={user.id} type='checkbox' name={user.id} className='w-4 h-4 mb-0' onChange={(event) => { userChanged(user, event.target.checked); }} ></input> <label className='w-full flex items-center space-x-6' htmlFor='users'> {user.image && ( <Image src={user.image} width={40} height={40} alt={user.name} className='w-8 h-8 rounded-full' /> )} {!user.image && <PersonIcon />} <p> <span className='block text-gray-600'>{user.name}</span> {user.lastOnline && ( <span className='text-sm text-gray-400'> Last online: {user.lastOnline.split('T')[0]} </span> )} </p> </label> </div> ); }

It is mainly code to show a checkbox and users’ names, images, and the last time they were online. Note that we inject a userChanged function that takes the user itself and whether they are checked or not. We’ll use that in the main component to add the user to the formData variable.

Open the CreateChannelForm again and first define the function userChanged at the bottom of the file:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
function userChanged(user: UserObject, checked: boolean) { if (checked) { setFormData({ ...formData, users: [...formData.users, user], }); } else { setFormData({ ...formData, users: formData.users.filter((thisUser) => thisUser.id !== user.id), }); } }

If checked is true we add the user to the user list in the formData property; if not, we filter it out and remove it from the list.

The last step is to render the users inside of our form. Add this code before the submit button of the form:

tsx
1
2
3
4
<h2 className='mb-2 labelTitle'>Add Users</h2> {users.map((user) => ( <UserRow user={user} userChanged={userChanged} key={user.id} /> ))}

We’re not showing the CreateChannelForm at the moment. Change that by opening up the CustomChannelList and adding it below the div container that holds the CategoryItems. The return function of the component should look like this now:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
return ( <div className='w-64 bg-medium-gray h-full flex flex-col items-start'> <div className='w-full'> {Array.from(channelsByCategories.keys()).map((category, index) => ( <CategoryItem key={`${category}-${index}`} category={category} serverName={server?.name || 'Direct Messages'} channels={channelsByCategories.get(category) || []} /> ))} </div> <CreateChannelForm /> </div> );

Awesome, we can now add channels into different categories and get a list of users to add to them. This required using many different functionalities around the SDKs. Great work!

Next, we can focus on adding the remaining UI, specifically the top and bottom bars.

Adding the Top Bar

The top bar will have two purposes. First, it shows the server's name, and second, it expands into a menu on click and shows different server options. Let’s take a look at the result first:

Let’s jump into the implementation. First, we have a few icons that we need to prepare. The arrow on the right of the menu transforms into a close button, and each menu option has an icon.

We use the great services of heroicons again and first add these icons to the Icons.tsx file so that it’s easier for us to integrate them. Add this to the existing code in the file:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
export function ChevronDown({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' strokeWidth={1.5} stroke='currentColor' className={className} > <path strokeLinecap='round' strokeLinejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5' /> </svg> ); } export function Boost({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M10.5 3.798v5.02a3 3 0 0 1-.879 2.121l-2.377 2.377a9.845 9.845 0 0 1 5.091 1.013 8.315 8.315 0 0 0 5.713.636l.285-.071-3.954-3.955a3 3 0 0 1-.879-2.121v-5.02a23.614 23.614 0 0 0-3 0Zm4.5.138a.75.75 0 0 0 .093-1.495A24.837 24.837 0 0 0 12 2.25a25.048 25.048 0 0 0-3.093.191A.75.75 0 0 0 9 3.936v4.882a1.5 1.5 0 0 1-.44 1.06l-6.293 6.294c-1.62 1.621-.903 4.475 1.471 4.88 2.686.46 5.447.698 8.262.698 2.816 0 5.576-.239 8.262-.697 2.373-.406 3.092-3.26 1.47-4.881L15.44 9.879A1.5 1.5 0 0 1 15 8.818V3.936Z' clipRule='evenodd' /> </svg> ); } export function PersonAdd({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path d='M5.25 6.375a4.125 4.125 0 1 1 8.25 0 4.125 4.125 0 0 1-8.25 0ZM2.25 19.125a7.125 7.125 0 0 1 14.25 0v.003l-.001.119a.75.75 0 0 1-.363.63 13.067 13.067 0 0 1-6.761 1.873c-2.472 0-4.786-.684-6.76-1.873a.75.75 0 0 1-.364-.63l-.001-.122ZM18.75 7.5a.75.75 0 0 0-1.5 0v2.25H15a.75.75 0 0 0 0 1.5h2.25v2.25a.75.75 0 0 0 1.5 0v-2.25H21a.75.75 0 0 0 0-1.5h-2.25V7.5Z' /> </svg> ); } export function Gear({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 0 0-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 0 0-2.282.819l-.922 1.597a1.875 1.875 0 0 0 .432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 0 0 0 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 0 0-.432 2.385l.922 1.597a1.875 1.875 0 0 0 2.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 0 0 2.28-.819l.923-1.597a1.875 1.875 0 0 0-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 0 0 0-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 0 0-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 0 0-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 0 0-1.85-1.567h-1.843ZM12 15.75a3.75 3.75 0 1 0 0-7.5 3.75 3.75 0 0 0 0 7.5Z' clipRule='evenodd' /> </svg> ); } export function PlusCircle({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25ZM12.75 9a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V15a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V9Z' clipRule='evenodd' /> </svg> ); } export function FolderPlus({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M19.5 21a3 3 0 0 0 3-3V9a3 3 0 0 0-3-3h-5.379a.75.75 0 0 1-.53-.22L11.47 3.66A2.25 2.25 0 0 0 9.879 3H4.5a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h15Zm-6.75-10.5a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25v2.25a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V10.5Z' clipRule='evenodd' /> </svg> ); } export function FaceSmile({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-2.625 6c-.54 0-.828.419-.936.634a1.96 1.96 0 0 0-.189.866c0 .298.059.605.189.866.108.215.395.634.936.634.54 0 .828-.419.936-.634.13-.26.189-.568.189-.866 0-.298-.059-.605-.189-.866-.108-.215-.395-.634-.936-.634Zm4.314.634c.108-.215.395-.634.936-.634.54 0 .828.419.936.634.13.26.189.568.189.866 0 .298-.059.605-.189.866-.108.215-.395.634-.936.634-.54 0-.828-.419-.936-.634a1.96 1.96 0 0 1-.189-.866c0-.298.059-.605.189-.866Zm2.023 6.828a.75.75 0 1 0-1.06-1.06 3.75 3.75 0 0 1-5.304 0 .75.75 0 0 0-1.06 1.06 5.25 5.25 0 0 0 7.424 0Z' clipRule='evenodd' /> </svg> ); } export function Bell({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M5.25 9a6.75 6.75 0 0 1 13.5 0v.75c0 2.123.8 4.057 2.118 5.52a.75.75 0 0 1-.297 1.206c-1.544.57-3.16.99-4.831 1.243a3.75 3.75 0 1 1-7.48 0 24.585 24.585 0 0 1-4.831-1.244.75.75 0 0 1-.298-1.205A8.217 8.217 0 0 0 5.25 9.75V9Zm4.502 8.9a2.25 2.25 0 1 0 4.496 0 25.057 25.057 0 0 1-4.496 0Z' clipRule='evenodd' /> </svg> ); } export function Shield({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M12.516 2.17a.75.75 0 0 0-1.032 0 11.209 11.209 0 0 1-7.877 3.08.75.75 0 0 0-.722.515A12.74 12.74 0 0 0 2.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 0 0 .374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 0 0-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08Zm3.094 8.016a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z' clipRule='evenodd' /> </svg> ); } export function Pen({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path d='M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z' /> </svg> ); } export function SpeakerMuted({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path d='M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 0 0 1.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06ZM17.78 9.22a.75.75 0 1 0-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L20.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-1.72 1.72-1.72-1.72Z' /> </svg> ); } export function LeaveServer({ className = 'h-5 w-5 text-gray-500', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path fillRule='evenodd' d='M7.5 3.75A1.5 1.5 0 0 0 6 5.25v13.5a1.5 1.5 0 0 0 1.5 1.5h6a1.5 1.5 0 0 0 1.5-1.5V15a.75.75 0 0 1 1.5 0v3.75a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3V5.25a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3V9A.75.75 0 0 1 15 9V5.25a1.5 1.5 0 0 0-1.5-1.5h-6Zm10.72 4.72a.75.75 0 0 1 1.06 0l3 3a.75.75 0 0 1 0 1.06l-3 3a.75.75 0 1 1-1.06-1.06l1.72-1.72H9a.75.75 0 0 1 0-1.5h10.94l-1.72-1.72a.75.75 0 0 1 0-1.06Z' clipRule='evenodd' /> </svg> ); }

Now, let’s create a new folder inside the ChannelList directory and call it TopBar. Inside it, we add a file called ChannelListTopBar.tsx.

All we need as input for the component is the serverName, and we can then show this using an h2. We’ll embed this in a button that will toggle a state variable (menuOpen) and consist of the title and an icon. The icon will either be a chevron pointing downwards (if menuOpen is false) or a close icon (if menuOpen is true).

Here’s the code for now:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default function ChannelListTopBar({ serverName, }: { serverName: string; }): JSX.Element { const [menuOpen, setMenuOpen] = useState(false); return ( <div className='w-full relative'> <button className={`flex w-full items-center justify-between p-4 border-b-2 ${ menuOpen ? 'bg-gray-300' : '' } border-gray-300 hover:bg-gray-300`} onClick={() => setMenuOpen((currentValue) => !currentValue)} > <h2 className='text-lg font-bold text-gray-700'>{serverName}</h2> {menuOpen && <CloseIcon />} {!menuOpen && <ChevronDown />} </button> </div> ); }

Note that we wrap the button in a div container because we must also add the menu. This container has the relative property applied because we want to render the menu relative to this container.

For the menu, we’ll take three steps:

  1. create the data for the menu items
  2. create a UI component to visualize them
  3. hook it up in the ChannelListTopBar

We start by creating a new file inside the TopBar folder and call it menuItems.tsx. We define a type ListRowElement and then an array of items, with a name, an icon, and whether it should have a bottomBorder (to distinguish different menu groups).

Also, there is one element (Invite People) that is pink and one that is red (Leave Server), so we add this to the logic as well.

Here’s the code:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export type ListRowElement = { name: string; icon: JSX.Element; bottomBorder?: boolean; purple?: boolean; red?: boolean; }; export const menuItems: ListRowElement[] = [ { name: 'Server Boost', icon: <Boost />, bottomBorder: true }, { name: 'Invite People', icon: <PersonAdd />, bottomBorder: false, purple: true, }, { name: 'Server Settings', icon: <Gear />, bottomBorder: false }, { name: 'Create Channel', icon: <PlusCircle />, bottomBorder: false }, { name: 'Create Category', icon: <FolderPlus />, bottomBorder: false }, { name: 'App Directory', icon: <FaceSmile />, bottomBorder: true }, { name: 'Notification Settings', icon: <Bell />, bottomBorder: false }, { name: 'Privacy Settings', icon: <Shield />, bottomBorder: true }, { name: 'Edit Server Profile', icon: <Pen />, bottomBorder: false }, { name: 'Hide Muted Channels', icon: <SpeakerMuted />, bottomBorder: true }, { name: 'Leave Server', icon: <LeaveServer />, bottomBorder: false, red: true, }, ];

Next, we want to create a separate component for the UI of each menu item, so let’s create a file called ChannelListMenuRow.tsx inside the TopBar folder. The UI is straightforward and consists of the name, the icon, a hover effect, and a bottom border (if the element has one).

The code looks like this:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function ChannelListMenuRow({ name, icon, bottomBorder = true, purple = false, red = false, }: ListRowElement): JSX.Element { return ( <> <p className={`flex items-center justify-between p-2 cursor-pointer text-gray-500 ${ purple ? 'text-dark-discord' : '' } ${red ? 'text-red-500' : ''} rounded-md hover:bg-dark-discord ${ red ? 'hover:bg-red-500' : '' } hover:text-white transition-colors ease-in-out duration-200`} > <span className='text-sm font-medium '>{name}</span> {icon} </p> {bottomBorder && <div className='my-1 mx-2 h-px bg-gray-300' />} </> ); }

The last step is to include this into the ChannelListTopBar if the menuOpen property is true. Add this after the button but inside the div container:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{menuOpen && ( <div className='absolute w-full p-2'> <div className='w-full bg-white p-2 shadow-lg rounded-md'> {menuItems.map((option) => ( <button key={option.name} className='w-full' onClick={() => setMenuOpen(false)} > <ChannelListMenuRow {...option} /> </button> ))} </div> </div> )}

When we save and run the project, we see…nothing. We need to add the ChannelListTopBar to the CustomChannelList, right before the div we added as a container for the CategoryItem components:

tsx
1
2
3
4
5
6
... <div className='w-64 bg-medium-gray h-full flex flex-col items-start'> <ChannelListTopBar serverName={server?.name || 'Direct Messages'} /> <div className='w-full'> ...

The result looks great. We don’t have the functionality hooked up, but that would break the scope of this article because we still need to implement the footer with the user’s profile image and name.

Let’s do this next.

The User Bar as the Bottom Bar

The last piece of UI is the bottom bar. It will show the currently logged-in user and microphone, speaker, and settings icons.

Note: we will implement the video calling and sharing functionality in a later post in this series, presumably part 5 (watch out on our socials when it drops) but we will prepare the UI nonetheless.

Here is what the result will look like:

First, we will grab the icons (microphone and speaker) from heroicons and add them to the bottom of Icons.tsx :

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export function Mic({ className = 'w-full h-full' }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path d='M8.25 4.5a3.75 3.75 0 117.5 0v8.25a3.75 3.75 0 11-7.5 0V4.5z' /> <path d='M6 10.5a.75.75 0 01.75.75v1.5a5.25 5.25 0 1010.5 0v-1.5a.75.75 0 011.5 0v1.5a6.751 6.751 0 01-6 6.709v2.291h3a.75.75 0 010 1.5h-7.5a.75.75 0 010-1.5h3v-2.291a6.751 6.751 0 01-6-6.709v-1.5A.75.75 0 016 10.5z' /> </svg> ); } export function Speaker({ className = 'w-full h-full', }: IconProps): JSX.Element { return ( <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor' className={className} > <path d='M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z' /> <path d='M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z' /> </svg> ); }

Next, we create a new folder called BottomBar and add a file ChannelListBottomBar.tsx.

To retrieve the user information, we can grab the client from the useChatContext hook. It has a user property that we use to get the image, name, and online information.

Also, we add two properties to track the active state of the microphone and audio (micActive and audioActive).

Then, we render the user image (if they have one), name, and online status. We also add buttons for the microphone, speaker, and settings. We configure a slight hover effect, and if the icons are inactive, we turn them red.

Here is the full code for the ChannelListBottomBar component:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
export default function ChannelListBottomBar(): JSX.Element { const { client } = useChatContext(); const [micActive, setMicActive] = useState(false); const [audioActive, setAudioActive] = useState(false); return ( <div className='mt-auto p-2 bg-light-gray w-full flex items-center justify-between'> <button className='flex items-center space-x-2 p-1 pr-2 rounded-md hover:bg-hover-gray '> {client.user?.image && ( <div className={`relative ${client.user?.online ? 'online-icon' : ''}`} > <Image src={client.user?.image ?? 'https://thispersondoesnotexist.com/'} alt='User image' width={36} height={36} className='rounded-full' /> </div> )} <p className='flex flex-col items-start space-y-1'> <span className='block text-gray-700 text-sm font-medium -mb-1.5 tracking-tight'> {client.user?.name} </span> <span className='text-xs text-gray-500 inline-block'> {client.user?.online ? 'Online' : 'Offline'} </span> </p> </button> <button className={`w-7 h-7 p-1 flex items-center justify-center relative rounded-lg hover:bg-gray-300 transition-all duration-100 ease-in-out ${ !micActive ? 'inactive-icon text-red-400' : 'text-gray-700' }`} onClick={() => setMicActive((currentValue) => !currentValue)} > <Mic /> </button> <button className={`w-7 h-7 p-1 flex items-center justify-center relative rounded-lg hover:bg-gray-300 transition-all duration-100 ease-in-out ${ !audioActive ? 'inactive-icon text-red-400' : 'text-gray-700' }`} onClick={() => setAudioActive((currentValue) => !currentValue)} > <Speaker /> </button> <button className='w-7 h-7 p-1 flex items-center justify-center relative rounded-md hover:bg-gray-300 transition-all duration-100 ease-in-out text-gray-700'> <Gear className='w-full h-full' /> </button> </div> ); }

We perform two small additions. The first is an online indicator if the user is online, which is overlayed at the bottom right of the user image. We can use a pseudo-element (::after).

Second, we want to cross the icons with a red line if they are inactive. For that, we added the inactive-icon CSS class to them, which we now also add to the globals.css:

css
1
2
3
4
5
6
7
8
9
.online-icon::after { @apply block absolute h-4 w-4 bg-green-600 bottom-0 right-0 rounded-full border-2 border-gray-200; content: ''; } .inactive-icon::after { @apply block absolute h-full w-0.5 bg-red-400 rotate-45 rounded-xl m-2; content: ''; }

Lastly, we must add the ChannelListBottomBar to the CustomChannelList, just below the CreateChannelForm. The final code for the CustomChannelList component looks like this:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const CustomChannelList: React.FC<ChannelListMessengerProps> = ( props: PropsWithChildren<ChannelListMessengerProps> ) => { const { server } = useDiscordContext(); const channelsByCategories = splitChannelsIntoCategories( props.loadedChannels || [], server ); return ( <div className='w-64 bg-medium-gray h-full flex flex-col items-start'> <ChannelListTopBar serverName={server?.name || 'Direct Messages'} /> <div className='w-full'> {Array.from(channelsByCategories.keys()).map((category, index) => ( <CategoryItem key={`${category}-${index}`} category={category} serverName={server?.name || 'Direct Messages'} channels={channelsByCategories.get(category) || []} /> ))} </div> <CreateChannelForm /> <UserBar /> </div> ); };

With this last addition, we are done with implementing the bottom bar and - in fact - the entire channel list.

Summary

We put a lot together in this blog post. The first part of this series covered the setup process, and in part two, we started doing a lot of custom UI for the server list.

In this one, we learned how to leverage the Stream Chat SDK to make it easy for us to focus on building out our custom UI components while using built-in components.

We did this using the ChannelList and injecting our CustomChannelList component inside it. This brings a lot of advantages, and we get a lot of functionality for free.

We added a top bar to the channel list that allows us to expand and show some server options. We then created a beautiful toggle to show channels split into categories. Finally, we showed the user information and prepared the UI for adding voice and video calls later.

Although this article was long, we hope you enjoyed it and learned something from it. Let us know if you have questions or ideas. You can find the project on GitHub.

The next part will cover customizing the message list to conform to the discord style. Stay tuned.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->