
Разработка чат-приложения с нуля может показаться довольно сложной задачей. Но при наличии правильных инструментов все становится намного проще, чем вы думаете.
В этой серии из трех частей мы подробно рассмотрим процесс создания клона веб-версии 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
Комментарии (3)
andreysam
06.06.2025 04:30Статья уровня "как сделать клон телеграм с помощью чатгпт". Если уж делаете тупо вебморду, ну используйте апи телеграмма напрямую, зачем какой-то Кларк. Ну и зачем здесь nextjs, если никогда не понадобится использовать ssr. Лучше бы голый vite...
Pavel-Lukyanov
06.06.2025 04:30Тоже не понял зачем лишняя нагрузка на сервер и проблемы в виде nextjs, если без авторизации контента не видно. Но с семантикой html тегов не сильно заморачивались, просто все в div заворачивали.
Surrogate
Ой, да вы своими статьями поломаете гешефт создателям IT-курсов где на выходе проект по созданию чат-бота в Telegram.