In part one of this series, we built the basics of our Slack clone by setting up user authentication, workspace creation, and designing a responsive layout for our workspace hub.In this second part, we’ll bring our Slack clone to life by adding real-time messaging with Steam React Chat SDK. We’ll add features like rich text, file sharing, images, and emoji reactions.
By the end of this part, users will be able to communicate with each other, making our app a functional chat platform.
Check out the live demo and GitHub repository to see the code and try it out for yourself.
Let’s get started!
Adding More Channels To Your Workspace
Currently, users can only have one channel in a workspace, which is the channel added during the workspace creation process. Before adding the messaging feature to our app, let’s enable users to create additional channels within a workspace.
To add more channels, we’ll create a pop-up modal that appears when users click an ‘Add a channel‘ button in the sidebar.
Creating the Channel API Route
First, we need an API route to handle channel creation. Create a /channels/create directory inside the existing /api/workspaces/[workspaceId] directory, then add a route.ts file with the following code:
import { NextResponse } from ‘next/server’;
import { auth, currentUser } from ‘@clerk/nextjs/server’;
import { generateChannelId } from ‘@/lib/utils’;
import prisma from ‘@/lib/prisma’;
export async function POST(
request: Request,
{ params }: { params: Promise<{ workspaceId: string }> }
) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json(
{ error: ‘Authentication required’ },
{ status: 401 }
);
}
const workspaceId = (await params).workspaceId;
if (!workspaceId || Array.isArray(workspaceId)) {
return NextResponse.json(
{ error: ‘Invalid workspace ID’ },
{ status: 400 }
);
}
try {
const user = await currentUser();
const userId = user!.id;
const body = await request.json();
const { name, description } = body;
if (!name || typeof name !== ‘string’ || name.trim() === ”) {
return NextResponse.json(
{ error: ‘Channel name is required’ },
{ status: 400 }
);
}
const membership = await prisma.membership.findUnique({
where: {
userId_workspaceId: {
userId,
workspaceId,
},
},
});
if (!membership) {
return NextResponse.json(
{ error: ‘Access denied: Not a member of the workspace’ },
{ status: 403 }
);
}
if (membership.role !== ‘admin’) {
return NextResponse.json(
{ error: ‘Access denied: Insufficient permissions’ },
{ status: 403 }
);
}
const existingChannel = await prisma.channel.findFirst({
where: {
name,
workspaceId,
},
});
if (existingChannel) {
return NextResponse.json(
{
error: ‘A channel with this name already exists in the workspace’,
},
{ status: 400 }
);
}
const newChannel = await prisma.channel.create({
data: {
id: generateChannelId(),
name,
description,
workspaceId,
},
});
return NextResponse.json(
{
message: ‘Channel created successfully’,
channel: newChannel,
},
{ status: 201 }
);
} catch (error) {
console.error(‘Error creating channel:’, error);
return NextResponse.json(
{ error: ‘Internal server error’ },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
In the code above:
- Authentication and Validation: We check if the user is authenticated and if they belong to the workspace.
- Permission Check: Only users with an ‘admin‘ role can create new channels.
- Duplicate Channel Check: We ensure that no other channel in the workspace has the same name.
- Channel Creation: If all checks pass, the channel is created and saved in the database.
Creating the Add Channel Modal
Next, let’s create a modal for adding new channels. In the components directory, create a file called AddChannelModal.tsx with the following code:
import { FormEvent, useContext, useMemo, useState } from ‘react’;
import { useRouter } from ‘next/navigation’;
import { AppContext } from ‘../app/client/layout’;
import Modal from ‘./Modal’;
import Spinner from ‘./Spinner’;
import TextField from ‘./TextField’;
interface AddChannelModalProps {
open: boolean;
onClose: () => void;
}
const AddChannelModal = ({ open, onClose }: AddChannelModalProps) => {
const router = useRouter();
const { setChannel, workspace, setWorkspace } = useContext(AppContext);
const [channelName, setChannelName] = useState(”);
const [channelDescription, setChannelDescription] = useState(”);
const [loading, setLoading] = useState(false);
const channelNameRegex = useMemo(() => {
const channelNames = workspace.channels.map((channel) => channel.name);
return `^(?!${channelNames.join(‘|’)}).+$`;
}, [workspace.channels]);
const createChannel = async (e: FormEvent) => {
const regex = new RegExp(channelNameRegex);
if (channelName && regex.test(channelName)) {
e.stopPropagation();
try {
setLoading(true);
const response = await fetch(
`/api/workspaces/${workspace.id}/channels/create`,
{
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify({
name: channelName.trim(),
description: channelDescription.trim(),
}),
}
);
const result = await response.json();
if (response.ok) {
const { channel } = result;
setWorkspace({
…workspace,
channels: […workspace.channels, { …channel }],
});
setChannel(channel);
setLoading(false);
closeModal();
router.push(`/client/${workspace.id}/${channel.id}`);
} else {
alert(`Error: ${result.error}`);
}
} catch (error) {
console.error(‘Error creating workspace:’, error);
alert(‘An unexpected error occurred.’);
} finally {
setLoading(false);
}
}
};
const closeModal = () => {
setChannelName(”);
setChannelDescription(”);
onClose();
};
if (!open) return null;
return (
<Modal
open={open}
onClose={closeModal}
loading={loading}
title=”Create a channel”
>
<form
onSubmit={createChannel}
action={() => {}}
className=”flex flex-col gap-6″
>
<TextField
name=”channelName”
label=”Channel name”
placeholder=”e.g. plan-budget”
value={channelName}
onChange={(e) =>
setChannelName(e.target.value.toLowerCase().replace(/s/g, ‘-‘))
}
pattern={channelNameRegex}
title=”That name is already taken by another channel in this workspace”
maxLength={80}
required
/>
<TextField
name=”channelDescription”
label={
<span>
Channel description{‘ ‘}
<span className=”text-[#9a9b9e] ml-0.5″>(optional)</span>
</span>
}
placeholder=”Add a description”
value={channelDescription}
onChange={(e) => setChannelDescription(e.target.value)}
multiline={5}
maxLength={250}
/>
“w-full flex items-center justify-end gap-3″>
type=”submit”
onClick={createChannel}
className=”order-2 flex items-center justify-center min-w-[80px] h-[36px] px-3 pb-[1px] text-[15px] border border-[#00553d] bg-[#00553d] hover:shadow-[0_1px_4px_#0000004d] hover:bg-blend-lighten hover:bg-[linear-gradient(#d8f5e914,#d8f5e914)] font-bold select-none text-white rounded-lg”
disabled={loading}
>
{loading ? : ‘Save’}
“min-w-[80px] h-[36px] px-3 pb-[1px] text-[15px] border border-[#797c8180] font-bold select-none text-white rounded-lg”
disabled={loading}
>
Cancel
</form>
</Modal>
);
};
export default AddChannelModal;
Let’s break down some of the component’s key features:
- We use the channelNameRegex regular expression to ensure that each channel name is unique within the workspace by comparing it against existing channel names.
- Loading State: We use the loading state to show a loading spinner (<Spinner />) while the channel creation is ongoing.
- Navigation to New Channel: After successfully creating a channel, we redirect users to the new channel page. The modal is also closed by resetting the input fields and calling the onClose function.
Next, let’s add the AddChannelModal to the Sidebar.tsx file:
…
import AddChannelModal from ‘./AddChannelModal’;
import Plus from ‘./icons/Plus’;
…
const Sidebar = ({ layoutWidth }: SidebarProps) => {
…
const [isModalOpen, setIsModalOpen] = useState(false);
…
const openCreateChannelModal = () => {
setIsModalOpen(true);
};
const onModalClose = () => {
setIsModalOpen(false);
};
const isWorkspaceOwner = workspace?.ownerId === user?.id;
return (
“sidebar”
…
>
{!loading && (
…
“w-full flex flex-col”>
…
{isWorkspaceOwner && (
“Add a channel”
onClick={openCreateChannelModal}
/>
)}
{}
…
<AddChannelModal open={isModalOpen} onClose={onModalClose} />
</>
)}
</div>
);
};
export default Sidebar;
In Sidebar.tsx, we add a useState hook to manage the modal’s open state, and an “Add Channel“ button that shows the modal if the current user is the workspace owner. This button is placed below the channel list for easy access.
With this setup, users can now create new channels to help organize conversations within the workspace.
Building the Chat Interface
Now that users can create multiple channels, let’s start working on our main chat interface. First, we’ll be building the loading state for our chat UI, then the main chat interface, and finally, we’ll customize different aspects of the chat, like the message input, date separator, and more.
Creating a Channel Loading Indicator
To let users know the channel chat is loading, we will create a loading indicator that provides a visual cue while fetching data. Stream already provides a default loading UI, but we want a custom one to match our application’s design.
Navigate to the components directory and create a new file called ChannelLoading.tsx with the following code:
const ChannelLoading = () => {
return (
“flex flex-col pt-14”>
“relative flex animate-pulse py-2 pl-5 pr-10”>
“flex shrink-0 mr-2”>
“w-fit h-fit inline-flex”>
“w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block”>
</div>
</div>
“flex-1 min-w-0 w-[426px]”>
“flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2”>
“w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]”>
</div>
</div>
“relative flex animate-pulse py-2 pl-5 pr-10”>
“flex shrink-0 mr-2”>
“w-fit h-fit inline-flex”>
“w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block”>
</div>
</div>
“flex-1 min-w-0 w-[426px]”>
“flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2”>
“w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]”>
</div>
</div>
“relative flex animate-pulse py-2 pl-5 pr-10”>
“flex shrink-0 mr-2”>
“w-fit h-fit inline-flex”>
“w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block”>
</div>
</div>
“flex-1 min-w-0 w-[426px]”>
“flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2”>
“w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]”>
</div>
</div>
“relative flex py-2 pl-5 pr-10”>
“flex shrink-0 mr-2 animate-pulse”>
“w-fit h-fit inline-flex”>
“w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block”>
</div>
</div>
“flex-1 min-w-0 w-[426px] animate-pulse”>
“flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2”>
“w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]”>
</div>
</div>
“absolute bottom-0 w-[98%] h-[134px] flex py-2 pl-5 pr-10”>
“w-full h-full bg-[#232529] rounded-lg border border-[#565856]”>
</div>
</div>
);
};
export default ChannelLoading;
The component shows a skeleton screen, which gives users a visual hint that content is loading.
Adding the Channel Chat
Next, let’s build the main chat interface so users can send messages and see their conversation history.
Go to the components folder, create a new file named ChannelChat.tsx, and add the following code:
import { createPortal } from ‘react-dom’;
import { Channel as ChannelType } from ‘stream-chat’;
import {
Channel,
DefaultStreamChatGenerics,
MessageInput,
MessageList,
Window,
} from ‘stream-chat-react’;
import ChannelLoading from ‘./ChannelLoading’;
interface ChannelChatProps {
channel: ChannelType<DefaultStreamChatGenerics>;
}
const ChannelChat = ({ channel }: ChannelChatProps) => {
const inputContainer = document.getElementById(‘message-input’);
return (
“w-full h-full”>
{inputContainer &&
createPortal(
,
inputContainer
)}
);
};
export default ChannelChat;
The ChannelChat component accepts the channel data as a prop and uses the Channel component from stream-chat-react to manage chat sessions. Here are its key components:
- MessageList: This displays the conversation history within the current channel.
- MessageInput: This component allows users to type and send messages. The MessageInput is rendered using React Portals, which helps position the input field in a different part of the DOM to match the layout we want for our Slack clone.
- Loading Indicator: The Channel component also accepts our custom ChannelLoading component as a prop to override the default loading UI.
Integrating the Channel Chat Component
Next, we need to integrate the ChannelChat component into our channel page. Go to the /client/[workspaceId]/[channelId]/page.tsx file and update it as follows:
…
import ChannelChat from ‘@/components/ChannelChat’;
import ChannelLoading from ‘@/components/ChannelLoading’;
…
const Channel = ({ params }: ChannelProps) => {
…
return (
{}
…
{}
…
{}
“…”>
{}
“…”>
“…”>
“absolute h-full inset-[0_-50px_0_0] overflow-y-scroll overflow-x-hidden z-[2]”>
{}
{channelLoading && }
{!channelLoading && }
</div>
</div>
</div>
{}
…
</div>
</div>
);
};
export default Channel;
In this update:
- We check if the channel is still loading using the channelLoading state. If it is, we display the ChannelLoading component.
- Once the channel data is loaded, we display the ChannelChat component, which provides the main chat interface for users to interact with.
Finally, let’s add some styling to customize the look of our chat UI. Navigate to the app directory and update the globals.css file with the following code:
…
@layer components {
…
.client ::selection {
background: #7d7e81;
}
.channel .str-chat {
background: transparent;
}
.channel .str-chat__list {
background: #1a1d21;
padding: 15px 0;
}
.channel .str-chat__empty-channel {
background: #1a1d21;
}
.channel .str-chat__li,
.channel .str-chat__message-text {
font-family: Lato, Arial, sans-serif;
}
.channel .str-chat__list .str-chat__message-list-scroll {
padding: 0;
}
.channel .str-chat__list .str-chat__message-list-scroll .str-chat__li {
padding-inline: 0;
margin-inline: 0;
}
.channel .str-chat__message-text {
color: var(–primary);
font-size: 14.8px;
line-height: 1.46668;
}
.channel .str-chat__main-panel-inner.str-chat__message-list-main-panel {
height: calc(100% – 8px);
}
.channel .str-chat__list-notifications {
display: none;
}
.channel
.str-chat__unread-messages-separator-wrapper
.str-chat__unread-messages-separator {
background: #ffffff21;
color: #ffffff;
user-select: none;
}
.channel .str-chat__unread-messages-notification {
display: none;
}
}
And with that, users can now send messages. However, the current UI still looks far from what we want, so in the following sections, we’ll add custom components to enhance it.
Adding a Custom Date Separator
To help users follow conversations more easily, we’ll add custom date separators that indicate when messages are from different days.
Go to the components folder, create a new file called DateSeparator.tsx, and add the following code:
import React from ‘react’;
import { DateSeparatorProps } from ‘stream-chat-react’;
import CaretDown from ‘./icons/CaretDown’;
import { getOrdinalSuffix } from ‘../lib/utils’;
const DateSeparator = ({ date }: DateSeparatorProps) => {
function formatDate(date: Date) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(today.getDate() – 1);
const isToday = date >= today;
const isYesterday = date >= yesterday && date < today;
if (isToday) {
return ‘Today’;
} else if (isYesterday) {
return ‘Yesterday’;
} else {
const options: Intl.DateTimeFormatOptions = {
weekday: ‘long’,
month: ‘long’,
day: ‘numeric’,
};
const day = date.getDate();
const suffix = getOrdinalSuffix(day);
return `${date.toLocaleDateString(‘en-US’, options)}${suffix}`;
}
}
return (
“relative font-lato w-full flex items-center justify-center h-10”>
“select-none bg-[#1a1d21] text-channel-gray font-bold flex pr-2 pl-4 z-20 items-center h-7 rounded-[24px] text-[13px] leading-[27px] border border-[#797c814d]”>
{formatDate(date)}
“ml-1”>
“var(–channel-gray)” size={13} />
“absolute h-[1px] w-full bg-[#797c814d] z-10” />
);
};
export default DateSeparator;
This component shows a separator to help users see when messages are from different days. Using the formatDate() function, we provide labels like “Today“, “Yesterday“, or a formatted date with an ordinal suffix.
Next, let’s add the DateSeparator to the ChannelChat component to make conversations more readable:
…
import DateSeperator from ‘./DateSeparator’;
…
const ChannelChat = ({ channel }: ChannelChatProps) => {
…
return (
“w-full h-full”>
…
);
};
export default ChannelChat;
Creating a Custom Emoji Picker
In this section, we’ll create a custom emoji picker for our Slack clone using the emoji-mart library. While Stream already provides an EmojiPicker using the same library, we want to build a more flexible version that better suits our chat components and integrates seamlessly into our clone.
Firstly, we need to install the necessary packages for the emoji picker. These include:
- emoji-mart: This library provides the emoji picker component.
- @emoji-mart/react: This package is specifically for using the emoji picker in React apps.
- @emoji-mart/data: This contains all the data needed for the emojis.
Run the following command in your terminal to install the packages:
npm install emoji-mart @emoji-mart/react @emoji-mart/data
Next, go to your components folder, create a new file called EmojiPicker.tsx, and add the following code:
import { ComponentType, useEffect, useState } from ‘react’;
import Picker from ‘@emoji-mart/react’;
import emojiData from ‘@emoji-mart/data’;
import { usePopper } from ‘react-popper’;
interface EmojiPickerProps {
ButtonIconComponent: ComponentType;
buttonClassName?: string;
wrapperClassName?: string;
onEmojiSelect: (e: { id: string; native: string }) => void;
}
const EmojiPicker = ({
buttonClassName,
ButtonIconComponent,
onEmojiSelect,
wrapperClassName,
}: EmojiPickerProps) => {
const [displayPicker, setDisplayPicker] = useState(false);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
);
const { attributes, styles } = usePopper(referenceElement, popperElement, {
placement: ‘top-end’,
});
useEffect(() => {
if (!referenceElement) return;
const handlePointerDown = (e: PointerEvent) => {
const target = e.target as Node;
const rootNode = target.getRootNode() as ShadowRoot;
if (
popperElement?.contains(!!rootNode?.host ? rootNode?.host : target) ||
referenceElement.contains(target)
) {
return;
}
setDisplayPicker(false);
};
window.addEventListener(‘pointerdown’, handlePointerDown);
return () => window.removeEventListener(‘pointerdown’, handlePointerDown);
}, [referenceElement, popperElement]);
return (
{displayPicker && (
“z-50″
>
as { default: object }).default}
onEmojiSelect={onEmojiSelect}
placement=”top-start”
/>
)}
<button
ref={setReferenceElement}
onClick={() => setDisplayPicker((prev) => !prev)}
aria-expanded=”true”
aria-label=”Emoji picker”
className={buttonClassName}
>
<ButtonIconComponent />
</button>
</div>
);
};
export default EmojiPicker;
In the code above:
- Dependencies: We use @emoji-mart/react to display the emoji picker and @emoji-mart/data to get all the emoji data. We also use usePopper from react-popper to handle the positioning of our emoji picker.
- Props: The component accepts several props, such as ButtonIconComponent for the button that triggers the picker, onEmojiSelect to handle emoji selection, and optional styling classes for customization.
- Popper Setup: The usePopper hook positions the emoji picker correctly relative to the button.
- State Handling: We use the displayPicker state to show or hide the picker. We also handle clicks outside the picker to close it.
Implementing a Custom Message Input
In this section, we’ll implement a custom message input for our Slack clone. This new input will allow users to easily add rich formatting, such as bold or italics, and even upload files and add emojis, creating a more dynamic chatting experience.
To achieve this, we’ll use slate, which is a robust framework for building rich text editors. We’ll also use is-hotkey to define keyboard shortcuts for formatting text.
First, let’s install the necessary libraries. Open your terminal and run the following commands:
npm install slate slate-react slate-history is-hotkey
npm install @types/is-hotkey –save-dev
Next, we’ll create our custom input component, which will act as the primary input container for our chat.
Navigate to the components directory, create a new file named InputContainer.tsx, and add the following code:
import {
ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from ‘react’;
import clsx from ‘clsx’;
import {
Editable,
withReact,
useSlate,
Slate,
RenderLeafProps,
RenderElementProps,
} from ‘slate-react’;
import {
Editor,
Transforms,
createEditor,
Descendant as SlateDescendant,
Element as SlateElement,
Text,
} from ‘slate’;
import isHotkey from ‘is-hotkey’;
import { withHistory } from ‘slate-history’;
import {
useChannelActionContext,
useChannelStateContext,
useMessageInputContext,
} from ‘stream-chat-react’;
import { AppContext } from ‘../app/client/layout’;
import Avatar from ‘./Avatar’;
import Bold from ‘./icons/Bold’;
import BulletedList from ‘./icons/BulletedList’;
import Close from ‘./icons/Close’;
import Code from ‘./icons/Code’;
import CodeBlock from ‘./icons/CodeBlock’;
import Emoji from ‘./icons/Emoji’;
import EmojiPicker from ‘./EmojiPicker’;
import Formatting from ‘./icons/Formatting’;
import Italic from ‘./icons/Italic’;
import Link from ‘./icons/Link’;
import Mentions from ‘./icons/Mentions’;
import Microphone from ‘./icons/Microphone’;
import NumberedList from ‘./icons/NumberedList’;
import Plus from ‘./icons/Plus’;
import Quote from ‘./icons/Quote’;
import Strikethrough from ‘./icons/Strikethrough’;
import SlashBox from ‘./icons/SlashBox’;
import Video from ‘./icons/Video’;
import Send from ‘./icons/Send’;
import CaretDown from ‘./icons/CaretDown’;
type Descendant = Omit<SlateDescendant, ‘children’> & {
children: (
| {
text: string;
}
| {
text: string;
bold: boolean;
}
| {
text: string;
italic: boolean;
}
| {
text: string;
code: boolean;
}
| {
text: string;
underline: boolean;
}
| {
text: string;
strikethrough: boolean;
}
)[];
url?: string;
type: string;
};
type FileInfo = {
name: string;
size: number;
type: string;
previewUrl?: string;
};
const HOTKEYS: {
[key: string]: string;
} = {
‘mod+b’: ‘bold’,
‘mod+i’: ‘italic’,
‘mod+u’: ‘underline’,
‘mod+`’: ‘code’,
};
const LIST_TYPES = [‘numbered-list’, ‘bulleted-list’];
const initialValue: Descendant[] = [
{
type: ‘paragraph’,
children: [{ text: ” }],
},
];
const InputContainer = () => {
const { workspace } = useContext(AppContext);
const { channel } = useChannelStateContext();
const { sendMessage } = useChannelActionContext();
const { uploadNewFiles, attachments, removeAttachments, cooldownRemaining } =
useMessageInputContext();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [filesInfo, setFilesInfo] = useState<FileInfo[]>([]);
const renderElement = useCallback(
(props: ElementProps) => <Element {…props} />,
[]
);
const renderLeaf = useCallback(
(props: RenderLeafProps) => <Leaf {…props} />,
[]
);
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
const channelName = useMemo(() => {
const currentChannel = workspace.channels.find((c) => c.id === channel.id);
return currentChannel?.name || ”;
}, [workspace.channels, channel.id]);
const serializeToMarkdown = (nodes: Descendant[]) => {
return nodes.map((n) => serializeNode(n)).join(‘n’);
};
const serializeNode = (
node: Descendant | Descendant[‘children’],
parentType: string | null = null,
indentation: string = ”
) => {
if (Text.isText(node)) {
let text = node.text;
const formattedNode = node as Text & {
bold?: boolean;
italic?: boolean;
code?: boolean;
strikethrough?: boolean;
};
if (formattedNode.bold) text = `**${text}**`;
if (formattedNode.italic) text = `*${text}*`;
if (formattedNode.strikethrough) text = `~~${text}~~`;
if (formattedNode.code) text = “${text}“;
return text;
}
const formattedNode = node as Descendant;
const children: string = formattedNode.children
.map((n) => serializeNode(n as never, formattedNode.type, indentation))
.join(”);
switch (formattedNode.type) {
case ‘paragraph’:
return `${children}`;
case ‘block-quote’:
return `> ${children}`;
case ‘bulleted-list’:
case ‘numbered-list’:
return `${children}`;
case ‘list-item’: {
const prefix = parentType === ‘numbered-list’ ? ‘1. ‘ : ‘- ‘;
const indentedPrefix = `${indentation}${prefix}`;
return `${indentedPrefix}${children}n`;
}
case ‘code-block’:
return ““n${children}n““;
default:
return `${children}`;
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.currentTarget.files;
if (files && files.length > 0) {
const filesArray = Array.from(files);
uploadNewFiles(files);
const newFilesInfo: FileInfo[] = [];
filesArray.forEach((file) => {
const fileData: FileInfo = {
name: file.name,
size: file.size,
type: file.type,
};
if (file.type.startsWith(‘image/’)) {
const reader = new FileReader();
reader.onloadend = () => {
setFilesInfo((prevFiles) => [
…prevFiles,
{ …fileData, previewUrl: reader.result as string },
]);
};
reader.readAsDataURL(file);
} else {
newFilesInfo.push(fileData);
}
});
setFilesInfo((prevFiles) => […prevFiles, …newFilesInfo]);
e.currentTarget.value = ”;
}
};
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current?.click();
}
};
const handleRemoveFile = (index: number) => {
setFilesInfo((prevFiles) => {
const newFiles = prevFiles.filter((_, i) => i !== index);
return newFiles;
});
removeAttachments([attachments[index].localMetadata.id]);
if (fileInputRef.current) {
fileInputRef.current.value = ”;
}
};
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
const clipboardItems = event.clipboardData.items;
for (let i = 0; i < clipboardItems.length; i++) {
const item = clipboardItems[i];
if (item.type.indexOf(‘image’) !== -1) {
const imageFile = item.getAsFile();
if (imageFile) {
const fileData: FileInfo = {
name: imageFile.name,
size: imageFile.size,
type: imageFile.type,
};
const reader = new FileReader();
reader.onloadend = () => {
uploadNewFiles([imageFile]);
setFilesInfo((prevFiles) => [
…prevFiles,
{ …fileData, previewUrl: reader.result as string },
]);
};
reader.readAsDataURL(imageFile);
}
event.preventDefault();
}
}
};
const handleSubmit = async () => {
const text = serializeToMarkdown(editor.children as Descendant[]);
if (text || attachments.length > 0) {
sendMessage({
text,
attachments,
});
setFilesInfo([]);
removeAttachments(attachments.map((a) => a.localMetadata.id));
const point = { path: [0, 0], offset: 0 };
editor.selection = { anchor: point, focus: point };
editor.history = { redos: [], undos: [] };
editor.children = initialValue;
}
};
return (
<Slate editor={editor} initialValue={initialValue}>
“input-container relative rounded-md border border-[#565856] has-[:focus]:border-[#868686] bg-[#22252a]”>
“[&>.formatting]:has-[:focus]:opacity-100 [&>.formatting]:has-[:focus]:select-text flex flex-col”>
{}
“formatting opacity-30 flex p-1 w-full rounded-t-lg cursor-text”>
“flex grow h-[30px]”>
type=”mark”
format=”bold”
icon={“var(–icon-gray)” />}
/>
type=”mark”
format=”italic”
icon={“var(–icon-gray)” />}
/>
type=”mark”
format=”strikethrough”
icon={“var(–icon-gray)” />}
/>
“separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]” />
“none” icon={“var(–icon-gray)” />} />
“separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]” />
type=”block”
format=”numbered-list”
icon={“var(–icon-gray)” />}
/>
type=”block”
format=”bulleted-list”
icon={“var(–icon-gray)” />}
/>
“separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]” />
type=”block”
format=”block-quote”
icon={“var(–icon-gray)” />}
/>
“hidden sm:block separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]” />
type=”mark”
format=”code”
icon={“var(–icon-gray)” />}
className=”hidden sm:inline-flex”
/>
type=”block”
format=”code-block”
icon={“var(–icon-gray)” />}
className=”hidden sm:inline-flex”
/>
</div>
{}
“flex self-stretch cursor-text”>
“flex grow text-[14.8px] leading-[1.46668] px-3 py-2″>
‘none’,
}}
className=”flex-1 min-h-[22px] scroll- overflow-y-scroll max-h-[calc(60svh-80px)]”
>
as never}
renderLeaf={renderLeaf}
placeholder={`Mesage #${channelName}`}
className=”editable outline-none”
onPaste={handlePaste}
spellCheck
autoFocus
onKeyDown={(event) => {
if (event.key === ‘Enter’) {
if (event.shiftKey) {
return;
} else {
event.preventDefault();
handleSubmit();
}
}
if (isHotkey(‘mod+a’, event)) {
event.preventDefault();
Transforms.select(editor, []);
return;
}
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event as never)) {
event.preventDefault();
const mark = HOTKEYS[hotkey];
toggleMark(editor, mark);
}
}
}}
/>
{}
{filesInfo.length > 0 && (
“relative mt-4 flex items-center gap-3 flex-wrap”>
{filesInfo.map((file, index) => (
“group relative max-w-[234px]”>
{file.previewUrl ? (
“relative w-[62px] h-[62px] grow shrink-0 cursor-pointer”>
{}
`File Preview ${index}`}
className=”w-full h-full object-cover rounded-xl border-[#d6d6d621] border”
/>
) : (
“flex items-center rounded-xl gap-3 p-3 border border-[#d6d6d621] bg-[#1a1d21]”>
32}
borderRadius={8}
data={{ name: file.type }}
/>
“flex flex-col gap-0.5”>
“text-sm text-[#d1d2d3] break-all whitespace-break-spaces line-clamp-1 mr-2”>
{file.name}
“text-[13px] text-[#ababad] break-all whitespace-break-spaces line-clamp-1”>
{file.type}
</div>
)}
“group-hover:opacity-100 opacity-0 absolute -top-2.5 -right-2.5 flex items-center justify-center w-[22px] h-[22px] rounded-full bg-black”>
() => handleRemoveFile(index)}
className=”w-[18px] h-[18px] flex items-center justify-center rounded-full bg-gray-300″
>
14} color=”black” />
</div>
))}
</div>
)}
</div>
</div>
</div>
{}
“flex items-center justify-between pl-1.5 pr-[5px] cursor-text rounded-b-lg h-[40px]”>
“flex item-center”>
“w-7 h-7 p-0.5 m-0.5 flex items-center justify-center rounded-full hover:bg-[#565856]”
>
18} color=”var(–icon-gray)” />
type=”file”
ref={fileInputRef}
className=”hidden”
onChange={handleFileInputChange}
/>
“none”
className=”rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray”
icon={“var(–icon-gray)” />}
/>
“w-7 h-7 p-0.5 m-0.5 inline-flex items-center justify-center rounded [&_path]:fill-icon-gray hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray”
ButtonIconComponent={Emoji}
wrapperClassName=”relative”
onEmojiSelect={(e) => {
Transforms.insertText(editor, e.native);
}}
/>
“mention”
className=”rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray”
icon={“var(–icon-gray)” />}
/>
“hidden sm:block separator h-5 w-[1px] mx-1.5 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]” />
“none”
className=”hidden sm:inline-flex rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray”
icon={“var(–icon-gray)” />}
/>
“none”
className=”hidden sm:inline-flex rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray”
icon={“var(–icon-gray)” />}
/>
“hidden sm:block separator h-5 w-[1px] mx-1.5 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]” />
“none”
className=”hidden sm:inline-flex rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray”
icon={“var(–icon-gray)” />}
/>
“flex items-center mr-0.5 ml-2 rounded h-7 border border-[#797c814d] text-[#e8e8e8b3] bg-[#007a5a] border-[#007a5a]”>
“px-2 h-[28px] rounded-l hover:bg-[#148567]”
>
Boolean(cooldownRemaining)
? ‘var(–primary)’
: ‘var(–icon-gray)’
}
size={16}
filled
/>
“cursor-pointer h-5 w-[1px] bg-[#ffffff80]” />
“w-[22px] flex items-center justify-center h-[26px] rounded-r hover:bg-[#148567]”>
16}
color={
!Boolean(cooldownRemaining)
? ‘var(–primary)’
: ‘var(–icon-gray)’
}
/>
</div>
</div>
</div>
</Slate>
);
};
const toggleBlock = (editor: Editor, format: string) => {
const isActive = isBlockActive(editor, format);
const isList = LIST_TYPES.includes(format);
Transforms.unwrapNodes(editor, {
match: (n) =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
LIST_TYPES.includes((n as Descendant).type),
split: true,
});
const newProperties: Partial<Descendant> = {
type: isActive ? ‘paragraph’ : isList ? ‘list-item’ : format,
};
Transforms.setNodes<SlateElement>(editor, newProperties);
if (!isActive && isList) {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block);
}
};
const toggleMark = (editor: Editor, format: string) => {
const isActive = isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
const isBlockActive = (editor: Editor, format: string, blockType = ‘type’) => {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
(n as never)[blockType] === format,
})
);
return !!match;
};
const isMarkActive = (editor: Editor, format: string) => {
const marks = Editor.marks(editor) as null;
return marks ? marks[format] : false;
};
type ElementProps = RenderElementProps & {
element: {
type: string;
align?: CanvasTextAlign;
};
};
const Element = (props: ElementProps) => {
const { attributes, children, element } = props;
switch (element.type) {
case ‘block-quote’:
return <blockquote {…attributes}>{children}</blockquote>;
case ‘bulleted-list’:
return <ul {…attributes}>{children}</ul>;
case ‘list-item’:
return <li {…attributes}>{children}</li>;
case ‘numbered-list’:
return <ol {…attributes}>{children}</ol>;
case ‘code-block’:
return (
“code-block”>
{children}
);
default:
return <p {…attributes}>{children}</p>;
}
};
interface LeafProps extends RenderLeafProps {
leaf: {
bold?: boolean;
code?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
text: string;
};
}
const Leaf = ({ attributes, children, leaf }: LeafProps) => {
if (leaf.bold) {
children = <strong>{children}</strong>;
}
if (leaf.code) {
children = <code>{children}</code>;
}
if (leaf.italic) {
children = <em>{children}</em>;
}
if (leaf.underline) {
children = <u>{children}</u>;
}
if (leaf.strikethrough) {
children = <s>{children}</s>;
}
return <span {…attributes}>{children}</span>;
};
interface ButtonProps {
active?: boolean;
className?: string;
icon: ReactNode;
format: string;
type?: ‘mark’ | ‘block’;
}
const Button = ({ className, format, icon, type }: ButtonProps) => {
const editor = useSlate();
const isActive =
type === ‘block’
? isBlockActive(editor, format)
: isMarkActive(editor, format);
return (
<button
className={clsx(
‘w-7 h-7 p-0.5 m-0.5 inline-flex items-center justify-center rounded’,
isActive ? ‘bg-[#414347] hover:bg-[#4b4c51]’ : ‘bg-transparent’,
className
)}
onClick={(e) => {
e.preventDefault();
if (type === ‘block’) {
toggleBlock(editor, format);
} else if (type === ‘mark’) {
toggleMark(editor, format);
}
}}
>
{icon}
</button>
);
};
export default InputContainer;
There’s a lot going on here, so let’s break it down:
- Slate Editor: We use Slate to create a rich text editor that supports multiple formatting options, like bold, italics, underline, and strikethrough.
- Serialization Functions: The serializeToMarkdown and serializeNode functions convert the editor’s content to markdown format, allowing us to maintain rich formatting in text.
- File Handling: Functions like handleFileInputChange, handleUploadButtonClick, and handleRemoveFile help manage file uploads, previews, and removal, making the chat input more versatile.
- Formatting Buttons: The buttons for formatting text (bold, italic, etc.) call the toggleMark function to add or remove specific text styles.
- Hotkey Support: The is-hotkey library binds hotkeys like Ctrl+B for bold, Ctrl+I for italics, and so on, making the editor more user-friendly.
- Send Button: The handleSubmit function is responsible for sending the message by serializing the editor’s content and then using Stream’s sendMessage function.
Next, let’s integrate the InputContainer with our channel chat interface.
Open the ChannelChat.tsx file and update it with the following code:
…
import InputContainer from ‘./InputContainer’;
…
const ChannelChat = ({ channel }: ChannelChatProps) => {
…
return (
“w-full h-full”>
…
{inputContainer &&
createPortal(
,
inputContainer
)}
);
};
export default ChannelChat;
In the code above, we import the InputContainer component and pass it as the input prop for the MessageInput component to override the default UI.
Next, let’s add some styling to support the rich text formatting features, ensuring elements like <code> blocks and other inline styles look polished.
Open your globals.css file, and include the following styles:
…
@layer components {
…
.input-container ul > li:before,
.input-container ol > li:before,
.channel .str-chat__message-text ul > li:before,
.channel .str-chat__message-text ol > li:before {
color: var(–channel-gray);
display: inline-block;
width: 24px;
margin-left: -24px;
vertical-align: baseline;
text-align: center;
content: ‘•’;
}
.input-container ul > li:before,
.channel .str-chat__message-text ul > li:before {
height: 15px;
font-size: 17px;
line-height: 17px;
}
.input-container ol,
.channel .str-chat__message-text ol {
counter-reset: list-0;
}
.input-container ol > li:before,
.channel .str-chat__message-text ol > li:before {
counter-increment: list-0;
content: counter(list-0, decimal) ‘. ‘;
}
.input-container ol > li,
.input-container ul > li,
.channel .str-chat__message-text ol > li,
.channel .str-chat__message-text ul > li {
margin-left: 24px;
}
.input-container ol > li > *,
.input-container ul > li > *,
.channel .str-chat__message-text ol > li > *,
.channel .str-chat__message-text ul > li > * {
margin-left: 3px;
line-height: 22px;
}
.channel code,
.channel .str-chat__message-text code {
color: #e8912d;
background: #2c2e33;
border: 1px solid #4a4d55;
font-variant-ligatures: none;
word-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
tab-size: 4;
border-radius: 3px;
padding: 2px 3px 1px;
font-size: 12px;
line-height: 1.50001;
}
.channel pre:first-of-type {
margin-top: 4px;
}
.channel .code-block {
font-family: monospace;
font-size: 12px;
}
.channel pre:first-of-type code,
.channel .code-block:first-of-type code,
.channel pre:last-of-type code,
.channel .code-block:last-of-type code,
.channel pre:not(:first-of-type):not(:last-of-type) code,
.channel .code-block:not(:first-of-type):not(:last-of-type) code {
border: none;
background: none;
border-radius: 0px;
color: #d1d2d3;
padding: 0px;
}
.channel pre:first-of-type,
.channel .code-block:first-of-type {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
border-top: 1px solid #e8e8e821;
border-left: 1px solid #e8e8e821;
border-right: 1px solid #e8e8e821;
border-bottom: 0px;
background-color: #232529;
padding: 8px 8px 0px 8px;
color: #d1d2d3;
}
.channel pre:last-of-type,
.code-block:last-of-type {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-top: 0px;
border-left: 1px solid #e8e8e821;
border-right: 1px solid #e8e8e821;
border-bottom: 1px solid #e8e8e821;
background-color: #232529;
padding: 0px 8px 8px 8px;
color: #d1d2d3;
}
.channel pre:not(:first-of-type):not(:last-of-type),
.code-block:not(:first-of-type):not(:last-of-type) {
border-radius: 0;
background-color: #232529;
border-left: 1px solid #e8e8e821;
border-right: 1px solid #e8e8e821;
padding: 0px 8px;
color: #d1d2d3;
}
.channel pre:first-of-type:only-child,
.channel .code-block:first-of-type:only-child {
padding-bottom: 8px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top: 1px solid #e8e8e821;
border-bottom: 1px solid #e8e8e821;
}
.emoji {
font-family: -apple-system, BlinkMacSystemFont, ‘Segoe UI’, Roboto,
‘Helvetica Neue’, Arial, sans-serif;
}
blockquote {
position: relative;
padding-left: 16px;
margin: 4px 0px;
}
blockquote:before {
background: rgba(221, 221, 221, 1);
content: ”;
border-radius: 8px;
width: 4px;
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
}
blockquote:not(:first-child):before {
background: rgba(221, 221, 221, 1);
content: ”;
border-radius: 8px;
width: 4px;
display: block;
position: absolute;
height: calc(100% + 6px);
top: -6px;
bottom: 0;
left: 0;
}
.channel a {
color: #1d9bd1;
}
}
While the chat interface is now visually improved with a customized message input, the message UI still needs work to match the look of the rest of the app.
Creating a Custom Message UI
In this section, we’ll create a custom message UI to match the look and feel of our Slack clone. This custom message component will display user messages in a clean interface with the ability to send reactions and view attachments.
To get started, navigate to the components directory, create a new file named ChannelMessage.tsx, and add the following code:
import { useMemo } from ‘react’;
import { useUser } from ‘@clerk/nextjs’;
import {
MessageText,
renderText,
useChannelStateContext,
useMessageContext,
} from ‘stream-chat-react’;
import clsx from ‘clsx’;
import emojiData from ‘@emoji-mart/data’;
import AddReaction from ‘./icons/AddReaction’;
import Avatar from ‘./Avatar’;
import Bookmark from ‘./icons/Bookmark’;
import Download from ‘./icons/Download’;
import EmojiPicker from ‘./EmojiPicker’;
import MoreVert from ‘./icons/MoreVert’;
import Share from ‘./icons/Share’;
import Threads from ‘./icons/Threads’;
const ChannelMessage = () => {
const { message } = useMessageContext();
const { channel } = useChannelStateContext(‘ChannelMessage’);
const { user } = useUser();
const reactionCounts = useMemo(() => {
if (!message.reaction_groups) {
return [];
}
return Object.entries(
Object.entries(message.reaction_groups!)
?.sort(
(a, b) =>
new Date(a[1].first_reaction_at!).getTime() –
new Date(b[1].first_reaction_at!).getTime()
)
.reduce((acc, entry) => {
const [type, event] = entry;
acc[type] = acc[type] || { count: 0, reacted: false };
acc[type].count = event.count;
if (
message.own_reactions?.some(
(reaction) =>
reaction.type === type && reaction.user_id === user!.id
)
) {
acc[type].reacted = true;
}
return acc;
}, {} as Record<string, { count: number; reacted: boolean }>)
);
}, [message.reaction_groups, message.own_reactions, user]);
const createdAt = new Date(message.created_at!).toLocaleTimeString(‘en-US’, {
hour: ‘numeric’,
minute: ‘2-digit’,
hour12: true,
});
const downloadFile = async (url: string) => {
const link = document.createElement(‘a’);
link.href = url;
link.download = url.split(‘/’).pop()!;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleReaction = async (e: { id: string; native?: string }) => {
await channel.sendReaction(message.id, { type: e.id });
};
const removeReaction = async (reactionType: string) => {
await channel.deleteReaction(message.id, reactionType);
};
const handleReactionClick = async (
reactionType: string,
isActive: boolean
) => {
if (isActive) {
removeReaction(reactionType);
} else {
handleReaction({ id: reactionType });
}
};
const getReactionEmoji = (reactionType: string) => {
const data = emojiData as {
emojis: {
[key: string]: { skins: { native: string }[] };
};
};
const emoji = data.emojis[reactionType];
if (emoji) return emoji.skins[0].native;
return null;
};
return (
“relative flex py-2 pl-5 pr-10 group/message hover:bg-[#22252a]”>
{}
“flex shrink-0 mr-2”>
“w-fit h-fit inline-flex”>
“w-9 h-9 shrink-0 inline-block”>
“w-full h-full overflow-hidden”>
{}
“profile-image”
className=”w-full h-full rounded-lg”
/>
{}
“flex-1 min-w-0”>
“flex items-center gap-2”>
“cursor-pointer text-[15px] leading-[1.46668] font-[900] text-white hover:underline”>
{message.user?.name}
“pt-1 cursor-pointer text-xs leading-[1.46668] text-[#ABABAD] hover:underline”>
{createdAt}
“mb-1”>
“w-full”>
“flex flex-col max-w-[245px] sm:max-w-full”>
(text, mentionedUsers) =>
renderText(text, mentionedUsers, {
customMarkDownRenderers: {
br: () => “paragraph_break block h-2” />,
},
})
}
/>
0
? ‘flex’
: ‘hidden’,
‘mt-3 flex-col gap-2’
)}
>
{message.attachments?.map((attachment) => (
‘group/attachment relative cursor-pointer flex items-center rounded-xl gap-3 border border-[#d6d6d621] bg-[#1a1d21]’,
attachment?.image_url && !attachment.asset_url
? ‘max-w-[360px] p-0’
: ‘max-w-[426px] p-3’
)}
>
{attachment.asset_url && (
32}
borderRadius={8}
data={{
name: attachment!.title!,
image: attachment!.image_url!,
}}
/>
“flex flex-col gap-0.5”>
“text-sm text-[#d1d2d3] break-all whitespace-break-spaces line-clamp-1 mr-2”>
{attachment.title || `attachment`}
“text-[13px] text-[#ababad] break-all whitespace-break-spaces line-clamp-1″>
{attachment.type}
</>
)}
{attachment.image_url && !attachment.asset_url && (
<img
src={attachment.image_url}
alt=”attachment”
className=”w-full max-h-[358px] aspect-auto rounded-lg”
/>
)}
{}
“z-20 hidden group-hover/attachment:inline-flex absolute top-2 right-2”>
“flex p-0.5 rounded-md ml-2 bg-[#1a1d21] border border-[#797c814d]”>
() =>
downloadFile(
attachment.asset_url! || attachment.image_url!
)
}
className=”group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”
>
“fill-[#e8e8e8b3] group-hover/button:fill-channel-gray” />
“group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”>
“fill-[#e8e8e8b3] group-hover/button:fill-channel-gray” />
“group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”>
18}
className=”fill-[#e8e8e8b3] group-hover/button:fill-channel-gray”
/>
</div>
</div>
))}
</div>
{reactionCounts.length > 0 && (
“flex items-center gap-1 flex-wrap mt-2”>
{reactionCounts.map(([reactionType, data], index) => (
() =>
handleReactionClick(reactionType, data.reacted)
}
className={`px-2 mb-1 h-6 flex items-center gap-1 border text-white text-[11.8px] rounded-full transition-colors ${
data.reacted
? ‘bg-[#004d76] border-[#004d76]’
: ‘bg-[#f8f8f80f] border-[#f8f8f80f]’
}`}
>
“emoji text-[14.5px]”>
{getReactionEmoji(reactionType)}
{‘ ‘}
{data.count}
))}
“group/button relative mb-1 rounded-full bg-[#f8f8f80f] flex w-8 h-6 items-center justify-center hover:bg-[#d1d2d30b]”
buttonClassName=”fill-[#e8e8e8b3] group-hover/button:fill-channel-gray”
onEmojiSelect={handleReaction}
/>
)}
</div>
</div>
</div>
</div>
{}
“z-20 hidden group-hover/message:inline-flex absolute -top-4 right-[38px]”>
“flex p-0.5 rounded-md ml-2 bg-[#1a1d21] border border-[#797c814d]”>
“group/button relative rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”
buttonClassName=”fill-[#e8e8e8b3] group-hover/button:fill-channel-gray”
onEmojiSelect={handleReaction}
/>
“group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”>
“fill-[#e8e8e8b3] group-hover/button:fill-channel-gray” />
“group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”>
“fill-[#e8e8e8b3] group-hover/button:fill-channel-gray” />
“group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”>
18}
className=”fill-[#e8e8e8b3] group-hover/button:fill-channel-gray”
/>
“group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]”>
18}
className=”fill-[#e8e8e8b3] group-hover/button:fill-channel-gray”
/>
</div>
</div>
);
};
export default ChannelMessage;
In the ChannelMessage component:
- Message Details: We use the useMessageContext hook to get information about the current message displayed, such as the message content and its author.
- Reactions: Using useMemo, we calculate the number of reactions and whether the user has reacted to the message or not. Users can add or remove reactions by clicking on the reaction buttons.
- Attachments: The message can contain attachments such as images or files. We provide download and preview options for attachments.
- Emoji Reactions: We added a button to send reactions using our custom EmojiPicker.
Now, let’s integrate our new ChannelMessage component into our ChannelChat. Navigate to components/ChannelChat.tsx and update it to use ChannelMessage:
…
import ChannelMessage from ‘./ChannelMessage’;
…
const ChannelChat = ({ channel }: ChannelChatProps) => {
…
return (
“w-full h-full”>
…
);
};
export default ChannelChat;
In ChannelChat.tsx, we update the MessageList to use our custom ChannelMessage component. This change allows our newly defined custom message UI to display each message.
And that’s it! We now have a fully customized chat experience similar to Slack.
Conclusion
In this part, we made our Slack clone more interactive by implementing core messaging features using Stream React Chat SDK. We added custom components to further style and enhance the user interface with features like rich text formatting, emojis, and file sharing.
In this series’s next and final part, we will integrate a video calling feature using Stream React Video and Audio SDK. This feature will allow users to transition between text and video conversations, making the app more versatile and interactive.
Stay tuned!
Source: hashnode.com