
Разработка чат-приложения с нуля может показаться довольно сложной задачей. Но при наличии правильных инструментов все становится намного проще, чем вы думаете.
В этой серии из трех частей мы подробно рассмотрим процесс создания клона веб-версии Telegram с использованием Next.js, TailwindCSS и Stream SDK. В первой части мы настроим все необходимые инструменты для нашего проекта, добавим аутентификацию и создадим макет приложения с помощью TailwindCSS.
Во второй части мы сосредоточимся на разработке диалоговой секции нашего пользовательского интерфейса и добавлении обмена сообщениями в режиме реального времени с помощью Stream React Chat SDK. Наконец, в третьей части мы добавим видео- и аудиовызовы в наше приложение, используя Stream React Video and Audio SDK.
К концу этой серии у вас будет полное понимание того, как работают современные чат-приложения, а также полностью функциональный проект, который вы сможете использовать в своей дальнейшей работе.
Вот как будет выглядеть конечный результат:
Вы можете попробовать в действии рабочее демо проекта и найти его код в этом 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, вам необходимо создать учетную запись на их веб-сайте. Для этого перейдите на страницу регистрации Clerk и зарегистрируйтесь, используя свой адрес электронной почты или какой-нибудь доступный социальный аккаунт.
Создание проекта Clerk

После входа в систему вам нужно будет создать проект вашего приложения. Для этого выполните следующие шаги:
-
Перейдите на панель управления и нажмите «Create application«.
-
Назовите свое приложение “Telegram clone”.
-
В разделе “Sign in options” выберите Email, Username, и Google.
-
Нажмите «Create application«, чтобы завершить процесс настройки.
Как только проект будет создан, вы получите доступ к странице обзора приложения. Здесь вы найдете свои Publishable Key и Secret Key — обязательно сохраните их, они понадобятся вам в дальнейшем.

Теперь нам нужно сделать так, чтобы пользователь в процессе регистрации мог ввести свои имя и фамилию, подобно тому, как это происходит в Telegram. Вы можете активировать эту функцию, выполнив следующие шаги:
-
Перейдите на вкладку «Configure» на своей панели управления.
-
Найдите опцию «Name» в разделе «Personal Information» и включите ее.
-
Нажмите на значок шестеренки рядом с полем «Name» и настройте его по своему усмотрению.
-
Нажмите “Continue”, чтобы сохранить изменения.
Установка Clerk в вашем проекте
Далее давайте установим Clerk в ваш Next.js проект. Для этого выполните следующие шаги:
-
Чтобы установить Next.js SDK Clerk, используйте следующую команду:
npm install @clerk/nextjs -
Создайте
.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:
-
Регистрация: Перейдите на страницу регистрации Stream и создайте новую учетную запись, используя свой адрес электронной почты или логин в социальной сети.
-
Заполните свой профиль:
-
После регистрации вас попросят предоставить дополнительную информацию, например, о вашем роде деятельности и отрасли.
-
Выберите опции «Chat Messaging» и «Video and Audio«, поскольку нам нужны эти инструменты для нашего приложения.
Strem sign up options -
Наконец, нажмите «Complete Signup«, чтобы продолжить.
-
После выполнения описанных выше шагов вы будете перенаправлены на панель управления Stream.
Создание нового проекта Stream

Теперь вам необходимо настроить Stream-приложение для вашего проекта:
-
Создайте новое приложение: В правом верхнем углу панели управления Stream нажмите «Create App».
-
Настройте свое приложение:
-
App Name: Введите подходящее имя, например «the-telegram-clone«, или любое другое имя по вашему выбору.
-
Region: Для оптимальной производительности рекомендуется выбрать ближайший к вам регион.
-
Environment: Оставьте в этой опции значение «Development«.
-
Нажмите кнопку «Create App«, чтобы завершить настройку.
-
-
Получите ключи 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], по которому пользователи будут взаимодействовать с чатом. Однако по умолчанию посещение / не приводит на главную страницу приложения, поэтому нам нужно позаботиться о перенаправлении пользователей в нужное место.
Структура маршрутизации в нашем приложении будет выглядеть следующим образом:
-
Мы будем перенаправлять из корневой страницы (
/) в/a. -
Создадим страницу-заглушку в /a из соображений чистоты архитектуры.
-
Настроим
/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гарантирует, что ширина остается в допустимых пределах при перетаскивании.
-
-
Отображение боковой панели:
-
Боковая панель имеет два режима:
-
Вид по умолчанию – отображает список чатов (ChatFolders).
-
Просмотр новой группы – открывается, когда пользователь нажимает кнопку «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"> ​ <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 июня.
А чтобы вы точно поняли, как все устроено — мы подготовили два бесплатных открытых урока, которые напрямую связаны с темами этой статьи:
-
Манипуляции с HTML и CSS с помощью JavaScript — 10 июня, 18:00
-
Работа с основными HTML‑тегами и их атрибутами — 17 июня, 19:00
ссылка на оригинал статьи https://habr.com/ru/articles/915946/
Добавить комментарий