Привет, друзья!


В данном туториале мы разработаем чат с использованием следующих технологий:


  • TypeScript — статический типизатор;
  • NestJS — сервер;
  • Socket.IO — библиотека для работы в [веб-сокетами]();
  • React — клиент;
  • TailwindCSS — библиотека для стилизации;
  • PostgreSQL — база данных (далее — БД);
  • PrismaORM;
  • Docker — платформа для разработки, доставки и запуска приложений в изолированной среде — контейнере.

Функционал чата будет таким:


  • фейковая регистрация пользователей:
    • хранение имен пользователей в памяти (объекте) на сервере;
    • хранение имен и идентификаторов пользователей в localStorage на клиенте;
  • регистрация подключений и отключений пользователей на сервере и передача этой информации подключенным клиентам;
  • запись, обновление и удаление сообщений из БД в реальном времени на сервере и передача этой информации клиентам.

Репозиторий с кодом проекта.


Если вам это интересно, прошу под кат.


Материалы для изучения (опционально):



Полезные расширения для VSCode (опционально):



Подготовка и настройка проекта


Обратите внимание: для успешного прохождения туториала на вашей машине должны быть установлены Node.js и Docker.


Для работы с зависимостями будет использоваться Yarn.


Создаем директорию, переходим в нее и инициализируем Node.js-проект:


mkdir react-nest-postgres-chat
cd react-nest-postgres-chat

yarn init -yp

База данных


Создаем файл docker-compose.yml следующего содержания:


services:
  # название сервиса
  postgres:
    # образ
    image: postgres
    # политика перезапуска
    restart: on-failure
    # файл с переменными среды окружения
    env_file:
      - .env
    # порты
    ports:
      - 5432:5432
    # тома для постоянного хранения данных
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

Создаем файл .env и определяем в нем переменные среды окружения для Postgres:


POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=chat

Запускаем сервер Postgres в контейнере:


docker compose up -d

Это приводит к созданию и запуску контейнера react-nest-postgres-chat-1 с сервером Postgres в сервисе react-nest-postgres-chat.





БД доступна по адресу http://localhost:5432/chat.


ORM


Устанавливаем Prisma в качестве зависимости для разработки и инициализируем ее:


yarn add -D prisma

prisma init

Это приводит к созданию файла prisma/schema.prisma. Определяем в нем модель сообщения:


model Message {
  id        Int      @id @default(autoincrement())
  userId    String
  userName  String
  text      String
  createdAt DateTime @default(now())
}

Определяем переменную со строкой для подключения к БД в файле .env:


DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?schema=public

Выполняем миграцию:


yarn prisma migrate dev --name init

Это приводит к созданию директории migrations с выполненной миграцией в формате SQL.


Устанавливаем клиента Prisma и генерируем типы:


yarn add @prisma/client

yarn prisma generate

Сервер и клиент


Глобально устанавливаем Nest CLI и инициализируем Nest-проект:


yarn global add @nestjs/cli

# выбираем `yarn` для работы с зависимостями
nest new server

Создаем шаблон React + TS приложения с помощью create-vite:


# client - название приложения (и директории)
# react-ts - используемый шаблон
yarn create vite client --template react-ts

Устанавливаем concurrently:


yarn add concurrently

Определяем команду для одновременного запуска сервера и клиента в режиме разработки в файле package.json:


"scripts": {
  "dev:client": "yarn --cwd client dev",
  "dev:server": "yarn --cwd server start:dev",
  "dev": "concurrently \"yarn dev:client\" \"yarn dev:server\""
}

Работоспособность приложения можно проверить, выполнив команду yarn dev.


На этом подготовка и настройка проекта завершены. Переходим к разработке сервера.


Сервер


Переходим в директорию сервера и устанавливаем модули для работы с сокетами:


cd server

yarn add @nestjs/websockets @nestjs/platform-socket.io

Генерируем шлюз (gateway) для модуля App:


# g ga - generate gateway
nest g ga app

Приводим файлы сервера к следующей структуре:


- src
  - app.gateway.ts
  - app.module.ts
  - app.service.ts
  - main.ts
  - prisma.service.ts
- constants.ts
- types.ts
- ...

Определяем сервис Prisma в файле prisma.service.ts:


import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }

  async enableShutdownHooks(app: INestApplication) {
    this.$on("beforeExit", async () => {
      await app.close();
    });
  }
}

Настраиваем данный сервис в файле main.ts:


import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
// !
import { PrismaService } from "./prisma.service";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // !
  const prismaService = app.get(PrismaService);
  await prismaService.enableShutdownHooks(app);

  // обратите внимание на порт
  await app.listen(3001);
}
bootstrap();

И подключаем его в качестве провайдера в модуле App (app.module.ts):


import { Module } from "@nestjs/common";
import { AppService } from "./app.service";
// !
import { PrismaService } from "./prisma.service";

@Module({
  imports: [],
  controllers: [],
  // !
  providers: [PrismaService, AppService]
})
export class AppModule {}

Определяем адрес клиента в файле constants.ts:


export const CLIENT_URI = "http://localhost:3000";

И тип полезной нагрузки для обновления сообщения в файле types.ts:


import { Prisma } from "@prisma/client";

// { id?: number, text?: string }
export type MessageUpdatePayload = Prisma.MessageWhereUniqueInput &
  Pick<Prisma.MessageUpdateInput, "text">;

Определяем методы для работы с сообщениями в файле app.service.ts:


import { Injectable } from "@nestjs/common";
import { Message, Prisma } from "@prisma/client";
import { MessageUpdatePayload } from "types";
import { PrismaService } from "./prisma.service";

@Injectable()
export class AppService {
  // инициализация сервиса `Prisma`
  constructor(private readonly prisma: PrismaService) {}

  // получение всех сообщений
  async getMessages(): Promise<Message[]> {
    return this.prisma.message.findMany();
  }

  // удаление всех сообщений - для отладки в процессе разработки
  async clearMessages(): Promise<Prisma.BatchPayload> {
    return this.prisma.message.deleteMany();
  }

  // создание сообщения
  async createMessage(data: Prisma.MessageCreateInput) {
    return this.prisma.message.create({ data });
  }

  // обновление сообщения
  async updateMessage(payload: MessageUpdatePayload) {
    const { id, text } = payload;
    return this.prisma.message.update({ where: { id }, data: { text } });
  }

  // удаление сообщения
  async removeMessage(where: Prisma.MessageWhereUniqueInput) {
    return this.prisma.message.delete({ where });
  }
}

Осталось реализовать обработку событий сокетов в файле app.gateway.ts.


Импортируем зависимости и прочее, а также определяем переменную для хранения записей "идентификатор сокета — имя пользователя":


import {
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer
} from "@nestjs/websockets";
import { Prisma } from "@prisma/client";
import { Server, Socket } from "Socket.IO";
import { MessageUpdatePayload } from "types";
import { CLIENT_URI } from "../constants";
import { AppService } from "./app.service";

const users: Record<string, string> = {};

Инициализируем сокет-соединение, определяем шлюз App и инициализируем в нем сервис App:


@WebSocketGateway({
  cors: {
    origin: CLIENT_URI // можно указать `*` для отключения `CORS`
  },
  serveClient: false,
  // название пространства может быть любым, но должно учитываться на клиенте
  namespace: "chat"
})
export class AppGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  constructor(private readonly appService: AppService) {}

  /**
   * @todo
  */
}

Инициализируем сокет-сервер и регистрируем инициализацию:


@WebSocketServer() server: Server;

afterInit(server: Server) {
  console.log(server);
}

Обрабатываем подключение и отключение клиентов:


// подключение
handleConnection(client: Socket, ...args: any[]) {
  // обратите внимание на структуру объекта `handshake`
  const userName = client.handshake.query.userName as string;
  const socketId = client.id;
  users[socketId] = userName;

  // передаем информацию всем клиентам, кроме текущего
  client.broadcast.emit("log", `${userName} connected`);
}

// отключение
handleDisconnect(client: Socket) {
  const socketId = client.id;
  const userName = users[socketId];
  delete users[socketId];

  client.broadcast.emit("log", `${userName} disconnected`);
}

Наконец, обрабатываем события сокетов:


// получение всех сообщений
@SubscribeMessage("messages:get")
async handleMessagesGet(): Promise<void> {
  const messages = await this.appService.getMessages();
  this.server.emit("messages", messages);
}

// удаление всех сообщений
@SubscribeMessage("messages:clear")
async handleMessagesClear(): Promise<void> {
  await this.appService.clearMessages();
}

// создание сообщения
@SubscribeMessage("message:post")
async handleMessagePost(
  @MessageBody()
  payload: // { userId: string, userName: string, text: string }
  Prisma.MessageCreateInput
): Promise<void> {
  const createdMessage = await this.appService.createMessage(payload);
  // можно сообщать клиентам о каждой операции по отдельности
  this.server.emit("message:post", createdMessage);
  // но мы пойдем более простым путем
  this.handleMessagesGet();
}

// обновление сообщения
@SubscribeMessage("message:put")
async handleMessagePut(
  @MessageBody()
  payload: // { id: number, text: string }
  MessageUpdatePayload
): Promise<void> {
  const updatedMessage = await this.appService.updateMessage(payload);
  this.server.emit("message:put", updatedMessage);
  this.handleMessagesGet();
}

// удаление сообщения
@SubscribeMessage("message:delete")
async handleMessageDelete(
  @MessageBody()
  payload: // { id: number }
  Prisma.MessageWhereUniqueInput
) {
  const removedMessage = await this.appService.removeMessage(payload);
  this.server.emit("message:delete", removedMessage);
  this.handleMessagesGet();
}

Полный код app.gateway.ts:
import {
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer
} from "@nestjs/websockets";
import { Prisma } from "@prisma/client";
import { Server, Socket } from "socket.io";
import { MessageUpdatePayload } from "types";
import { CLIENT_URI } from "../constants";
import { AppService } from "./app.service";

const users: Record<string, string> = {};

@WebSocketGateway({
  cors: {
    origin: CLIENT_URI
  },
  serveClient: false,
  namespace: "chat"
})
export class AppGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  constructor(private readonly appService: AppService) {}

  @WebSocketServer() server: Server;

  @SubscribeMessage("messages:get")
  async handleMessagesGet(): Promise<void> {
    const messages = await this.appService.getMessages();
    this.server.emit("messages", messages);
  }

  @SubscribeMessage("messages:clear")
  async handleMessagesClear(): Promise<void> {
    await this.appService.clearMessages();
  }

  @SubscribeMessage("message:post")
  async handleMessagePost(
    @MessageBody()
    payload: // { userId: string, userName: string, text: string }
    Prisma.MessageCreateInput
  ): Promise<void> {
    const createdMessage = await this.appService.createMessage(payload);
    this.server.emit("message:post", createdMessage);
    this.handleMessagesGet();
  }

  @SubscribeMessage("message:put")
  async handleMessagePut(
    @MessageBody()
    payload: // { id: number, text: string }
    MessageUpdatePayload
  ): Promise<void> {
    const updatedMessage = await this.appService.updateMessage(payload);
    this.server.emit("message:put", updatedMessage);
    this.handleMessagesGet();
  }

  @SubscribeMessage("message:delete")
  async handleMessageDelete(
    @MessageBody()
    payload: // { id: number }
    Prisma.MessageWhereUniqueInput
  ) {
    const removedMessage = await this.appService.removeMessage(payload);
    this.server.emit("message:delete", removedMessage);
    this.handleMessagesGet();
  }

  afterInit(server: Server) {
    console.log(server);
  }

  handleConnection(client: Socket, ...args: any[]) {
    const userName = client.handshake.query.userName as string;
    const socketId = client.id;
    users[socketId] = userName;

    client.broadcast.emit("log", `${userName} connected`);
  }

  handleDisconnect(client: Socket) {
    const socketId = client.id;
    const userName = users[socketId];
    delete users[socketId];

    client.broadcast.emit("log", `${userName} disconnected`);
  }
}

Подключаем шлюз в качестве провайдера в модуле App (app.module.ts):


import { Module } from "@nestjs/common";
import { AppService } from "./app.service";
// !
import { AppGateway } from "./app.gateway";
import { PrismaService } from "./prisma.service";

@Module({
  imports: [],
  controllers: [],
  // !
  providers: [PrismaService, AppService, AppGateway]
})
export class AppModule {}

На этом разработка сервера завершена. Переходим к разработке клиента.


Клиент


Переходим в директорию клиента и инициализируем Tailwind:


cd client

yarn add -D tailwindcss postcss autoprefixer
yarn tailwindcss init -p

Редактируем файл tailwind.config.js:


/** @type {import('tailwindcss').Config} */
module.exports = {
  // !
  content: ['./src/**/*.{ts,tsx}'],
  theme: {
    extend: {}
  },
  plugins: []
}

Добавляем директивы @tailwind в файл App.css:


@tailwind base;
@tailwind components;
@tailwind utilities;

Переопределяем дефолтный шрифт и добавляем несколько переиспользуемых стилей (reused styles):


@layer base {
  html {
    font-family: "Montserrat", sans-serif;
  }
}

@layer components {
  #root {
    width: 100vw;
    height: 100vh;
    overflow: hidden;
  }

  .title {
    @apply mb-4 text-2xl text-center font-bold;
  }

  .btn {
    @apply py-2 px-4 text-white rounded-md shadow-md focus:outline-none focus:ring-2 focus:ring-opacity-75 transition-all duration-150;
  }

  .btn-primary {
    @apply btn bg-blue-500 hover:bg-blue-600 focus:ring-blue-400;
  }

  .btn-success {
    @apply btn bg-green-500 hover:bg-green-600 focus:ring-green-400;
  }

  .btn-error {
    @apply btn bg-red-500 hover:bg-red-600 focus:ring-red-400;
  }

  .input {
    @apply py-2 px-4 border border-blue-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400 transition-all duration-150;
  }
}

Подключаем шрифт в файле index.html:


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="data:." />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React Nest Postgres Chat</title>
    <!-- ! -->
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Montserrat&display=swap"
      rel="stylesheet"
    />
    <!-- ! -->
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Устанавливаем несколько библиотек:


yarn add socket.io-client react-icons react-timeago react-toastify

  • socket.io-client — клиент Socket.IO;
  • react-icons — иконки в виде компонентов;
  • react-timeago — компонент для форматирования даты и времени с обновлением в реальном времени;
  • react-toastify — библиотека для реализации всплывающих уведомлений.

Приводим файлы клиента к следующей структуре:


- src
  - components
    - ChatScreen.tsx
    - index.ts
    - WelcomeScreen.tsx
  - hooks
    - useChat.ts
  - App.css
  - App.tsx
  - constants.ts
  - main.ts
  - types.ts
  - utils.ts
- postcss.config.js
- tailwind.config.js
- ...

Определяем константы в файле constants.ts:


// ключ для `localStorage`
export const USER_INFO = "user-info";
// адрес шлюза на сервере
export const SERVER_URI = "http://localhost:3001/chat";

Определяем типы в файле types.ts:


import { Prisma } from "@prisma/client";

export type UserInfo = {
  userId: string;
  userName: string;
};

export type MessageUpdatePayload = Prisma.MessageWhereUniqueInput &
  Pick<Prisma.MessageUpdateInput, "text">;

И утилиты в файле utils.ts:


// утилита для генерации идентификатора пользователя
export const getId = () => Math.random().toString(36).slice(2);

// утилита для работы с `localStorage`
export const storage = {
  set<T>(key: string, value: T) {
    localStorage.setItem(key, JSON.stringify(value));
  },
  get<T>(key: string): T | null {
    return localStorage.getItem(key)
      ? JSON.parse(localStorage.getItem(key) as string)
      : null;
  }
};

Начнем, пожалуй, с основного компонента приложения (App.tsx):


import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import { ChatScreen, WelcomeScreen } from "./components";
import { USER_INFO } from "./constants";
import { UserInfo } from "./types";
import { storage } from "./utils";

function App() {
  const userInfo = storage.get<UserInfo>(USER_INFO);

  return (
    <section className="w-[480px] h-full mx-auto flex flex-col py-4">
      {userInfo ? <ChatScreen /> : <WelcomeScreen />}
    </section>
  );
}

export default App;

Если в локальном хранилище содержится информация о пользователе, рендерится экран чата. В противном случае, рендерится экран приветствия.


Экран приветствия (ChatScreen.tsx):


import React, { useState } from "react";
import { FiUser } from "react-icons/fi";
import { USER_INFO } from "../constants";
import { UserInfo } from "../types";
import { getId, storage } from "../utils";

export const WelcomeScreen = () => {
  const [userName, setUserName] = useState("");

  const changeUserName = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUserName(e.target.value);
  };

  const setUserInfo = (e: React.FormEvent) => {
    e.preventDefault();

    const trimmed = userName.trim();
    if (!trimmed) return;

    // генерируем идентификатор пользователя
    const userId = getId();
    // сохраняем информацию о пользователе в локальном хранилище
    storage.set<UserInfo>(USER_INFO, { userName: trimmed, userId });

    // и перезагружаем локацию
    location.reload();
  };

  return (
    <section>
      <h1 className="title">Welcome, friend!</h1>
      <form onSubmit={setUserInfo} className="flex flex-col items-center gap-4">
        <div className="flex flex-col gap-2">
          <label
            htmlFor="username"
            className="text-lg flex items-center justify-center"
          >
            <span className="mr-1">
              <FiUser />
            </span>
            <span>What is your name?</span>
          </label>
          <input
            type="text"
            id="username"
            name="userName"
            value={userName}
            onChange={changeUserName}
            required
            autoComplete="off"
            className="input"
          />
        </div>
        <button className="btn-success">Start chat</button>
      </form>
    </section>
  );
};

Инкапсулируем логику обработки событий сокетов в кастомном хуке (useChat.ts):


import { Message, Prisma } from "@prisma/client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { io, Socket } from "Socket.IO-client";
import { SERVER_URI, USER_INFO } from "../constants";
import { MessageUpdatePayload, UserInfo } from "../types";
import { storage } from "../utils";

// экземпляр сокета
let socket: Socket;

export const useChat = () => {
  const userInfo = storage.get<UserInfo>(USER_INFO) as UserInfo;

  // это важно: один пользователь - один сокет
  if (!socket) {
    socket = io(SERVER_URI, {
      // помните сигнатуру объекта `handshake` на сервере?
      query: {
        userName: userInfo.userName
      }
    });
  }

  const [messages, setMessages] = useState<Message[]>();
  const [log, setLog] = useState<string>();

  useEffect(() => {
    // подключение/отключение пользователя
    socket.on("log", (log: string) => {
      setLog(log);
    });

    // получение сообщений
    socket.on("messages", (messages: Message[]) => {
      setMessages(messages);
    });

    socket.emit("messages:get");
  }, []);

  // отправка сообщения
  const send = useCallback((payload: Prisma.MessageCreateInput) => {
    socket.emit("message:post", payload);
  }, []);

  // обновление сообщения
  const update = useCallback((payload: MessageUpdatePayload) => {
    socket.emit("message:put", payload);
  }, []);

  // удаление сообщения
  const remove = useCallback((payload: Prisma.MessageWhereUniqueInput) => {
    socket.emit("message:delete", payload);
  }, []);

  // очистка сообщения - для отладки при разработке
  // можно вызывать в консоли браузера, например
  window.clearMessages = useCallback(() => {
    socket.emit("messages:clear");
    location.reload();
  }, []);

  // операции
  const chatActions = useMemo(
    () => ({
      send,
      update,
      remove
    }),
    []
  );

  return { messages, log, chatActions };
};

Наконец, экран чата (ChatScreen.tsx):


import React, { useEffect, useState } from "react";
import { FiEdit2, FiSend, FiTrash } from "react-icons/fi";
import { MdOutlineClose } from "react-icons/md";
import TimeAgo from "react-timeago";
import { Slide, toast, ToastContainer } from "react-toastify";
import { USER_INFO } from "../constants";
import { useChat } from "../hooks/useChat";
import { UserInfo } from "../types";
import { storage } from "../utils";

// уведомление о подключении/отключении пользователя
const notify = (message: string) =>
  toast.info(message, {
    position: "top-left",
    autoClose: 1000,
    hideProgressBar: true,
    transition: Slide
  });

export const ChatScreen = () => {
  const userInfo = storage.get<UserInfo>(USER_INFO) as UserInfo;
  const { userId, userName } = userInfo;

  // получаем сообщения, лог и операции
  const { messages, log, chatActions } = useChat();

  const [text, setText] = useState("");
  // индикатор состояния редактирования сообщения
  const [editingState, setEditingState] = useState(false);
  // идентификатор редактируемого сообщения
  const [editingMessageId, setEditingMessageId] = useState(0);

  const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const sendMessage = (e: React.FormEvent) => {
    e.preventDefault();

    const trimmed = text.trim();
    if (!trimmed) return;

    const message = {
      userId,
      userName,
      text
    };

    // если компонент находится в состоянии редактирования
    if (editingState) {
      // обновляем сообщение
      chatActions.update({ id: editingMessageId, text });
      setEditingState(false);
    // иначе
    } else {
      // отправляем сообщение
      chatActions.send(message);
    }

    setText("");
  };

  const removeMessage = (id: number) => {
    chatActions.remove({ id });
  };

  // эффект для отображения уведомлений при изменении лога
  useEffect(() => {
    if (!log) return;

    notify(log);
  }, [log]);

  return (
    <>
      <h1 className="title">Let's Chat</h1>
      <div className="flex-1 flex flex-col">
        {messages &&
          messages.length > 0 &&
          messages.map((message) => {
            // определяем принадлежность сообщения пользователю
            const isMsgBelongsToUser = message.userId === userInfo.userId;

            return (
              <div
                key={message.id}
                // цвет фона сообщения зависит от 2 факторов:
                // 1) принадлежность пользователю;
                // 2) состояние редактирования
                className={[
                  "my-2 p-2 rounded-md text-white w-1/2",
                  isMsgBelongsToUser
                    ? "self-end bg-green-500"
                    : "self-start bg-blue-500",
                  editingState ? "bg-gray-300" : ""
                ].join(" ")}
              >
                <div className="flex justify-between text-sm mb-1">
                  <p>
                    By <span>{message.userName}</span>
                  </p>
                  <TimeAgo date={message.createdAt} />
                </div>
                <p>{message.text}</p>
                {/* пользователь может редактировать и удалять только принадлежащие ему сообщения */}
                {isMsgBelongsToUser && (
                  <div className="flex justify-end gap-2">
                    <button
                      disabled={editingState}
                      className={`${
                        editingState ? "hidden" : "text-orange-500"
                      }`}
                      // редактирование сообщения
                      onClick={() => {
                        setEditingState(true);
                        setEditingMessageId(message.id);
                        setText(message.text);
                      }}
                    >
                      <FiEdit2 />
                    </button>
                    <button
                      disabled={editingState}
                      className={`${
                        editingState ? "hidden" : "text-red-500"
                      }`}
                      // удаление сообщения
                      onClick={() => {
                        removeMessage(message.id);
                      }}
                    >
                      <FiTrash />
                    </button>
                  </div>
                )}
              </div>
            );
          })}
      </div>
      {/* отправка сообщения */}
      <form onSubmit={sendMessage} className="flex items-stretch">
        <div className="flex-1 flex">
          <input
            type="text"
            id="message"
            name="message"
            value={text}
            onChange={changeText}
            required
            autoComplete="off"
            className="input flex-1"
          />
        </div>
        {editingState && (
          <button
            className="btn-error"
            type="button"
            // отмена редактирования
            onClick={() => {
              setEditingState(false);
              setText("");
            }}
          >
            <MdOutlineClose fontSize={18} />
          </button>
        )}
        <button className="btn-primary">
          <FiSend fontSize={18} />
        </button>
      </form>
      {/* контейнер для уведомлений */}
      <ToastContainer />
    </>
  );
};

На этом разработка клиента также завершена. Посмотрим, как выглядит наш чат и убедимся в его работоспособности.


Результат


Находясь в корневой директории проекта, выполняем команду yarn dev и открываем 2 вкладки браузера по адресу http://localhost:3000 (хотя бы одну вкладку необходимо открыть в режиме инкогнито):





Вводим имя пользователя, например, Bob в одной из вкладок и нажимаем Start chat:





Делаем тоже самое (только с другим именем, например, Alice) в другой вкладке:





Получаем в первой вкладке сообщение о подключении Alice.


Данные пользователя можно найти в разделе Storage -> Local Storage вкладки Application инструментов разработчика в браузере:





Обмениваемся сообщениями:





Как проверить, что сообщения записываются в БД? С Prisma — сделать это проще простого. Выполняем команду yarn prisma studio и в открывшейся по адресу http://localhost:5555 вкладке выбираем модель Message:





Пробуем редактировать и удалять сообщения — все работает, как ожидается:





Закрываем одну из вкладок:





Получаем во второй вкладке сообщение об отключении Alice.


Открываем консоль инструментов разработчика и вызываем метод clearMessages. Все сообщения удаляются, вкладка перезагружается:





Пожалуй, это все, чем я хотел поделиться с вами в этой статье. Надеюсь, вы нашли для себя что-то интересное и не зря потратили время. Благодарю за внимание и happy coding!




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


  1. rqdkmndh
    04.08.2022 12:51

    Надеюсь, вы нашли для себя что-то интересное и не зря потратили время.

    Конечно, не зря. Всегда, с удовольствием читаю ваши статьи.


    1. aio350 Автор
      04.08.2022 19:52

      Спасибо, друг.


  1. Alexandroppolus
    04.08.2022 14:57
    -1

    Если кто-то набрасывает мессагу на чатик, то всем коннектам рассылается полный список сообщений? Ну такое..


    1. aio350 Автор
      04.08.2022 19:53
      +1

      Какое такое?) Я специально сделал оговорку, что мы пойдем простым путем. Разумеется, в реальном приложение каждое событие должно обрабатываться отдельно.