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
:
123function 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:
12345678910111213141516171819import { 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
:
12345<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
:
123456789101112import { 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:
123456<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:
12345{ 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:
123456789101112131415161718192021222324252627export 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:
1234567<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.
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.
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
:
123456789function 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:
1234567891011121314151617181920const { 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:
12345678<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:
12345678910111213141516171819202122export 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:
12345<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
:
1234567891011121314151617181920212223<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:
12345678910111213141516171819202122232425262728export 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:
123456789101112131415export 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:
123456789101112131415{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.