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

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

В данной статье я хочу показать вам, как разработать простое приложение для обмена сообщениями в режиме реального времени с использованием 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!


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

Статья подойдёт состоявшимся программистам и тем, кто только интересуется, как войти в 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 ✌🏼.

В этой статье, речь пойдет о том, как создать свой месседжер на NodeJS, пусть и самый простой, зато своими руками 🙂

Как сервер — мы будем использовать NodeJS
Как клиент — браузерный JS

Устанавливаем NodeJS (https://nodejs.org), тут все интуитивно понятно, далее устанавливаем зависимости, создаем файл node.bat:

cd #Переходим в текущий каталог

md server #Создаем папку

cd server #Переходим в папку

npm install socket.io #Устанавливаем express и socket.io

Socket.IO нужен нам для того — чтобы общаться между клиентом и серверов в реалтайме(онлайн, прям как сообщения в ВК)

Написание кода (Server Side)

Создаем файл server.js, в него пишем:

var PORT = 8008; #Порт, который будет слушать соккет

var io = require('socket.io').listen(server);
server.listen(PORT);

io.sockets.on('connection', function (client) { #Когда соединяется новый клиент
console.info("New Client Connected"); #Выводим в консоль сервера сообщение о том, что клиент подключился
client.on('message', function (message) { #Когда клиент отправляет сообщение
client.emit('message', message); #Отправляем сообщение самому клиенту
client.broadcast.emit('message', message); #Всем подключенным клиентам
});
});

Сервер написан, сохраняем

Написание кода (Client Side)

Создаем файл client.js, и пишем туда

window.onload = function(){ #Когда все компоненты на странице загружены
var socket = io.connect('http://localhost:8008'); #Подключаемся к нашему соккету

socket.on('connect', function(){ #При успешном соединении с сервером

console.info("Connected to server");

});

socket.on('message', function(data){ #Когда с сервера приходит сообщение

console.info(data);

});
}

Вот и все, это конечно не телеграмм, но для первого клиент-серверного приложения — пойдет

Понравилось? Поделись! Еще больше статей по разработке и безопасности на нашем Дзен канале: HellBytes

Рассказываем, как создать собственный чат на 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

В этой статье вы прочитаете  как сделать чат на 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.

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

Оценка:

Загрузка…

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

Друзья привет! Сегодня я хочу рассказать про скрипт мессенджер(чем-то похожий на WhatsApp или Telegram), написанный мною для установки на сайт, как замена стандартной системы личных сообщений или как дополнение к ней. Все начиналось с того, что пользователи одного из моих проектов, начали жаловаться на то, что им неудобно общаться по средствам личных сообщений и емаил. Им была нужна оперативность, быстрая и наглядная доставка сообщений, возможность просматривать историю переписки, возможность отправки изображений и просмотр статусов сообщений и пользователей находящихся в сети, и все это в рамках одного сайта. Т.е. переход в сторонние приложения не подходит — неудобно, нестабильно, ограничения и т.д. В общем, все их критерии удовлетворил мессенджер, который я написал на JS и PHP, в последствии который превратился в самостоятельный отдельный проект.

Функционал мессенджера

Список контактов

  • Возможность добавлять пользователей в список контактов;
  • Поиск пользователей по списку контактов;
  • Глобальный поиск пользователей по сайту (функция живого поиска);
  • Отображение в списке контактов последнего сообщения из чата (под именем пользователя);
  • Дата последнего сообщения;
  • Подсветка непрочитанных сообщений;
  • Возможность удаления пользователя из списка контактов (история переписки сохранится).

Окно чата

  • Отображение имени и аватара собеседника, со ссылкой на его полный профиль;
  • Отображение даты последнего посещения сайта собеседником (в формате: Был(a) онлайн 45 мин назад);
  • Возможность отправки текстового сообщения с прикрепленным изображением;
  • Возможность удаления последних непрочитанных сообщений;
  • Отображение даты и времени сообщений;
  • Отображение статуса сообщений (прочитано или нет).

Дополнительные возможности

При интеграции мессенджера в систему сайта можно также реализовать ряд дополнительных возможностей (что например, реализованы на моем сайте):

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

Скриншоты

мессенджер

Мессенджер

Окно чата

Глобальный, «живой» поиск пользователей

Добавление пользователя в список контактов

Поиск по списку контактов

Отправка изображения

Сообщения в чате

Удаление пользователя из списка контактов

Удаление последнего непрочитанного сообщения

Возможность интеграции

Данный мессенджер может быть интегрирован в среду любого сайта, независимо от установленной на нем CMS. Также возможна интеграция с действующей базой данных пользователей, для работы глобального поиска и возможности добавления пользователей в свой список контактов. Интеграция осуществляется программистом, путем замены SQL запросов к новой таблице пользователей (в будущих версиях будет реализована упрощенная схема интеграции).

Возможность доработок

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

Заключение

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

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