Как написать мессенджер на html

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

Статья подойдёт состоявшимся программистам и тем, кто только интересуется, как войти в IT.

Используемые технологии и инструменты

  1. Стек MEAN (Mongo, Express, Angular, Node).
  2. Сокеты для прямого обмена сообщениями.
  3. AJAX для регистрации и входа.

Подготовка

Структура будущего приложения выглядит примерно так:

мессенджер

Установите Node.js и MongoDB. Кроме того, нам понадобится библиотека AngularJS, скачайте её и скопируйте в папку lib каталога Client.

Чтобы сделать пользовательский интерфейс приложения привлекательнее, вы можете воспользоваться любой CSS-библиотекой. Скачайте её и скопируйте в lib.

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

Серверная часть

Шаг 1. Запуск проекта

Перейдите в каталог Server и выполните команду:

npm init

Она запустит новый проект.

Укажите все необходимые сведения. В результате будет создан файл package.json примерно следующего вида:

{
  "name": "chat",
  "version": "1.0.0",
  "description": "Chat application",
  "main": "server.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "author": "Your name",
  "license": "ISC"
}

Шаг 2. Установка зависимостей

  • socket.io — JavaScript-библиотека, которая предоставляет двустороннюю связь клиента и сервера в режиме реального времени;
  • express — фреймворк Node.js, предоставляющий набор функций для разработки мобильных и веб-приложений. Позволяет отвечать на HTTP-запросы, используя промежуточное ПО, а также отображать HTML-страницы.

Выполнение этих команд установит необходимые зависимости и добавит их в package.json:

npm install --save socket.io
npm install --save express

Выглядеть они будут примерно так:

"dependencies": {
    "express": "^4.14.0",
    "socket.io": "^1.4.8"
}

Шаг 3. Создание сервера

Создадим сервер, который обслуживает порт 3000 и возвращает HTML-файл при вызове. Для инициализации нового соединения сокету нужно передать HTTP-объект. Событие connection будет прослушивать входящие сокеты, каждый сокет будет выпускать событие disconnect, которое будет вызвано при отключении клиента. Мы будем использовать следующие функции:

  • socket.on(...) — ожидает событие, и когда оно происходит, то выполняет функцию обратного вызова.
  • io.emit(...) — используется для отправки сообщения всем подключенным сокетам.

Синтаксис следующий:

socket.on('event', function(msg){})
io.emit('event', 'message')

Создайте сервер с именем server.js. Он должен:

  • Выводить сообщение в консоль при подключении пользователя.
  • Слушать событие chat message и транслировать полученное сообщение на все подключенные сокеты.
  • Когда пользователь отключается, выводить сообщение в консоль.

В результате ваш сервер будет выглядеть примерно так:

var app = require('express')();
var http = require('http').Server(app);
var io = require('socket.io')(http);

app.get('/', function(req, res){
  res.sendfile('index.html');
});

io.on('connection', function(socket){
  console.log('user connected');
  socket.on('chat message', function(msg){
    io.emit('chat message', msg);
  });
  socket.on('disconnect', function(){
    console.log('user disconnected');
  });
});

http.listen(3000, function(){
  console.log('listening on *:3000');
});

Клиентская часть

Создайте файлы index.html в каталоге Client, style.css в каталоге CSS и app.js в каталоге js.

Client/index.html

Пусть это будет простой HTML-код, который получает и отображает наши сообщения.

Включите скрипты socket.io-client и angular.js в ваш HTML:

<script src="/path/to/angular.x16217.js"></script>
<script src="/socket.io/socket.io.x16217.js"></script>

socket.io служит для нас клиентом. Он по умолчанию подключается к хосту, обслуживающему страницу.

В результате index.html должен выглядеть примерно так:

<!doctype html>
<html ng-app="myApp">
  <head>
    <title>Socket.IO chat</title>
    <link rel="stylesheet" href="/css/style.x16217.css">
    <script src="/lib/angular/angular.x16217.js"></script>
    <script src="/socket.io/socket.io.x16217.js"></script>
    <script src="http://code.jquery.com/jquery-1.11.1.js"></script>
    <script src="/js/app.x16217.js"></script>
  </head>
  <body ng-controller="mainController">
    <ul id="messages"></ul>
    <div>
      <input id="m" ng-model="message" autocomplete="off" />
      <button ng-click="send()">Send</button>
    </div>
  </body>
</html>

CSS/style.css

Чтобы придать нашей странице внешний вид окна чата, добавим немного стилей. Вы можете использовать любую CSS-библиотеку. Получим следующее:

* {
  margin: 0; 
  padding: 0; 
  box-sizing: border-box; 
}
body { 
  font: 13px Helvetica, Arial;
}
div {
  background: #000; 
  padding: 3px; 
  position: fixed; 
  bottom: 0; 
  width: 100%; 
}
div input { 
  border: 0; 
  padding: 10px; 
  width: 90%; 
  margin-right: .5%; 
}
div button { 
  width: 9%; 
  background: rgb(130, 224, 255); 
  border: none; 
  padding: 10px; 
}
#messages { 
  list-style-type: none; 
  margin: 0; 
  padding: 0; 
}
#messages li { 
  padding: 5px 10px; 
}
#messages li:nth-child(odd) { 
  background: #eee; 
}

js/app.js:

Создайте Angular-приложение и инициализируйте соединение сокета. Для этого нужны следующие функции:

  • socket.on(...) — слушает определенное событие, и, когда оно происходит, выполняет функцию обратного вызова.
  • socket.emit(...) — используется для отправки сообщения конкретному событию.

Синтаксис следующий:

socket.on('event name', function(msg){});
socket.emit('event name', message);

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

В результате app.js будет выглядеть примерно так:

var app=angular.module('myApp',[]);

app.controller('mainController',['$scope',function($scope){
  var socket = io.connect();
  $scope.send = function(){
    socket.emit('chat message', $scope.message);
    $scope.message="";
  }
  socket.on('chat message', function(msg){
    var li=document.createElement("li");
    li.appendChild(document.createTextNode(msg));
    document.getElementById("messages").appendChild(li);
  });
}]);

Запуск приложения

Перейдите в папку с server.js и запустите команду:

node server.js

Сервер начнет работу на порте 3000. Чтобы в этом убедиться, перейдите по ссылке в браузере:

http://localhost:3000

Ваш собственный мессенджер готов!

Что можно улучшить?

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

Установите Mongoose или MongoDB для работы с базами данных Mongo:

npm install --save mongoose

или:

npm install --save mongodb

Можете ознакомиться с документацией по их использованию: mongoose и mongodb.

Схема должна получиться примерно следующего вида:

{
 "_id" : ObjectId("5809171b71e640556be904ef"),
 "name" : "Monkey proger",
 "handle" : "mkproger",
 "password" : "proger228",
 "phone" : "8888888888",
 "email" : "dontwritemepleez@gmail.com",
 "friends" : [
    {
      "name" : "habrick",
      "status" : "Friend"
    },
    {
      "name" : "javaman",
      "status" : "Friend"
    }
 ],
 "__v" : 0
}

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

  • Friend — собеседник является другом.
  • Pending — собеседник пока не принял запрос.
  • Blocked — собеседник заблокирован.

Предположим, что собеседник отклонил запрос на приватную беседу. В таком случае отправитель должен иметь возможность снова направить запрос.

Неплохо было бы реализовать для пользователя функционал сохранения сообщений в дополнительные коллекции. Пусть каждый её объект содержит сообщение, отправителя, получателя и время. Спроектируйте вашу базу данных в соответствии с конкретными потребностями и методами обработки сообщений.

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

Некоторые из возможных конечных точек API:

app.post('/register', function(req,res){})

app.post('/login', function(req,res){})

app.post('/friend_request', function(req,res){})

app.post('/friend_request/confirmed', function(req,res){})

Вот какой мессенджер получился у автора статьи:

мессенджер

Окно для входа

мессенджер

Внешний вид приложения

Исходный код приложения можно найти на GitHub.

Адаптированный перевод статьи «How to build your own real-time chat app»

Cover image for How to: Make a Mini Messenger with JavaScript For Beginners

Hey there, welcome to my first post. So, in this post our goal is to make a Mini Messenger. In this tutorial I’ll be using JavaScript mostly but I will also include a link to my CodePen where you will be able to access the HTML and CSS of this project. This tutorial is aimed at beginners or anyone interested in catching a thing or two.

Getting Started

The messenger I will be making in this tutorial will include a text box where the message will be written and once submitted it will be displayed on the screen for 2 seconds then disappear.

I will try to explain as I continue and include code snippets as well, so make sure you try it out by yourself. Here is the CodePen Project in case you need a reference point.


Structure

Below I have provided the structure of my HTML document which will help you understand the some of the classes and elements I will be referring to as We go along.

!DOCTYPE html>
<html lang="en">
<head>
    <title>Messenger</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="main">
        <h4>A Message You would like to pass</h4>

        <!-- Input form and submission -->
        <form class="message-form">
            <input type="text" class="typedMessage" placeholder="Type Your Message..." autofocus>
            <button class="submit">Send</button>
        </form>

        <!-- Output for two seconds -->
        <h5>Last Message Delivered</h5>
        <div class="messages"></div>

    </div>

    <script src="app.js"></script>
</body>
</html>

Enter fullscreen mode

Exit fullscreen mode

Add a message

To start off I need to set up an array that will hold our messages. Each message will be an object with two properties, text and id. text will be used to store the message that has been typed by the user and the id will be used to give the message a unique number . They will all be stored within the chat object within the addMessage() function.

//Create an array where the message along with it's ID will be stored.
let message = [];

// This fuction will enables us to add the message to the DOM
function addMessage(text){
    //Object where message will be stored
    const chat = {
        text,
        id: Date.now()
    }

    message.push(chat);
    console.log(message);
}

Enter fullscreen mode

Exit fullscreen mode

Next I will add an event listener to listen for the submit event within the input form .message-form. Inside the form I have a text input which has a class called .typedMessage. The event will store the message within the input constant.

//Create event listener to detect when a message has been submitted
const form = document.querySelector('.message-form');
form.addEventListener('submit', event => {
    event.preventDefault();

    //input to save the message itself
    const input = document.querySelector('.typedMessage');

    //This helps us to detect empty messages and ignore them
    const text = input.value.trim();

    if(text !== ''){
        addMessage(text);
        input.value = '';
        input.focus();

    }
});

Enter fullscreen mode

Exit fullscreen mode

Then the .trim() method will be used to remove extra space from the beginning of the message at the end of the message. This will help us to know whether the string is empty or not. If the message is empty it will be ignored. If not empty, then it will be passed through the addMessage() function, the input field will be cleared and be focused on.

The Date.now() function allows us to create unique ID for each message. It returns the number of elapsed milliseconds since January 1, 1970 This will assist you when you want to refer to a specific message for other features you may wish to include such as a delete button.

Up to where I’ve reached if you type a message in your text box you should be able to see the output of your message and ID in the console.

Output from the console

Render the Message

After the message has been pushed to the array, I now want to be able to display it on the page, and I will do this by adding a p element with the message to a class in our html called .messages.

Replace the console.log() statement in the addMessage() as follows:

function addMessage(text){
    //Object where message will be stored
    const chat = {
        text,
        id: Date.now()
    }

    message.push(chat);

    //Render message to the screen
    const list = document.querySelector('.messages');
    list.insertAdjacentHTML('beforeend', 
        `<p class="message-item" data-key="${chat.id}">
            <span>${chat.text}</span>
        </p>`

    );

}

Enter fullscreen mode

Exit fullscreen mode

In the list constant I select the .messages class and I use the insertAdjacentHTML() method to insert the html code into the html document specifically beforeend, which means just inside the element, after its last child. After this you should be able to type your message and it will be displayed on the screen.

The results printed on screen

Add Timer

So far our app is working great, but I want the message I wrote to disappear after 2 seconds. To achieve this I will make use of the setTimeout() method which executes only once after a certain period of time. This method takes two main parameters which are function to be executed and the delay in milliseconds.

Add the timer variable last in the addMessage() function.


function addMessage(text){
    //Object where message will be stored
    const chat = {
        text,
        id: Date.now()
    }

    message.push(chat);

    //Render message to the screen
    const list = document.querySelector('.messages');
    list.insertAdjacentHTML('beforeend', 
        `<p class="message-item" data-key="${chat.id}">
            <span>${chat.text}</span>
        </p>`

    );

    // Delete the message from the screen after 2 seconds
    let timer = setTimeout(() => {
        Array.from(list.children).forEach((child) => 
       list.removeChild(child))
       clearTimeout(timer);
      },2000);

}

Enter fullscreen mode

Exit fullscreen mode

Within the setTimeout() I create an arrow function, then I use Array.from() to create a method that selects all the children within the list variable. Next I use a .forEach()method which executes an arrow function that removes all the children from the list variable. Then finally I use clearTimeout(timer) that cancels the timer that I set. After the function parameter I also remember to include the time limit which is 2000 milliseconds for 2 seconds.

Message disappear after 2 seconds

Here is a link to the finished version on CodePen

Thank You for taking your time to read this. I hope it has helped you or given you an idea of what you can make using the same concepts I used. Feel free to share with your friends and let me know your thoughts or an idea you would like to see in my future posts. If you do make your own version tag me in it on Twitter i’d love to see what you made. See you in the next one ✌🏼.

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

В данной статье я хочу показать вам, как разработать простое приложение для обмена сообщениями в режиме реального времени с использованием Socket.io, Express и React с акцентом на работе с медиа.

Функционал нашего приложения будет следующим:

  • при первом запуске приложение предлагает пользователю ввести свое имя;
  • имя пользователя и его идентификатор записываются в локальное хранилище;
  • при повторном запуске приложения имя и идентификатор пользователя извлекаются из локального хранилища (имитация системы аутентификации/авторизации);
  • выполняется подключение к серверу через веб-сокеты и вход в комнату main_room (при желании можно легко реализовать возможность выбора или создания других комнат);
  • пользователи обмениваются сообщениями в реальном времени;
  • типом сообщения может быть текст, аудио, видео или изображение;
  • передаваемые файлы сохраняются на сервере;
  • путь к сохраненному на сервере файлу добавляется в сообщение;
  • сообщение записывается в базу данных;
  • пользователи могут записывать аудио и видеосообщения;
  • после прикрепления файла и записи аудио или видео сообщения, отображается превью созданного контента;
  • пользователи могут добавлять в текст сообщения эмодзи;
  • текстовые сообщения могут озвучиваться;
  • и т.д.

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

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

Справедливости ради следует отметить, что я уже писал о разработке чата на Хабре. Будем считать, что это новая (продвинутая) версия.

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

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

mkdir chat-app
cd chat-app

yarn init -yp
# or
npm init -y

Создаем директорию для сервера и шаблон для клиента с помощью Create React App:

mkdir server

yarn create react-app client
# or
npx create-react-app client

Нам потребуется одновременно запускать два сервера (для клиента и самого сервера), поэтому установим concurrently — утилиту для одновременного выполнения нескольких команд, определенных в файле package.json:

yarn add concurrently
# or
npm i concurrently

Определяем команды в package.json:

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

Или, если вы используете npm:

"scripts": {
  "dev:client": "npm run start --prefix client",
  "dev:server": "npm run dev --prefix server",
  "dev": "concurrently "npm run dev:client" "npm run dev:server""
}

В качестве БД мы будем использовать MongoDb Atlas Database.

Переходим по ссылке, создаем аккаунт, создаем проект и кластер и получаем строку для подключения вида mongodb+srv://<user>:<password>@cluster0.f7292.mongodb.net/<database>?retryWrites=true&w=majority*, где <user>, <password> и <database> — данные, которые вы указали при создании проекта и кластера.

  • Для получения адреса БД необходимо нажать Connect рядом с названием кластера (Cluster0) и затем Connect your application.
  • Если у вас, как и у меня, динамический IP, во вкладке Network Access раздела Security надо прописать 0.0.0.0/0

Можно приступать к разработке сервера.

Сервер

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

cd server

# производственные зависимости
yarn add express socket.io mongoose cors multer
# or
npm i ...

# зависимость для разработки
yarn add -D nodemon
# or
npm i -D nodemon

  • expressNode.js-фреймворк для разработки веб-серверов;
  • socket.io — библиотека, облегчающая работу с веб-сокетами;
  • mongoose — ORM для работы с MongoDB;
  • cors — утилита для работы с CORS;
  • multer — утилита для разбора (парсинга) данных в формате multipart/form-data (для сохранения файлов на сервере);
  • nodemon — утилита для запуска сервера для разработки.

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

"type": "module",
"scripts": {
  "dev": "nodemon"
}

Структура директории server будет следующей:

- files - директория для хранения файлов
- models
  - message.model.js - модель сообщения для `Mongoose`
- socket_io
  - handlers
    - message.handlers.js - обработчики для сообщений
    - user.handler.js - обработчики для пользователей
  - onConnection.js - обработка подключения
- utils
  - file.js - утилиты для работы с файлами
  - onError.js - обработчик ошибок
  - upload.js - утилита для сохранения файлов
- config.js - настройки (в репозитории имеется файл `config.example.js` с примером настроек)
- index.js - основной файл сервера

Определяем настройки в файле config.js (не забудьте добавить его в .gitignore):

// разрешенный источник
export const ALLOWED_ORIGIN = 'http://localhost:3000'
// адрес БД
export const MONGODB_URI =
  'mongodb+srv://<user>:<password>@cluster0.f7292.mongodb.net/<database>?retryWrites=true&w=majority'

Определяем модель в файле models/message.model.js:

import mongoose from 'mongoose'

const { Schema, model } = mongoose

const messageSchema = new Schema(
  {
    messageId: {
      type: String,
      required: true,
      unique: true
    },
    messageType: {
      type: String,
      required: true
    },
    textOrPathToFile: {
      type: String,
      required: true
    },
    roomId: {
      type: String,
      required: true
    },
    userId: {
      type: String,
      required: true
    },
    userName: {
      type: String,
      required: true
    }
  },
  {
    timestamps: true
  }
)

export default model('Message', messageSchema)

Каждое наше сообщение будет включать следующую информацию:

  • messageId — идентификатор сообщения;
  • messageType — тип сообщения;
  • textOrPathToFile — текст сообщения или путь к файлу;
  • roomId — идентификатор комнаты;
  • userId — идентификатор пользователя;
  • userName — имя пользователя;
  • createdAt, updatedAt — дата и время создания и обновления сообщения, соответственно (timestamps: true).

Кратко рассмотрим утилиты (директория utils).

Обработчик ошибок (onError.js):

export default function onError(err, req, res, next) {
  console.log(err)

  // если имеется объект ответа
  if (res) {
    // статус ошибки
    const status = err.status || err.statusCode || 500
    // сообщение об ошибке
    const message = err.message || 'Something went wrong. Try again later'
    res.status(status).json({ message })
  }
}

Утилита для работы с файлами (file.js):

import { unlink } from 'fs/promises'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import onError from './onError.js'

// путь к текущей директории
const _dirname = dirname(fileURLToPath(import.meta.url))

// путь к директории с файлами
const fileDir = join(_dirname, '../files')

// утилита для получения пути к файлу
export const getFilePath = (filePath) => join(fileDir, filePath)

// утилита для удаления файла
export const removeFile = async (filePath) => {
  try {
    await unlink(join(fileDir, filePath))
  } catch (e) {
    onError(e)
  }
}

Утилита для сохранения файлов (upload.js):

import { existsSync, mkdirSync } from 'fs'
import multer from 'multer'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'

// путь к текущей директории
const _dirname = dirname(fileURLToPath(import.meta.url))

const upload = multer({
  storage: multer.diskStorage({
    // директория для записи файлов
    destination: async (req, _, cb) => {
      // извлекаем идентификатор комнаты из HTTP-заголовка `X-Room-Id`
      const roomId = req.headers['x-room-id']
      // файлы хранятся по комнатам
      // название директории - идентификатор комнаты
      const dirPath = join(_dirname, '../files', roomId)

      // создаем директорию при отсутствии
      if (!existsSync(dirPath)) {
        mkdirSync(dirPath, { recursive: true })
      }

      cb(null, dirPath)
    },
    filename: (_, file, cb) => {
      // названия файлов могут быть одинаковыми
      // добавляем к названию время с начала эпохи и дефис
      const fileName = `${Date.now()}-${file.originalname}`

      cb(null, fileName)
    }
  })
})

export default upload

Рассмотрим основной файл сервера (index.js).

Импортируем все и вся:

import cors from 'cors'
import express from 'express'
import { createServer } from 'http'
import mongoose from 'mongoose'
import { Server } from 'socket.io'
import { ALLOWED_ORIGIN, MONGODB_URI } from './config.js'
import onConnection from './socket_io/onConnection.js'
import { getFilePath } from './utils/file.js'
import onError from './utils/onError.js'
import upload from './utils/upload.js'

Создаем экземпляр Express-приложения и подключаем посредников для работы с CORS и парсинга JSON:

const app = express()

app.use(
  cors({
    origin: ALLOWED_ORIGIN
  })
)
app.use(express.json())

Обрабатываем загрузку файлов:

app.use('/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.sendStatus(400)

  // формируем относительный путь к файлу
  const relativeFilePath = req.file.path
    .replace(/\/g, '/')
    .split('server/files')[1]

  // и возвращаем его
  res.status(201).json(relativeFilePath)
})

Обрабатываем получение файлов:

app.use('/files', (req, res) => {
  // формируем абсолютный путь к файлу
  const filePath = getFilePath(req.url)

  // и возвращаем файл по этому пути
  res.status(200).sendFile(filePath)
})

Добавляем обработчик ошибок и подключаемся к БД:

app.use(onError)

try {
  await mongoose.connect(MONGODB_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true
  })
  console.log('🚀 Connected')
} catch (e) {
  onError(e)
}

Создаем экземпляры сервера и Socket.io и обрабатываем подключение:

const server = createServer(app)

const io = new Server(server, {
  cors: ALLOWED_ORIGIN,
  serveClient: false
})

io.on('connection', (socket) => {
  onConnection(io, socket)
})

Наконец, определяем порт и запускаем сервер:

const PORT = process.env.PORT || 4000
server.listen(PORT, () => {
  console.log(`🚀 Server started on port ${PORT}`)
})

Полный код сервера:

import cors from 'cors'
import express from 'express'
import { createServer } from 'http'
import mongoose from 'mongoose'
import { Server } from 'socket.io'
import { ALLOWED_ORIGIN, MONGODB_URI } from './config.js'
import onConnection from './socket_io/onConnection.js'
import { getFilePath } from './utils/file.js'
import onError from './utils/onError.js'
import upload from './utils/upload.js'

const app = express()

app.use(
  cors({
    origin: ALLOWED_ORIGIN
  })
)
app.use(express.json())

app.use('/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.sendStatus(400)

  const relativeFilePath = req.file.path
    .replace(/\/g, '/')
    .split('server/files')[1]

  res.status(201).json(relativeFilePath)
})

app.use('/files', (req, res) => {
  const filePath = getFilePath(req.url)

  res.status(200).sendFile(filePath)
})

app.use(onError)

try {
  await mongoose.connect(MONGODB_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true
  })
  console.log('🚀 Connected')
} catch (e) {
  onError(e)
}

const server = createServer(app)

const io = new Server(server, {
  cors: ALLOWED_ORIGIN,
  serveClient: false
})

io.on('connection', (socket) => {
  onConnection(io, socket)
})

const PORT = process.env.PORT || 4000
server.listen(PORT, () => {
  console.log(`🚀 Server started on port ${PORT}`)
})

Рассмотрим работу с сокетами (директория socket_io).

Обработка подключения (onConnection.js):

import userHandlers from './handlers/user.handlers.js'
import messageHandlers from './handlers/message.handlers.js'

export default function onConnection(io, socket) {
  // извлекаем идентификатор комнаты и имя пользователя
  const { roomId, userName } = socket.handshake.query

  // записываем их в объект сокета
  socket.roomId = roomId
  socket.userName = userName

  // присоединяемся к комнате
  socket.join(roomId)

  // регистрируем обработчики для пользователей
  userHandlers(io, socket)

  // регистрируем обработчики для сообщений
  messageHandlers(io, socket)
}

Обработчики для пользователей (handlers/user.handlers.js):

// "хранилище" пользователей
const users = {}

export default function userHandlers(io, socket) {
  // извлекаем идентификатор комнаты и имя пользователя из объекта сокета
  const { roomId, userName } = socket

  // инициализируем хранилище пользователей
  if (!users[roomId]) {
    users[roomId] = []
  }

  // утилита для обновления списка пользователей
  const updateUserList = () => {
    // сообщение получают только пользователи, находящиеся в комнате
    io.to(roomId).emit('user_list:update', users[roomId])
  }

  // обрабатываем подключение нового пользователя
  socket.on('user:add', async (user) => {
    // сообщаем другим пользователям об этом
    socket.to(roomId).emit('log', `User ${userName} connected`)

    // записываем идентификатор сокета пользователя
    user.socketId = socket.id

    // записываем пользователя в хранилище
    users[roomId].push(user)

    // обновляем список пользователей
    updateUserList()
  })

  // обрабатываем отключения пользователя
  socket.on('disconnect', () => {
    if (!users[roomId]) return

    // сообщаем об этом другим пользователям
    socket.to(roomId).emit('log', `User ${userName} disconnected`)

    // удаляем пользователя из хранилища
    users[roomId] = users[roomId].filter((u) => u.socketId !== socket.id)

    // обновляем список пользователей
    updateUserList()
  })
}

Обработчики для сообщений (handlers/message.handlers.js):

import Message from '../../models/message.model.js'
import { removeFile } from '../../utils/file.js'
import onError from '../../utils/onError.js'

// "хранилище" для сообщений
const messages = {}

export default function messageHandlers(io, socket) {
  // извлекаем идентификатор комнаты
  const { roomId } = socket

  // утилита для обновления списка сообщений
  const updateMessageList = () => {
    io.to(roomId).emit('message_list:update', messages[roomId])
  }

  // обрабатываем получение сообщений
  socket.on('message:get', async () => {
    try {
      // получаем сообщения по `id` комнаты
      const _messages = await Message.find({
        roomId
      })
      // инициализируем хранилище сообщений
      messages[roomId] = _messages

      // обновляем список сообщений
      updateMessageList()
    } catch (e) {
      onError(e)
    }
  })

  // обрабатываем создание нового сообщения
  socket.on('message:add', (message) => {
    // пользователи не должны ждать записи сообщения в БД
    Message.create(message).catch(onError)

    // это нужно для клиента
    message.createdAt = Date.now()

    // создаем сообщение оптимистически,
    // т.е. предполагая, что запись сообщения в БД будет успешной
    messages[roomId].push(message)

    // обновляем список сообщений
    updateMessageList()
  })

  // обрабатываем удаление сообщения
  socket.on('message:remove', (message) => {
    const { messageId, messageType, textOrPathToFile } = message

    // пользователи не должны ждать удаления сообщения из БД
    // и файла на сервере (если сообщение является файлом)
    Message.deleteOne({ messageId })
      .then(() => {
        if (messageType !== 'text') {
          removeFile(textOrPathToFile)
        }
      })
      .catch(onError)

    // удаляем сообщение оптимистически
    messages[roomId] = messages[roomId].filter((m) => m.messageId !== messageId)

    // обновляем список сообщений
    updateMessageList()
  })
}

При реализации операций по созданию и удалению сообщения я исходил из предположения, что задержка в передаче данных является более критичной, чем неудачное сохранение или удаление сообщения из БД, поскольку речь идет о коммуникации в реальном времени. В идеале, хорошо иметь в БД отдельную таблицу для фиксации случаев неудачной записи/удаления сообщений.

Это все, что требуется от нашего сервера.

Переходим к реализации клиента.

Клиент

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

cd client

# производственные зависимости
yarn add react-router-dom zustand react-icons emoji-mart react-speech-kit react-timeago socket.io-client nanoid
# or
npm i ...

# зависимость для разработки
yarn add -D sass
# or
npm i -D sass

  • react-router-dom — библиотека для маршрутизации на стороне клиента;
  • zustand — библиотека для управления состоянием приложения;
  • react-icons — большой набор иконок в виде компонентов;
  • emoji-mart — компонент с эмодзи;
  • react-speech-kit — обертка над Web Speech API для react;
  • react-timeago — компонент для отображения относительного времени;
  • socket.io-client — клиент socket.io;
  • nanoid — утилита для генерации идентификаторов;
  • sass — препроцессор CSS.

Структура директории src будет следующей:

- api
  - file.api.js - интерфейс для загрузки файлов
- components
  - NameInput
    - NameInput.js - компонент для ввода имени пользователя
  - Room
    - MessageInput
      - EmojiMart
        - EmojiMart.js - компонент для эмодзи
      - FileInput
        - FileInput.js - компонент для выбора (прикрепления) файла для отправки
        - FilePreview.js - компонент для отображения превью файла
      - Recorder
        - Recorder.js - компонент для создания аудио или видеозаписи
        - RecordingModal.js - модальное окно для выбора типа и управления процессом записи
      - MessageInput.js - компонент для ввода сообщения пользователем, выбора эмодзи, прикрепления файла или создания аудио или видеозаписи
    - MessageList
      - MessageItem.js - компонент для одного сообщения
      - MessageList.js - компонент для списка сообщений
    - UserList
      - UserList - компонент для списка пользователей
    - Room.js - компонент для комнаты
  - index.js - повторный экспорт компонентов
- hooks
  - useChat.js - хук для работы с сокетами
  - useStore.js - хранилище состояния в форме хука
- pages
  - Home
    - Home.js - домашняя страница
  - index.js - повторный экспорт страниц
- routes
  - app.routes.js - роуты приложения
- styles - стили (я не буду на них останавливаться, просто скопируйте их из репозитория с исходным кодом проекта)
- utils
  - recording.js - утилиты для создания аудио или видеозаписи
  - storage.js - утилита для работы с локальным хранилищем
- App.js - основной компонент приложения
- App.scss - стили
- constants.js - константы
- index.js - основной файл клиента

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

import { BrowserRouter } from 'react-router-dom'
import AppRoutes from 'routes/app.routes'
import './App.scss'

function App() {
  return (
    <BrowserRouter>
      <AppRoutes />
    </BrowserRouter>
  )
}

export default App

Подключаем роутер и рендерим роуты приложения.

Рассмотрим эти роуты (routes/app.routes.js):

import { Home } from 'pages'
import { Route, Routes } from 'react-router-dom'

const AppRoutes = () => (
  <Routes>
    <Route path='*' element={<Home />} />
  </Routes>
)

export default AppRoutes

Все дороги, т.е. пути ведут в Рим, т.е. на главную страницу.

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

Взглянем на домашнюю страницу (pages/Home/Home.js):

import { NameInput, Room } from 'components'
import { USER_KEY } from 'constants'
import storage from 'utils/storage'

export const Home = () => {
  const user = storage.get(USER_KEY)

  return user ? <Room /> : <NameInput />
}

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

Утилита для работы с локальным хранилищем (utils/storage.js):

const storage = {
  get: (key) =>
    window.localStorage.getItem(key)
      ? JSON.parse(window.localStorage.getItem(key))
      : null,
  set: (key, value) => window.localStorage.setItem(key, JSON.stringify(value))
}

export default storage

Константы (constants.js):

export const USER_KEY = 'chat_app_user'
export const SERVER_URI = 'http://localhost:4000'

Займемся реализацией компонентов (components).

Компонент для ввода имени пользователя (NameInput/NameInput.js):

// импорты
import { USER_KEY } from 'constants'
import { nanoid } from 'nanoid'
import { useEffect, useState } from 'react'
import storage from 'utils/storage'

export const NameInput = () => {
  // начальные данные
  const [formData, setFormData] = useState({
    userName: '',
    // фиксируем ("хардкодим") название (идентификатор) комнаты
    roomId: 'main_room'
  })
  // состояние блокировки кнопки
  const [submitDisabled, setSubmitDisabled] = useState(true)

  // все поля формы являются обязательными
  useEffect(() => {
    const isSomeFieldEmpty = Object.values(formData).some((v) => !v.trim())
    setSubmitDisabled(isSomeFieldEmpty)
  }, [formData])

  // функция для изменения данных
  const onChange = ({ target: { name, value } }) => {
    setFormData({ ...formData, [name]: value })
  }

  // функция для отправки формы
  const onSubmit = (e) => {
    e.preventDefault()
    if (submitDisabled) return

    // генерируем идентификатор пользователя
    const userId = nanoid()

    // записываем данные пользователя в локальное хранилище
    storage.set(USER_KEY, {
      userId,
      userName: formData.userName,
      roomId: formData.roomId
    })

    // перезагружаем приложение для того, чтобы "попасть" в комнату
    window.location.reload()
  }

  return (
    <div className='container name-input'>
      <h2>Welcome</h2>
      <form onSubmit={onSubmit} className='form name-room'>
        <div>
          <label htmlFor='userName'>Enter your name</label>
          <input
            type='text'
            id='userName'
            name='userName'
            minLength={2}
            required
            value={formData.userName}
            onChange={onChange}
          />
        </div>
        {/* скрываем поле для создания комнаты (возможность масштабирования) */}
        <div class='visually-hidden'>
          <label htmlFor='roomId'>Enter room ID</label>
          <input
            type='text'
            id='roomId'
            name='roomId'
            minLength={4}
            required
            value={formData.roomId}
            onChange={onChange}
          />
        </div>
        <button disabled={submitDisabled} className='btn chat'>
          Chat
        </button>
      </form>
    </div>
  )
}

Компонент комнаты (Room/Room.js):

import useChat from 'hooks/useChat'
import MessageInput from './MessageInput/MessageInput'
import MessageList from './MessageList/MessageList'
import UserList from './UserList/UserList'

export const Room = () => {
  // получаем список пользователей, список сообщений, системную информацию и методы для отправки и удаления сообщения
  const { users, messages, log, sendMessage, removeMessage } = useChat()
  // и передаем их соответствующим компонентам
  return (
    <div className='container chat'>
      <div className='container message'>
        <MessageList
          log={log}
          messages={messages}
          removeMessage={removeMessage}
        />
        <MessageInput sendMessage={sendMessage} />
      </div>
      <UserList users={users} />
    </div>
  )
}

Рассмотрим хук для работы с сокетами (hooks/useChat.js):

import { SERVER_URI, USER_KEY } from 'constants'
import { useEffect, useRef, useState } from 'react'
import { io } from 'socket.io-client'
import storage from 'utils/storage'

export default function useChat() {
  // извлекаем данные пользователя из локального хранилища
  const user = storage.get(USER_KEY)
  // локальное состояние для списка пользователей
  const [users, setUsers] = useState([])
  // локальное состояние для списка сообщений
  const [messages, setMessages] = useState([])
  // состояние для системного сообщения
  const [log, setLog] = useState(null)
  // иммутабельное состояние для сокета
  const { current: socket } = useRef(
    io(SERVER_URI, {
      query: {
        // отправляем идентификатор комнаты и имя пользователя на сервер
        roomId: user.roomId,
        userName: user.userName
      }
    })
  )

  // регистрируем обработчики
  useEffect(() => {
    // сообщаем о подключении нового пользователя
    socket.emit('user:add', user)

    // запрашиваем сообщения из БД
    socket.emit('message:get')

    // обрабатываем получение системного сообщения
    socket.on('log', (log) => {
      setLog(log)
    })

    // обрабатываем получение обновленного списка пользователей
    socket.on('user_list:update', (users) => {
      setUsers(users)
    })

    // обрабатываем получение обновленного списка сообщений
    socket.on('message_list:update', (messages) => {
      setMessages(messages)
    })
  }, [])

  // метод для отправки сообщения
  const sendMessage = (message) => {
    socket.emit('message:add', message)
  }

  // метод для удаления сообщения
  const removeMessage = (message) => {
    socket.emit('message:remove', message)
  }

  return { users, messages, log, sendMessage, removeMessage }
}

Компонент для отображения списка пользователей (UserList/UserList.js):

import { AiOutlineUser } from 'react-icons/ai'

export default function UserList({ users }) {
  return (
    <div className='container user'>
      <h2>Users</h2>
      <ul className='list user'>
        {users.map(({ userId, userName }) => (
          <li key={userId} className='item user'>
            <AiOutlineUser className='icon user' />
            {userName}
          </li>
        ))}
      </ul>
    </div>
  )
}

Перебираем пользователей и рендерим список имен.

Компонент для отображения списка сообщений (MessageList/MessageList.js):

import { useEffect, useRef } from 'react'
import MessageItem from './MessageItem'

export default function MessageList({ log, messages, removeMessage }) {
  // иммутабельная ссылка на элемент для отображения системных сообщений
  const logRef = useRef()
  // иммутабельная ссылка на конец списка сообщений
  const bottomRef = useRef()

  // выполняем прокрутку к концу списка при добавлении нового сообщения
  // это может стать проблемой при большом количестве пользователей,
  // когда участники чата не будут успевать читать сообщения
  useEffect(() => {
    bottomRef.current.scrollIntoView({
      behavior: 'smooth'
    })
  }, [messages])

  // отображаем и скрываем системные сообщения
  useEffect(() => {
    if (log) {
      logRef.current.style.opacity = 0.8
      logRef.current.style.zIndex = 1

      const timerId = setTimeout(() => {
        logRef.current.style.opacity = 0
        logRef.current.style.zIndex = -1

        clearTimeout(timerId)
      }, 1500)
    }
  }, [log])

  return (
    <div className='container message'>
      <h2>Messages</h2>
      <ul className='list message'>
        {/* перебираем список и рендерим сообщения */}
        {messages.map((message) => (
          <MessageItem
            key={message.messageId}
            message={message}
            removeMessage={removeMessage}
          />
        ))}

        <p ref={bottomRef}></p>

        <p ref={logRef} className='log'>
          {log}
        </p>
      </ul>
    </div>
  )
}

Компонент сообщения (MessageList/MessageItem.js):

import { SERVER_URI, USER_KEY } from 'constants'
import { CgTrashEmpty } from 'react-icons/cg'
import { GiSpeaker } from 'react-icons/gi'
import { useSpeechSynthesis } from 'react-speech-kit'
import TimeAgo from 'react-timeago'
import storage from 'utils/storage'

export default function MessageItem({ message, removeMessage }) {
  // извлекаем данные пользователя из локального хранилища
  const user = storage.get(USER_KEY)
  // утилиты для перевода текста в речь
  const { speak, voices } = useSpeechSynthesis()
  // определяем язык приложения
  const lang = document.documentElement.lang || 'en'
  // мне нравится голос от гугла
  const voice = voices.find(
    (v) => v.lang.includes(lang) && v.name.includes('Google')
  )

  // элемент для рендеринга зависит от типа сообщения
  let element

  // извлекаем из сообщения тип и текст или путь к файлу
  const { messageType, textOrPathToFile } = message

  // формируем абсолютный путь к файлу
  const pathToFile = `${SERVER_URI}/files${textOrPathToFile}`

  // определяем элемент для рендеринга на основе типа сообщения
  switch (messageType) {
    case 'text':
      element = (
        <>
          <button
            className='btn'
            // озвучиваем текст при нажатии кнопки
            onClick={() => speak({ text: textOrPathToFile, voice })}
          >
            <GiSpeaker className='icon speak' />
          </button>
          <p>{textOrPathToFile}</p>
        </>
      )
      break
    case 'image':
      element = <img src={pathToFile} alt='' />
      break
    case 'audio':
      element = <audio src={pathToFile} controls></audio>
      break
    case 'video':
      element = <video src={pathToFile} controls></video>
      break
    default:
      return null
  }

  // определяем принадлежность сообщения текущему пользователю
  const isMyMessage = user.userId === message.userId

  return (
    <li className={`item message ${isMyMessage ? 'my' : ''}`}>
      <p className='username'>{isMyMessage ? 'Me' : message.userName}</p>

      <div className='inner'>
        {element}

        {isMyMessage && (
          {/* пользователь может удалять только свои сообщения */}
          <button className='btn' onClick={() => removeMessage(message)}>
            <CgTrashEmpty className='icon remove' />
          </button>
        )}
      </div>

      <p className='datetime'>
        <TimeAgo date={message.createdAt} />
      </p>
    </li>
  )
}

Рассмотрим хранилище в форме хука (hooks/useStore.js):

import create from 'zustand'

const useStore = create((set, get) => ({
  // файл
  file: null,
  // индикатор отображения превью файла
  showPreview: false,
  // индикатор отображения компонента с эмодзи
  showEmoji: false,
  // метод для обновления файла
  setFile: (file) => {
    // получаем предыдущий файл
    const prevFile = get().file
    if (prevFile) {
      // https://w3c.github.io/FileAPI/#creating-revoking
      // это позволяет избежать утечек памяти
      URL.revokeObjectURL(prevFile)
    }
    // обновляем файл
    set({ file })
  },
  // метод для обновления индикатора отображения превью
  setShowPreview: (showPreview) => set({ showPreview }),
  // метод для обновления индикатора отображения эмодзи
  setShowEmoji: (showEmoji) => set({ showEmoji })
}))

export default useStore

Компонент для ввода сообщения (MessageInput/MessageInput.js):

import fileApi from 'api/file.api'
import { USER_KEY } from 'constants'
import useStore from 'hooks/useStore'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useState } from 'react'
import { FiSend } from 'react-icons/fi'
import storage from 'utils/storage'
import EmojiMart from './EmojiMart/EmojiMart'
import FileInput from './FileInput/FileInput'
import Recorder from './Recorder/Recorder'

export default function MessageInput({ sendMessage }) {
  // извлекаем данные пользователя из локального хранилища
  const user = storage.get(USER_KEY)
  // извлекаем состояние из хранилища
  const state = useStore((state) => state)
  const {
    file,
    setFile,
    showPreview,
    setShowPreview,
    showEmoji,
    setShowEmoji
  } = state
  // локальное состояние для текста сообщения
  const [text, setText] = useState('')
  // локальное состояние блокировки кнопки
  const [submitDisabled, setSubmitDisabled] = useState(true)
  // иммутабельная ссылка на инпут для ввода текста сообщения
  const inputRef = useRef()

  // для отправки сообщения требуется либо текст сообщения, либо файл
  useEffect(() => {
    setSubmitDisabled(!text.trim() && !file)
  }, [text, file])

  // отображаем превью при наличии файла
  useEffect(() => {
    setShowPreview(file)
  }, [file, setShowPreview])

  // функция для отправки сообщения
  const onSubmit = async (e) => {
    e.preventDefault()
    if (submitDisabled) return

    // извлекаем данные пользователя и формируем начальное сообщение
    const { userId, userName, roomId } = user
    let message = {
      messageId: nanoid(),
      userId,
      userName,
      roomId
    }

    if (!file) {
      // типом сообщения является текст
      message.messageType = 'text'
      message.textOrPathToFile = text
    } else {
      // типом сообщения является файл
      try {
        // загружаем файл на сервер и получаем относительный путь к нему
        const path = await fileApi.upload({ file, roomId })
        // получаем тип файла
        const type = file.type.split('/')[0]

        message.messageType = type
        message.textOrPathToFile = path
      } catch (e) {
        console.error(e)
      }
    }

    // скрываем компонент с эмодзи, если он открыт
    if (showEmoji) {
      setShowEmoji(false)
    }

    // отправляем сообщение
    sendMessage(message)

    // сбрасываем состояние
    setText('')
    setFile(null)
  }

  return (
    <form onSubmit={onSubmit} className='form message'>
      <EmojiMart setText={setText} messageInput={inputRef.current} />
      <FileInput />
      <Recorder />
      <input
        type='text'
        autoFocus
        placeholder='Message...'
        value={text}
        onChange={(e) => setText(e.target.value)}
        ref={inputRef}
        // при наличии файла вводить текст нельзя
        disabled={showPreview}
      />
      <button className='btn' type='submit' disabled={submitDisabled}>
        <FiSend className='icon' />
      </button>
    </form>
  )
}

Компонент для отображения эмодзи (MessageInput/EmojiMart/EmojiMart.js):

import { Picker } from 'emoji-mart'
import 'emoji-mart/css/emoji-mart.css'
import useStore from 'hooks/useStore'
import { useCallback, useEffect } from 'react'
import { BsEmojiSmile } from 'react-icons/bs'

export default function EmojiMart({ setText, messageInput }) {
  // извлекаем соответствующие методы из хранилища
  const { showEmoji, setShowEmoji, showPreview } = useStore(
    ({ showEmoji, setShowEmoji, showPreview }) => ({
      showEmoji,
      setShowEmoji,
      showPreview
    })
  )

  // обработчик нажатия клавиши `Esc`
  const onKeydown = useCallback(
    (e) => {
      if (e.key === 'Escape') {
        setShowEmoji(false)
      }
    },
    [setShowEmoji]
  )

  // регистрируем данный обработчик на объекте `window`
  useEffect(() => {
    window.addEventListener('keydown', onKeydown)

    return () => {
      window.removeEventListener('keydown', onKeydown)
    }
  }, [onKeydown])

  // метод для добавления эмодзи к тексту сообщения
  const onSelect = ({ native }) => {
    setText((text) => text + native)
    messageInput.focus()
  }

  return (
    <div className='container emoji'>
      <button
        className='btn'
        type='button'
        {/* отображаем/скрываем эмодзи при нажатии кнопки */}
        onClick={() => setShowEmoji(!showEmoji)}
        disabled={showPreview}
      >
        <BsEmojiSmile className='icon' />
      </button>
      {showEmoji && (
        <Picker
          onSelect={onSelect}
          emojiSize={20}
          showPreview={false}
          perLine={6}
        />
      )}
    </div>
  )
}

Компонент для прикрепления файла (MessageInput/FileInput/FileInput.js):

import useStore from 'hooks/useStore'
import { useEffect, useRef } from 'react'
import { MdAttachFile } from 'react-icons/md'
import FilePreview from '../FilePreview/FilePreview'

export default function FileInput() {
  // извлекаем файл и метод для его обновления из хранилища
  const { file, setFile } = useStore(({ file, setFile }) => ({ file, setFile }))
  // иммутабельная ссылка на инпут для добавления файла
  // мы скрываем инпут за кнопкой
  const inputRef = useRef()

  // сбрасываем значение инпута при отсутствии файла
  useEffect(() => {
    if (!file) {
      inputRef.current.value = ''
    }
  }, [file])

  return (
    <div className='container file'>
      <input
        type='file'
        accept='image/*, audio/*, video/*'
        onChange={(e) => setFile(e.target.files[0])}
        className='visually-hidden'
        ref={inputRef}
      />
      <button
        type='button'
        className='btn'
        // передаем клик инпуту
        onClick={() => inputRef.current.click()}
      >
        <MdAttachFile className='icon' />
      </button>

      {file && <FilePreview />}
    </div>
  )
}

Компонент для отображения превью файла (MessageInput/FileInput/FilePreview.js):

import useStore from 'hooks/useStore'
import { useEffect, useState } from 'react'
import { AiOutlineClose } from 'react-icons/ai'

export default function FilePreview() {
  // извлекаем файл и метод для его обновления из хранилища
  const { file, setFile } = useStore(({ file, setFile }) => ({ file, setFile }))
  // локальное состояние для источника файла
  const [src, setSrc] = useState()
  // локальное состояние для типа файла
  const [type, setType] = useState()

  // при наличии файла обновляем источник и тип файла
  useEffect(() => {
    if (file) {
      setSrc(URL.createObjectURL(file))
      setType(file.type.split('/')[0])
    }
  }, [file])

  // элемент для рендеринга зависит от типа файла
  let element

  switch (type) {
    case 'image':
      element = <img src={src} alt={file.name} />
      break
    case 'audio':
      element = <audio src={src} controls></audio>
      break
    case 'video':
      element = <video src={src} controls></video>
      break
    default:
      element = null
      break
  }

  return (
    <div className='container preview'>
      {element}

      <button
        type='button'
        className='btn close'
        // обнуляем файл при закрытии превью
        onClick={() => setFile(null)}
      >
        <AiOutlineClose className='icon close' />
      </button>
    </div>
  )
}

Нам осталось рассмотреть компонент для создания аудио или видеозаписи. Но сначала рассмотрим соответствующие утилиты (utils/recording.js):

// https://www.w3.org/TR/mediastream-recording/
// переменные для рекордера, частей данных и требований к потоку данных
let mediaRecorder = null
let mediaChunks = []
let mediaConstraints = null

// https://w3c.github.io/mediacapture-main/#constrainable-interface
// требования к аудиопотоку
export const audioConstraints = {
  audio: {
    echoCancellation: true,
    autoGainControl: true,
    noiseSuppression: true
  }
}

// требования к медиапотоку (аудио + видео)
export const videoConstraints = {
  ...audioConstraints,
  video: {
    width: 1920,
    height: 1080,
    frameRate: 60.0
  }
}

// индикатор начала записи
export const isRecordingStarted = () => !!mediaRecorder

// метод для приостановки записи
export const pauseRecording = () => {
  mediaRecorder.pause()
}

// метод для продолжения записи
export const resumeRecording = () => {
  mediaRecorder.resume()
}

// метод для начала записи
// принимает требования к потоку
export const startRecording = async (constraints) => {
  mediaConstraints = constraints

  try {
    // https://w3c.github.io/mediacapture-main/#dom-mediadevices-getusermedia
    // получаем поток с устройств пользователя
    const stream = await navigator.mediaDevices.getUserMedia(constraints)
    // определяем тип создаваемой записи
    const type = constraints.video ? 'video' : 'audio'

    // https://www.w3.org/TR/mediastream-recording/#mediarecorder-constructor
    // создаем экземпляр рекордера
    mediaRecorder = new MediaRecorder(stream, { mimeType: `${type}/webm` })

    // обрабатываем запись данных
    mediaRecorder.ondataavailable = ({ data }) => {
      mediaChunks.push(data)
    }

    // запускаем запись
    mediaRecorder.start(250)

    // возвращаем поток
    return stream
  } catch (e) {
    console.error(e)
  }
}

// метод для завершения записи
export const stopRecording = () => {
  // останавливаем рекордер
  mediaRecorder.stop()
  // останавливаем треки из потока
  mediaRecorder.stream.getTracks().forEach((t) => {
    t.stop()
  })

  // определяем тип записи
  const type = mediaConstraints.video ? 'video' : 'audio'
  // https://w3c.github.io/FileAPI/#file-constructor
  // создаем новый файл
  const file = new File(mediaChunks, 'my_record.webm', {
    type: `${type}/webm`
  })

  // без этого запись можно будет создать только один раз
  mediaRecorder.ondataavailable = null
  // обнуляем рекордер
  mediaRecorder = null
  // очищаем массив с данными
  mediaChunks = []

  // возвращаем файл
  return file
}

Компонент для создания записи (MessageInput/Recorder/Recorder.js):

import useStore from 'hooks/useStore'
import { useState } from 'react'
import { RiRecordCircleLine } from 'react-icons/ri'
import RecordingModal from './RecordingModal'

export default function Recorder() {
  // извлекаем индикатор отображения превью файла из хранилища
  const showPreview = useStore(({ showPreview }) => showPreview)
  // локальное состояние для индикатора отображения модального окна
  const [showModal, setShowModal] = useState(false)

  return (
    <div className='container recorder'>
      <button
        type='button'
        className='btn'
        // показываем модальное окно при нажатии кнопки
        onClick={() => setShowModal(true)}
        // блокируем кнопку при отображении превью файла
        disabled={showPreview}
      >
        <RiRecordCircleLine className='icon' />
      </button>
      {showModal && <RecordingModal setShowModal={setShowModal} />}
    </div>
  )
}

Одна из самых интересных частей приложения — модальное окно для выбора типа и создания записи (MessageInput/Recorder/RecordingModal.js):

import useStore from 'hooks/useStore'
import { useRef, useState } from 'react'
import { BsFillPauseFill, BsFillPlayFill, BsFillStopFill } from 'react-icons/bs'
import {
  audioConstraints,
  isRecordingStarted,
  pauseRecording,
  resumeRecording,
  startRecording,
  stopRecording,
  videoConstraints
} from 'utils/recording'

export default function RecordingModal({ setShowModal }) {
  // извлекаем метод для обновления файла из хранилища
  const setFile = useStore(({ setFile }) => setFile)
  // локальное состояние для требований к потоку данных
  // по умолчанию создается аудиозапись
  const [constraints, setConstraints] = useState(audioConstraints)
  // локальный индикатор начала записи
  const [recording, setRecording] = useState(false)
  // иммутабельная ссылка на элемент для выбора типа записи
  const selectBlockRef = useRef()
  // иммутабельная ссылка на элемент `video`
  const videoRef = useRef()

  // функция для обновления требований к потоку на основе типа записи
  const onChange = ({ target: { value } }) =>
    value === 'audio'
      ? setConstraints(audioConstraints)
      : setConstraints(videoConstraints)

  // функция для приостановки/продолжения записи
  const pauseResume = () => {
    if (recording) {
      pauseRecording()
    } else {
      resumeRecording()
    }
    setRecording(!recording)
  }

  // функция для начала записи
  const start = async () => {
    if (isRecordingStarted()) {
      return pauseResume()
    }

    // получаем поток
    const stream = await startRecording(constraints)

    // обновляем локальный индикатор начала записи
    setRecording(true)

    // скрываем элемент для выбора типа записи
    selectBlockRef.current.style.display = 'none'

    // если создается видеозапись
    if (constraints.video && stream) {
      videoRef.current.style.display = 'block'
      // направляем поток в элемент `video`
      videoRef.current.srcObject = stream
    }
  }

  // функция для завершения записи
  const stop = () => {
    // получаем файл
    const file = stopRecording()

    // обновляем локальный индикатор начала записи
    setRecording(false)

    // обновляем файл
    setFile(file)

    // скрываем модалку
    setShowModal(false)
  }

  return (
    <div
      className='overlay'
      onClick={(e) => {
        // скрываем окно при клике за его пределами
        if (e.target.className !== 'overlay') return
        setShowModal(false)
      }}
    >
      <div className='modal'>
        <div ref={selectBlockRef}>
          <h2>Select type</h2>
          <select onChange={onChange}>
            <option value='audio'>Audio</option>
            <option value='video'>Video</option>
          </select>
        </div>

        {/* вот для чего нам нужны 2 индикатора начала записи */}
        {isRecordingStarted() && <p>{recording ? 'Recording...' : 'Paused'}</p>}

        <video ref={videoRef} autoPlay muted />

        <div className='controls'>
          <button className='btn play' onClick={start}>
            {recording ? (
              <BsFillPauseFill className='icon' />
            ) : (
              <BsFillPlayFill className='icon' />
            )}
          </button>
          {isRecordingStarted() && (
            <button className='btn stop' onClick={stop}>
              <BsFillStopFill className='icon' />
            </button>
          )}
        </div>
      </div>
    </div>
  )
}

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

Проверка работоспособности приложения

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

Вводим имена пользователей и входим в комнату:

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

Обмениваемся эмодзи:

Обмениваемся файлами:

Обмениваемся аудио/видео записями:

Удаляем парочку сообщений:

Приложение работает, как ожидается.

Поскольку, у наших пользователей имеются относительно стабильные и известные клиенту id, ничто не мешает нам масштабировать приложение, добавив в него возможность совершения аудио и видеозвонков посредством WebRTC, о чем я рассказывал в этой статье. Будем считать это вашим домашним заданием.

Пожалуй, это все, чем я хотел поделиться с вами в данной статье.

Благодарю за внимание и happy coding!


#Руководства

  • 22 апр 2019

  • 13

Когда обычных комментариев становится недостаточно, приходит время создать чат.

 vlada_maestro / shutterstock

Евгений Кучерявый

Пишет о программировании, в свободное время создаёт игры. Мечтает открыть свою студию и выпускать ламповые RPG.

С помощью чата пользователи общаются друг с другом, повышая интерес к сайту. Это важный элемент для вебинарных площадок, порталов со службой поддержки и страниц, где необходимо более живое, нефорумное общение. Гайд поможет на практике скомбинировать знания по HTML, JS, PHP и AJAX и создать готовый продукт.

Удобство для пользователей превыше всего. Позаботьтесь, чтобы чат соответствовал современным требованиям:

  • сообщения отображаются корректно на всех устройствах;
  • их загрузка и отправка происходят без обновления страницы;
  • есть дополнительный контент — изображения, аудиосообщения, смайлики, стикеры и прочее.

В первую очередь создаём форму отправки и контейнер для отображения сообщений:

<div class='chat'>
	<div class='chat-messages'>
		<div class='chat-messages__content' id='messages'>
			Загрузка...
		</div>
	</div>
	<div class='chat-input'>
		<form method='post' id='chat-form'>
			<input type='text' id='message-text' class='chat-form__input' placeholder='Введите сообщение'> <input type='submit' class='chat-form__submit' value='=>'>
		</form>
	</div>
</div>

.chat {
	border:1px solid #333;
	margin:15px;
	width:40%;
	height:70%;
	background:#555;
	color:#fff;
}

.chat-messages {
	min-height:93%;
	max-height:93%;
	overflow:auto;
}

.chat-messages__content {
	padding:1px;
}

.chat__message {
	border-left:3px solid #333;
	margin-top:2px;
	padding:2px;
}

.chat__message_black {
	border-color:#000;
}

.chat__message_blue {
	border-color:blue;
}

.chat__message_green {
	border-color:green;
}

.chat__message_red {
	border-color:red;
}

.chat-input {
	min-height:6%;
}
input {
	font-family:arial;
	font-size:16px;
	vertical-align:middle;
	background:#333;
	color:#fff;
	border:0;
	display:inline-block;
	margin:1px;
	height:30px;
}

.chat-form__input {
	width:79%;
}

.chat-form__submit {
	width:18%;
}

Первый этап пройден:

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

Сообщения будут отправляться и загружаться с помощью AJAX. На JavaScript пишем функции работы с интерфейсом и связи с серверной частью, а на PHP — методы обработки полученных данных и взаимодействия с базой данных.

var messages__container = document.getElementById('messages'); 
//Контейнер сообщений — скрипт будет добавлять в него сообщения

var interval = null; //Переменная с интервалом подгрузки сообщений

var sendForm = document.getElementById('chat-form'); //Форма отправки
var messageInput = document.getElementById('message-text'); //Инпут для текста сообщения

Она получает переменную act, в которой хранится одно из трёх значений: auth (авторизация), load (загрузка) и send (отправка). От них зависит, какая информация будет передана в PHP-файл.

function send_request(act, login = null, password = null) {//Основная функция
	//Переменные, которые будут отправляться
	var var1 = null;
	var var2 = null;
	
	if(act == 'auth') {
		//Если нужно авторизоваться, получаем логин и пароль, которые были переданы в функцию
		var1 = login;
		var2 = password;
	} else if(act == 'send') {
//Если нужно отправить сообщение, то получаем текст из поля ввода
		var1 = messageInput.value;
	}
	
	$.post('includes/chat.php',{ //Отправляем переменные
		act: act,
		var1: var1,
		var2: var2
	}).done(function (data) { 
		//Заносим в контейнер ответ от сервера
		messages__container.innerHTML = data;
		if(act == 'send') {
			//Если нужно было отправить сообщение, очищаем поле ввода
			messageInput.value = '';
		}
	});
}

И укажем для нашей функции интервал выполнения:

function update() {
	send_request('load');
}
interval = setInterval(update,500);

После отлавливается событие отправки формы — это поможет отказаться от обновления страницы:

sendForm.onsubmit = function () {
	send_request('send');
	return false; //Возвращаем ложь, чтобы остановить классическую отправку формы
};

Теперь займёмся самим обработчиком. В первую очередь с помощью функции session_start () запускается сессия, затем подключается база данных:

session_start();//Подключение должно быть на первой строчке в коде, иначе появится ошибка
$db = mysqli_connect("localhost","login","password"); 
mysqli_select_db($db,"chat");
//Заносим данные админа в сессию
$_SESSION['login'] = 'admin';
$_SESSION['password'] = 'admin';
$_SESSION['id'] = 1;

function auth($db,$login,$pass) {
	//Находим совпадение в базе данных
	$result = mysqli_query($db,"SELECT * FROM userlist WHERE login='$login' AND password='$pass'");
	if($result) {
		if(mysqli_num_rows($result) == 1) {//Проверяем, одно ли совпадение
			$user = mysqli_fetch_array($result); //Получаем данные пользователя и заносим их в сессию
			$_SESSION['login'] = $login;
			$_SESSION['password'] = $pass;
			$_SESSION['id'] = $user['id'];
			return true; //Возвращаем true, потому что авторизация успешна
		} else {
			unset($_SESSION); //Удаляем все данные из сессии и возвращаем false, если совпадений нет или их больше 1
			return false;
		}
	} else {
		return false; //Возвращаем ложь, если произошла ошибка
	}
}

function load($db) {
	$echo = "";
	if(auth($db,$_SESSION['login'],$_SESSION['password'])) {//Проверяем успешность авторизации
		$result = mysqli_query($db,"SELECT * FROM messages"); //Запрашиваем сообщения из базы
		if($result) {
			if(mysqli_num_rows($result) >= 1) {
				while($array = mysqli_fetch_array($result)) {//Выводим их с помощью цикла
					$user_result = mysqli_query($db,"SELECT * FROM userlist WHERE id='$array[user_id]'");//Получаем данные об авторе сообщения
					if(mysqli_num_rows($user_result) == 1) {
						$user = mysqli_fetch_array($user_result);
						$echo .= "<div class='chat__message chat__message_$user[nick_color]'><b>$user[login]:</b> $array[message]</div>"; //Добавляем сообщения в переменную $echo
					}
				}
			
			} else {
				$echo = "Нет сообщений!";//В базе ноль записей
			}
		}
	} else {
		$echo = "Проблема авторизации";//Авторизация не удалась
	}
	
	return $echo;//Возвращаем результат работы функции
}

function send($db,$message) {
	if(auth($db,$_SESSION['login'],$_SESSION['password'])) {//Если авторизация удачна
		$message = htmlspecialchars($message);//Заменяем символы ‘<’ и ‘>’на ASCII-код
		$message = trim($message); //Удаляем лишние пробелы
		$message = addslashes($message); //Экранируем запрещенные символы
		$result = mysqli_query($db,"INSERT INTO messages (user_id,message) VALUES ('$_SESSION[id]','$message')");//Заносим сообщение в базу данных
	}
	return load($db); //Вызываем функцию загрузки сообщений
}

В функции уже присутствует несложная валидация, но можно добавить и другие меры защиты от инъекций и спама:

  • проверку на дублирование прошлого сообщения;
  • удаление внешних ссылок;
  • цензуру мата;
  • премодерацию сообщений у некоторых групп пользователей и так далее.

Теперь, когда все функции готовы, пропишем их вызов.

//Получаем переменные из супермассива $_POST
//Тут же их можно проверить на наличие инъекций
if(isset($_POST['act'])) {$act = $_POST['act'];}
if(isset($_POST['var1'])) {$var1 = $_POST['var1'];}
if(isset($_POST['var2'])) {$var2 = $_POST['var2'];}

switch($_POST['act']) {//В зависимости от значения act вызываем разные функции
	case 'load': 
		$echo = load($db); //Загружаем сообщения
	break;
	
	case 'send': 
		if(isset($var1)) {
			$echo = send($db,$var1); //Отправляем сообщение
		}
	break;
	
	case 'auth': 
		if(isset($var1) && isset($var2)) {//Авторизуемся
			if(auth($db,$var1,$var2)) {
				$echo = load($db);
			}
		}
	break;
}

echo $echo;//Выводим результат работы кода

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

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

$.post('includes/chat.php',{
	act: act,
	var1: var1,
	var2: var2
}).done(function (data) {
	messages__container.innerHTML = data;
	if(data == 'Проблема авторизации') {
		clearInterval(interval); //Если проблема авторизации, отключаем автообновление
		if(login == null && password == null) {
			login = prompt('Введите логин: ');//Запрашиваем логин
			if(login != null) {
				password = prompt('Введите пароль: ');//Запрашиваем пароль
				send_request('auth',login,password); //Отправляем еще один запрос
			}
		}
	} 
	if(act == 'auth') {
		interval = setInterval(update,500); //Заново запускаем автообновление
	}
	if(act == 'send') {
		messageInput.value = '';
	}
});

Вот как это выглядит:

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

Создадим свой набор смайликов чата. Работать это будет так:

  • пользователь открывает специальное окошко и кликает по смайлику;
  • в поле ввода добавляется код смайлика (например, : sad: или: crazy:);
  • при выводе сообщения код смайлика заменяется на изображение.

Для начала добавим контейнер со смайликами и кнопку для его открытия:

<form method='post' id='chat-form'>
<div class='emojis-container emojis-container_hidden' id='emojis'></div>
	<img src='/images/emojis/happy.png' class='emoji-img' id='emoji-button'>
<input type='text' id='message-text' class='chat-form__input' placeholder='Введите сообщение'> 
<input type='submit' class='chat-form__submit' value='=>'>
</form>

Зададим стили:

.emojis-container {
	position:absolute;
	z-index:100;
	background:#555;
	border:1px solid #333;
	padding:2px;
	max-width:38%;
	top:20px;
}

.emojis-container_hidden {
	left:-9999999999999999px;
}

.emoji-img {
	vertical-align:middle;
	width:20px;
	margin:1px;
	cursor:pointer;
}

Добавим скрипт для загрузки смайликов и открытия меню с ними:

//Создаем переменные
var emojis__container = document.getElementById('emojis');
var emojis__button = document.getElementById('emoji-button');
var showed = false;

function getEmojis() {//Получаем смайлики из PHP-файла
	$.post('/includes/chat.php',{act: 'load-emojis'}).done(function (data) {
		emojis__container.innerHTML = data;
	});
}

function showEmojis() {//Добавляем отображение и скрытие окна
	if(showed) {
		emojis__container.setAttribute('class','emojis-container emojis-container_hidden');
		showed = false;
	} else {
		emojis__container.setAttribute('class','emojis-container');
		showed = true;
	}
}

А теперь и функцию добавления смайлика в поле:

function addEmoji(title) {
	messageInput.value += " " + title + " ";
//Тут же можно добавить закрытие контейнера
messageInput.focus();
}

После этого укажем, когда вызываются функции:

$(document).ready(function (){
	$(".emoji-add").on("click", function () {addEmoji($(this).attr('title'));});//Добавление
});
emojis__button.addEventListener('click',showEmojis); 

getEmojis(); //Сразу же вызываем получение смайликов

Приступим к загрузке смайликов и их преобразованию на PHP:

function getEmojis() {
	$dir = '../images/emojis';
	$echo = "";
	$files = scandir($dir);
	
	for($i = 0; $i != count($files); $i++) {
		$ext = explode('.',$files[$i]);
		if($ext[1] == 'png') {
			$echo .= "<img src='/images/emojis/".$files[$i]."' title=':"</span>.$ext[<span class="hljs-number" style="color: #ff73fd;">0</span>].<span class="hljs-string" style="color: #a8ff60;">":' class='emoji-img emoji-add'> ";
		} 
	}
	return $echo;
}

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

Чтобы вызвать её, добавим ещё один case в функцию switch () в конце обработчика:

case 'load-emojis': 
	$echo = getEmojis();
break;

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

function transformEmoji($message) {
	$pattern = '/(:S*:)/i'; //Паттерн смайлика
	if(preg_match($pattern,$message,$matches)) {//Ищем все совпадения для смайлика одного типа — только :happy: или только :sad:
		$path = explode(":",$matches[0]);
		if(file_exists("../images/emojis/".$path[1].".png")) {//Проверяем, существует ли такое изображение
			$message = preg_replace("/".$matches[0]."/","<img src='/images/emojis/$path[1].png' class='emoji-img'>",$message);//Заменяем код на изображение
		}
		$message = transformEmoji($message); //Повторяем, пока в $message есть коды смайликов
	}
	return $message;
}

Вызывается эта функция при загрузке сообщений:

$array['message'] = transformEmoji($array['message']);

Вот как это выглядит:

Чтобы добавить возможность отвечать кому-то конкретному, изменим функцию addEmoji (). При нажатии на ник собеседника будет меняться текст в поле ввода.

Для этого в load () изменим формат сообщений, добавив span к нику:

$echo .= "<div class='chat__message chat__message_$user[nick_color]'><b><span class='answer-span'>$user[login]</span>:</b> $array[message]</div>";

Пишем саму функцию:

function addAnswer(login) {
	messageInput.value = login + ", " + messageInput.value;
	messageInput.focus();
}

И вызываем функцию:

$(document).ready(function (){
	$(".add-answer").on("click", function () {addEmoji($(this).text());});
});

Одной статьи недостаточно, чтобы охватить все возможности PHP и JS для разработки чатов. С помощью этих языков легко добавить:

  • ответ на конкретные сообщения;
  • форматирование текста;
  • отправку аудио;
  • разные «комнаты»;
  • чат-бота и многое другое.

Учись бесплатно:
вебинары по программированию, маркетингу и дизайну.

Участвовать

Научитесь: Веб-разработчик с нуля до PRO
Узнать больше

В этом уроке мы будем создавать простое приложение веб-чата с помощью PHP и jQuery. Утилита такого типа прекрасно подойдет для системы онлайн-поддержки вашего сайта.


Введение

final productfinal productfinal product

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


Шаг 1: HTML разметка

Мы начнем этот урок с создания нашего первого файла index.php.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Chat - Customer Module</title>
<link type="text/css" rel="stylesheet" href="style.css" />
</head>

<div id="wrapper">
  <div id="menu">
		<p class="welcome">Welcome, <b></b></p>
		<p class="logout"><a id="exit" href="#">Exit Chat</a></p>
		<div style="clear:both"></div>
	</div>
	
	<div id="chatbox"></div>
	
	<form name="message" action="">
		<input name="usermsg" type="text" id="usermsg" size="63" />
		<input name="submitmsg" type="submit"  id="submitmsg" value="Send" />
	</form>
</div>
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.3/jquery.min.js"></script>
<script type="text/javascript">
// jQuery Document
$(document).ready(function(){

});
</script>
</body>
</html>
  • Мы начнем наш html с обычных DOCTYPE, html, head, и body тагов. В таг head мы добавим наш заголовок и ссылку на нашу таблицу стилей css (style.css).
  • Внутри тага body, мы структурируем наш макет внутри блока — обертки #wrapper div. У нас будет три главных блока: простое меню, окно чата и поле ввода нашего сообщения; каждый со своим соответствующим div и id.
    • Блок меню #menu div будет состоять из двух абзацев. Первый будет приветствием пользователю и поплывет налево, а второй будет ссылкой на выход и поплывет направо. Мы также включим блок div для очистки элементов.
    • Блок чата #chatbox div будет содержать лог нашего чата.  Мы будем загружать наш лог из внешнего файла с использованием ajax-запроса jQuery.  
    • Последним пунктом в нашем блоке-обертке #wrapper div будет наша форма, которая будет включать в себя текстовое поле ввода для сообщения пользователя и кнопку отправки.
  • Мы добавляем наши скрипты последними, чтобы грузить страницу быстрее. Сначала мы вставим ссылку Google jQuery CDN, так как в этом уроке мы будем использовать библиотеку jQuery. Наш второй таг скрипта будет там, где мы будем работать. Мы загрузим весь наш код после того, как документ будет готов.

Шаг 2: Создание стиля CSS

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

 
/* CSS Document */
body {
	font:12px arial;
	color: #222;
	text-align:center;
	padding:35px; }
 
form, p, span {
	margin:0;
	padding:0; }
 
input { font:12px arial; }
 
a {
	color:#0000FF;
	text-decoration:none; }
 
	a:hover { text-decoration:underline; }
 
#wrapper, #loginform {
	margin:0 auto;
	padding-bottom:25px;
	background:#EBF4FB;
	width:504px;
	border:1px solid #ACD8F0; }
 
#loginform { padding-top:18px; }
 
	#loginform p { margin: 5px; }
 
#chatbox {
	text-align:left;
	margin:0 auto;
	margin-bottom:25px;
	padding:10px;
	background:#fff;
	height:270px;
	width:430px;
	border:1px solid #ACD8F0;
	overflow:auto; }
 
#usermsg {
	width:395px;
	border:1px solid #ACD8F0; }
 
#submit { width: 60px; }
 
.error { color: #ff0000; }
 
#menu { padding:12.5px 25px 12.5px 25px; }
 
.welcome { float:left; }
 
.logout { float:right; }
 
.msgln { margin:0 0 2px 0; }

В вышеуказанном css нет ничего особенного, кроме того факта, что некоторые id или классы, для которых мы устанавливаем стиль, будут добавлены немного позже.

Как вы можете видеть выше, мы закончили строить пользовательский интерфейс чата.

Шаг 3: Используем PHP, чтобы создать форму входа.

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

<?
session_start();

function loginForm(){
	echo'
	<div id="loginform">
	<form action="index.php" method="post">
		<p>Please enter your name to continue:</p>
		<label for="name">Name:</label>
		<input type="text" name="name" id="name" />
		<input type="submit" name="enter" id="enter" value="Enter" />
	</form>
	</div>
	';
}

if(isset($_POST['enter'])){
	if($_POST['name'] != ""){
		$_SESSION['name'] = stripslashes(htmlspecialchars($_POST['name']));
	}
	else{
		echo '<span class="error">Please type in a name</span>';
	}
}
?>

Функция loginForm(), которую мы создали, состоит из простой формы входа, которая спрашивает у пользователя его/ее имя. Затем мы используем конструкцию if else, чтобы проверить, ввел ли пользователь имя. Если человек ввел имя, мы устанавливаем его, как $_SESSION[‘имя’]. Так как мы используем сессию, основанную на cookie, чтобы хранить имя, мы должны вызвать session_start() перед тем, как что-нибудь выводить в браузер.

Есть одна вещь, на которую вы возможно захотите обратить особое внимание — то, что мы использовали функцию htmlspecialchars(), которая конвертирует специальные символы в HTML сущности, тем самым защищая имя переменной, чтобы оно не стало жертвой межсайтового скриптинга (XSS). Мы также добавим эту функцию позже к текстовой переменной, которая будет опубликована в логе чата.

Отображение формы входа

Для того, чтобы показать форму логина в случае, если пользователь не вошел в систему, и следовательно, не сессия не создалась, мы используем другую инструкцию if else вокруг блока-обертки #wrapper div и тагов скрипта в нашем исходном коде. В противоположном случае, если пользователь вошел в систему и создал сессию, этот код спрячет форму входа и покажет окно чата.

<?php
if(!isset($_SESSION['name'])){
	loginForm();
}
else{
?>
<div id="wrapper">
	<div id="menu">
		<p class="welcome">Welcome, <b><?php echo $_SESSION['name']; ?></b></p>
		<p class="logout"><a id="exit" href="#">Exit Chat</a></p>
		<div style="clear:both"></div>
	</div>	
	<div id="chatbox"></div>
	
	<form name="message" action="">
		<input name="usermsg" type="text" id="usermsg" size="63" />
		<input name="submitmsg" type="submit"  id="submitmsg" value="Send" />
	</form>
</div>
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.3/jquery.min.js"></script>
<script type="text/javascript">
// jQuery Document
$(document).ready(function(){
});
</script>
<?php
}
?>

Приветствие и меню выхода из системы

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

Прежде всего, давайте добавим имя пользователя в сообщение приветствия. Мы сделаем это, выводя сессию имени пользователя.

<p class="welcome">Welcome, <b><?php echo $_SESSION['name']; ?></b></p>

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

<script type="text/javascript">
// jQuery Document
$(document).ready(function(){
	//If user wants to end session
	$("#exit").click(function(){
		var exit = confirm("Are you sure you want to end the session?");
		if(exit==true){window.location = 'index.php?logout=true';}		
	});
});
</script>

Код jquery, приведенный выше просто показывает диалог подтверждения, если пользователь кликнет по ссылке выхода #exit. Если пользователь подтвердит выход, тем самым решив закончить сессию, мы отправим его в index.php?logout=true. Это просто создаст переменную с именем logout со значением true. Мы должны перехватить эту переменную с помощью PHP:

if(isset($_GET['logout'])){	
	
	//Simple exit message
	$fp = fopen("log.html", 'a');
	fwrite($fp, "<div class='msgln'><i>User ". $_SESSION['name'] ." has left the chat session.</i><br></div>");
	fclose($fp);
	
	session_destroy();
	header("Location: index.php"); //Redirect the user
}

Теперь мы увидим, существует ли get переменная ‘logout’, используя функцию isset(). Если переменная была передана через url, такой, как ссылка, упомянутая выше, мы переходим к завершению сессии пользователя с текущим именем.

Перед уничтожением сессии пользователя с текущим именем с помощью функции session_destroy() мы хотим выводить простое сообщение о выходе в лог чата. В нем будет сказано, что пользователь покинул сессию чата. Мы сделаем это, используя функции  fopen(), fwrite() и fclose(), чтобы манипулировать нашим файлом log.html, который, как мы увидим позднее, будет создан в качестве лога нашего чата. Пожалуйста, обратите внимание, что мы добавили класс ‘msgln’ в блок div. Мы уж определили стиль css для этого блока.

Проделав это, мы уничтожаем сессию и перенаправляем пользователя на ту же страницу, где появится форма входа в систему.


Шаг 4: Поддержка пользовательского ввода данных

После того, как пользователь подтвердил свои действия в нашей форме, нам нужно захватывать его ввод с клавиатуры и писать его в лог нашего чата. Для того, чтобы сделать это, мы должны использовать jQuery и PHP, чтобы работать синхронно на стороне сервера и на стороне клиента.

jQuery

Практически все, что мы собираемся делать с jQuery для обработки наших данных, будет вращаться вокруг запроса на jQuery post.

	//If user submits the form
	$("#submitmsg").click(function(){	
		var clientmsg = $("#usermsg").val();
		$.post("post.php", {text: clientmsg});				
		$("#usermsg").attr("value", "");
		return false;
	});
  1. Перед тем, как мы что-то начнем делать, мы должны захватить пользовательский ввод, или то, что он напечатал в поток ввода #submitmsg. Этого можно достигнуть функцией val(), которая берет значение, установленное в поле формы. Теперь мы сохраняем это значение в переменную clientmsg.
  2. Вот и наступает самая важная часть: запрос jQuery post. Она отправляет запрос POST в файл post.php, который мы создадим через мгновение. Он отправляет ввод клиента, или то, что было сохранено в переменную clientmsg.
  3. В конце мы очищаем ввод #usermsg, очищая атрибут значения.

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

PHP — post.php

На данный момент мы имеем данные POST, отправляемые в файл post.php каждый раз, когда пользователь отправляет форму и посылает новое сообщение. Наша задача теперь захватить эти данные и записать их в лог нашего чата.

<?
session_start();
if(isset($_SESSION['name'])){
	$text = $_POST['text'];
	
	$fp = fopen("log.html", 'a');
	fwrite($fp, "<div class='msgln'>(".date("g:i A").") <b>".$_SESSION['name']."</b>: ".stripslashes(htmlspecialchars($text))."<br></div>");
	fclose($fp);
}
?>
  1. Прежде чем мы что-либо сделаем, мы должны начать файл post.php с помощью функции session_start(), так как мы будем использовать сессию по имени пользователя в этом файле.
  2. Используя логическую isset, мы проверим, существует ли сессия для ‘name’, перед тем, как что-то делать дальше.
  3. Теперь мы захватим данные POST, которые jQuery послал в этот файл. Мы сохраним эти данные в переменную $text.
  4. Эти данные, так же, как и вообще все данные, вводимые пользователем, будут храниться в файле log.html. Чтобы сделать это, мы откроем файл в режиме ‘a’ функции fopen, который согласно php.net открывает файл только для записи; помещает указатель файла на конец файла. Если файл не существует, попытаемся создать его. Затем мы запишем наше сообщение в файл, используя функцию fwrite().
    • Сообщение, которое мы будем записывать, будет заключено внутри блока .msgln div. Он будет содержать дату и время, сгенерированную функцией date(), сессию имени пользователя и текст, которые также будет окружен функцией htmlspecialchars(), чтобы избежать XSS.

    И наконец, мы закрываем наш файл с помощью fclose().


Шаг 5: Отображение содержимого лога чата (log.html)

Все, что пользователь разместил, обработано и опубликовано с помощью jQuery; оно записано в лог чата с помощью PHP. Единственное, что осталось сделать — это показать обновленный лог чата пользователю.

Чтобы сэкономить нам немного времени, мы предварительно загрузим лог чата в блок #chatbox div, как если бы он что-то содержал.

	<div id="chatbox"><?php
	if(file_exists("log.html") && filesize("log.html") > 0){
		$handle = fopen("log.html", "r");
		$contents = fread($handle, filesize("log.html"));
		fclose($handle);
		
		echo $contents;
	}
	?></div>

Мы используем процедуру, похожую на ту, что мы использовали в файле post.php, но на этот раз мы только читаем и выводим содержимое файла.

Запрос jQuery.ajax

Запрос ajax — это ядро всего, что мы делаем. Этот запрос не только позволяет нам посылать и принимать данные через форму без обновления страницы, но также и позволяет нам обрабатывать запрошенные данные.

//Load the file containing the chat log
	function loadLog(){		

		$.ajax({
			url: "log.html",
			cache: false,
			success: function(html){		
				$("#chatbox").html(html); //Insert chat log into the #chatbox div				
		  	},
		});
	}

Мы завернем наш ajax запрос в функцию. Вы увидите, зачем, прямо сейчас. Как вы можете видеть выше, мы использовали только три из объектов запроса jQuery ajax.

  • url: Строка URL для запроса. Мы используем имя файла лога нашего чата log.html.
  • cache: Это предотвратит кэширование нашего файла. Это обеспечит нам то, что всегда, когда мы посылаем запрос, мы будем иметь обновленный лог чата.
  • sucess: Это позволит нам прикрепить функцию, которая передаст запрошенные нами данные.

Как вы видите, затем мы перемещаем запрошенные нами данные (html) в блок #chatbox div.

Автопрокрутка

Как мы, возможно, видели в других приложениях чатов, содержимое автоматически прокручивается вниз, если контейнер лога чата (#chatbox) переполняется. Мы воплотим простую и похожую возможность, которая будет сравнивать высоту полосы прокрутки контейнера до и после того, как мы выполним ajax запрос. Если высота полосы прокрутки стала больше после запроса, мы используем эффект анимации jQuery, чтобы прокрутить блок #chatbox div.

	//Load the file containing the chat log
	function loadLog(){		
		var oldscrollHeight = $("#chatbox").attr("scrollHeight") - 20; //Scroll height before the request
		$.ajax({
			url: "log.html",
			cache: false,
			success: function(html){		
				$("#chatbox").html(html); //Insert chat log into the #chatbox div	
				
				//Auto-scroll			
				var newscrollHeight = $("#chatbox").attr("scrollHeight") - 20; //Scroll height after the request
				if(newscrollHeight > oldscrollHeight){
					$("#chatbox").animate({ scrollTop: newscrollHeight }, 'normal'); //Autoscroll to bottom of div
				}				
		  	},
		});
	}
  • Сначала мы сохраним высоту полосы прокрутки блока  #chatbox div в переменную oldscrollHeight перед выполнением запроса.
  • После того, как наш запрос вернет успех, мы сохраним высоту полосы прокрутки блока #chatbox div в переменную newscrollHeight.
  • Затем мы сравним высоту полосы прокрутки в обеих переменных, используя конструкцию if. Если newscrollHeight больше, чем oldscrollHeight, мы используем эффект анимации, чтобы прокрутить блок #chatbox div.

Постоянное обновление лога чата

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

setInterval (loadLog, 2500);	//Reload file every 2500 ms or x ms if you wish to change the second parameter

Ответ на наш вопрос находится в функции setInterval. Эта функция будет запускать нашу функцию loadLog() каждые 2,5 секунды, которая будет запрашивать обновленный файл и делать автопрокрутку блока.

Закончили

Мы закончили! Я надеюсь, что вы изучили, как работает базовая система чата, и, если у вас есть какие-либо пожелания, я с радостью их приветствую. Это максимально простая система чата, которую вы можете создать как приложение чата. Вы можете оттолкнуться от нее и построить множественные чат комнаты, добавить админку, эмотиконы и т.д. Здесь ваш предел — это небо.

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

  • Защитите ваши формы ключами форм — избегайте XSS (межсайтового скриптинга) и подделок межсайтовых запросов.
  • Отправка формы без обновления страницы с использованием jQuery — расширьте наш ajax запрос
  • Как делать AJAX запросы на чистом Javascript  — изучите, как работает кухня запросов на чистом javascript.

  • Следите за нами на Twitter, или подпишитесь на RSS ленту NETTUTS, чтобы получать больше ежедневных уроков и статей по веб-разработке.

В этой статье вы прочитаете  как сделать чат на JavaScript и HTML, ещё для сервера будем использовать Node.js.

Если у вас не установлен Node.js и вы не знаете как это сделать, то прочитайте эти статьи:

  • Как установить Node.JS на Linux или на Ubuntu 19.04;
  • Как установить Node.js на любую версию Windows;

Front-end:

Для начала разберём как сделать клиентскую часть сайта, создадим файл «chat.html», вот он:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

<!DOCTYPE html>

<html lang=«ru»>

<head>

    <meta charset=«UTF-8»>

    <meta name=«viewport» content=«width=device-width, initial-scale=1.0»>

    <meta http-equiv=«X-UA-Compatible» content=«ie=edge»>

    <link rel=«stylesheet» href=«https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css» integrity=«sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS» crossorigin=«anonymous»>

    <title>Chat</title>

</head>

<body>

    <main>

        <div id=«divMessages» class=«message border rounded container»>

        </div>

        <div class=«container»>

            <div id=«blockSendMessage» class=«row»>

                <input id=«inputMessage» class=«form-control col-8 col-sm-9 col-md-8» type=«text» placeholder=«Сообщений»>

                <button id=«btnSend» type=«button» class=«btn btn-secondary col-4 col-sm-3 col-md-4»>Отправить</button>

            </div>

        </div>

    </main>

</body>

</html>

Тут не чего сложного и особенного нет, единственное мы добавляем CSS bootstrap, чтобы всё чуть красивее стало и так же имеем два блока, первый куда будем выводить сообщения, второе для форм, куда будем вводить текст сообщения и кнопка отправить.

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

// Получаем элемент чата

let chat = document.querySelector(«#divMessages»)

// Получаем строку ввода сообщения

let input = document.querySelector(«#inputMessage»)

// Получаем кнопку для ввода сообщения

let btnSubmit = document.querySelector(«#btnSend»)

// Подключаем WebSocket

const webSocket = new WebSocket(‘ws://localhost:8081’);

// Получаем сообщение от сервера

webSocket.onmessage = function(e) {

    // Парсим полученные данные

    const data = JSON.parse(e.data);

    // Выводим в блог сообщение от сервера

    chat.innerHTML += ‘<div class=»msg»>’ + data.message + ‘</div>’

};

// Отслеживаем нажатие мыши

btnSubmit.addEventListener(«click», () => {

    // Получаем текст из формы для ввода сообщения

    message = input.value;

    // Отправка сообщения через WS

    webSocket.send(JSON.stringify({

        ‘message’: message

    }));

    // Очищаем поле для ввода текста

    input.value = »;

})

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

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

Последние отслеживаем нажатие кнопки отправить сообщение, при клики на неё берём строку из формы и отправляем сообщение предварительно из JSON сделав строку и чистим форму.

На этом мы закончили делать клиентскую часть.

Back-end:

Теперь будем делать чат для сайт на Node.js, то есть сделаем серверную часть, для этого создадим файл «App.js».

Примечание:

Если вы не хотите использовать Node.js для вашего чата, не знаете его, или предпочитаете Python Django, то почитайте эту статью «Как сделать чат на Python Django»

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

// Подключаем библиотеку для работы с WebSocket

const WebSocket = require(‘ws’);

// Создаём подключение к WS

let wsServer = new WebSocket.Server({

    port: 8081

});

// Создаём массив для хранения всех подключенных пользователей

let users = []

// Проверяем подключение

wsServer.on(‘connection’, function (ws) {

    Делаем подключение пользователя

    let user = {

        connection: ws

    }

    // Добавляем нового пользователя ко всем остальным

    users.push(user)

    // Получаем сообщение от клиента

    ws.on(‘message’, function (message) {

        // Перебираем всех подключенных клиентов

        for (let u of users) {

            // Отправляем им полученное сообщения

            u.connection.send(message)

        }

    })

    // Делаем действие при выходе пользователя из чата

    ws.on(‘close’, function () {

        // Получаем ID этого пользователя

        let id = users.indexOf(user)

        // Убираем этого пользователя

        users.splice(id, 1)

    })

})

Как можете заметить код на Node.js тоже очень простой, в начале подключаем библиотеку «ws» для работы с WebSocket,  потом создаём подключение к серверу через этот протокол, и делаем массив в котором будут хранится все пользователи.

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

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

На этом разработка серверной части закончилась и как видите тоже не чего особо сложного нет, для запуска видите эту команду:

Всё должно запустится, открываем HTML документ, в котором мы сделали front-end и отправляем сообщения.

Вывод:

В этой статье вы прочитали как сделать чат на JavaScript и HTML, так же для разработки серверной части использовали Node.JS.

Подписываетесь на соц-сети:

Оценка:

Загрузка…

Также рекомендую:

Рассказываем, как создать собственный чат на React.js с помощью Charset SDK: от создания компонентов до работы со сторонним API.

Шаг 1: раскладываем интерфейс на компоненты

Так как Реакт – компонентный фреймворк, первое, что нам нужно сделать, это представить чат на React как набор элементов.

Начнем с создания корневого компонента – своеобразного холдера для всех остальных элементов приложения:

cabinet

Красный прямоугольник вокруг макета – корневой элемент, назовем его App.

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

  • Title
  • MessageList
  • SendMessageForm

Выделим их на макете:

cabinet

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

Шаг 2: настраиваем кодовую базу

Для знакомства с фреймворком воспользуемся самым простым путем сборки приложения: просто создадим файл index.html, к которому подключим необходимые библиотеки и код.

cabinet

Кроме самого Реакта, мы импортируем Charset SDK и Babel, который необходим для преобразования JSX.

Откройте готовый проект в новой вкладке и попробуйте поиграть с кодом, если будете испытывать трудности с пониманием туториала.

cabinet

Также по ссылке проект можно скачать в виде архива и развернуть на локальном сервере.

Шаг 3: создаем корневой компонент

Когда все необходимые файлы подключены, можно приступать к написанию компонентов. Они будут создаваться в файле index.js.

Начнем с создания основного компонента App. App станет единственным «умным» компонентом нашей системы, так как он будет обрабатывать данные и общаться с API. Его вид в базовом виде (без логики):

class App extends React.Component {
  
  render() {
    return (
      <div className="app">
        <Title />
        <MessageList />
        <SendMessageForm />
     </div>
    )
  }
}

Сейчас App только рендерит три дочерних компонента: <Title>,<MessageList>, и <SendMessageForm>.

Сообщения чата будут храниться в свойстве state компонента. Таким образом, мы будем иметь доступ к сообщениям через this.state.messages и сможем передавать их во все дочерние элементы чата.

Для начала работы над сообщениями воспользуемся «рыбой», а позже заменим фиктивные сообщения на данные от Chatkit API.

Создадим переменную DUMMY_DATA:

const DUMMY_DATA = [
  {
    senderId: "perborgen",
    text: "who'll win?"
  },
  {
    senderId: "janedoe",
    text: "who'll win?"
  }
]

Теперь, добавим эти данные в state компонента App и передадим в MessageList как свойство.

class App extends React.Component {
  
  constructor() {
    super()
    this.state = {
       messages: DUMMY_DATA
    }
  }
  
  render() {
    return (
      <div className="app">
        <MessageList messages={this.state.messages} />
        <SendMessageForm />
     </div>
    )
  }
}

Так, мы инициализируем state в конструкторе и передаем this.state.messages компоненту MessageList. Обратите внимание, что мы использовали super(). Необходимо делать это каждый раз, когда вам нужен компонент с запоминанием состояния.

Шаг 4: рендеринг сообщений

Посмотрим, как можно отрисовать сообщения в компоненте MessageList:

class MessageList extends React.Component {
  render() {
    return (
      <ul className="message-list">                 
        {this.props.messages.map(message => {
          return (
           <li key={message.id}>
             <div>
               {message.senderId}
             </div>
             <div>
               {message.text}
             </div>
           </li>
         )
       })}
     </ul>
    )
  }
}

Это так называемый «глупый» компонент. Он принимает единственное свойство messages, которое содержит массив объектов. Затем, он просто рендерит свойства text и senderId из этих объектов.

В нашем случае, рендер будет выглядеть следующим образом:

cabinet

Теперь у нас есть каркас приложения, и мы можем рендерить сообщения. Заменим фиктивные сообщения на настоящие.

Шаг 5: получаем API-ключ для Chatkit

Chatkit – это инструмент для быстрого создания пользовательских чатов внутри существующих приложений. Чтобы начать с ним работу, нам нужно получить доступ к API сервиса.

Начнем с регистрации аккаунта.

cabinet

В работе нам понадобится 4 значения из личного кабинета:

  • Instance Locator
  • Test Token Provider
  • Room id
  • Username

Instance Locator:

cabinet

Чуть ниже находится Test Token Provider:

cabinet

Следующим шагом нужно создать User и Room, что делается на той же странице. В первую очередь необходимо создать пользователя, потом можно будет создать комнату и получить ее id.

cabinet

Теперь вернемся в index.js и сохраним полученные идентификаторы в виде переменных. Пример выглядит так:

const instanceLocator = "v1:us1:dfaf1e22-2d33-45c9-b4f8-31f634621d24"
const testToken =
"https://us1.pusherplatform.io/services/chatkit_token_provider/v1/
dfaf1e22-2d33-45c9-b4f8-31f634621d24/token"
const username = "perborgen"
const roomId = 9796712

С этими данными мы готовы подключиться к Chatkit. Это будет происходить в методе componentDidMount компонента App.

В первую очередь создаем ChatManager:

componentDidMount() {
  const chatManager = new Chatkit.ChatManager({
    instanceLocator: instanceLocator,
    userId: username,
    tokenProvider: new Chatkit.TokenProvider({
      url: testToken
    })
 })

Затем вызовем chatManager.connect(), чтобы подключиться к API:

  chatManager.connect().then(currentUser => {
      currentUser.subscribeToRoom({
      roomId: roomId,
      hooks: {
        onNewMessage: message => {
          this.setState({
            messages: [...this.state.messages, message]
          })
        }
      }
    })
  })
}

Так мы получим доступ к объекту currentUser, который послужит интерфейсом для взаимодействия с API. Поскольку с currentUser нам еще предстоит работать, сохраним его в this.currentUser = currentUser.

Дальше мы вызываем currentUser.subscribeToRoom() и передаем наш roomId в хук onNewMessage. Хук срабатывает каждый раз, когда сообщение передается в комнату, и в этот момент мы просто передаем новое сообщение в конец this.state.messages.

Так будет выглядеть результат наших последних действий после рендера:

chat

Шаг 7: обрабатываем пользовательский ввод

Теперь нам нужно создать компонент SendMessageForm. SendMessageForm будет так называемым «контролируемым» компонентом. Это значит, что данный компонент контролирует рендер в поле ввода через свой state.

Рассмотрим метод render():

class SendMessageForm extends React.Component {
  render() {
    return (
      <form
        className="send-message-form">
        <input
          onChange={this.handleChange}
          value={this.state.message}
          placeholder="Type your message and hit ENTER"
          type="text" />
      </form>
    )
  }
}

Здесь мы делаем две вещи:

  1. Следим за пользовательским вводом с помощью onChange, благодаря которому можем инициировать метод handleChange.
  2. Устанавливаем значение value поля ввода используя this.state.message.

Эти два действия соединяются в методе handleChange. Он просто обновляет состояние в соответствии с тем, что ввел пользователь:

handleChange(e) {
  this.setState({
    message: e.target.value
  })
}

Затем страница перерисовывается, и поскольку поле ввода задано явно из состояния с использованием value={this.state.message}, оно будет обновляться.

Теперь дадим компоненту конструктор. В нем мы инициализируем state и привяжем к this метод handleChange:

constructor() {
    super()
    this.state = {
       message: ''
    }
    this.handleChange = this.handleChange.bind(this)
}

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

Шаг 8: отправка сообщений

Компонент SendMessageForm почти завершен, осталось только позаботиться об отправке данных. Для этого мы будем вызывать обработчик handleSubmit при onSubmit компонента формы.

render() {
    return (
      <form
        onSubmit={this.handleSubmit}
        className="send-message-form">
        <input
          onChange={this.handleChange}
          value={this.state.message}
          placeholder="Type your message and hit ENTER"
          type="text" />
      </form>
    )
  }

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

handleSubmit(e) {
  e.preventDefault()
  this.props.sendMessage(this.state.message)
  this.setState({
    message: ''
  })
}

Здесь мы вызвали sendMessage и передали ему this.state.message в качестве параметра. Мы еще не создали метод sendMessage, но займемся этим в следующем шаге, так как это метод компонента App.

SendMessageForm целиком выглядит следующим образом:

class SendMessageForm extends React.Component {
  constructor() {
    super()
    this.state = {
      message: ''
    }
    this.handleChange = this.handleChange.bind(this)
    this.handleSubmit = this.handleSubmit.bind(this)
  }
  handleChange(e) {
    this.setState({
      message: e.target.value
    })
  }
  handleSubmit(e) {
    e.preventDefault()
    this.props.sendMessage(this.state.message)
    this.setState({
      message: ''
    })
  }
  render() {
    return (
      <form
        onSubmit={this.handleSubmit}
        className="send-message-form">
        <input
          onChange={this.handleChange}
          value={this.state.message}
          placeholder="Type your message and hit ENTER"
          type="text" />
      </form>
    )
  }
}

Шаг 9: отправка сообщений в Chatkit

Приложение готово к отправке сообщений в Chatkit. Это будет происходить в компоненте App, в котором мы создадим метод this.sendMessage:

sendMessage(text) {
  this.currentUser.sendMessage({
    text,
    roomId: roomId
  })
}

Метод принимает один параметр (текст сообщения). Передадим его в качестве свойства в <SendMessageForm>:

render() {
  return (
    <div className="app">
      <Title />
      <MessageList messages={this.state.messages} />
      <SendMessageForm sendMessage={this.sendMessage} />
  )
}

Шаг 10: компонент Title

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

function Title() {
  return <p class="title">My awesome chat app</p>
}

chat

Вот и все, теперь у вас есть собственный мессенджер! Еще раз напомним: если у вас возникнут трудности с пониманием материала, попробуйте поиграть с кодом на Scrimba, чтобы лучше разобраться в деталях уже работающего приложения.

Оригинал

Больше Реакта:

  • Объясняем React так доходчиво, что поймет даже ребенок
  • Пособие по React: всестороннее изучение React.js
  • React.js: делаем код чище с централизованными PropTypes

Понравилась статья? Поделить с друзьями:
  • Как написать меню на css
  • Как написать меню для дисплея на arduino
  • Как написать менуэт
  • Как написать менее 1 процента
  • Как написать менеджеру озон