Разработка чат-приложения с нуля может показаться довольно сложной задачей. Но при наличии правильных инструментов все становится намного проще, чем вы думаете.

В этой серии из трех частей мы подробно рассмотрим процесс создания клона веб-версии Telegram с использованием Next.js, TailwindCSS и Stream SDK. В первой части мы настроим все необходимые инструменты для нашего проекта, добавим аутентификацию и создадим макет приложения с помощью TailwindCSS.

Во второй части мы сосредоточимся на разработке диалоговой секции нашего пользовательского интерфейса и добавлении обмена сообщениями в режиме реального времени с помощью Stream React Chat SDK. Наконец, в третьей части мы добавим видео- и аудиовызовы в наше приложение, используя Stream React Video and Audio SDK.

К концу этой серии у вас будет полное понимание того, как работают современные чат-приложения, а также полностью функциональный проект, который вы сможете использовать в своей дальнейшей работе.

Вот как будет выглядеть конечный результат:

https://youtu.be/I6EnQlVdMpc

Вы можете попробовать в действии рабочее демо проекта и найти его код в этом GitHub-репозитории.

Что ж, давайте приступим!

Предварительные требования

Чтобы извлечь максимальную пользу из этого руководства, прежде чем мы начнем, убедитесь, что вы знакомы со следующими концепциями:

  • Основы React: Вы должны знать, как создавать компоненты, управлять состоянием и работать с компонентной архитектурой React.

  • Node.js & npm: Убедитесь, что у вас установлены Node.js и npm, поскольку они необходимы для запуска и сборки нашего проекта.

  • Основы TypeScript, Next.js и TailwindCSS: Мы будем активно использовать эти технологии, поэтому, имея базовые знания о них, вам будет легче разобраться в этом руководстве.

Подготовка проекта

Давайте начнем с подготовки проекта. Мы будем использовать стартовый шаблон, содержащий весь шаблонный код, который нам нужен для начала работы.

Чтобы клонировать стартовый шаблон, выполните следующие команды:

git clone https://github.com/TropicolX/telegram-clone.git
cd telegram-clone
git checkout starter
npm install

После выполнения этих команд структура вашего проекта должна выглядеть следующим образом:

Этот шаблон содержит наш Next.js сетап с предварительно настроенными TypeScript и TailwindCSS. Он также включает в себя другие базовые модули и каталоги, которые мы будем использовать в этом руководстве, в том числе:

  • components: Здесь мы будем хранить все наши повторно используемые компоненты.

  • hooks: В этой папке будут содержаться все наши пользовательские React-хуки.

  • lib: Эта папка содержит файл utils.ts, который мы используем для хранения служебных функций.

Аутентификация пользователя с помощью Clerk

Чтобы использовать приложение Telegram Web, пользователи должны войти в систему. Поэтому мы тоже добавим аутентификацию в наш клон и будем использовать для этого Clerk.

Что такое Clerk?

Clerk — это платформа для управления пользователями. Она предоставляет широкий набор инструментов для аутентификации и профилей пользователей, включающий компоненты пользовательского интерфейса, API и панель мониторинга для администраторов.

Этот инструмент значительно упростит добавление функций аутентификации в наш Telegram-клон.

Создание учетной записи Clerk

Clerk sign-up page
Clerk sign-up page

Чтобы начать работу с Clerk, вам необходимо создать учетную запись на их веб-сайте. Для этого перейдите на страницу регистрации Clerk и зарегистрируйтесь, используя свой адрес электронной почты или какой-нибудь доступный социальный аккаунт.

Создание проекта Clerk

После входа в систему вам нужно будет создать проект вашего приложения. Для этого выполните следующие шаги:

  1. Перейдите на панель управления и нажмите "Create application".

  2. Назовите свое приложение “Telegram clone”.

  3. В разделе “Sign in options” выберите Email, Username, и Google.

  4. Нажмите "Create application", чтобы завершить процесс настройки.

Clerk dashboard steps
Clerk dashboard steps

Как только проект будет создан, вы получите доступ к странице обзора приложения. Здесь вы найдете свои Publishable Key и Secret Key — обязательно сохраните их, они понадобятся вам в дальнейшем.

Теперь нам нужно сделать так, чтобы пользователь в процессе регистрации мог ввести свои имя и фамилию, подобно тому, как это происходит в Telegram. Вы можете активировать эту функцию, выполнив следующие шаги:

  1. Перейдите на  вкладку "Configure" на своей панели управления.

  2. Найдите опцию "Name" в разделе "Personal Information" и включите ее.

  3. Нажмите на значок шестеренки рядом с полем "Name" и настройте его по своему усмотрению.

  4. Нажмите “Continue”, чтобы сохранить изменения.

Установка Clerk в вашем проекте

Далее давайте установим Clerk в ваш Next.js проект. Для этого выполните следующие шаги:

  1. Чтобы установить Next.js SDK Clerk, используйте следующую команду:

    npm install @clerk/nextjs

  2. Создайте .env.local-файл в корневом каталоге вашего проекта и добавьте в него следующие переменные среды:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key

 CLERK_SECRET_KEY=your_clerk_secret_key

Замените your_clerk_publishable_key и your_clerk_secret_key ключами со страницы обзора вашего проекта Clerk.

3.Чтобы иметь доступ к пользовательским данным и аутентификации во всем приложении, необходимо обернуть наш основной макет в компонент <ClerkProvider /> Clerk.Для этого откройте файл app/layout.tsx и добавьте в него следующее:

import type { Metadata } from 'next';
 import { ClerkProvider } from '@clerk/nextjs';

 ...

 export default function RootLayout({
   children,
 }: Readonly<{
   children: React.ReactNode;
 }>) {
   return (
     <ClerkProvider>
       <html lang="en">
         <body className="h-svh w-svw lg:h-screen lg:w-screen antialiased text-color-text select-none overflow-hidden">
           {children}
         </body>
       </html>
     </ClerkProvider>
   );
 }

Создание страниц регистрации и логина

Следующим шагом в разработке нашего Telegram-клона станет создание страниц регистрации и логина. Для этого мы будем использовать компоненты Clerk <SignUp /> и <SignIn />. Эти компоненты включают в себя все элементы пользовательского интерфейса и логику аутентификации, которые нам понадобятся.

Чтобы добавить эти страницы в ваше приложение, выполните следующие шаги:

1.Настройте URL-адреса аутентификации: Компоненты Clerk <SignUp /> и <SignIn /> требуют, чтобы мы указали, где они расположены в нашем приложении. Мы можем сделать это с помощью переменных окружения. Добавьте следующие маршруты в ваш .env.local-файл:

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
 NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

2.Создайте страницу регистрации: Создайте файл по адресу app/sign-up/[[...sign-up]]/page.tsx и добавьте в него следующий код:

import { SignUp } from '@clerk/nextjs';

 export default function Page() {
   return (
     <div className="sm:w-svw sm:h-svh py-4 bg-background w-full h-full flex items-center justify-center">
       <SignUp />
     </div>
   );
 }

3.Создайте страницу входа: Создайте аналогичный файл page.tsx в папке app/sign-in/[[...sign-in]] со следующим кодом:

import { SignIn } from '@clerk/nextjs';

 export default function Page() {
   return (
    <div className="w-svw h-svh bg-background flex items-center justify-center">
       <SignIn />
     </div>
   );
 }

4.Добавьте Clerk Middleware: Следующим шагом мы создадим вспомогательное middleware для настройки наших защищенных маршрутов. Наша цель — сделать доступными для всех пользователей только маршруты регистрации, закрыв все остальные. Для этого создайте в каталоге src файл middleware.ts со следующим кодом:

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

 const isPublicRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)']);

 export default clerkMiddleware(async (auth, request) => {
   if (!isPublicRoute(request)) {
     await auth.protect();
   }
 });

 export const config = {
   matcher: [
     // Пропускаем внутренние файлы Next.js и все статические файлы, если они не найдены в параметрах поиска

     '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
     // Всегда запускается для маршрутов API
     '/(api|trpc)(.*)',
   ],
 };

После выполнения этих шагов Clerk будет интегрирован в ваше приложение вместе со страницами входа и регистрации.

Настройка Stream

What is Stream?

Что такое Stream?

Stream — это платформа, которая позволяет разработчикам легко интегрировать расширенные функции чата и видео в свои приложения. Вместо того чтобы самостоятельно разрабатывать эти функции с нуля, Stream предлагает API и SDK, которые значительно упрощают процесс.

Мы будем использовать React SDK for Video и React Chat SDK Stream для реализации функций чата и видеозвонков в нашем Telegram-клоне.

Создание учетной записи Stream

Давайте начнем с создания учетной записи Stream:

  1. Регистрация: Перейдите на страницу регистрации Stream и создайте новую учетную запись, используя свой адрес электронной почты или логин в социальной сети.

  2. Заполните свой профиль:

    • После регистрации вас попросят предоставить дополнительную информацию, например, о вашем роде деятельности и отрасли.

    • Выберите  опции "Chat Messaging" и "Video and Audio", поскольку нам нужны эти инструменты для нашего приложения.

      Strem sign up options
      Strem sign up options
    • Наконец, нажмите "Complete Signup", чтобы продолжить.

После выполнения описанных выше шагов вы будете перенаправлены на панель управления Stream.

Создание нового проекта Stream

Теперь вам необходимо настроить Stream-приложение для вашего проекта:

  1. Создайте новое приложение: В правом верхнем углу панели управления Stream нажмите "Create App".

  2. Настройте свое приложение:

    • App Name: Введите подходящее имя, например "the-telegram-clone", или любое другое имя по вашему выбору.

    • Region: Для оптимальной производительности рекомендуется выбрать ближайший к вам регион.

    • Environment: Оставьте в этой опции значение "Development".

    • Нажмите кнопку "Create App", чтобы завершить настройку.

  3. Получите ключи API: После создания приложения перейдите в раздел "App Access Keys". Эти ключи понадобятся вам для подключения Stream к вашему проекту.

Установка Stream SDK

Чтобы начать использовать Stream в нашем Next.js проекте, нам понадобится установить несколько пакетов SDK:

1.Установите Stream SDK: Для установки необходимых пакетов выполните следующую команду:

npm install @stream-io/node-sdk @stream-io/video-react-sdk stream-chat-react stream-chat

2.Добавьте ключи приложения Stream: Добавьте ключи API Stream в свой .env.local-файл:

NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key
 STREAM_API_SECRET=your_stream_api_secret

Замените your_stream_api_key и your_stream_api_secret на ключи, которые вы получили в разделе "App Access Keys" на панели управления Stream.

3.Импортируйте таблицы стилей: Пакеты Stream  @stream-io/video-react-sdk and stream-chat-react включают CSS таблицы для своих компонентов. Импортируйте CSS @stream-io/video-react-sdk в ваш файл app/layout.tsx:

... 
 import '@stream-io/video-react-sdk/dist/css/styles.css';
 import './globals.scss';
 ...

Затем импортируйте стили stream-chat-react в свой файл globals.scss:

...
 @import "~stream-chat-react/dist/scss/v2/index.scss";
 ...

Создание исходного макета

После успешной установки Clerk и Stream мы готовы приступить к созданию главной страницы нашего Telegram-клона.

Первым шагом станет разработка общего макета нашего приложения. Этот макет будет включать все настройки, необходимые для отображения чата и видеоданных Stream во всем нашем приложении. На нем также будет боковая панель, на которой мы разместим список чатов пользователя.

Для начала создайте новую папку a в каталоге app и добавьте туда файл layout.tsx со следующим содержимым:

'use client';
import { ReactNode, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import { StreamChat } from 'stream-chat';
import { Chat } from 'stream-chat-react';
import { StreamVideo, StreamVideoClient } from '@stream-io/video-react-sdk';
import clsx from 'clsx';

interface LayoutProps {
  children?: ReactNode;
}

const tokenProvider = async (userId: string) => {
  const response = await fetch('/api/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ userId: userId }),
  });
  const data = await response.json();
  return data.token;
};

const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY as string;
export const [minWidth, defaultWidth, defaultMaxWidth] = [256, 420, 424];
export default function Layout({ children }: LayoutProps) {
  const { user } = useUser();
  const { channelId } = useParams<{ channelId?: string }>();
  const [loading, setLoading] = useState(true);
  const [chatClient, setChatClient] = useState<StreamChat>();
  const [videoClient, setVideoClient] = useState<StreamVideoClient>();
  const [sidebarWidth, setSidebarWidth] = useState(0);

  useEffect(() => {
    const savedWidth =
      parseInt(localStorage.getItem('sidebarWidth') as string) || defaultWidth;
    localStorage.setItem('sidebarWidth', String(savedWidth));
    setSidebarWidth(savedWidth);
  }, []);

  useEffect(() => {
    const customProvider = async () => {
      const token = await tokenProvider(user!.id);
      return token;
    };

    const setUpChatAndVideo = async () => {
      const chatClient = StreamChat.getInstance(API_KEY);
      const clerkUser = user!;
      const chatUser = {
        id: clerkUser.id,
        name: clerkUser.fullName!,
        image: clerkUser.hasImage ? clerkUser.imageUrl : undefined,
        custom: {
          username: clerkUser.username,
        },
      };

      if (!chatClient.user) {
        await chatClient.connectUser(chatUser, customProvider);
      }

      setChatClient(chatClient);
      const videoClient = StreamVideoClient.getOrCreateInstance({
        apiKey: API_KEY,
        user: chatUser,
        tokenProvider: customProvider,
      });
      setVideoClient(videoClient);
      setLoading(false);
    };

    if (user) setUpChatAndVideo();
  }, [user, videoClient, chatClient]);

  if (loading)
    return (
      <div className="flex h-full w-full">
        <div
          style={{
            width: ${sidebarWidth || defaultWidth}px,
          }}
          className="bg-background h-full flex-shrink-0 relative"
        ></div>
        <div className="relative flex flex-col items-center w-full h-full overflow-hidden border-l border-solid border-l-color-borders">
          <div className="chat-background absolute top-0 left-0 w-full h-full -z-10 overflow-hidden bg-theme-background"></div>
        </div>
      </div>
    );
  return (
    <Chat client={chatClient!}>
      <StreamVideo client={videoClient!}>
        <div className="flex h-full w-full">
          <div
            className={clsx(
              'fixed max-w-none left-0 right-0 top-0 bottom-0 lg:relative flex w-full h-full justify-center z-[1] min-w-0',
              !channelId &&
                'translate-x-[100vw] min-[601px]:translate-x-[26.5rem] lg:translate-x-0'
            )}
          >
            <div className="relative flex flex-col items-center w-full h-full overflow-hidden border-l border-solid border-l-color-borders">
              <div className="chat-background absolute top-0 left-0 w-full h-full -z-10 overflow-hidden bg-theme-background"></div>
              {children}
            </div>
          </div>
        </div>
      </StreamVideo>
    </Chat>
  );
}

В этом файле много важных вещей, поэтому давайте рассмотрим их все по порядку:

  • Поставщик токенов: Мы используем функцию tokenProvider, которая извлекает токен из конечной точки /api/token. Этот токен необходим сервисам Stream для идентификации пользователя.

  • Настройка чата и видео: Мы определяем setUpChatAndVideo внутри useEffect для подключения пользователя к чат- и видео-клиенту Stream. Мы получаем данные пользователя от Clerk и передаем их обоим клиентам вместе с токеном от нашего поставщика токенов.

  • Управление шириной боковой панели: Мы сохраняем ширину боковой панели (sidebarWidth) в localStorage. Когда компонент монтируется, мы загружаем это значение, чтобы боковая панель всегда соответствовала заданному пользователем размеру.

  • Общий макет:

    • Если мы все еще загружаем пользователя или что-то монтируем, мы создаем макет-заполнитель.

    • Как только все будет готово, мы оборачиваем все в компоненты <Chat> и <StreamVideo>.

    • Боковая панель (левая область), которая представляет собой отдельный раздел, и основное содержимое (правая область), которая содержит чат, передаются макету в качестве дочерних элементов.

Создание маршрута API для токена 

Теперь давайте создадим маршрут для конечной точки /api/token, которую мы уже упоминали ранее.

Создайте в каталоге app папку /api/token, а затем добавьте туда файл route.ts со следующим содержимым:

import { StreamClient } from '@stream-io/node-sdk';

const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const SECRET = process.env.STREAM_API_SECRET!;

export async function POST(request: Request) {
  const client = new StreamClient(API_KEY, SECRET);

  const body = await request.json();

  const userId = body?.userId;

  if (!userId) {
    return Response.error();
  }

  const token = client.generateUserToken({ user_id: userId });

  const response = {
    userId: userId,
    token: token,
  };

  return Response.json(response);
}

Этот код отвечает за генерацию и возврат токена аутентификации для пользователя на основе предоставленного userId.

Настройка перенаправления и структура каналов

Каждый чат в нашем Telegram-клоне будет обрабатываться в рамках отдельного канала. В Stream каждый канал включает в себя:

  • Сообщения, которыми обмениваются пользователи.

  • Список людей, следящих за каналом (активных участников).

  • Опциональный список участников (для личных бесед).

Поскольку каждый канал имеет уникальный ID, нашей главной точкой входа станет маршрут /a/[channelId], по которому пользователи будут взаимодействовать с чатом. Однако по умолчанию посещение / не приводит на главную страницу приложения, поэтому нам нужно позаботиться о перенаправлении пользователей в нужное место.

Структура маршрутизации в нашем приложении будет выглядеть следующим образом:

  1. Мы будем перенаправлять из корневой страницы (/) в /a.

  2. Создадим страницу-заглушку в /a из соображений чистоты архитектуры.

  3. Настроим /a/[channelId], где и будет располагаться чат.

Перенаправление с корневой страницы

Прежде всего, добавим следующий код в файл /app/page.tsx:

import { redirect } from 'next/navigation';

export default function Home() {
  redirect('/a');
}

Таким образом при посещении / пользователи автоматически перенаправляются на /a, где будут обрабатываться наши каналы.

Создание страницы-заглушки

Затем создайте файл page.tsx внутри каталога /app/a/ и добавьте туда следующий код:

const Main = () => {
  return null;
};

export default Main;

Этот компонент не будет ничего отображать, он просто служит заглушкой для поддержания чистоты нашей структуры маршрутизации.

Создание страницы канала

Теперь создадим главную страницу чата. Создайте внутри каталога /app/a/ папку с именем [channelId] и добавьте туда файл page.tsx со следующим кодом:

'use client';

import { useParams } from 'next/navigation';

const Chat = () => {
  const { channelId } = useParams<{ channelId: string }>();
  return <div>{channelId}</div>;
};

export default Chat;

Этот компонент извлекает channelId из URL и отображает его. Позже мы будем использовать этот ID для загрузки корректных данных чата из Stream.

Благодаря этому сетапу наша маршрутизация теперь структурирована должным образом.

Отображение списка каналов на боковой панели

В этом разделе мы добавим в наше приложение компонент боковой панели. Боковая панель позволит пользователям просматривать свои активные чаты, искать разговоры и инициировать новые. Для реализации этой задачи мы будем использовать компонент Stream channelList. Мы настроим его стиль и функции, чтобы он максимально соответствовал Telegram.

Создание компонента папки с чатами

Первый компонент, который мы создадим для нашей боковой панели, — это компонент ChatFolders. Этот компонент будет содержать ChannelList, строку поиска и меню профиля Clerk для нашего приложения.

Создайте в каталоге components новый файл под названием ChatFolders.tsx и добавьте в него следующий код:

import { useRouter } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import {
  ChannelList,
  ChannelSearchProps,
  useChatContext,
} from 'stream-chat-react';

import ChatPreview from './ChatPreview';
import SearchBar from './SearchBar';
import Spinner from './Spinner';

const ChatFolders = ({}: ChannelSearchProps) => {
  const { user } = useUser();
  const { client } = useChatContext();
  const router = useRouter();

  return (
    <div className="flex-1 overflow-hidden relative w-full h-[calc(100%-3.5rem)]">
      <div className="flex flex-col w-full h-full overflow-hidden">
        <div className="flex-1 overflow-hidden relative w-full h-full">
          <div className="w-full h-full">
            <div className="custom-scroll p-2 overflow-y-scroll overflow-x-hidden h-full bg-background pe-2 min-[820px]:pe-[0px]">
              <ChannelList
                Preview={ChatPreview}
                sort={{
                  last_message_at: -1,
                }}
                filters={{
                  members: { $in: [client.userID!] },
                }}
                showChannelSearch
                additionalChannelSearchProps={{
                  searchForChannels: true,
                  onSelectResult: async (_, result) => {
                    if (result.cid) {
                      router.push(/a/${result.id});
                    } else {
                      const channel = client.getChannelByMembers('messaging', {
                        members: [user!.id, result.id!],
                      });
                      await channel.create();
                      router.push(/a/${channel.data?.id});
                    }
                  },
                  SearchBar: SearchBar,
                }}
                LoadingIndicator={() => (
                  <div className="w-full h-full flex items-center justify-center">
                    <div className="relative w-12 h-12">
                      <Spinner color="var(--color-primary)" />
                    </div>
                  </div>
                )}
              />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default ChatFolders;

Давайте разберем некоторые ключевые моменты этого компонента:

  • Контекст чата и пользователь:

    • Мы получаем текущего пользователя (user) из Clerk с помощью useUser().

    • client извлекается из useChatContext(), которая предоставляет доступ к функциям чата Stream.

  • Список каналов и поиск:

    • Мы используем ChannelList из Stream для отображения каналов чатов пользователя, отсортированных по последнему сообщению.

    • Чтобы отображались только те каналы, участником которых является текущий пользователь, мы применяем фильтр (members: { $in: [client.userId!] }).

    • Панель поиска (SearchBar) позволяет пользователям искать каналы и других пользователей.

  • Выбор или создание чата:

    • Если результатом поиска является канал (cid существует), мы переходим к нему.

    • В противном случае, если это другой пользователь, мы создаем с ним новый чат один на один, используя getChannelByMembers, а затем перенаправляем пользователя в новый чат.

  • Индикатор загрузки: Во время загрузки мы показываем отцентрированный Spinner в области списка чатов.

Теперь нам необходимо создать компоненты ChatPreview и Searchbar, которые мы уже импортировали в наш код.

Отображение превью чатов

Каждый канал на боковой панели должен отображать превью, содержащее название чата, последнее сообщение, время и количество непрочитанных сообщений. Мы реализуем это с помощью компонента ChatPreview.

Создайте в каталоге components новый файл под именем ChatPreview.tsx и добавьте туда следующий код:

import { useCallback, useMemo } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
  ChannelPreviewUIComponentProps,
  useChatContext,
} from 'stream-chat-react';
import clsx from 'clsx';

import Avatar from './Avatar';

const ChatPreview = ({
  channel,
  displayTitle,
  unread,
  displayImage,
  lastMessage,
}: ChannelPreviewUIComponentProps) => {
  const { client } = useChatContext();
  const router = useRouter();
  const pathname = usePathname();

  const isDMChannel = channel.id?.startsWith('!members');
  const goToChat = () => {
    const channelId = channel.id;
    router.push(/a/${channelId});
  };

  const getDMUser = useCallback(() => {
    const members = { ...channel.state.members };
    delete members[client.userID!];
    return Object.values(members)[0].user!;
  }, [channel.state.members, client.userID]);

  const getChatName = useCallback(() => {
    if (displayTitle) return displayTitle;
    else {
      const member = getDMUser();
      return member.name || ${member.first_name} ${member.last_name};
    }
  }, [displayTitle, getDMUser]);

  const getImage = useCallback(() => {
    if (displayImage) return displayImage;
    else if (isDMChannel) {
      const member = getDMUser();
      return member.image;
    }
  }, [displayImage, getDMUser, isDMChannel]);

  const lastText = useMemo(() => {
    if (lastMessage) {
      return lastMessage.text;
    }

    if (isDMChannel) {
      return ${getChatName()} joined Telegram;
    } else {
      return `${
        // @ts-expect-error one of these will be defined
        channel.data?.created_by?.first_name ||
        // @ts-expect-error one of these will be defined
        channel.data?.created_by?.name.split(' ')[0]
      } created the group "${displayTitle}"`;
    }
  }, [
    lastMessage,
    channel.data?.created_by,
    getChatName,
    displayTitle,
    isDMChannel,
  ]);
  const lastMessageDate = useMemo(() => {
    const date = new Date(
      lastMessage?.created_at || (channel.data?.created_at as string)
    );
    const today = new Date();
    if (
      date.getDate() === today.getDate() &&
      date.getMonth() === today.getMonth() &&
      date.getFullYear() === today.getFullYear()
    ) {
      return date.toLocaleTimeString('en-US', {
        hour: 'numeric',
        minute: 'numeric',
        hour12: false,
      });
    } else if (date.getFullYear() === today.getFullYear()) {
      return date.toLocaleDateString('en-US', {
        month: 'short',
        day: 'numeric',
      });
    } else {
      return date.toLocaleDateString('en-US', {
        month: 'short',
        day: 'numeric',
        year: 'numeric',
      });
    }
  }, [lastMessage, channel.data?.created_at]);

  const active = useMemo(() => {
    const pathChannelId = pathname.split('/').filter(Boolean).pop();
    return pathChannelId === channel.id;
  }, [pathname, channel.id]);

  return (
    <div
      className={clsx(
        'relative p-[.5625rem] cursor-pointer min-h-auto overflow-hidden flex items-center rounded-xl whitespace-nowrap gap-2',
        active && 'bg-chat-active text-white',
        !active && 'bg-background text-color-text hover:bg-chat-hover'
      )}
      onClick={goToChat}
    >
      <div className="relative">
        <Avatar
          data={{
            name: getChatName(),
            image: getImage(),
          }}
          width={54}
        />
      </div>
      <div className="flex-1 overflow-hidden">
        <div className="flex items-center justify-start overflow-hidden">
          <div className="flex items-center justify-start overflow-hidden gap-1">
            <h3 className="font-semibold truncate text-base">
              {getChatName()}
            </h3>
          </div>
          <div className="grow min-w-2" />
          <div className="flex items-center shrink-0 mr-[.1875rem] text-[.75rem]">
            <span className={active ? 'text-white' : 'text-color-text-meta'}>
              {lastMessageDate}
            </span>
          </div>
        </div>
        <div className="flex items-center justify-start truncate">
          <p
            className={clsx(
              'truncate text-[.9375rem] text-left pr-1 grow',
              active && 'text-white',
              !active && 'text-color-text-secondary'
            )}
          >
            {lastText}
          </p>
          {unread !== undefined && unread > 0 && (
            <div
              className={clsx(
                'min-w-6 h-6 shrink-0 rounded-xl text-sm leading-6 text-center py-0 px-[.4375rem] font-medium',
                active && 'bg-white text-primary',
                !active && 'bg-green text-white'
              )}
            >
              <span className="inline-flex whitespace-pre">{unread}</span>
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default ChatPreview;

В приведенном выше коде:

  • Получение информации о пользователе и чате:

    • Мы получаем чат-клиент (client) смомощью хука useChatContext().

    • Функция getDMUser() определяет другого участника в личном чате, не учитывая текущего пользователя.

  • Отображение информацию о чате:

    • Функция getChatName() извлекает отображаемое имя для групповых чатов или имя другого участника для личных чатов.

    • Функция getImage() извлекает аватар чата.

  • Форматирование последнего сообщения и времени:

    • Последнее сообщение отображается, если оно существует. В противном случае отображается дефолтное системное сообщение (например, “Пользователь присоединился к Telegram” или “Группа создана”).

    • Функция lastMessageDate обеспечивает корректное форматирование времени:

      • Если сообщение отправлено сегодня, оно отображается в формате HH:MM.

      • Если оно было отправлено в течение этого года, то отображается месяц и дата.

      • В противном случае отображается только год.

  • Переход к чатам:

    • Когда пользователь нажимает на чат, он перенаправляется на него с помощью router.push().

  • Выделение чата:

    • Если текущий чат соответствует указанному URL, превью чата подсвечивается, чтобы показать, что он активен.

    • Если в чате есть непрочитанные сообщения, отображается зеленый значок с их количеством.

Добавление панели поиска

Панель поиска позволит пользователям выполнять поиск чатов и каналов. Она также будет содержать меню профиля для пользователей.

Создайте внутри каталога components новый файл под именем SearchBar.tsx и добавьте в него следующий код:

import { UserButton, useUser } from '@clerk/nextjs';
import { SearchBarProps } from 'stream-chat-react';

import RippleButton from './RippleButton';

const SearchBar = ({ exitSearch, onSearch, query }: SearchBarProps) => {
  const { user } = useUser();

  const handleClick = () => {
    if (query) {
      exitSearch();
    }
  };

  return (
    <div className="flex items-center bg-background px-[.8125rem] pt-1.5 pb-2 gap-[.625rem] h-[56px]">
      <div className="relative h-10 w-10 [&>div:first-child]">
        <div className="[&>div]:opacity-0">
          {user && !query && <UserButton />}
        </div>
        <div className="absolute left-0 top-0 flex items-center justify-center pointer-events-none">
          <RippleButton
            onClick={handleClick}
            icon={query ? 'arrow-left' : 'menu'}
          />
        </div>
      </div>
      <div className="relative w-full bg-chat-hover text-[rgba(var(--color-text-secondary-rgb),0.5)] max-w-[calc(100%-3.25rem)] border-[2px] border-chat-hover has-[:focus]:border-primary has-[:focus]:bg-background rounded-[1.375rem] flex items-center pe-[.1875rem] transition-opacity ease-[cubic-bezier(0.33,1,0.68,1)] duration-[330ms]">
        <input
          type="text"
          name="Search"
          value={query}
          onChange={onSearch}
          placeholder="Search"
          autoComplete="off"
          className="peer order-2 h-10 text-black rounded-[1.375rem] bg-transparent pl-[11px] pt-[6px] pb-[7px] pr-[9px] focus:outline-none focus:caret-primary"
        />
        <div className="w-6 h-6 ms-3 shrink-0 flex items-center justify-center peer-focus:text-primary">
          <i className="icon icon-search text-2xl leading-[1]" />
        </div>
      </div>
    </div>
  );
};

export default SearchBar;

Давайте подробно рассмотрим этот компонент:

  • Отображение и кнопка меню:

    • Если пользователь авторизован и поисковый запрос отсутствует, отображается кнопка Clerk UserButton.

    • RippleButton динамически переключается между иконкой меню и стрелкой назад в зависимости от состояния поиска.

  • Обработка поиска и динамический стиль:

    • Когда пользователь вводит текст, onSearch обновляет запрос и фильтрует результаты.

    • Клик по стрелке назад завершает поиск (exitSearch()).

Создание компонента боковой панели

Теперь, когда все подкомпоненты готовы, давайте объединим их в нашей боковой панели.

Создайте файл Sidebar.tsx в каталоге components и добавьте в него следующий код:

'use client';
import React, { useState, useEffect, RefObject } from 'react';
import clsx from 'clsx';

import Button from './Button';
import ChatFolders from './ChatFolders';
import { minWidth, defaultMaxWidth } from '@/app/a/layout';
import useClickOutside from '@/hooks/useClickOutside';

enum SidebarView {
  Default,
  NewGroup,
}

interface SidebarProps {
  width: number;
  setWidth: React.Dispatch<React.SetStateAction<number>>;
}

export default function Sidebar({ width, setWidth }: SidebarProps) {
  const getMaxWidth = () => {
    const windowWidth = window.innerWidth;
    let newMaxWidth = defaultMaxWidth;

    if (windowWidth >= 1276) {
      newMaxWidth = Math.floor(windowWidth * 0.33);
    } else if (windowWidth >= 926) {
      newMaxWidth = Math.floor(windowWidth * 0.4);
    }
    return newMaxWidth;
  };

  const [maxWidth, setMaxWidth] = useState(getMaxWidth());
  const [menuOpen, setMenuOpen] = useState(false);
  const [view, setView] = useState(SidebarView.Default);

  const menuDomNode = useClickOutside(() => {
    setMenuOpen(false);
  }) as RefObject<HTMLDivElement>;

  const toggleMenu = () => {
    setMenuOpen((prev) => !prev);
  };

  const openNewGroupView = () => {
    setView(SidebarView.NewGroup);
    setMenuOpen(false);
  };

  useEffect(() => {
    const calculateMaxWidth = () => {
      const newMaxWidth = getMaxWidth();
      setMaxWidth(newMaxWidth);
      setWidth(width >= newMaxWidth ? newMaxWidth : width);
    };

    calculateMaxWidth();

    window.addEventListener('resize', calculateMaxWidth);

    return () => {
      window.removeEventListener('resize', calculateMaxWidth);
    };
  }, [setWidth, width]);

  useEffect(() => {
    if (width) {
      let newWidth = width;
      if (width > maxWidth) {
        newWidth = maxWidth;
      }
      setWidth(newWidth);
      localStorage.setItem('sidebarWidth', String(width));
    }
  }, [width, maxWidth, setWidth]);

  // Обработчик изменения размера боковой панели
  const handleResize = (
    event: React.MouseEvent<HTMLDivElement, MouseEvent>
  ) => {
    const startX = event.clientX;
    const startWidth = width;

    const onMouseMove = (e: MouseEvent) => {
      const newWidth = Math.min(
        Math.max(minWidth, startWidth + (e.clientX - startX)),
        maxWidth
      );
      setWidth(newWidth);
    };

    const onMouseUp = () => {
      window.removeEventListener('mousemove', onMouseMove);
      window.removeEventListener('mouseup', onMouseUp);
    };

    window.addEventListener('mousemove', onMouseMove);
    window.addEventListener('mouseup', onMouseUp);
  };

  return (
    <div
      id="sidebar"
      style={{ width: ${width}px }}
      className="max-[600px]:!w-full max-[925px]:!w-[26.5rem] w-auto group bg-background h-full flex-shrink-0 relative"
      onMouseLeave={() => setMenuOpen(false)}
    >
      {/* Вид по умолчанию */}
      <div
        className={clsx(
          'contents',
          view === SidebarView.Default ? 'block' : 'hidden'
        )}
      >
        <ChatFolders />
      </div>
      {/* Кнопка создания нового чата */}
      <div
        className={clsx(
          'absolute right-4 bottom-4 translate-y-20 transition-transform duration-[.25s] ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover:translate-y-0',
          menuOpen && 'translate-y-0',
          view === SidebarView.NewGroup && 'hidden'
        )}
      >
        <Button
          active
          icon="new-chat-filled"
          onClick={toggleMenu}
          className={clsx('sidebar-button', menuOpen ? 'active' : '')}
        >
          <i className="absolute icon icon-close" />
        </Button>
        <div>
          {menuOpen && (
            <div className="fixed left-[-100vw] right-[-100vw] top-[-100vh] bottom-[-100vh] z-20" />
          )}
          <div
            ref={menuDomNode}
            className={clsx(
              'bg-background-compact-menu backdrop-blur-[10px] custom-scroll py-1 bottom-[calc(100%+0.5rem)] right-0 origin-bottom-right overflow-hiddden list-none absolute shadow-[0_.25rem_.5rem_.125rem_#72727240] rounded-xl min-w-[13.5rem] z-[21] overscroll-contain text-black transition-[opacity,_transform] duration-150 ease-[cubic-bezier(0.2,0.0.2,1)]',
              menuOpen
                ? 'block opacity-100 scale-100'
                : 'hidden opacity-0 scale-[.85]'
            )}
          >
            <div
              onClick={openNewGroupView}
              className="text-sm my-[.125rem] mx-1 p-1 pe-3 rounded-md font-medium scale-100 transition-transform duration-150 ease-in-out bg-transparent flex items-center relative overflow-hidden leading-6 whitespace-nowrap text-black cursor-pointer"
            >
              <i
                className="icon icon-group max-w-5 text-[1.25rem] me-5 ms-2 text-[#707579]"
                aria-hidden="true"
              />
              {'New Group'}
            </div>
          </div>
        </div>
      </div>
      {/* Изменение размера */}
      <div
        className="hidden lg:block absolute z-20 top-0 -right-1 h-full w-2 cursor-ew-resize"
        onMouseDown={handleResize}
      />
    </div>
  );
}

В приведенном выше компоненте:

  • Управление шириной боковой панели:

    • Ширина боковой панели динамически рассчитывается на основе размера окна и настраивается в определенном диапазоне (от minWidth до maxWidth).

    • Ширина сохраняется в localStorage для сохранения между сеансами.

  • Изменение размера боковой панели:

    • cursor-ew-resize позволяет пользователям перетаскивать боковую панель для изменения размера.

    • Функция handleResize гарантирует, что ширина остается в допустимых пределах при перетаскивании.

  • Отображение боковой панели:

    • Боковая панель имеет два режима:

      1. Вид по умолчанию – отображает список чатов (ChatFolders).

      2. Просмотр новой группы – открывается, когда пользователь нажимает кнопку "New Group".

  • Переключение меню и клик вне компонента:

    • При нажатии кнопки "New Chat" открывается плавающее меню.

    • Если пользователь кликает за пределами меню, оно автоматически закрывается (useClickOutside()).

Добавление стилей боковой панели

Теперь давайте оформим нашу боковую панель, чтобы она хорошо сочеталась с остальным пользовательским интерфейсом чата.

Откройте файл globals.scss в каталоге app и добавьте туда следующий CSS-код:

...
#sidebar .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react {
  background: none;
  border: none;
  box-shadow: none;
}

#sidebar 
.str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react>div {
  padding: 0;
}

#sidebar 
.str-chat__channel-search {
  position: absolute;
  width: 100%;
  top: 0;
  left: 0;
}

#sidebar .str-chat__channel-list-react .str-chat__channel-list-messenger-react {
  margin-top: 56px;
}

#sidebar .str-chat__channel-search-result-list.inline {
  padding: 0.5rem;
}

#sidebar .str-chat__channel-search-result {
  border-radius: 0.75rem;
}
...

Добавление боковой панели в макет

Наконец, давайте добавим боковую панель в макет нашего приложения.

Откройте файл /a/layout.tsx и добавьте туда следующий код:

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

export default function Layout({ children }: LayoutProps) {
  ...
  return (
    <Chat client={chatClient!}>
      <StreamVideo client={videoClient!}>
        <div className="flex h-full w-full">
          <Sidebar width={sidebarWidth} setWidth={setSidebarWidth} />
          ...
        </div>
      </StreamVideo>
    </Chat>
  );
}

Здесь мы импортируем компонент Sidebar, включаем его в макет и и предоставляем значения для его пропов width и setWidth, чтобы можно было динамически изменять размер.

Создание группового чата на боковой панели

Теперь, когда у нас есть боковая панель, следующим шагом станет реализация функции создания групповых чатов. Для этого мы создадим на боковой панели представление, которое позволит пользователям:

  • Давать название групповому чату

  • Выбирать доступных пользователей в приложении

  • Создавать новый канал чата с выбранными пользователями

Создайте в каталоге components новый файл под именем NewGroupView.tsx и добавьте туда следующий код:

import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { DefaultStreamChatGenerics, useChatContext } from 'stream-chat-react';
import { UserResponse } from 'stream-chat';

import Avatar from './Avatar';
import Button from './Button';
import RippleButton from './RippleButton';
import Spinner from './Spinner';
import { customAlphabet } from 'nanoid';
import { getLastSeen } from '../lib/utils';
import clsx from 'clsx';

interface NewGroupViewProps {
  goBack: () => void;
}

const NewGroupView = ({ goBack }: NewGroupViewProps) => {
  const { client } = useChatContext();

  const [creatingGroup, setCreatingGroup] = useState(false);
  const [query, setQuery] = useState('');
  const [groupName, setGroupName] = useState('');
  const [users, setUsers] = useState<UserResponse<DefaultStreamChatGenerics>[]>(
    []
  );
  const [originalUsers, setOriginalUsers] = useState<
    UserResponse<DefaultStreamChatGenerics>[]
  >([]);
  const [selectedUsers, setSelectedUsers] = useState<string[]>([]);

  const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
  const cancelled = useRef(false);

  useEffect(() => {
    const getAllUsers = async () => {
      const userId = client.userID;
      const { users } = await client.queryUsers(
        // @ts-expect-error - id
        { id: { $ne: userId } },
        { id: 1, name: 1 },
        { limit: 20 }
      );

      setUsers(users);
      setOriginalUsers(users);
    };
    getAllUsers();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const handleUserSearch = async (e: ChangeEvent<HTMLInputElement>) => {
    const query = e.target.value.trim();
    setQuery(query);
    if (!query) {
      if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
      cancelled.current = true;
      setUsers(originalUsers);
      return;
    }

    cancelled.current = false;

    if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
    debounceTimeout.current = setTimeout(async () => {
      if (cancelled.current) return;

      try {
        const userId = client.userID;
        const { users } = await client.queryUsers(
          {
            $or: [
              { id: { $autocomplete: query } },
              { name: { $autocomplete: query } },
            ],
            // @ts-expect-error - id
            id: { $ne: userId },
          },
          { id: 1, name: 1 },
          { limit: 5 }
        );

        if (!cancelled.current) setUsers(users);
      } catch (error) {
        console.error('Error fetching users:', error);
      }
    }, 200);
  };

  const leave = () => {
    setCreatingGroup(false);
    setGroupName('');
    setQuery('');
    setSelectedUsers([]);
    goBack();
  };

  const createNewGroup = async () => {
    if (!groupName) {
      alert('Please enter a group name.');
      return;
    }
    if (selectedUsers.length < 2) {
      alert('Please select at least two users.');
      return;
    }

    setCreatingGroup(true);

    try {
      const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
      const nanoid = customAlphabet(alphabet, 7);
      const group = client.channel('messaging', nanoid(7), {
        name: groupName,
        members: [...selectedUsers, client.userID!],
      });

      await group.create();
      leave();
    } catch (error) {
      console.error(error);
      alert('Error creating group');
    } finally {
      setCreatingGroup(false);
    }
  };

  const onSelectUser = (e: ChangeEvent<HTMLInputElement>) => {
    const userId = e.target.id;
    setSelectedUsers((prevSelectedUsers) => {
      if (prevSelectedUsers.includes(userId)) {
        return prevSelectedUsers.filter((id) => id !== userId);
      } else {
        return [...prevSelectedUsers, userId];
      }
    });
  };

  const sortedUsers = useMemo(
    () =>
      users.sort((a, b) => {
        if (selectedUsers.includes(a.id)) {
          return -1;
        } else if (selectedUsers.includes(b.id)) {
          return 1;
        } else {
          return 0;
        }
      }),
    [users, selectedUsers]
  );

  return (

    <>
      <div className="flex items-center bg-background px-[.8125rem] pt-1.5 pb-2 gap-[1.375rem] h-[56px]">
        <RippleButton onClick={leave} icon="arrow-left" />
        <h3 className="text-[1.25rem] font-medium mr-auto select-none truncate">
          New Group
        </h3>
      </div>
      <div className="flex flex-col px-5 h-[calc(100%-3.5rem)] overflow-hidden">
        <div>
          <label
            htmlFor="groupName"
            className="relative block mt-5 py-[11px] px-[18px] rounded-xl border border-color-borders-input shadow-sm focus-within:border-primary focus-within:ring-1 focus-within:ring-primary"
          >
            <input
              type="text"
              id="groupName"
              value={groupName}
              onChange={(e) => setGroupName(e.target.value)}
              className="peer caret-primary border-none bg-transparent placeholder-transparent placeholder:text-base focus:border-transparent focus:outline-none focus:ring-0"
              placeholder="Group name"
            />
            <span className="pointer-events-none absolute start-[18px] top-0 -translate-y-1/2 bg-white p-0.5 text-sm text-[#a2acb4] transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-base peer-focus:top-0 peer-focus:text-xs peer-focus:text-primary">
              Group name
            </span>
          </label>
          <h3 className="my-4 mx-1 font-medium text-[1rem] text-color-text-secondary">
            Add members
          </h3>
          <label
            htmlFor="user"
            className="relative caret-primary block overflow-hidden border-b border-color-borders-input bg-transparent py-3 px-5 focus-within:border-primary"
          >
            <input
              type="text"
              id="users"
              placeholder="Who would you like to add?"
              value={query}
              onChange={(e) => handleUserSearch(e)}
              className="text-base h-8 w-full border-none bg-transparent p-0 placeholder:text-base focus:border-transparent focus:outline-none focus:ring-0"
            />
          </label>
          <fieldset className="flex flex-col gap-2 mt-2 custom-scroll">
            {sortedUsers.map((user) => (
              <UserCheckbox
                key={user.id}
                user={user}
                checked={selectedUsers.includes(user.id)}
                onChange={onSelectUser}
              />
            ))}
          </fieldset>
        </div>
      </div>
      <div className="absolute right-4 bottom-4 transition-transform duration-[.25s] ease-[cubic-bezier(0.34,1.56,0.64,1)] translate-y-0">
        <Button
          active
          icon="arrow-right"
          onClick={createNewGroup}
          disabled={creatingGroup}
          className={clsx('sidebar-button', creatingGroup ? 'active' : '')}
        >
          <div className="icon-loading absolute">
            <div className="relative w-6 h-6 before:relative before:content-none before:block before:pt-full">
              <Spinner />
            </div>
          </div>
        </Button>
      </div>
    </>
  );
};

interface UserCheckboxProps {
  user: UserResponse<DefaultStreamChatGenerics>;
  checked: boolean;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}

const UserCheckbox = ({ user, checked, onChange }: UserCheckboxProps) => {

  return (
    <label
      htmlFor={user.id}
      className="flex items-center gap-2 p-2 h-[3.5rem] rounded-xl hover:bg-chat-hover bg-background-compact-menu cursor-pointer"
    >
      <div className="relative h-10 w-10">
        <Avatar
          data={{
            name: user.name || ${user.first_name} ${user.last_name},
            image: user.image || '',
          }}
          width={40}
        />
      </div>
      <div>
        <p className="text-base leading-5">
          {user.name || ${user.first_name} ${user.last_name}}
        </p>
        <p className="text-sm text-color-text-meta">
          {getLastSeen(user.last_active!)}
        </p>
      </div>
      <div className="flex items-center ml-auto">
        &#8203;
        <input
          id={user.id}
          type="checkbox"
          checked={checked}
          onChange={onChange}
          className="size-4 rounded border-2 border-color-borders-input"
        />
      </div>
    </label>
  );
};

export default NewGroupView;


Давайте рассмотрим ключевые функции этого компонента:

  • Извлечение пользователей:

    • Компонент извлекает список пользователей (исключая текущего пользователя) с помощью client.queryUsers().

    • Список хранится в users и originalUsers, чтобы обеспечить фильтрацию при поиске.

  • Поиск пользователей:

    • Функция handleUserSearch динамически фильтрует пользователей на основе входных данных.

    • Debounce-механизм предотвращает избыточные вызовы API.

  • Процесс создания группы:

    • Пользователь выбирает участников с помощью чекбоксов.

    • Прежде чем продолжить, необходимо указать название группы.

    • При создании группы с помощью nanoid() генерируется случайный ID и создается новый канал чата.

  • Пользовательский интерфейс и взаимодействие:

    • Поля ввода: Ввод названия группы и панель поиска пользователя с динамическими метками.

    • Выбор участников: Пользователи сортируются в соответствии с их приоритетом, при этом выбранные участники отображаются вверху списка.

    • Состояния кнопки:

      • Кнопка "Create" отключается при создании группы.

      • Во время загрузки появляется индикатор загрузки.

  • Возврат и сброс состояния:

    • Нажатие кнопки отмены сбрасывает форму и удаляет выбранных пользователей.

Далее, мы добавим на боковую панель компонент NewGroupView, чтобы пользователи могли легко получить доступ к функции создания групп.

Для этого перейдите в файл /components/Sidebar.tsx и внесите следующие изменения, добавив в него NewGroupView:

...
import NewGroupView from './NewGroupView';
...

export default function Sidebar({ width, setWidth }: SidebarProps) {
  ...

  return (
    <div
      id="sidebar"
      ...
    >
      {/* Вид по умолчанию */}
      ...
      {/* Создание новой группы */}
      <div
        className={clsx(
          'contents',
          view === SidebarView.NewGroup ? 'block' : 'hidden'
        )}
      >
        <NewGroupView goBack={() => setView(SidebarView.Default)} />
      </div>
      {/* Кнопка создания нового чата */}
      ...
      {/* Изменение размера */}
      ...
    </div>
  );
}

Здесь мы добавили новый компонент под названием NewGroupView, который теперь отображается в боковой панели в зависимости от действий пользователя. Когда пользователь нажимает на кнопку “New Group”, боковая панель меняет свой вид на NewGroupView. После создания группы или отмены операции боковая панель возвращается к своему первоначальному виду.

Вот и все! С этим обновлением пользователи теперь могут создавать новые группы прямо на боковой панели.

Заключение

В этой части серии мы заложили фундамент для нашего клона веб-версии Telegram, настроив проект Next.js, интегрировав аутентификацию Clerk и создав базовый макет с помощью TailwindCSS. Мы также установили Stream SDK и добавили возможность создавать групповые чаты.

В следующей части мы сосредоточимся на создании интерфейса чата и реализации обмена сообщениями в режиме реального времени с помощью Stream React Chat SDK.

Продолжение следует…


Хотите создавать такие же современные веб-приложения, как Telegram-клон на Next.js и TailwindCSS?

Тогда вам точно стоит обратить внимание на курс JavaScript Developer. Basic, который стартует 26 июня.

А чтобы вы точно поняли, как все устроено — мы подготовили два бесплатных открытых урока, которые напрямую связаны с темами этой статьи:

Комментарии (3)


  1. Surrogate
    06.06.2025 04:30

    Ой, да вы своими статьями поломаете гешефт создателям IT-курсов где на выходе проект по созданию чат-бота в Telegram.


  1. andreysam
    06.06.2025 04:30

    Статья уровня "как сделать клон телеграм с помощью чатгпт". Если уж делаете тупо вебморду, ну используйте апи телеграмма напрямую, зачем какой-то Кларк. Ну и зачем здесь nextjs, если никогда не понадобится использовать ssr. Лучше бы голый vite...


    1. Pavel-Lukyanov
      06.06.2025 04:30

      Тоже не понял зачем лишняя нагрузка на сервер и проблемы в виде nextjs, если без авторизации контента не видно. Но с семантикой html тегов не сильно заморачивались, просто все в div заворачивали.