Создаём свой Telegram-клон с помощью Next.js и TailwindCSS — Часть 1

от автора

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

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

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

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

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

https://youtu.be/I6EnQlVdMpc

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Что такое Clerk?

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

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

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

Clerk sign-up page

Clerk sign-up page

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

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

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

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

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

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

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

Clerk dashboard steps

Clerk dashboard steps

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

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

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

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

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

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

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

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

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

    npm install @clerk/nextjs

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

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key

 CLERK_SECRET_KEY=your_clerk_secret_key

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

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

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

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

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

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

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

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

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

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

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

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

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

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';   const isPublicRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)']);   export default clerkMiddleware(async (auth, request) => {    if (!isPublicRoute(request)) {      await auth.protect();    }  });   export const config = {    matcher: [      // Пропускаем внутренние файлы Next.js и все статические файлы, если они не найдены в параметрах поиска       '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',      // Всегда запускается для маршрутов API      '/(api|trpc)(.*)',    ],  };

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

Настройка Stream

What is Stream?

Что такое Stream?

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

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

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

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

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

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

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

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

      Strem sign up options

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

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

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

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

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

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

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

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

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

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

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

Установка Stream SDK

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

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

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

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

NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key  STREAM_API_SECRET=your_stream_api_secret

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

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

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

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

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

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

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

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

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

'use client'; import { ReactNode, useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import { useUser } from '@clerk/nextjs'; import { StreamChat } from 'stream-chat'; import { Chat } from 'stream-chat-react'; import { StreamVideo, StreamVideoClient } from '@stream-io/video-react-sdk'; import clsx from 'clsx';  interface LayoutProps {   children?: ReactNode; }  const tokenProvider = async (userId: string) => {   const response = await fetch('/api/token', {     method: 'POST',     headers: {       'Content-Type': 'application/json',     },     body: JSON.stringify({ userId: userId }),   });   const data = await response.json();   return data.token; };  const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY as string; export const [minWidth, defaultWidth, defaultMaxWidth] = [256, 420, 424]; export default function Layout({ children }: LayoutProps) {   const { user } = useUser();   const { channelId } = useParams<{ channelId?: string }>();   const [loading, setLoading] = useState(true);   const [chatClient, setChatClient] = useState<StreamChat>();   const [videoClient, setVideoClient] = useState<StreamVideoClient>();   const [sidebarWidth, setSidebarWidth] = useState(0);    useEffect(() => {     const savedWidth =       parseInt(localStorage.getItem('sidebarWidth') as string) || defaultWidth;     localStorage.setItem('sidebarWidth', String(savedWidth));     setSidebarWidth(savedWidth);   }, []);    useEffect(() => {     const customProvider = async () => {       const token = await tokenProvider(user!.id);       return token;     };      const setUpChatAndVideo = async () => {       const chatClient = StreamChat.getInstance(API_KEY);       const clerkUser = user!;       const chatUser = {         id: clerkUser.id,         name: clerkUser.fullName!,         image: clerkUser.hasImage ? clerkUser.imageUrl : undefined,         custom: {           username: clerkUser.username,         },       };        if (!chatClient.user) {         await chatClient.connectUser(chatUser, customProvider);       }        setChatClient(chatClient);       const videoClient = StreamVideoClient.getOrCreateInstance({         apiKey: API_KEY,         user: chatUser,         tokenProvider: customProvider,       });       setVideoClient(videoClient);       setLoading(false);     };      if (user) setUpChatAndVideo();   }, [user, videoClient, chatClient]);    if (loading)     return (       <div className="flex h-full w-full">         <div           style={{             width: ${sidebarWidth || defaultWidth}px,           }}           className="bg-background h-full flex-shrink-0 relative"         ></div>         <div className="relative flex flex-col items-center w-full h-full overflow-hidden border-l border-solid border-l-color-borders">           <div className="chat-background absolute top-0 left-0 w-full h-full -z-10 overflow-hidden bg-theme-background"></div>         </div>       </div>     );   return (     <Chat client={chatClient!}>       <StreamVideo client={videoClient!}>         <div className="flex h-full w-full">           <div             className={clsx(               'fixed max-w-none left-0 right-0 top-0 bottom-0 lg:relative flex w-full h-full justify-center z-[1] min-w-0',               !channelId &&                 'translate-x-[100vw] min-[601px]:translate-x-[26.5rem] lg:translate-x-0'             )}           >             <div className="relative flex flex-col items-center w-full h-full overflow-hidden border-l border-solid border-l-color-borders">               <div className="chat-background absolute top-0 left-0 w-full h-full -z-10 overflow-hidden bg-theme-background"></div>               {children}             </div>           </div>         </div>       </StreamVideo>     </Chat>   ); }

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

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

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

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

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

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

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

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

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

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

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

import { StreamClient } from '@stream-io/node-sdk';  const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!; const SECRET = process.env.STREAM_API_SECRET!;  export async function POST(request: Request) {   const client = new StreamClient(API_KEY, SECRET);    const body = await request.json();    const userId = body?.userId;    if (!userId) {     return Response.error();   }    const token = client.generateUserToken({ user_id: userId });    const response = {     userId: userId,     token: token,   };    return Response.json(response); }

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

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

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

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

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

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

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

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

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

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

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

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

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

import { redirect } from 'next/navigation';  export default function Home() {   redirect('/a'); }

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

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

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

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

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

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

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

'use client';  import { useParams } from 'next/navigation';  const Chat = () => {   const { channelId } = useParams<{ channelId: string }>();   return <div>{channelId}</div>; };  export default Chat;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

import { useCallback, useMemo } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import {   ChannelPreviewUIComponentProps,   useChatContext, } from 'stream-chat-react'; import clsx from 'clsx';  import Avatar from './Avatar';  const ChatPreview = ({   channel,   displayTitle,   unread,   displayImage,   lastMessage, }: ChannelPreviewUIComponentProps) => {   const { client } = useChatContext();   const router = useRouter();   const pathname = usePathname();    const isDMChannel = channel.id?.startsWith('!members');   const goToChat = () => {     const channelId = channel.id;     router.push(/a/${channelId});   };    const getDMUser = useCallback(() => {     const members = { ...channel.state.members };     delete members[client.userID!];     return Object.values(members)[0].user!;   }, [channel.state.members, client.userID]);    const getChatName = useCallback(() => {     if (displayTitle) return displayTitle;     else {       const member = getDMUser();       return member.name || ${member.first_name} ${member.last_name};     }   }, [displayTitle, getDMUser]);    const getImage = useCallback(() => {     if (displayImage) return displayImage;     else if (isDMChannel) {       const member = getDMUser();       return member.image;     }   }, [displayImage, getDMUser, isDMChannel]);    const lastText = useMemo(() => {     if (lastMessage) {       return lastMessage.text;     }      if (isDMChannel) {       return ${getChatName()} joined Telegram;     } else {       return `${         // @ts-expect-error one of these will be defined         channel.data?.created_by?.first_name ||         // @ts-expect-error one of these will be defined         channel.data?.created_by?.name.split(' ')[0]       } created the group "${displayTitle}"`;     }   }, [     lastMessage,     channel.data?.created_by,     getChatName,     displayTitle,     isDMChannel,   ]);   const lastMessageDate = useMemo(() => {     const date = new Date(       lastMessage?.created_at || (channel.data?.created_at as string)     );     const today = new Date();     if (       date.getDate() === today.getDate() &&       date.getMonth() === today.getMonth() &&       date.getFullYear() === today.getFullYear()     ) {       return date.toLocaleTimeString('en-US', {         hour: 'numeric',         minute: 'numeric',         hour12: false,       });     } else if (date.getFullYear() === today.getFullYear()) {       return date.toLocaleDateString('en-US', {         month: 'short',         day: 'numeric',       });     } else {       return date.toLocaleDateString('en-US', {         month: 'short',         day: 'numeric',         year: 'numeric',       });     }   }, [lastMessage, channel.data?.created_at]);    const active = useMemo(() => {     const pathChannelId = pathname.split('/').filter(Boolean).pop();     return pathChannelId === channel.id;   }, [pathname, channel.id]);    return (     <div       className={clsx(         'relative p-[.5625rem] cursor-pointer min-h-auto overflow-hidden flex items-center rounded-xl whitespace-nowrap gap-2',         active && 'bg-chat-active text-white',         !active && 'bg-background text-color-text hover:bg-chat-hover'       )}       onClick={goToChat}     >       <div className="relative">         <Avatar           data={{             name: getChatName(),             image: getImage(),           }}           width={54}         />       </div>       <div className="flex-1 overflow-hidden">         <div className="flex items-center justify-start overflow-hidden">           <div className="flex items-center justify-start overflow-hidden gap-1">             <h3 className="font-semibold truncate text-base">               {getChatName()}             </h3>           </div>           <div className="grow min-w-2" />           <div className="flex items-center shrink-0 mr-[.1875rem] text-[.75rem]">             <span className={active ? 'text-white' : 'text-color-text-meta'}>               {lastMessageDate}             </span>           </div>         </div>         <div className="flex items-center justify-start truncate">           <p             className={clsx(               'truncate text-[.9375rem] text-left pr-1 grow',               active && 'text-white',               !active && 'text-color-text-secondary'             )}           >             {lastText}           </p>           {unread !== undefined && unread > 0 && (             <div               className={clsx(                 'min-w-6 h-6 shrink-0 rounded-xl text-sm leading-6 text-center py-0 px-[.4375rem] font-medium',                 active && 'bg-white text-primary',                 !active && 'bg-green text-white'               )}             >               <span className="inline-flex whitespace-pre">{unread}</span>             </div>           )}         </div>       </div>     </div>   ); };  export default ChatPreview;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

'use client'; import React, { useState, useEffect, RefObject } from 'react'; import clsx from 'clsx';  import Button from './Button'; import ChatFolders from './ChatFolders'; import { minWidth, defaultMaxWidth } from '@/app/a/layout'; import useClickOutside from '@/hooks/useClickOutside';  enum SidebarView {   Default,   NewGroup, }  interface SidebarProps {   width: number;   setWidth: React.Dispatch<React.SetStateAction<number>>; }  export default function Sidebar({ width, setWidth }: SidebarProps) {   const getMaxWidth = () => {     const windowWidth = window.innerWidth;     let newMaxWidth = defaultMaxWidth;      if (windowWidth >= 1276) {       newMaxWidth = Math.floor(windowWidth * 0.33);     } else if (windowWidth >= 926) {       newMaxWidth = Math.floor(windowWidth * 0.4);     }     return newMaxWidth;   };    const [maxWidth, setMaxWidth] = useState(getMaxWidth());   const [menuOpen, setMenuOpen] = useState(false);   const [view, setView] = useState(SidebarView.Default);    const menuDomNode = useClickOutside(() => {     setMenuOpen(false);   }) as RefObject<HTMLDivElement>;    const toggleMenu = () => {     setMenuOpen((prev) => !prev);   };    const openNewGroupView = () => {     setView(SidebarView.NewGroup);     setMenuOpen(false);   };    useEffect(() => {     const calculateMaxWidth = () => {       const newMaxWidth = getMaxWidth();       setMaxWidth(newMaxWidth);       setWidth(width >= newMaxWidth ? newMaxWidth : width);     };      calculateMaxWidth();      window.addEventListener('resize', calculateMaxWidth);      return () => {       window.removeEventListener('resize', calculateMaxWidth);     };   }, [setWidth, width]);    useEffect(() => {     if (width) {       let newWidth = width;       if (width > maxWidth) {         newWidth = maxWidth;       }       setWidth(newWidth);       localStorage.setItem('sidebarWidth', String(width));     }   }, [width, maxWidth, setWidth]);    // Обработчик изменения размера боковой панели   const handleResize = (     event: React.MouseEvent<HTMLDivElement, MouseEvent>   ) => {     const startX = event.clientX;     const startWidth = width;      const onMouseMove = (e: MouseEvent) => {       const newWidth = Math.min(         Math.max(minWidth, startWidth + (e.clientX - startX)),         maxWidth       );       setWidth(newWidth);     };      const onMouseUp = () => {       window.removeEventListener('mousemove', onMouseMove);       window.removeEventListener('mouseup', onMouseUp);     };      window.addEventListener('mousemove', onMouseMove);     window.addEventListener('mouseup', onMouseUp);   };    return (     <div       id="sidebar"       style={{ width: ${width}px }}       className="max-[600px]:!w-full max-[925px]:!w-[26.5rem] w-auto group bg-background h-full flex-shrink-0 relative"       onMouseLeave={() => setMenuOpen(false)}     >       {/* Вид по умолчанию */}       <div         className={clsx(           'contents',           view === SidebarView.Default ? 'block' : 'hidden'         )}       >         <ChatFolders />       </div>       {/* Кнопка создания нового чата */}       <div         className={clsx(           'absolute right-4 bottom-4 translate-y-20 transition-transform duration-[.25s] ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover:translate-y-0',           menuOpen && 'translate-y-0',           view === SidebarView.NewGroup && 'hidden'         )}       >         <Button           active           icon="new-chat-filled"           onClick={toggleMenu}           className={clsx('sidebar-button', menuOpen ? 'active' : '')}         >           <i className="absolute icon icon-close" />         </Button>         <div>           {menuOpen && (             <div className="fixed left-[-100vw] right-[-100vw] top-[-100vh] bottom-[-100vh] z-20" />           )}           <div             ref={menuDomNode}             className={clsx(               'bg-background-compact-menu backdrop-blur-[10px] custom-scroll py-1 bottom-[calc(100%+0.5rem)] right-0 origin-bottom-right overflow-hiddden list-none absolute shadow-[0_.25rem_.5rem_.125rem_#72727240] rounded-xl min-w-[13.5rem] z-[21] overscroll-contain text-black transition-[opacity,_transform] duration-150 ease-[cubic-bezier(0.2,0.0.2,1)]',               menuOpen                 ? 'block opacity-100 scale-100'                 : 'hidden opacity-0 scale-[.85]'             )}           >             <div               onClick={openNewGroupView}               className="text-sm my-[.125rem] mx-1 p-1 pe-3 rounded-md font-medium scale-100 transition-transform duration-150 ease-in-out bg-transparent flex items-center relative overflow-hidden leading-6 whitespace-nowrap text-black cursor-pointer"             >               <i                 className="icon icon-group max-w-5 text-[1.25rem] me-5 ms-2 text-[#707579]"                 aria-hidden="true"               />               {'New Group'}             </div>           </div>         </div>       </div>       {/* Изменение размера */}       <div         className="hidden lg:block absolute z-20 top-0 -right-1 h-full w-2 cursor-ew-resize"         onMouseDown={handleResize}       />     </div>   ); }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

... #sidebar .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react {   background: none;   border: none;   box-shadow: none; }  #sidebar  .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react>div {   padding: 0; }  #sidebar  .str-chat__channel-search {   position: absolute;   width: 100%;   top: 0;   left: 0; }  #sidebar .str-chat__channel-list-react .str-chat__channel-list-messenger-react {   margin-top: 56px; }  #sidebar .str-chat__channel-search-result-list.inline {   padding: 0.5rem; }  #sidebar .str-chat__channel-search-result {   border-radius: 0.75rem; } ...

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

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

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

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

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

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

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

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

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

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

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

import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; import { DefaultStreamChatGenerics, useChatContext } from 'stream-chat-react'; import { UserResponse } from 'stream-chat';  import Avatar from './Avatar'; import Button from './Button'; import RippleButton from './RippleButton'; import Spinner from './Spinner'; import { customAlphabet } from 'nanoid'; import { getLastSeen } from '../lib/utils'; import clsx from 'clsx';  interface NewGroupViewProps {   goBack: () => void; }  const NewGroupView = ({ goBack }: NewGroupViewProps) => {   const { client } = useChatContext();    const [creatingGroup, setCreatingGroup] = useState(false);   const [query, setQuery] = useState('');   const [groupName, setGroupName] = useState('');   const [users, setUsers] = useState<UserResponse<DefaultStreamChatGenerics>[]>(     []   );   const [originalUsers, setOriginalUsers] = useState<     UserResponse<DefaultStreamChatGenerics>[]   >([]);   const [selectedUsers, setSelectedUsers] = useState<string[]>([]);    const debounceTimeout = useRef<NodeJS.Timeout | null>(null);   const cancelled = useRef(false);    useEffect(() => {     const getAllUsers = async () => {       const userId = client.userID;       const { users } = await client.queryUsers(         // @ts-expect-error - id         { id: { $ne: userId } },         { id: 1, name: 1 },         { limit: 20 }       );        setUsers(users);       setOriginalUsers(users);     };     getAllUsers();     // eslint-disable-next-line react-hooks/exhaustive-deps   }, []);    const handleUserSearch = async (e: ChangeEvent<HTMLInputElement>) => {     const query = e.target.value.trim();     setQuery(query);     if (!query) {       if (debounceTimeout.current) clearTimeout(debounceTimeout.current);       cancelled.current = true;       setUsers(originalUsers);       return;     }      cancelled.current = false;      if (debounceTimeout.current) clearTimeout(debounceTimeout.current);     debounceTimeout.current = setTimeout(async () => {       if (cancelled.current) return;        try {         const userId = client.userID;         const { users } = await client.queryUsers(           {             $or: [               { id: { $autocomplete: query } },               { name: { $autocomplete: query } },             ],             // @ts-expect-error - id             id: { $ne: userId },           },           { id: 1, name: 1 },           { limit: 5 }         );          if (!cancelled.current) setUsers(users);       } catch (error) {         console.error('Error fetching users:', error);       }     }, 200);   };    const leave = () => {     setCreatingGroup(false);     setGroupName('');     setQuery('');     setSelectedUsers([]);     goBack();   };    const createNewGroup = async () => {     if (!groupName) {       alert('Please enter a group name.');       return;     }     if (selectedUsers.length < 2) {       alert('Please select at least two users.');       return;     }      setCreatingGroup(true);      try {       const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';       const nanoid = customAlphabet(alphabet, 7);       const group = client.channel('messaging', nanoid(7), {         name: groupName,         members: [...selectedUsers, client.userID!],       });        await group.create();       leave();     } catch (error) {       console.error(error);       alert('Error creating group');     } finally {       setCreatingGroup(false);     }   };    const onSelectUser = (e: ChangeEvent<HTMLInputElement>) => {     const userId = e.target.id;     setSelectedUsers((prevSelectedUsers) => {       if (prevSelectedUsers.includes(userId)) {         return prevSelectedUsers.filter((id) => id !== userId);       } else {         return [...prevSelectedUsers, userId];       }     });   };    const sortedUsers = useMemo(     () =>       users.sort((a, b) => {         if (selectedUsers.includes(a.id)) {           return -1;         } else if (selectedUsers.includes(b.id)) {           return 1;         } else {           return 0;         }       }),     [users, selectedUsers]   );    return (      <>       <div className="flex items-center bg-background px-[.8125rem] pt-1.5 pb-2 gap-[1.375rem] h-[56px]">         <RippleButton onClick={leave} icon="arrow-left" />         <h3 className="text-[1.25rem] font-medium mr-auto select-none truncate">           New Group         </h3>       </div>       <div className="flex flex-col px-5 h-[calc(100%-3.5rem)] overflow-hidden">         <div>           <label             htmlFor="groupName"             className="relative block mt-5 py-[11px] px-[18px] rounded-xl border border-color-borders-input shadow-sm focus-within:border-primary focus-within:ring-1 focus-within:ring-primary"           >             <input               type="text"               id="groupName"               value={groupName}               onChange={(e) => setGroupName(e.target.value)}               className="peer caret-primary border-none bg-transparent placeholder-transparent placeholder:text-base focus:border-transparent focus:outline-none focus:ring-0"               placeholder="Group name"             />             <span className="pointer-events-none absolute start-[18px] top-0 -translate-y-1/2 bg-white p-0.5 text-sm text-[#a2acb4] transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-base peer-focus:top-0 peer-focus:text-xs peer-focus:text-primary">               Group name             </span>           </label>           <h3 className="my-4 mx-1 font-medium text-[1rem] text-color-text-secondary">             Add members           </h3>           <label             htmlFor="user"             className="relative caret-primary block overflow-hidden border-b border-color-borders-input bg-transparent py-3 px-5 focus-within:border-primary"           >             <input               type="text"               id="users"               placeholder="Who would you like to add?"               value={query}               onChange={(e) => handleUserSearch(e)}               className="text-base h-8 w-full border-none bg-transparent p-0 placeholder:text-base focus:border-transparent focus:outline-none focus:ring-0"             />           </label>           <fieldset className="flex flex-col gap-2 mt-2 custom-scroll">             {sortedUsers.map((user) => (               <UserCheckbox                 key={user.id}                 user={user}                 checked={selectedUsers.includes(user.id)}                 onChange={onSelectUser}               />             ))}           </fieldset>         </div>       </div>       <div className="absolute right-4 bottom-4 transition-transform duration-[.25s] ease-[cubic-bezier(0.34,1.56,0.64,1)] translate-y-0">         <Button           active           icon="arrow-right"           onClick={createNewGroup}           disabled={creatingGroup}           className={clsx('sidebar-button', creatingGroup ? 'active' : '')}         >           <div className="icon-loading absolute">             <div className="relative w-6 h-6 before:relative before:content-none before:block before:pt-full">               <Spinner />             </div>           </div>         </Button>       </div>     </>   ); };  interface UserCheckboxProps {   user: UserResponse<DefaultStreamChatGenerics>;   checked: boolean;   onChange: (e: ChangeEvent<HTMLInputElement>) => void; }  const UserCheckbox = ({ user, checked, onChange }: UserCheckboxProps) => {    return (     <label       htmlFor={user.id}       className="flex items-center gap-2 p-2 h-[3.5rem] rounded-xl hover:bg-chat-hover bg-background-compact-menu cursor-pointer"     >       <div className="relative h-10 w-10">         <Avatar           data={{             name: user.name || ${user.first_name} ${user.last_name},             image: user.image || '',           }}           width={40}         />       </div>       <div>         <p className="text-base leading-5">           {user.name || ${user.first_name} ${user.last_name}}         </p>         <p className="text-sm text-color-text-meta">           {getLastSeen(user.last_active!)}         </p>       </div>       <div className="flex items-center ml-auto">         &#8203;         <input           id={user.id}           type="checkbox"           checked={checked}           onChange={onChange}           className="size-4 rounded border-2 border-color-borders-input"         />       </div>     </label>   ); };  export default NewGroupView;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

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

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


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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/915946/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *