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

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

In the first part of this series, we added authentication to our Telegram clone, built the general layout, and implemented group chat creation. Now, it’s time to focus on the chat interface itself.

This section will cover building a fully functional chat page with real-time messaging using the Stream React Chat SDK. By the end of this article, users will be able to open a chat, see messages, and send new ones while enjoying a sleek, Telegram-like experience.

You can check out the live demo and GitHub repository to get the full code.

Let’s get started!

Creating a Loading State for Channels

When a user navigates to a chat, we’ll need to display a loading screen that appears while we load the channel data. We will do this using two components:

  • ChatLoading – Displays a spinner inside the message area to indicate that content is being fetched.

  • ChannelLoading – Wraps ChatLoading.tsx and provides placeholders for the chat header and actions while loading.

Inside the components directory, create a new file called ChatLoading.tsx and add the following code:

import Appendix from './Appendix';
import Button from './Button';
import Spinner from './Spinner';

const ChatLoading = () => {
  return (
    <>
      <div className="custom-scroll flex-1 w-full mb-2 overflow-y-scroll overflow-x-hidden transition-[bottom,_transform] duration-[150ms,_300ms] ease-[ease-out,_cubic-bezier(0.33,1,0.68,1)] xl:transition-transform xl:duration-300 xl:ease-[cubic-bezier(0.33,1,0.68,1)] xl:translate-x-0 xl:translate-y-0">
        <div className="w-full h-full flex items-center justify-center">
          <div className="w-14 h-14 p-1 rounded-full bg-[#00000068]">
            <div className="relative w-12 h-12">
              <Spinner strokeWidth={2} />
            </div>
          </div>
        </div>
      </div>
      <div className="relative flex items-end gap-2 z-10 mb-5 w-full xl:w-[calc(100%-25vw)] max-w-[45.5rem] px-4 transition-[top,_transform] duration-[200ms,_300ms] ease-[ease,_cubic-bezier(0.33,1,0.68,1)]">
        <div className="composer-wrapper relative z-[1] grow bg-background max-w-[calc(100%-4rem)] rounded-[var(--border-radius-messages)] rounded-br-none shadow-[0_1px_2px_var(--color-default-shadow)] transition-[transform,_border-bottom-right-radius] duration-200 ease-out">
          <Appendix position="right" />
          <div className="flex opacity-100 transition-opacity duration-200 ease-out">
            <div className="relative w-8 h-14 ml-3 flex items-center justify-center leading-[1.2] overflow-hidden transition-colors duration-150 uppercase rounded-full self-end shrink-0 text-color-composer-button">
              <i className="icon icon-smile" />
            </div>
            <div className="relative grow">
              <div className="custom-scroll mr-2 pr-1 min-h-14 max-h-[26rem] overflow-y-auto transition-[height] duration-100 ease-[ease]">
                <div className="pl-2 py-4"></div>
              </div>
            </div>
            <div className="self-end">
              <button className="relative w-14 h-14 ml-3 flex items-center justify-center leading-[1.2] overflow-hidden transition-colors duration-150 uppercase rounded-full self-end shrink-0 text-color-composer-button">
                <i className="icon icon-attach" />
              </button>
            </div>
          </div>
        </div>
        <Button className="text-primary" icon="send" />
      </div>
    </>
  );
};

export default ChatLoading;

This component acts as a temporary placeholder while the chat interface is loading. It includes:

  • A spinner centered inside the chat window to indicate that messages are being fetched.

  • A composer wrapper to display a disabled message input area mimicking the chat layout.

Next, let’s create the ChannelLoading component. Navigate to the components folder and create a ChannelLoading.tsx file with the following:

import Avatar from './Avatar';
import ChatLoading from './ChatLoading';
import RippleButton from './RippleButton';

const ChannelLoading = () => {
  return (
    <>
      <div className="flex items-center w-full bg-background relative z-10 py-1 pl-[23px] pr-[13px] shrink-0 h-[3.5rem]">
        {/* Chat Info */}
        <div className="grow overflow-hidden">
          <div className="flex items-center cursor-pointer py-[.0625rem] pl-[.0625rem]">
            {/* Avatar */}
            <div className="w-10 h-10 mr-[.625rem] text-[1.0625rem]">
              <Avatar
                data={{
                  name: '',
                }}
                width={40}
              />
            </div>
            {/* Info */}
            <div className="flex flex-col justify-center grow overflow-hidden">
              <div className="flex items-center gap-1"></div>
              <span className="inline text-sm leading-[1.125rem] text-color-text-secondary overflow-hidden truncate"></span>
            </div>
          </div>
        </div>
        {/* Actions */}
        <div className="flex gap-1">
          <RippleButton icon="search" />
          <RippleButton icon="phone" />
          <RippleButton icon="more" />
        </div>
      </div>
      <div id="channel" className="relative w-full h-full overflow-hidden">
        <div className="flex flex-col grow items-center w-full h-full">
          <ChatLoading />
        </div>
      </div>
    </>
  );
};

export default ChannelLoading;

This component wraps ChatLoading.tsx and provides:

  • A placeholder for the chat header (user info, search, call, and menu buttons).

  • A placeholder for the message area.

Building the Chat Page

With the loading indicators ready, let’s update the main chat page so users can:

  • View messages from a selected channel

  • See user/channel information

  • Send messages and interact with the chat

Inside the app/a/[channelId] directory, modify the page.tsx with the following code:

'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Channel as ChannelType } from 'stream-chat';
import { useUser } from '@clerk/nextjs';
import {
  Channel,
  DefaultStreamChatGenerics,
  MessageInput,
  MessageList,
  useChatContext,
  Window,
} from 'stream-chat-react';

import Avatar from '@/components/Avatar';
import ChannelLoading from '@/components/ChannelLoading';
import ChatLoading from '@/components/ChatLoading';
import { getLastSeen } from '@/lib/utils';
import RippleButton from '@/components/RippleButton';

const Chat = () => {
  const { channelId } = useParams<{ channelId: string }>();
  const router = useRouter();
  const { user } = useUser();
  const { client: chatClient } = useChatContext();

  const [chatChannel, setChatChannel] =
    useState<ChannelType<DefaultStreamChatGenerics>>();
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const loadChannel = async () => {
      const chatChannel = chatClient.channel('messaging', channelId);
      await chatChannel.watch();
      setChatChannel(chatChannel);
      setLoading(false);
    };

    if (chatClient && !chatChannel) loadChannel();
  }, [channelId, chatChannel, chatClient]);

  const isDMChannel = useMemo(
    () => chatChannel?.id?.startsWith('!members'),
    [chatChannel?.id]
  );

  const getDMUser = useCallback(() => {
    if (!chatChannel || !user) return;
    const members = { ...chatChannel.state.members };
    delete members[user!.id];
    return Object.values(members)[0].user!;
  }, [chatChannel, user]);

  const getChatName = useCallback(() => {
    if (!chatChannel || !user) return;
    if (isDMChannel) {
      const member = getDMUser()!;
      return member.name || `${member.first_name} ${member.last_name}`;
    } else {
      return chatChannel?.data?.name as string;
    }
  }, [chatChannel, user, isDMChannel, getDMUser]);

  const getImage = useCallback(() => {
    if (!chatChannel || !user) return;
    if (isDMChannel) {
      const member = getDMUser()!;
      return member.image;
    } else {
      return chatChannel?.data?.image;
    }
  }, [chatChannel, user, isDMChannel, getDMUser]);

  const getSubText = useCallback(() => {
    if (!chatChannel || !user) return;
    if (isDMChannel) {
      const member = getDMUser()!;
      return member.online ? 'Online' : getLastSeen(member.last_active!);
    } else {
      return chatChannel?.data?.member_count?.toLocaleString() + ' members';
    }
  }, [chatChannel, user, isDMChannel, getDMUser]);

  if (loading) return <ChannelLoading />;

  return (
    <>
      <div className="flex items-center px-2 w-full bg-background relative z-10 py-1 md:pl-[23px] md:pr-[13px] shrink-0 h-[3.5rem]">
        {/* Chat Info */}
        <div className="flex grow overflow-hidden gap-2">
          <div className="lg:hidden [&>button]:pe-2">
            <RippleButton
              icon="arrow-left text-3xl ml-1"
              onClick={() => router.push('/a')}
            />
          </div>
          <div className="flex items-center cursor-pointer py-[.0625rem] pl-[.0625rem]">
            {/* Avatar */}
            <div className="w-10 h-10 mr-[.625rem] text-[1.0625rem]">
              <Avatar
                data={{
                  name: getChatName()!,
                  image: getImage(),
                }}
                width={40}
              />
            </div>
            {/* Info */}
            <div className="flex flex-col justify-center grow overflow-hidden">
              <div className="flex items-center gap-1">
                <h3 className="text-[1.0625rem] font-semibold leading-[1.375rem] whitespace-pre overflow-hidden text-ellipsis">
                  {getChatName()}
                </h3>
              </div>
              <span className="inline text-sm leading-[1.125rem] text-color-text-secondary overflow-hidden truncate">
                <span>{getSubText()}</span>
              </span>
            </div>
          </div>
        </div>
        {/* Actions */}
        <div className="flex gap-1">
          <RippleButton icon="search" />
          <RippleButton icon="phone" />
          <RippleButton icon="more" />
        </div>
      </div>
      <div id="channel" className="relative w-full h-full overflow-hidden">
        <div className="flex flex-col grow items-center w-full h-full">
          <Channel channel={chatChannel} LoadingIndicator={ChatLoading}>
            <Window>
              <MessageList />
              <MessageInput />
            </Window>
          </Channel>
        </div>
      </div>
    </>
  );
};

export default Chat;

Let’s break down what’s going on here:

  • Retrieving the Channel ID: The page starts by retrieving the channelId from the URL using useParams(). This ID is used to fetch the corresponding chat channel from Stream Chat's API.

  • Watching the Channel: A useEffect hook is used to watch the channel and update the component state with its data. The chat interface remains in a loading state until the channel is successfully loaded, at which point the ChannelLoading component is replaced with the actual chat UI.

  • Helper Functions for Chat Data:

    • getDMUser(): This function determines whether the chat is a direct message (DM) channel. If the channel starts with !members, it is a private conversation between two users. The function then retrieves the other participant by filtering out the current user.

    • getChatName(): Determines the name of the chat. If it's a DM, it fetches the other user's name. Otherwise, it returns the group name stored in the channel data.

    • getImage(): Retrieves the chat profile picture. If it's a DM, it fetches the other user’s profile picture. If it's a group chat, it fetches the group avatar.

    • getSubText(): Generates the chat's status message. For DMs, it displays whether the user is online or when they were last active. For group chats, it shows the total number of members.

  • Chat Header: The chat header displays the channel name, avatar, and action buttons (search, call, and more options).

  • Rendering the Chat Interface:

    • We use the Channel component to wrap up all the chat channel's logic, functionality, and UI.

    • The MessageList component fetches and renders chat messages, while the MessageInput provides an area to send new messages.

    • The LoadingIndicator={ChatLoading} prop for Channel ensures that if messages take a moment to load, our ChatLoading component will be displayed instead.

Next, let’s add some styling to our channel components. Modify the globals.scss file with the following:

...
#channel .str-chat__channel .str-chat__container .str-chat__main-panel {
  display: none;
}

#channel .str-chat__channel {
  background: transparent;
  width: 100%;
  display: contents;
}

#channel .str-chat__channel .str-chat__container,
#channel .str-chat__main-panel-inner {
  display: contents;
}

#channel .str-chat__channel .str-chat__container .str-chat__main-panel {
  display: contents;
  min-width: auto;
}

#channel .str-chat__list {
  background: transparent;
  overflow-x: visible;
  overflow-y: initial;
}
...

Now, users can send and view messages in a channel. However, the current UI isn't what we're aiming for yet, so we’ll enhance it with some custom components in the upcoming sections.

Adding a Custom Empty Chat State

An empty state UI shows no messages have been added to a chat yet. Stream’s Channel component comes with a pre-built empty state indicator, but we’ll be building our custom version that mimics Telegram’s UI.

In the components directory, create an EmptyChat.tsx file with the following code:

import { EmptyStateIndicatorProps } from 'stream-chat-react';

const EmptyChat = ({}: EmptyStateIndicatorProps) => {
  return (
    <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full flex flex-col items-center justify-center text-center gap-2">
      <div className="inline-flex flex-col items-center bg-[#4A8E3A8C] w-[14.5rem] py-3 px-4 text-white rounded-3xl">
        <p className="font-medium">No messages here yet...</p>
        <p className="text-[.9375rem]">
          Send a message or send a greeting below
        </p>
      </div>
    </div>
  );
};

export default EmptyChat;

This component provides a friendly notification that prompts the user to send their first message.

Next, let’s integrate this into the Channel component. Modify the /a/[channelId]/page.tsx file with the following:

...
import EmptyChat from '@/components/EmptyChat';
...

const Chat = () => {
  ...

  return (
    ...
    <Channel
      ...
      EmptyStateIndicator={EmptyChat}
    >
      ...
    </Channel>
    ...
  );
};

export default Chat;

Adding a Custom Date Separator

Stream also provides a default date separator to group messages by date, but in this section, we’ll create our own custom DateSeperator component.

Inside the components folder, create a DateSeparator.tsx file with the following:

import { DateSeparatorProps } from 'stream-chat-react';

import { formatDate } from '@/lib/utils';

const DateSeparator = ({ date }: DateSeparatorProps) => {
  return (
    <div className="sticky top-[.625rem] text-center select-none cursor-pointer my-4 z-[9] pointer-events-none opacity-100 transition-opacity duration-300 ease-[ease]">
      <div className="relative inline-block bg-[#4A8E3A8C] text-white text-sm leading-[16.5px] font-medium py-[.1875rem] px-2 rounded-full z-0 break-words">
        {formatDate(date)}
      </div>
    </div>
  );
};

export default DateSeparator;

This component receives a timestamp and formats it into a user-friendly date. It is styled with a floating label design, making it look like a natural part of the chat UI.

Let’s integrate it into our chat UI. Modify the /a/[channelId]/page.tsx file to use the date separator:

...
import DateSeparator from '@/components/DateSeparator';
...

const Chat = () => {
  ...

  return (
    ...
    <Channel
      ...
      DateSeparator={DateSeparator}
    >
      ...
    </Channel>
    ...
  );
};

export default Chat;

Creating a Custom Emoji Picker

The next custom component we’ll be creating is an emoji picker. Just like with the previous custom components, Stream already provides a default EmojiPicker component using the emoji-mart library. However, we want a more flexible component for our Telegram clone, but one that uses the same library.

Run the following command in your terminal to install the necessary 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 { ReactNode, useEffect, useState } from 'react';
import Picker from '@emoji-mart/react';
import emojiData from '@emoji-mart/data';
import { usePopper } from 'react-popper';

interface EmojiPickerProps {
  buttonIcon: ReactNode;
  buttonClassName?: string;
  wrapperClassName?: string;
  onEmojiSelect: (e: { id: string; native: string }) => void;
}

const EmojiPicker = ({
  buttonClassName,
  buttonIcon,
  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-start',
  });

  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-[60]"
        >
          <Picker
            data={(emojiData as { default: object }).default}
            onEmojiSelect={onEmojiSelect}
            placement="top-start"
            theme="light"
          />
        </div>
      )}
      <button
        ref={setReferenceElement}
        onClick={() => setDisplayPicker((prev) => !prev)}
        aria-expanded="true"
        aria-label="Emoji picker"
        className={buttonClassName}
      >
        {buttonIcon}
      </button>
    </div>
  );
};

export default EmojiPicker;

In the code above:

  • We use @emoji-mart/react to show the emoji picker and @emoji-mart/data for the emoji data. We also utilize usePopper from react-popper to position the picker.

  • The component accepts props for the button icon, emoji selection, and optional styling classes for customization.

  • The usePopper correctly positions the picker relative to the button.

  • We use the displayPicker state to toggle the picker and handle outside clicks to close it.

Implementing a Custom Message Input

In this section, we’ll create a custom message input for our chat UI using slate. This input will support emojis, images, files, and rich text.

Firstly, let's install the necessary libraries. Run the following commands in your terminal:

npm install slate slate-dom slate-react slate-history is-hotkey
npm install @types/is-hotkey --save-dev

Next, we'll create our custom input component. Navigate to the components directory and create a new file named MessageInput.tsx with the following code:

'use client';
import { useCallback, 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,
  useMessageInputContext,
} from 'stream-chat-react';

type Descendant = Omit<SlateDescendant, 'children'> & {
  children: (
    | {
        text: string;
      }
    | {
        text: string;
        bold: boolean;
      }
    | {
        text: string;
        italic: boolean;
      }
    | {
        text: string;
        code: boolean;
      }
    | {
        text: string;
        strikethrough: boolean;
      }
  )[];
  url?: string;
  type: string;
};

type FileInfo = {
  name: string;
  size: number;
  type: string;
  previewUrl?: string;
};

interface PopupPosition {
  top: number;
  left: number;
}

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: '' }],
  },
];

import Appendix from './Appendix';
import Avatar from './Avatar';
import Button from './Button';
import EmojiPicker from './EmojiPicker';

const MessageInput = () => {
  const { sendMessage } = useChannelActionContext();
  const { uploadNewFiles, attachments, removeAttachments, cooldownRemaining } =
    useMessageInputContext();

  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const [filesInfo, setFilesInfo] = useState<FileInfo[]>([]);
  const [isVisible, setIsVisible] = useState(false);
  const [position, setPosition] = useState<PopupPosition>({
    top: 0,
    left: 0,
  });

  const popupRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  const renderElement = useCallback(
    (props: ElementProps) => <Element {...props} />,
    []
  );
  const renderLeaf = useCallback(
    (props: RenderLeafProps) => <Leaf {...props} />,
    []
  );
  const editor = useMemo(() => withHistory(withReact(createEditor())), []);

  const serializeToMarkdown = (nodes: Descendant[]) => {
    return nodes.map((n) => serializeNode(n)).join('\n');
  };

  const serializeNode = (
    node: Descendant | Descendant['children'],
    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, indentation))
      .join('');

    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;
    }
  };

  const handleMouseUp = () => {
    const selection = window.getSelection();
    if (!selection) {
      setIsVisible(false);
      return;
    }

    if (selection.isCollapsed) {
      setIsVisible(false);
      return;
    }

    const range = selection.getRangeAt(0);
    const rect = range.getBoundingClientRect();

    if (containerRef.current) {
      const containerRect = containerRef.current.getBoundingClientRect();
      const selectionTop = rect.top - containerRect.top;
      const selectionLeft = rect.left - containerRect.left;

      setPosition({
        top: selectionTop - 55,
        left: selectionLeft - 55,
      });

      setIsVisible(true);
    }
  };

  return (
    <Slate editor={editor} initialValue={initialValue}>
      <div className="relative flex items-end gap-2 z-10 mb-5 w-full xl:w-[calc(100%-25vw)] max-w-[45.5rem] px-4 transition-[top,_transform] duration-[200ms,_300ms] ease-[ease,_cubic-bezier(0.33,1,0.68,1)]">
        {/* Input */}
        <div
          ref={containerRef}
          className="composer-wrapper relative z-[1] grow bg-background max-w-[calc(100%-4rem)] rounded-[var(--border-radius-messages)] rounded-br-none shadow-[0_1px_2px_var(--color-default-shadow)] transition-[transform,_border-bottom-right-radius] duration-200 ease-out"
        >
          <Appendix position="right" />
          <div className="flex opacity-100 transition-opacity duration-200 ease-out">
            {/* Popup */}
            {isVisible && (
              <div
                ref={popupRef}
                style={{ top: position.top, left: position.left }}
                className="absolute z-30 bg-background rounded-[.9375rem] py-2 px-[.375rem] shadow-[0_1px_2px_var(--color-default-shadow)]"
              >
                <div
                  onClick={() => setIsVisible(false)}
                  className="flex flex-nowrap items-center"
                >
                  <FormattingButton type="mark" format="bold" icon="bold" />
                  <FormattingButton type="mark" format="italic" icon="italic" />
                  <FormattingButton
                    type="mark"
                    format="strikethrough"
                    icon="strikethrough"
                  />
                  <FormattingButton
                    type="mark"
                    format="code"
                    icon="monospace"
                  />
                </div>
              </div>
            )}
            <EmojiPicker
              buttonClassName="relative w-8 h-14 ml-3 flex items-center justify-center leading-[1.2] overflow-hidden transition-colors duration-150 uppercase rounded-full self-end shrink-0 text-color-composer-button"
              buttonIcon={<i className="icon icon-smile" />}
              wrapperClassName="relative flex"
              onEmojiSelect={(e) => {
                Transforms.insertText(editor, e.native);
              }}
            />
            <div
              style={{
                wordBreak: 'break-word',
              }}
              className="relative grow whitespace-pre-wrap"
            >
              <div className="custom-scroll mr-2 pr-1 min-h-14 max-h-[26rem] overflow-y-auto transition-[height] duration-100 ease-[ease]">
                <div className="pl-2 py-4">
                  {/* File preview section */}
                  {filesInfo.length > 0 && (
                    <div className="relative mb-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-[#46ba431a] border"
                              />
                            </div>
                          ) : (
                            <div className="flex items-center rounded-xl gap-3 p-3 border border-[#46ba431a] bg-[#46ba431a]">
                              <Avatar
                                width={32}
                                borderRadius={8}
                                data={{ name: file.type }}
                              />
                              <div className="flex flex-col gap-0.5">
                                <p className="text-sm text-black break-all whitespace-break-spaces line-clamp-1 mr-2">
                                  {file.name}
                                </p>
                                <p className="text-[13px] text-black 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-slate-200">
                            <button
                              onClick={() => handleRemoveFile(index)}
                              className="w-[18px] h-[18px] flex items-center justify-center rounded-full bg-slate-200"
                            >
                              <i className="icon icon-close text-sm text-black" />
                            </button>
                          </div>
                        </div>
                      ))}
                    </div>
                  )}
                  <Editable
                    renderElement={renderElement as never}
                    renderLeaf={renderLeaf}
                    onMouseUp={handleMouseUp}
                    className="editable outline-none leading-[1.3125]"
                    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);
                        }
                      }
                    }}
                  />
                </div>
              </div>
            </div>
            <div className="self-end">
              <button
                onClick={handleUploadButtonClick}
                className="relative w-14 h-14 ml-3 flex items-center justify-center leading-[1.2] overflow-hidden transition-colors duration-150 uppercase rounded-full self-end shrink-0 text-color-composer-button"
              >
                <i className="icon icon-attach" />
                <input
                  type="file"
                  ref={fileInputRef}
                  className="hidden"
                  onChange={handleFileInputChange}
                />
              </button>
            </div>
          </div>
        </div>
        <Button
          onClick={handleSubmit}
          disabled={!!cooldownRemaining}
          className="text-primary"
          icon="send"
        />
      </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 } = props;
  return <p {...attributes}>{children}</p>;
};

interface LeafProps extends RenderLeafProps {
  leaf: {
    bold?: boolean;
    code?: boolean;
    italic?: 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.strikethrough) {
    children = <s>{children}</s>;
  }

  return <span {...attributes}>{children}</span>;
};

interface ButtonProps {
  active?: boolean;
  className?: string;
  icon: string;
  format: string;
  type?: 'mark' | 'block';
}

const FormattingButton = ({ className, format, icon, type }: ButtonProps) => {
  const editor = useSlate();
  const isActive =
    type === 'block'
      ? isBlockActive(editor, format)
      : isMarkActive(editor, format);

  return (
    <button
      className={clsx(
        'w-8 h-8 p-1 text-[1.5rem] text-color-text-secondary leading-[1.2] mx-0.5 shrink-0 flex items-center justify-center rounded-[.375rem] transition-colors duration-150',
        isActive
          ? 'bg-interactive-element-hover'
          : 'bg-transparent hover:bg-interactive-element-hover',
        className
      )}
      onClick={(e) => {
        e.preventDefault();
        if (type === 'block') {
          toggleBlock(editor, format);
        } else if (type === 'mark') {
          toggleMark(editor, format);
        }
      }}
    >
      <i className={`icon icon-${icon}`} aria-hidden="true" />
    </button>
  );
};

export default MessageInput;

A lot is going on here, so let’s break it down:

  • Slate Editor: Uses Slate to create a rich text editor supporting multiple formatting options like bold, italics, underline, and strikethrough.

  • Serialization Functions: serializeToMarkdown and serializeNode convert the editor's content to markdown format, preserving rich formatting.

  • File Handling:

    • handleFileInputChange: Manages file selection and previews before sending.

    • handleUploadButtonClick: Opens the file picker for uploads.

    • handleRemoveFile: Removes selected files before sending a message.

  • Formatting Buttons: Buttons for bold, italic, and other styles call the toggleMark() function to apply or remove text formatting.

  • Hotkey Support: The is-hotkey library binds shortcuts like Ctrl+B for bold and Ctrl+I for italics, improving usability.

  • Send Button: handleSubmit serializes the editor’s content and sends the message using Stream’s sendMessage function.

  • Popup for Formatting:

    • handleMouseUp detects when the user selects text and determines if a popup should appear.

    • If text is selected, it calculates the position relative to the input box and displays the formatting options above the selection.

    • If no text is selected, it hides the popup.

    • When the popup appears above a selected text, users can apply bold, italics, strikethrough, or inline code formatting.

Now that our message input component is complete, we need to add it to the chat layout.

Open the /[channelId]/page.tsx file and update it with the following code:

...
import Input from '@/components/MessageInput';
...

const Chat = () => {
  ...

  return (
    ...
    <Channel
      ...
    >
      <Window>
        <MessageList />
        <MessageInput Input={Input} />
      </Window>
    </Channel>
    ...
  );
};

export default Chat;

In the code above, we import the MessageInput component as <Input /> and pass it as the Input prop for the MessageInput component to override the default UI.

Finally, let’s add some styling to our input container. Update your globals.scss file with the following code:

...
.composer-wrapper .svg-appendix {
  position: absolute;
  bottom: -0.1875rem;
  right: -0.5rem;
  width: .5625rem;
  height: 1.25rem;
  transition: opacity 200ms;
  font-size: 1rem !important;
}

.composer-wrapper .svg-appendix .corner {
  fill: var(--color-background);
}
...

Creating a Custom Message UI

To give our chat a unique look similar to Telegram Web, we’ll create a custom component that includes:

  • Custom message bubbles to differentiate sent and received messages.

  • Emoji reactions to allow users to interact with messages.

  • Attachment support for sharing files and images.

  • A message context menu for options like adding new reactions.

To get started, navigate to the components directory, create a new file named Message.tsx, and add the following code:

import {
  MouseEvent,
  RefObject,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useUser } from '@clerk/nextjs';
import {
  Attachment,
  MessageText,
  renderText,
  useChannelStateContext,
  useMessageContext,
} from 'stream-chat-react';
import clsx from 'clsx';
import emojiData from '@emoji-mart/data';

import Appendix from './Appendix';
import Avatar from './Avatar';
import EmojiPicker from './EmojiPicker';
import useClampPopup from '../hooks/useClampPopup';
import useClickOutside from '../hooks/useClickOutside';
import useIsMobile from '../hooks/useIsMobile';

const Message = () => {
  const { message, isMyMessage, handleAction, readBy, handleRetry } =
    useMessageContext();
  const { channel } = useChannelStateContext('ChannelMessage');
  const { user } = useUser();
  const isMobile = useIsMobile();

  const [showPopup, setShowPopup] = useState(false);
  const [popupPosition, setPopupPosition] = useState({
    x: 0,
    y: 0,
  });

  const messageRef = useRef<HTMLDivElement | null>(null);
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const popupRef = useClickOutside(() => {
    setShowPopup(false);
  }) as RefObject<HTMLDivElement>;

  const isDMChannel = channel?.id?.startsWith('!members');
  const own = isMyMessage();
  const createdAt = new Date(message.created_at!).toLocaleTimeString('en-US', {
    hour: 'numeric',
    minute: '2-digit',
    hour12: false,
  });
  const justReadByMe =
    readBy?.length === 0 || (readBy?.length === 1 && readBy[0].id === user?.id);

  const sending = message.status === 'sending';
  const delivered = message.status === 'received';
  const deliveredAndRead = delivered && !justReadByMe;
  const allowRetry =
    message.status === 'failed' && message.errorStatusCode !== 403;

  useClampPopup({
    wrapperRef,
    popupRef,
    showPopup,
    popupPosition,
    setPopupPosition,
  });

  useEffect(() => {
    if (!own || !deliveredAndRead) return;

    const message = messageRef.current;
    if (message) {
      const parentLi = message?.parentElement;
      let lastLi = parentLi?.previousElementSibling as HTMLElement;

      while (lastLi) {
        const status = lastLi.querySelector('.delivery-status');
        if (status) {
          status.classList.add('icon-message-read');
          status.classList.remove('icon-message-succeeded');
        }
        lastLi = lastLi.previousElementSibling as HTMLElement;
      }
    }
  }, [deliveredAndRead, messageRef, own]);

  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 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;
  };

  const setPosition = (e: MouseEvent<HTMLDivElement>) => {
    const containerRect =
      messageRef?.current?.getBoundingClientRect() as DOMRect;
    const top = e.clientY - containerRect.top + 10;
    const left = e.clientX - containerRect.left + 10;

    setPopupPosition({ x: left, y: top });
    setShowPopup(true);
  };

  const handleClick = (e: MouseEvent<HTMLDivElement>) => {
    if (allowRetry) {
      handleRetry(message);
    }

    if (isMobile && !showPopup) {
      e.preventDefault();
      setPosition(e);
    }
  };

  const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
    if (isMobile) return;
    e.preventDefault();

    if (showPopup) return setShowPopup(false);
    setPosition(e);
  };

  return (
    <div
      ref={messageRef}
      className={clsx(
        'message before:absolute before:bg-[#4A8E3A8C] before:top-[-0.1875rem] before:bottom-[-0.1875rem] before:left-[-50vw] before:right-[-50vw] before:z-0 before:transition-opacity before:duration-200 before:ease-out',
        showPopup ? 'before:opacity-55' : 'before:opacity-0',
        own && 'own'
      )}
      onClick={handleClick}
      onContextMenu={!isMobile ? handleContextMenu : undefined}
    >
      {showPopup && (
        <div className="fixed z-[1] top-0 left-0 w-full h-full"></div>
      )}
      <div ref={wrapperRef} className="relative content-wrapper">
        {/* Popup */}
        {showPopup && (
          <div
            ref={popupRef}
            className="absolute flex flex-col w-[200px] py-1 z-10 bg-background-compact-menu backdrop-blur-[10px] shadow-[0_.25rem_.5rem_.125rem_var(--color-default-shadow)] rounded-xl"
            style={{ top: popupPosition.y, left: popupPosition.x }}
          >
            <EmojiPicker
              buttonIcon={
                <>
                  <i className="icon icon-smile" />
                  Add reaction
                </>
              }
              wrapperClassName="contents"
              buttonClassName="flex grow relative items-center whitespace-nowrap hover:bg-background-compact-menu-hover text-black text-sm leading-6 mx-1 my-[.125rem] p-1 pe-3 rounded-[.375rem] font-medium [&>i]:max-w-5 [&>i]:text-[1.25rem] [&>i]:ms-2 [&>i]:me-[1.25rem] [&>i]:mr-4 [&>i]:text-[#707579]"
              onEmojiSelect={handleReaction}
            />
            <MenuItem label="Reply" icon="reply" />
            <MenuItem label="Copy Text" icon="copy" />
            <MenuItem label="Forward" icon="forward" />
            <MenuItem label="Select" icon="select" />
            <MenuItem label="Report" icon="flag" />
          </div>
        )}
        <div className="message-content relative max-w-[var(--max-width)] bg-[var(--background-color)] shadow-[0_1px_2px_var(--color-default-shadow)] p-[.3125rem_.5rem_.375rem] text-[15px]">
          <div className="content-inner min-w-0">
            <div className="break-words whitespace-pre-wrap leading-[1.3125] block rounded-[.25rem] relative overflow-clip">
              <div
                className={clsx(
                  message.attachments && message.attachments.length > 0
                    ? 'flex'
                    : 'hidden',
                  'mt-1 mb-1.5 flex-col gap-2 [&>div]:max-w-[315px] sm:[&>div]:max-w-none'
                )}
              >
                {message.attachments?.length && !message.quoted_message ? (
                  <Attachment
                    actionHandler={handleAction}
                    attachments={message.attachments}
                  />
                ) : null}
              </div>
              <MessageText
                renderText={(text, mentionedUsers) =>
                  renderText(text, mentionedUsers, {
                    customMarkDownRenderers: {
                      br: () => <span className="paragraph_break block h-2" />,
                    },
                  })
                }
                customWrapperClass="contents"
                customInnerClass="contents [&>*]:contents [&>*>*]:contents"
              />
              {reactionCounts.length > 0 && (
                <div className="flex-shrink flex items-center gap-1 flex-wrap mt-2 mr-[62px]">
                  {reactionCounts.map(([reactionType, data], index) => (
                    <button
                      key={index}
                      onClick={() =>
                        handleReactionClick(reactionType, data.reacted)
                      }
                      className={clsx(
                        'px-2 mb-1 h-[30px] flex items-center gap-1.5 border text-[11.8px] rounded-full transition-colors',
                        data.reacted &&
                          own &&
                          'text-white bg-[#45af54] border-[#45af54] hover:bg-[#3f9d4b] hover:border-[#3f9d4b]',
                        !data.reacted &&
                          own &&
                          'text-[#45af54] bg-[#c6eab2] border-[#c6eab2] hover:bg-[#b5e0a4] hover:border-[#b5e0a4]',
                        data.reacted &&
                          !own &&
                          'text-white bg-[#3390ec] border-[#3390ec] hover:bg-[#1a82ea] hover:border-[#1a82ea]',
                        !data.reacted &&
                          !own &&
                          'text-[#3390ec] bg-[#ebf3fd] border-[#ebf3fd] hover:bg-[#c5def9] hover:border-[#c5def9]'
                      )}
                    >
                      <span className="emoji text-[16px] mt-[1px]">
                        {getReactionEmoji(reactionType)}
                      </span>{' '}
                      <span className="text-sm font-medium whitespace-pre">
                        {data.count}
                      </span>
                    </button>
                  ))}
                </div>
              )}
              <div
                className={clsx(
                  'relative top-[.375rem] bottom-auto right-0 flex items-center rounded-[.625rem] px-1 cursor-pointer select-none float-right leading-[1.35] h-[19px] ml-[.4375rem] mr-[-0.375rem]',
                  own && 'text-message-meta-own',
                  !own && 'text-[#686c72bf]',
                  reactionCounts.length > 0 && '-mt-5'
                )}
              >
                <div className="mr-1 text-[.75rem] whitespace-nowrap">
                  {createdAt}
                </div>
                {own && (
                  <div className="overflow-hidden inline-block leading-[1] text-accent-own ml-[-0.1875rem] rounded-[.625rem] shrink-0">
                    <i
                      className={clsx(
                        'delivery-status icon pl-[.125rem] text-[1.1875rem]',
                        sending && 'icon-message-pending',
                        delivered &&
                          !deliveredAndRead &&
                          'icon-message-succeeded',
                        deliveredAndRead && 'icon-message-read'
                      )}
                    />
                  </div>
                )}
              </div>
            </div>
          </div>
          {own && <Appendix className="hidden" position="right" />}
          {!own && <Appendix className="hidden" />}
          {!own && !isDMChannel && (
            <div className="message-dp absolute w-[2.125rem] h-[2.125rem] left-[-2.5rem] bottom-[.0625rem] overflow-hidden">
              <Avatar
                data={{
                  name: message.user?.name as string,
                  image: message.user?.image as string,
                }}
                width={34}
              />
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

interface MenuItem {
  label: string;
  onClick?: () => void;
  icon: string;
}

const MenuItem = ({ label, onClick, icon }: MenuItem) => {
  return (
    <button
      onClick={onClick}
      className="flex grow relative items-center whitespace-nowrap hover:bg-background-compact-menu-hover text-black text-sm leading-6 mx-1 my-[.125rem] p-1 pe-3 rounded-[.375rem] font-medium [&>i]:max-w-5 [&>i]:text-[1.25rem] [&>i]:ms-2 [&>i]:me-[1.25rem] [&>i]:mr-4 [&>i]:text-[#707579]"
    >
      <i className={`icon icon-${icon}`} />
      {label}
    </button>
  );
};

export default Message;

In the code above:

  • Message Context & Details:

    • Uses useMessageContext to get message content, sender details, message status, and read receipts.

    • Uses useChannelStateContext to access the chat channel to send reactions and handle message actions.

  • Message Styling & Status Indicators:

    • Messages are aligned based on the sender (left for others, right for the user).

    • Timestamps and delivery status indicators (sent, received, read) are displayed for clarity.

  • Reactions:

    • useMemo calculates the total number of reactions and checks if the current user has reacted.

    • Clicking a reaction toggles it on or off.

  • Attachments:

    • If a message includes images, videos, or files, they are displayed using the Attachment component.

    • Users can preview or download attachments.

  • Context Menu & Message Actions:

    • Right-clicking (desktop) or long-pressing (mobile) opens a menu with different options.

    • The only active option in the menu is the “Add reaction” button, which uses the EmojiPicker component. Clicking the option opens the picker to select an emoji.

  • Message Delivery Status:

    • Displays sent (clock icon), delivered (check icon), and read (double-check icon) statuses.

    • Icons update in real-time based on message state.

Now that we have a custom message component, we need to integrate it with Stream’s message list.

We’ll do this in a new Messages component. Inside the components folder, create a Messages.tsx file with the following code:

import { useEffect, useRef } from 'react';
import { MessageList } from 'stream-chat-react';

import Message from './Message';

const Messages = () => {
  const scrollRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (scrollRef.current) {
      const scrollToTarget = () => {
        const unreadSeparator = scrollRef.current?.querySelector(
          '.str-chat__li.str-chat__unread-messages-separator-wrapper'
        ) as HTMLDivElement | null;

        if (unreadSeparator) {
          const separatorPosition =
            unreadSeparator.offsetTop -
            (scrollRef.current?.offsetTop as number);

          // Scroll to the unread separator
          scrollRef.current?.scrollTo({
            top: separatorPosition,
            behavior: 'smooth',
          });
        } else {
          // Scroll to the bottom
          scrollRef.current?.scrollTo({
            top: scrollRef.current?.scrollHeight,
            behavior: 'smooth',
          });
        }
      };

      const chatListObserver = new MutationObserver((mutations) => {
        const hasNewLI = mutations.some((mutation) =>
          Array.from(mutation.addedNodes).some(
            (node) =>
              node.nodeType === 1 && (node as HTMLElement).tagName === 'LI'
          )
        );

        if (hasNewLI) {
          scrollToTarget();
        }
      });

      const addChatListObserver = () => {
        const chatList = scrollRef.current?.querySelector('.str-chat__ul');
        if (!chatList) return;
        chatListObserver.observe(chatList, { childList: true });
      };

      const scrollObserver = new MutationObserver(addChatListObserver);
      scrollObserver.observe(scrollRef.current, {
        childList: true,
        subtree: true,
      });

      return () => {
        scrollObserver.disconnect();
        chatListObserver.disconnect();
      };
    }
  }, [scrollRef]);

  return (
    <div
      ref={scrollRef}
      className="custom-scroll px-8 xl:px-0 flex-1 w-full mb-2 overflow-y-scroll overflow-x-hidden transition-[bottom,_transform] duration-[150ms,_300ms] ease-[ease-out,_cubic-bezier(0.33,1,0.68,1)] xl:transition-transform xl:duration-300 xl:ease-[cubic-bezier(0.33,1,0.68,1)]"
    >
      <div className="flex flex-col justify-end mx-auto min-h-full w-full xl:w-[calc(100%-25vw)] max-w-[45.5rem] pt-4 pr-4 pl-[1.125rem]">
        <MessageList Message={Message} />
      </div>
    </div>
  );
};

export default Messages;

This component is responsible for rendering all messages in a chat channel:

  • Automatic Scrolling:

    • When new messages arrive, the chat scrolls to the unread separator or the bottom of the list.

    • This ensures that users always see the latest messages.

  • Using the Custom Message Component:

    • Instead of Stream’s default message renderer, we pass Message.tsx as a prop to MessageList.

    • This allows Stream to use our custom message UI.

To use the custom messages UI, we need to replace Stream’s default message renderer with our Messages component.

Modify the /a/[channelId]/page.tsx file to include Messages.tsx:

...
import {
  Channel,
  DefaultStreamChatGenerics,
  MessageInput,
  useChatContext,
  Window,
} from 'stream-chat-react';
...
import Messages from '@/components/Messages';
...

const Chat = () => {
  ...

  return (
    ...
    <Channel
      ...
    >
      <Window>
        <Messages />
        ...
      </Window>
    </Channel>
    ...
  );
};

export default Chat;

Finally, let’s update the globals.scss file to give our chat interface a polished look:

...
.message {
  position: relative;
  opacity: 1;
  transition: opacity .2s ease, transform .2s ease;
  user-select: text;
}

.message {
  display: flex;
  align-items: flex-end;
  margin-bottom: .375rem;
  position: relative;
  --background-color: var(--color-background);
  --hover-color: var(--color-reply-hover);
  --color-reaction: var(--color-message-reaction);
  --hover-color-reaction: var(--color-message-reaction-hover);
  --text-color-reaction: var(--accent-color);
  --color-reaction-chosen: var(--accent-color);
  --text-color-reaction-chosen: #FFFFFF;
  --hover-color-reaction-chosen: var(--color-message-reaction-chosen-hover);
  --active-color: var(--color-reply-active);
  --max-width: 29rem;
  --accent-color: var(--color-primary);
  --accent-shade-color: var(--color-primary-shade);
  --secondary-color: var(--color-text-secondary);
  --color-voice-transcribe: var(--color-voice-transcribe-button);
  --thumbs-background: var(--color-background);
  --deleting-translate-x: -50%;
  --select-message-scale: 0.9;
  --border-top-left-radius: var(--border-radius-messages);
  --border-top-right-radius: var(--border-radius-messages);
  --border-bottom-left-radius: var(--border-radius-messages);
  --border-bottom-right-radius: var(--border-radius-messages);
}

.message.own {
  flex-direction: row-reverse;
  --background-color: var(--color-background-own);
  --hover-color: var(--color-reply-own-hover);
  --color-reaction: var(--color-message-reaction-own);
  --hover-color-reaction: var(--color-message-reaction-hover-own);
  --text-color-reaction: var(--accent-color);
  --color-reaction-chosen: var(--accent-color);
  --text-color-reaction-chosen: var(--color-background);
  --hover-color-reaction-chosen: var(--color-message-reaction-chosen-hover-own);
  --active-color: var(--color-reply-own-active);
  --max-width: 30rem;
  --accent-color: var(--color-accent-own);
  --accent-shade-color: var(--color-green);
  --secondary-color: var(--color-accent-own);
  --color-code: var(--color-code-own);
  --color-code-bg: var(--color-code-own-bg);
  --color-links: var(--color-own-links);
  --deleting-translate-x: 50%;
  --color-text-green: var(--color-accent-own);
  --color-voice-transcribe: var(--color-voice-transcribe-button-own);
  --thumbs-background: var(--color-background-own);
  --color-background-own: var(--color-background-own-apple);
  --color-reply-own-hover: var(--color-reply-own-hover-apple);
  --color-reply-own-active: var(--color-reply-own-active-apple);
}

.str-chat__li--top .message:not(.own) {
  --border-bottom-left-radius: var(--border-radius-messages-small);
}

.str-chat__li--middle .message:not(.own) {
  --border-top-left-radius: var(--border-radius-messages-small);
  --border-bottom-left-radius: var(--border-radius-messages-small);
}

.str-chat__li--bottom .message:not(.own) {
  --border-top-left-radius: var(--border-radius-messages-small);
}

.str-chat__li--single .message:not(.own) {
  --border-bottom-left-radius: var(--border-radius-messages-small);
}

.str-chat__li--top .message.own {
  --border-bottom-right-radius: var(--border-radius-messages-small);
}

.str-chat__li--middle .message.own {
  --border-top-right-radius: var(--border-radius-messages-small);
  --border-bottom-right-radius: var(--border-radius-messages-small);
}

.str-chat__li--bottom .message.own {
  --border-top-right-radius: var(--border-radius-messages-small);
}

.str-chat__li--single .message.own {
  --border-bottom-right-radius: var(--border-radius-messages-small);
}

.str-chat__li--bottom .message .svg-appendix,
.str-chat__li--single .message .svg-appendix {
  display: block;
}

.message-content {
  border-top-left-radius: var(--border-top-left-radius);
  border-top-right-radius: var(--border-top-right-radius);
  border-bottom-left-radius: var(--border-bottom-left-radius);
  border-bottom-right-radius: var(--border-bottom-right-radius);
  --accent-background-color: var(--hover-color);
  --accent-background-active-color: var(--active-color);
  overflow-clip-margin: .5rem;
}

.str-chat__li--bottom .message:not(.own) .message-content,
.str-chat__li--single .message:not(.own) .message-content {
  --border-bottom-left-radius: 0;
}

.str-chat__li--bottom .message.own .message-content,
.str-chat__li--single .message.own .message-content {
  --border-bottom-right-radius: 0;
  border-bottom-right-radius: 0;
}

.message-content .svg-appendix {
  overflow: hidden;
  position: absolute;
  bottom: -0.0625rem;
  width: .5625rem;
  height: 1.125rem;
  font-size: 1rem !important;
}

.message-content .svg-appendix .corner {
  fill: var(--background-color);
}

.message.own .message-content .svg-appendix {
  right: -0.551rem;
}

.message:not(.own) .message-content .svg-appendix {
  left: -0.562rem;
}

.message-dp {
  display: none;
}

.str-chat__li--bottom .message-dp,
.str-chat__li--single .message-dp {
  display: block;
}

#channel .str-chat__li,
#channel .str-chat__message-text {
  font-family: var(--font-family);
}

#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 {
  font-size: 15px;
  line-height: 1.3125;
}

#channel .str-chat__main-panel-inner.str-chat__message-list-main-panel {
  height: 100%;
  position: initial;
}

#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;
}

#channel .str-chat__message-attachment-file--item {
  background: #ffffff30;
}

#channel .str-chat__message-attachment-file--item-size {
  color: #45af54;
}

#channel .str-chat__attachment-list .str-chat__message-attachment-download-icon path {
  fill: #0a0f316e
}

.emoji {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
    'Helvetica Neue', Arial, sans-serif;
}
...

And that’s it! With these updates, our chat interface is now more polished, interactive, and user-friendly.

Conclusion

In this second part of the series, we’ve successfully:

  • Created key components like the date separator and input field to closely match Telegram’s interface.

  • Built a custom message component with reactions, attachments, and a context menu.

  • Enhanced the scrolling experience to ensure users always see the latest messages.

These updates bring us closer to replicating a fully functional messaging app. Now that the chat system is in place, the next step is integrating voice and video calls.

In this series' third and final part, we will use Stream React Video and Audio SDK to manage call states and add UI components for starting and joining calls.

Stay tuned!