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 }
);
}
// Check if the user is a member of the workspace
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 }
);
}
// Check if the user has permission to create channels
if (membership.role !== 'admin') {
return NextResponse.json(
{ error: 'Access denied: Insufficient permissions' },
{ status: 403 }
);
}
// Check if a channel with the same name already exists in the workspace
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 }
);
}
// Create the new channel
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}
/>
<div className="w-full flex items-center justify-end gap-3">
<button
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 ? <Spinner /> : 'Save'}
</button>
<button
onClick={closeModal}
className="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
</button>
</div>
</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.
Adding the 'Add Channel' Button to the Sidebar
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 (
<div
id="sidebar"
...
>
{!loading && (
<>
...
<div className="w-full flex flex-col">
...
<ChannelList
...
/>
{isWorkspaceOwner && (
<SidebarButton
icon={Plus}
title="Add a channel"
onClick={openCreateChannelModal}
/>
)}
</div>
{/* Handle */}
...
<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 (
<div className="flex flex-col pt-14">
<div className="relative flex animate-pulse py-2 pl-5 pr-10">
<div className="flex shrink-0 mr-2">
<div className="w-fit h-fit inline-flex">
<div className="w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block"></div>
</div>
</div>
<div className="flex-1 min-w-0 w-[426px]">
<div className="flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2"></div>
<div className="w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]"></div>
</div>
</div>
<div className="relative flex animate-pulse py-2 pl-5 pr-10">
<div className="flex shrink-0 mr-2">
<div className="w-fit h-fit inline-flex">
<div className="w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block"></div>
</div>
</div>
<div className="flex-1 min-w-0 w-[426px]">
<div className="flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2"></div>
<div className="w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]"></div>
</div>
</div>
<div className="relative flex animate-pulse py-2 pl-5 pr-10">
<div className="flex shrink-0 mr-2">
<div className="w-fit h-fit inline-flex">
<div className="w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block"></div>
</div>
</div>
<div className="flex-1 min-w-0 w-[426px]">
<div className="flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2"></div>
<div className="w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]"></div>
</div>
</div>
<div className="relative flex py-2 pl-5 pr-10">
<div className="flex shrink-0 mr-2 animate-pulse">
<div className="w-fit h-fit inline-flex">
<div className="w-9 h-9 bg-[#797c814d] rounded-lg shrink-0 inline-block"></div>
</div>
</div>
<div className="flex-1 min-w-0 w-[426px] animate-pulse">
<div className="flex rounded-full items-center w-[200px] h-[14px] bg-[#797c814d] gap-2"></div>
<div className="w-[98%] h-[80px] mt-1.5 rounded-lg bg-[#797c814d]"></div>
</div>
</div>
<div className="absolute bottom-0 w-[98%] h-[134px] flex py-2 pl-5 pr-10">
<div className="w-full h-full bg-[#232529] rounded-lg border border-[#565856]"></div>
</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 (
<div className="w-full h-full">
<Channel
LoadingIndicator={ChannelLoading}
channel={channel}
>
<Window>
<MessageList />
{inputContainer &&
createPortal(
<MessageInput />,
inputContainer
)}
</Window>
</Channel>
</div>
);
};
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 customChannelLoading
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 (
<div
...
>
{/* Toolbar */}
...
{/* Tab Bar */}
...
{/* Chat */}
<div className="...">
{/* Body */}
<div className="...">
<div className="...">
<div
...
>
<div className="absolute h-full inset-[0_-50px_0_0] overflow-y-scroll overflow-x-hidden z-[2]">
{/* Messages */}
{channelLoading && <ChannelLoading />}
{!channelLoading && <ChannelChat channel={chatChannel!} />}
</div>
</div>
</div>
</div>
{/* Footer */}
...
</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 theChannelLoading
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 (
<div className="relative font-lato w-full flex items-center justify-center h-10">
<div className="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)}
<span className="ml-1">
<CaretDown color="var(--channel-gray)" size={13} />
</span>
</div>
<div className="absolute h-[1px] w-full bg-[#797c814d] z-10" />
</div>
);
};
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 (
<div className="w-full h-full">
<Channel
...
DateSeparator={DateSeperator}
>
...
</Channel>
</div>
);
};
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 (
<div className={wrapperClassName}>
{displayPicker && (
<div
ref={setPopperElement}
{...attributes.popper}
style={styles.popper}
className="z-50"
>
<Picker
data={(emojiData as { default: object }).default}
onEmojiSelect={onEmojiSelect}
placement="top-start"
/>
</div>
)}
<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 useusePopper
fromreact-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}>
<div className="input-container relative rounded-md border border-[#565856] has-[:focus]:border-[#868686] bg-[#22252a]">
<div className="[&>.formatting]:has-[:focus]:opacity-100 [&>.formatting]:has-[:focus]:select-text flex flex-col">
{/* Formatting */}
<div className="formatting opacity-30 flex p-1 w-full rounded-t-lg cursor-text">
<div className="flex grow h-[30px]">
<Button
type="mark"
format="bold"
icon={<Bold color="var(--icon-gray)" />}
/>
<Button
type="mark"
format="italic"
icon={<Italic color="var(--icon-gray)" />}
/>
<Button
type="mark"
format="strikethrough"
icon={<Strikethrough color="var(--icon-gray)" />}
/>
<div className="separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]" />
<Button format="none" icon={<Link color="var(--icon-gray)" />} />
<div className="separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]" />
<Button
type="block"
format="numbered-list"
icon={<NumberedList color="var(--icon-gray)" />}
/>
<Button
type="block"
format="bulleted-list"
icon={<BulletedList color="var(--icon-gray)" />}
/>
<div className="separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]" />
<Button
type="block"
format="block-quote"
icon={<Quote color="var(--icon-gray)" />}
/>
<div className="hidden sm:block separator h-5 w-[1px] mx-1 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]" />
<Button
type="mark"
format="code"
icon={<Code color="var(--icon-gray)" />}
className="hidden sm:inline-flex"
/>
<Button
type="block"
format="code-block"
icon={<CodeBlock color="var(--icon-gray)" />}
className="hidden sm:inline-flex"
/>
</div>
</div>
{/* Input */}
<div className="flex self-stretch cursor-text">
<div className="flex grow text-[14.8px] leading-[1.46668] px-3 py-2">
<div
style={{
scrollbarWidth: 'none',
}}
className="flex-1 min-h-[22px] scroll- overflow-y-scroll max-h-[calc(60svh-80px)]"
>
<Editable
renderElement={renderElement 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);
}
}
}}
/>
{/* File preview section */}
{filesInfo.length > 0 && (
<div className="relative mt-4 flex items-center gap-3 flex-wrap">
{filesInfo.map((file, index) => (
<div key={index} className="group relative max-w-[234px]">
{file.previewUrl ? (
<div className="relative w-[62px] h-[62px] grow shrink-0 cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={file.previewUrl}
alt={`File Preview ${index}`}
className="w-full h-full object-cover rounded-xl border-[#d6d6d621] border"
/>
</div>
) : (
<div className="flex items-center rounded-xl gap-3 p-3 border border-[#d6d6d621] bg-[#1a1d21]">
<Avatar
width={32}
borderRadius={8}
data={{ name: file.type }}
/>
<div className="flex flex-col gap-0.5">
<p className="text-sm text-[#d1d2d3] break-all whitespace-break-spaces line-clamp-1 mr-2">
{file.name}
</p>
<p className="text-[13px] text-[#ababad] break-all whitespace-break-spaces line-clamp-1">
{file.type}
</p>
</div>
</div>
)}
<div className="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">
<button
onClick={() => handleRemoveFile(index)}
className="w-[18px] h-[18px] flex items-center justify-center rounded-full bg-gray-300"
>
<Close size={14} color="black" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Composer actions */}
<div className="flex items-center justify-between pl-1.5 pr-[5px] cursor-text rounded-b-lg h-[40px]">
<div className="flex item-center">
<button
onClick={handleUploadButtonClick}
className="w-7 h-7 p-0.5 m-0.5 flex items-center justify-center rounded-full hover:bg-[#565856]"
>
<Plus size={18} color="var(--icon-gray)" />
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileInputChange}
/>
</button>
<Button
format="none"
className="rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray"
icon={<Formatting color="var(--icon-gray)" />}
/>
<EmojiPicker
buttonClassName="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);
}}
/>
<Button
format="mention"
className="rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray"
icon={<Mentions color="var(--icon-gray)" />}
/>
<div className="hidden sm:block separator h-5 w-[1px] mx-1.5 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]" />
<Button
format="none"
className="hidden sm:inline-flex rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray"
icon={<Video color="var(--icon-gray)" />}
/>
<Button
format="none"
className="hidden sm:inline-flex rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray"
icon={<Microphone color="var(--icon-gray)" />}
/>
<div className="hidden sm:block separator h-5 w-[1px] mx-1.5 my-0.5 self-center flex-shrink-0 bg-[#e8e8e821]" />
<Button
format="none"
className="hidden sm:inline-flex rounded hover:bg-[#d1d2d30b] [&_path]:hover:fill-channel-gray"
icon={<SlashBox color="var(--icon-gray)" />}
/>
</div>
<div className="flex items-center mr-0.5 ml-2 rounded h-7 border border-[#797c814d] text-[#e8e8e8b3] bg-[#007a5a] border-[#007a5a]">
<button
onClick={handleSubmit}
disabled={!!cooldownRemaining}
className="px-2 h-[28px] rounded-l hover:bg-[#148567]"
>
<Send
color={
!Boolean(cooldownRemaining)
? 'var(--primary)'
: 'var(--icon-gray)'
}
size={16}
filled
/>
</button>
<div className="cursor-pointer h-5 w-[1px] bg-[#ffffff80]" />
<button className="w-[22px] flex items-center justify-center h-[26px] rounded-r hover:bg-[#148567]">
<CaretDown
size={16}
color={
!Boolean(cooldownRemaining)
? 'var(--primary)'
: 'var(--icon-gray)'
}
/>
</button>
</div>
</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 (
<div {...attributes} className="code-block">
{children}
</div>
);
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
andserializeNode
functions convert the editor's content to markdown format, allowing us to maintain rich formatting in text.File Handling: Functions like
handleFileInputChange
,handleUploadButtonClick
, andhandleRemoveFile
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 likeCtrl+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'ssendMessage
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 (
<div className="w-full h-full">
<Channel
...
>
<Window>
...
{inputContainer &&
createPortal(
<MessageInput Input={InputContainer} />,
inputContainer
)}
</Window>
</Channel>
</div>
);
};
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 (
<div className="relative flex py-2 pl-5 pr-10 group/message hover:bg-[#22252a]">
{/* Image */}
<div className="flex shrink-0 mr-2">
<span className="w-fit h-fit inline-flex">
<button className="w-9 h-9 shrink-0 inline-block">
<span className="w-full h-full overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={message.user?.image}
alt="profile-image"
className="w-full h-full rounded-lg"
/>
</span>
</button>
</span>
</div>
{/* Details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="cursor-pointer text-[15px] leading-[1.46668] font-[900] text-white hover:underline">
{message.user?.name}
</span>
<span className="pt-1 cursor-pointer text-xs leading-[1.46668] text-[#ABABAD] hover:underline">
{createdAt}
</span>
</div>
<div className="mb-1">
<div className="w-full">
<div className="flex flex-col max-w-[245px] sm:max-w-full">
<MessageText
renderText={(text, mentionedUsers) =>
renderText(text, mentionedUsers, {
customMarkDownRenderers: {
br: () => <span className="paragraph_break block h-2" />,
},
})
}
/>
<div
className={clsx(
message.attachments && message.attachments.length > 0
? 'flex'
: 'hidden',
'mt-3 flex-col gap-2'
)}
>
{message.attachments?.map((attachment) => (
<div
key={
attachment?.id ||
attachment.image_url ||
attachment.asset_url
}
className={clsx(
'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 && (
<>
<Avatar
width={32}
borderRadius={8}
data={{
name: attachment!.title!,
image: attachment!.image_url!,
}}
/>
<div className="flex flex-col gap-0.5">
<p className="text-sm text-[#d1d2d3] break-all whitespace-break-spaces line-clamp-1 mr-2">
{attachment.title || `attachment`}
</p>
<p className="text-[13px] text-[#ababad] break-all whitespace-break-spaces line-clamp-1">
{attachment.type}
</p>
</div>
</>
)}
{attachment.image_url && !attachment.asset_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachment.image_url}
alt="attachment"
className="w-full max-h-[358px] aspect-auto rounded-lg"
/>
)}
{/* Message Actions */}
<div className="z-20 hidden group-hover/attachment:inline-flex absolute top-2 right-2">
<div className="flex p-0.5 rounded-md ml-2 bg-[#1a1d21] border border-[#797c814d]">
<button
onClick={() =>
downloadFile(
attachment.asset_url! || attachment.image_url!
)
}
className="group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]"
>
<Download className="fill-[#e8e8e8b3] group-hover/button:fill-channel-gray" />
</button>
<button className="group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]">
<Share className="fill-[#e8e8e8b3] group-hover/button:fill-channel-gray" />
</button>
<button className="group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]">
<MoreVert
size={18}
className="fill-[#e8e8e8b3] group-hover/button:fill-channel-gray"
/>
</button>
</div>
</div>
</div>
))}
</div>
{reactionCounts.length > 0 && (
<div className="flex items-center gap-1 flex-wrap mt-2">
{reactionCounts.map(([reactionType, data], index) => (
<button
key={index}
onClick={() =>
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]'
}`}
>
<span className="emoji text-[14.5px]">
{getReactionEmoji(reactionType)}
</span>{' '}
{data.count}
</button>
))}
<EmojiPicker
ButtonIconComponent={AddReaction}
wrapperClassName="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>
</div>
{/* Message Actions */}
<div className="z-20 hidden group-hover/message:inline-flex absolute -top-4 right-[38px]">
<div className="flex p-0.5 rounded-md ml-2 bg-[#1a1d21] border border-[#797c814d]">
<EmojiPicker
ButtonIconComponent={AddReaction}
wrapperClassName="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}
/>
<button className="group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]">
<Threads className="fill-[#e8e8e8b3] group-hover/button:fill-channel-gray" />
</button>
<button className="group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]">
<Share className="fill-[#e8e8e8b3] group-hover/button:fill-channel-gray" />
</button>
<button className="group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]">
<Bookmark
size={18}
className="fill-[#e8e8e8b3] group-hover/button:fill-channel-gray"
/>
</button>
<button className="group/button rounded flex w-8 h-8 items-center justify-center hover:bg-[#d1d2d30b]">
<MoreVert
size={18}
className="fill-[#e8e8e8b3] group-hover/button:fill-channel-gray"
/>
</button>
</div>
</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 (
<div className="w-full h-full">
<Channel
...
>
<Window>
<MessageList Message={ChannelMessage} />
...
</Window>
</Channel>
</div>
);
};
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!