Building a Slack Clone with Next.js and TailwindCSS - Part Two

Building a Slack Clone with Next.js and TailwindCSS - Part Two

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 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 (
    <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 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 (
    <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 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}>
      <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 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 (
    <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!