Build a Discord Clone Using Next.js and Tailwind: Message List — Part Four

12 min read
Stefan B.
Stefan B.
Published June 3, 2024

Welcome to part four of our series about building a Discord clone using Next.js and TailwindCSS. In the previous parts, we covered a lot of customization of the Stream Chat SDK and its UI components.

After setting up the project, we started with the server list, a fully customized component. We built upon the SDK but defined all the logic ourselves.

Then, the channel list was mostly based on the provided UI components from the SDK, but we tailored it to our specific customization needs.

In this part, we focus on the message list. This is the part of the UI where we display the channel's name, allow users to scroll through all the messages, and allow them to compose new messages themselves.

To achieve the message list's intended look and feel, we will modify five components, going over them individually. However, the SDK offers many customization options (e.g. via Theming).

The interesting thing about this part is that we can focus on building out the custom components since the integration into the SDK is a one-liner. The Channel object that we added to our MyChat component before takes a wide range of arguments.

By simply providing our custom component as a parameter to the Channel object, the SDK is smart enough to replace the built-in UI component with ours. We will see that this includes handing properties to the elements without any further setup.

The complexity of the customization will increase over the course of the article, so let’s start with our first component.

DateSeparator

The DateSeparator will be our first custom component, so let’s set up a folder structure where we can put all custom components. Within the components folder, create a new folder named MessageList.

In the MessageList folder, create a folder called CustomDateSeparator. Finally, inside this folder, create a file named CustomDateSeparator.tsx.

In this file, we will export a new component that adopts DateSeparotorProps. This type comes from the Stream SDK and is used by the built-in DateSeparator component. One of the neat features of the injection mechanism we’re using is that we get these parameters handed down for free. It’s important for us that customized components are handled the same way as built-in ones, allowing for maximum freedom.

We first extract the date element inside the component from the props we get handed. Then we add a convenience function to format that date to a string:

tsx
1
2
3
function formatDate(date: Date): string { return `${date.toLocaleDateString('en-US', { dateStyle: 'long' })}`; }

Then, we add the UI part, which has a div working as a container for a span that displays the formatted date. We add a bottom border for the container. We achieve an overlay effect of the date over that border through a combination of relative (for the div) and absolute (for the span) positioning.

Here is the full code for the CustomDateSeparator component:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { DateSeparatorProps } from 'stream-chat-react'; export default function CustomDateSeparator( props: DateSeparatorProps ): JSX.Element { const { date } = props; function formatDate(date: Date): string { return `${date.toLocaleDateString('en-US', { dateStyle: 'long' })}`; } return ( <div className='border-b-2 relative flex items-center justify-center my-6'> <span className='absolute left-auto right-auto text-xs font-semibold text-gray-500 bg-white px-2'> {formatDate(date)} </span> </div> ); }

Now that we’ve created the custom component, the only thing left is to tell the application to use it instead of the built-in one. This is a one-liner inside MyChat.tsx when we initialize the Channel.

Add the following parameter to the Channel:

tsx
1
2
3
4
5
<Channel DateSeparator={CustomDateSeparator} > /** ... **/ </Channel>

By handing in the CustomDateSeparator here, the Stream SDK automatically hands the parameters upon initialization. That’s why we can use the DateSeparatorProps type for our custom component.

With the SDK's modular nature, replacing components becomes natural without manually retrieving and inputting parameters into the custom views we’re building.

ChannelHeader

The next customization involves the channel header. First, we create another folder inside the MessageList folder called CustomChannelHeader. We insert a file inside named CustomChannelHeader.tsx. UI-wise, we need to achieve a very simple thing. Looking at Discord, we need to prefix each channel's name with a hashmark, and this is exactly what we will do.

To achieve this, we need access to the channel’s name. To do this, we can use a hook called useChannelStateContext. This gives us access to the channel and, inside of that, the name.

Now, the question is: how does the component know which channel it is displayed in? Depending on the app, there can be multiple channels. This is, again, a powerful feature of the injection mechanism. Adding the CustomChannelHeader to the Channel property will provide all children elements with the necessary hooks to access the active (currently displayed) channel data.

The UI code is rather trivial. Again, it uses a div container and two span elements, one for the hashmark and one for the channel name. With some Tailwind styling, we align them and use a flex layout to display them in a row.

Here is the full code for the CustomChannelHeader:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
import { useChannelStateContext } from 'stream-chat-react'; export default function CustomChannelHeader(): JSX.Element { const { channel } = useChannelStateContext(); const { name } = channel?.data || {}; return ( <div className='flex items-center space-x-3 p-3 border-b-2 border-b-gray-200'> <span className='text-3xl text-gray-500'>#</span> <span className='font-bold lowercase'>{name}</span> </div> ); }

Inside MyChat.tsx we once again add a parameter to the Channel element, this time for the HeaderComponent parameter, and inject our custom channel header there:

tsx
1
2
3
4
5
6
<Channel DateSeparator={CustomDateSeparator} HeaderComponent={CustomChannelHeader} > /** ... **/ </Channel>

Custom Emoji Reactions

A common feature in messaging apps is emoji reactions to messages. These come built into the SDK. We get a set of pre-selected emojis that users can use to react to messages.

We can customize this in multiple ways, but we want to focus on a simple change: we want to put in a custom set of emojis that we want to provide the user with for reactions. This time, we’ll implement the change first and discuss the implications and possibilities afterward.

We define a folder named CustomReactions in the MessageList folder. Inside the folder, create a file named customMessageReactions.tsx. This file will contain our custom reactions implementation.

Instead of starting with a UI component, we create a set of options we want to show. The format of one element of the array can best be shown with an example:

tsx
1
2
3
4
5
{ type: 'runner', Component: () => <>🏃🏼</>, name: 'Runner' }

First, we define a unique type that identifies each element. Then the Component defines the UI element of the emoji itself. It is a JSX element, so we are not restricted to what we want to display here. Last, we define a name property (which is even optional).

For the full code in the customMessageReactions.tsx file, we create an array of these objects with a custom selection of emojis:

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 const customMessageReactions = [ { type: 'runner', Component: () => <>🏃🏼</>, name: 'Runner', }, { type: 'sun', Component: () => <>🌞</>, name: 'Sun', }, { type: 'star', Component: () => <>🤩</>, name: 'Star', }, { type: 'confetti', Component: () => <>🎉</>, name: 'Confetti', }, { type: 'howdy', Component: () => <>🤠</>, name: 'Howdy', }, ];

Then, inside our MyChat.tsx file we can configure the Channel object to use our custom message reactions:

tsx
1
2
3
4
5
6
7
<Channel DateSeparator={CustomDateSeparator} HeaderComponent={CustomChannelHeader} reactionOptions={customMessageReactions} > /** ... **/ </Channel>

This customization is once again different from the ones before. We’re not changing a UI element itself but keeping the container the same and simply changing its contents, in our case, the emojis.

The SDK's modular structure allows us to have full control of what we display here. We can show one element, five, or as many as we like (though the layout might not look great at some point).

Also, we’re not limited to showing a simple emoji here. Taking a look at the source code (which we can, since it’s open-source) reveals that the type is a generic JSX.Component. This means we can build up any HTML element and inject it here.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

We demonstrated this by simply replacing the existing emojis with new ones, but the possibilities are endless.

Custom Messages

The messages inside the message list come with a blue-tinted bubble. In Discord, however, they look different. They don’t have a background color but show the name of the person sending the message at the top with the time next to it. The message itself is shown below, with the user's avatar displayed on the left.

Screenshot of a message element.

We start the implementation—as usual—by creating a new folder inside the MessageList folder, this time naming it CustomMessage. We add a file inside called CustomMessage.tsx.

To show the message, we first need to know which message to show. We use the same technique we used for the CustomChannelHeader. The useChannelStateContext hook gave us information about the current channel, inferring it from the parent context it was placed in.

The useMessageContext does the same thing for the message. Since the element will be a child element of the Channel, the SDK provides the context in which the CustomMessage component appears. We can extract the message and all the relevant properties from the hook.

We want to display the date the message was last updated (which, in most cases, will be the same as when it was sent). For this, we define a function called formatDate:

tsx
1
2
3
4
5
6
7
8
9
function formatDate(date: Date | string): string { if (typeof date === 'string') { return date; } return `${date.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short', })}`; }

The reason the parameter is of type Date | string is that the message property updated_at is of exactly this type as well.

The UI code for the message contains the text to show for the user’s name (extracted from the message.user?.name property) and the message text (from the message.text property). To show the user's image, we can use a component from the Stream SDK called Avatar (which once again shows the power of the modular architecture).

Here’s the code for the CustomMessage property so far:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { message } = useMessageContext(); return ( <div className='flex relative space-x-2 p-2 rounded-md transition-colors ease-in-out duration-200 hover:bg-gray-100' > <Avatar image={message.user?.image} size={40} shape='rounded' /> <div> <div className='space-x-2'> <span className='font-semibold text-sm text-black'> {message.user?.name} </span> {message.updated_at && ( <span className='text-xs text-gray-600'> {formatDate(message.updated_at)} </span> )} </div> <p className='text-sm text-gray-700'>{message.text}</p> </div> </div>

Now, we can replace the built-in message component with our custom one by going to MyChat.tsx and changing the Channel to accept a new value for its Message property:

tsx
1
2
3
4
5
6
7
8
<Channel DateSeparator={CustomDateSeparator} HeaderComponent={CustomChannelHeader} reactionOptions={customMessageReactions} Message={CustomMessage} > /** ... **/ </Channel>

But we’re not finished here. If you noticed, before changing the Message parameter, multiple properties appeared whenever we hovered over the message element. Because we completely replaced it, this is gone.

We want to fix this. Specifically, we want to show a few icons when hovering over the message, just like in the Discord app. This includes the emoji picker, which we want to toggle. We’ll also add other icons, but we'll leave it up to you to fill it with functionality. For us, it's mainly important to demonstrate the technique.

First, we build up the UI for our custom reactions. It will display three icons we take from heroicons.com and store them inside the Icons.We created the tsx file in the channel list post (see full code here).

We create a new file inside the CustomMessage folder and name it MessageOptions.tsx. It receives one parameter we want to use to toggle the visibility of the emoji reactions on our message element. We will add that to the CustomMessage.tsx component afterward.

Here’s the full code for the MessageOptions component:

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 MessageOptions({ showEmojiReactions, }: { showEmojiReactions: Dispatch<SetStateAction<boolean>>; }): JSX.Element { return ( <div className='absolute flex items-center -top-4 right-2 rounded-md bg-gray-50 border-2 border-gray-200'> <button className='p-1 transition-colors duration-200 ease-in-out hover:bg-gray-200' onClick={() => showEmojiReactions((currentValue) => !currentValue)} > <Emoji className='w-6 h-6' /> </button> <button className='p-1 transition-colors duration-200 ease-in-out hover:bg-gray-200'> <ArrowUturnLeft className='w-6 h-6' /> </button> <button className='p-1 transition-colors duration-200 ease-in-out hover:bg-gray-200'> <Thread className='w-6 h-6' /> </button> </div> ); }

Inside CustomMessage, we add two properties: one to show these message options that we just defined, called showOptions, and another to toggle whether to show the emoji reactions, called showReactions.

We want to show the message options when hovering over the message, so we add code to the container div for the onMouseEnter and onMouseLeave parameters:

tsx
1
2
3
4
5
<div onMouseEnter={() => setShowOptions(true)} onMouseLeave={() => setShowOptions(false)} className='/* ... */' >

Then, for the div inside the container that contains the text elements, we add the code for both the MessageOptions and a ReactionSelector that we conditionally show when the showOptions and showReactions parameters are true, respectively.

After showing the text, we also add the ReactionsList element. Both the ReactionSelector and the ReactionsList are container elements from the Stream SDK. We can use them while still keeping our customMessageReactions configuration intact.

Here’s the code for inside the root div of the CustomMessage:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<Avatar image={message.user?.image} size={40} shape='rounded' /> <div> {showOptions && ( <MessageOptions showEmojiReactions={setShowReactions} /> )} {showReactions && ( <div className='absolute'> <ReactionSelector /> </div> )} <div className='space-x-2'> <span className='font-semibold text-sm text-black'> {message.user?.name} </span> {message.updated_at && ( <span className='text-xs text-gray-600'> {formatDate(message.updated_at)} </span> )} </div> <p className='text-sm text-gray-700'>{message.text}</p> <ReactionsList /> </div>

With that, we added completely custom functionality to the message component while still being able to reuse the components from the SDK that we wanted.

Message Composer

The final component we will create is a custom message composer. Once again, we will not cover every aspect of functionality, but the goal is to demonstrate how to do it. We're happy to see if you want to go the extra mile and add more to it.

We want to demonstrate how to copy the style of the Discord message composer. We add multiple icons and provide the functionality to open up a menu upon clicking on one of them. We’ll re-use code from a component we created for the CustomChannelList.

We create a folder named MessageComposer in MessageList and add a file called MessageComposer.tsx inside. The first thing we do is define the composer's UI, adding the menu code later. Once again, we’ll use icons from the Icons file.

By using the useChatContext hook we can retrieve access to the channel data. The rest of the code is UI customization using Tailwind. 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
export default function MessageComposer(): JSX.Element { const [plusMenuOpen, setPlusMenuOpen] = useState(false); const { channel } = useChatContext(); const [message, setMessage] = useState(''); return ( <div className='flex mx-6 my-6 px-4 py-1 bg-composer-gray items-center justify-center space-x-4 rounded-md text-gray-600 relative'> <button onClick={() => setPlusMenuOpen((menuOpen) => !menuOpen)}> <PlusCircle className='w-8 h-8 hover:text-gray-800' /> </button> <input className='border-transparent bg-transparent outline-none text-sm font-semibold m-0 text-gray-normal' type='text' value={message} onChange={(e) => setMessage(e.target.value)} placeholder={`Message #${channel?.data?.name || 'general'}`} /> <Present className='w-8 h-8 hover:text-gray-800' /> <GIF className='w-8 h-8 hover:text-gray-800' /> <Emoji className='w-8 h-8 hover:text-gray-800' /> <SendButton sendMessage={() => { channel?.sendMessage({ text: message }); setMessage(''); }} /> </div> ); }

We only want to discuss the last element, the SendButton from the Stream SDK. It allows us to reuse the component. But we need to add the functionality to send a message ourselves with its sendMessage parameter. Luckily, the channel object has a sendMessage function, which makes it straightforward.

To show elements when clicking on the PlusCircle button, we first define an array of items to show. We add a file called plusItems.tsx and fill it with the following code:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const plusItems: ListRowElement[] = [ { name: 'Upload a File', icon: <FolderPlus />, bottomBorder: false, reverseOrder: true, }, { name: 'Create Thread', icon: <Thread />, bottomBorder: false, reverseOrder: true, }, { name: 'Use Apps', icon: <Apps />, bottomBorder: false, reverseOrder: true }, ];

The types we use here are mainly taken from the code we wrote in part 3 of the series. Take a look at that again to learn more. We’ll also re-use the code for the ChannelListMenuRow to display the options.
For that, inside MessageComposer we can add the following code before the input element:

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{plusMenuOpen && ( <div className='absolute p-2 z-10 -left-6 bottom-12'> <div className='bg-white p-2 shadow-lg rounded-md w-40 flex flex-col'> {plusItems.map((option) => ( <button key={option.name} className='' onClick={() => setPlusMenuOpen(false)} > <ChannelListMenuRow {...option} /> </button> ))} </div> </div> )}

With that, we have fully customized the message composer. We added a menu that pops up when clicking, and users can enter and send messages. Here’s a demo of how this looks in the end:

Summary

In the last part of our chat customization series for the Discord clone, we learned about many more customization options in the Stream Chat SDK.

We could replace full components with our CustomDateSeparator by providing a new parameter for the Channel object. We have re-used parts of the UI from the SDK and combined it with our own full-customized code.

Also, we learned about different ways to retrieve the necessary data. This can happen through the SDK automatically injecting parameters to a component (in the example of the DateSeparatorProps). It can also be done using custom hooks that can inflect the necessary data and provide it to us.

The final result looks similar to the Discord app, and we’re happy we could achieve that with a decent amount of effort. This ends the customization part of the chat functionality, but stay tuned for the last part of the series, where we’ll add voice calls and video calling to the project.

We hope you enjoyed it and learned something from it. Let us know if you have questions or ideas. The full project is on GitHub.

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