Приложение Socketio React Chat

У меня было приложение для чата на базе graphql с фронт-эндом react и бэкэндом express.
Оно прекрасно работало, но возникли проблемы, когда я попытался разместить backend на heroku, что-то в том, что они поддерживают только определенный тип websockets.
приложение для чата с подписками на graphql
клиентский код init commit

Один из перечисленных поддерживаемых типов был socketio, так что я попробовал его.
Для фронт-энда мы будем использовать react с vite, потому что create-react-app имеет некоторые устаревшие библиотеки, связанные с webpack, которые вызывают предупреждения об уязвимости.

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

для начала работы запустите,

npm init vite
Войти в полноэкранный режим Выйдите из полноэкранного режима

и следуйте инструкциям

зависимости

npm install axios react-icons socket.io-client
Войти в полноэкранный режим Выйти из полноэкранного режима

Я использовал tailwindcss для этого, не стесняйтесь пропускать и внедрять свои собственные стили, добавляя tailwind в приложение react

в oredr для начала нам сначала нужно сделать макет сервера.

поэтому выйдите из папки react и запустите

npm init -y 
Войти в полноэкранный режим Выйти из полноэкранного режима

затем

npm install -D typescript
Войти в полноэкранный режим Выйти из полноэкранного режима

Вы можете использовать свой собственный файл tsconfig или использовать этот, который работает для меня

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
    "skipLibCheck": true,
    "sourceMap": true,
    "outDir": "./dist",
    "moduleResolution": "node",
    "removeComments": true,
    "noImplicitAny": false,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "resolveJsonModule": true,
    "baseUrl": "."


  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules"
  ]

}
Войти в полноэкранный режим Выйти из полноэкранного режима

затем добавьте скрипты в ваш package.json

  "scripts": {
    "watch": "tsc -w",
    "start": "nodemon dist/index.js",
    "test": "echo "Error: no test specified" && exit 1"
  },
Войдите в полноэкранный режим Выйти из полноэкранного режима

создайте каталог src и добавьте в него файл index.ts.

затем установите

npm install cors express body-parser nodemon socket.io
Войти в полноэкранный режим Выйти из полноэкранного режима

затем создайте простой сервер в файле index.ts

import express,{ Request,Response } from "express";
import { Server } from "socket.io";
import { createServer } from "http";
import { addUser, checkUserNameExists,removeUser} from './utils/usersutil';
import { makeTimeStamp } from './utils/utils';

// const express = require('express');

const cors = require('cors');
const bodyParser = require('body-parser')





const app = express();

const PORT = process.env.PORT||4000
const server = createServer(app);
// const httpServer = createServer(app);
var jsonParser = bodyParser.json()
 // create application/x-www-form-urlencoded parser
// var urlencodedParser = bodyParser.urlencoded({ extended: false })

const io = new Server(server,{ 
  cors: {
    origin: "http://localhost:3000",
    credentials: true,
    allowedHeaders: ["my-custom-header"],

}
});

(async () => {

app.use(cors())
app.options('*', cors());

app.get('/', (req:Request, res:Response) => {
  res.send({ message: "We did it!" })
});

app.get('/me', (req:Request, res:Response) => {
  res.send({ message: "smoosh" })
});

app.post('/users',jsonParser,(req:Request, res:Response) => {

  const user = req.body?.user.username
  // //console.log("looking for ===== ",user)
 const userExists = checkUserNameExists(user)
 //console.log("looking for ===== ",user, userExists)
  res.send({data:userExists})
});



io.on("connection", async(socket) => {



//console.log(`Client ${socket.id} connected`);


  // Join a conversation
  const { room,user } = socket.handshake.query;
  const room_id = room as string
 const user_count =  addUser({id:socket.id,name:user,room})
  // //console.log("room  id / user==== ",room_id,user,user_count)
  socket.join(room_id);
  io.in(room_id).emit('room_data', {room,users:user_count});
  const join_room_message={message:`${user} joined`, time:makeTimeStamp(), user:"server" }
  io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});




socket.on('new_message', (newMessage) => {
 //console.log("new message ",newMessage,room_id)
 const user = newMessage.user
      //@ts-ignore
  io.in(room_id).emit('new_message_added', { user: user?.name,newMessage});

 })




socket.on("disconnect", () => {
    //console.log("User Disconnected new user count ====", socket.id,user_count);
    removeUser(socket.id)
    io.in(room_id).emit('room_data', {room: room_id,users:user_count - 1 });
    const join_room_message={message:`${user} left`, time:makeTimeStamp(), user:"server" }
    io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});
  });
});




server.listen(PORT, () => {
  console.log(`listening on  http://localhost:${PORT}`)
});

})().catch(e=> console.log('error on server ====== ',e)
)
Войдите в полноэкранный режим Выйти из полноэкранного режима

есть также файл вспомогательных функций пользователя

interface User{
 id:string
 name:string
 room:string   
}
const users:User[] = [];

const userExists=(users:User[],name:string)=>{
let status = false    
for(let i = 0;i<users.length;i++){
    if(users[i].name===name){
    status = true;
    break
    }
}
return status;
}

//console.log("all users in list=== ",users)


export const addUser = ({id, name, room}) => {
    name = name?.trim().toLowerCase();
    room = room?.trim().toLowerCase();


    //console.log("user to add ==== ",name)

   const existingUser = userExists(users,name)
    //console.log("existing user????====",existingUser)
    if(existingUser) {
    //console.log("existing user")
    return users.length;
    }else{
    const user = {id,name,room};
    //console.log("adding user === ",user)
    users.push(user);
    //console.log("all users === ",users)
    const count = getUsersInRoom(room).length
    return count
    }


}




const userExistsIndex=(users:User[],id:string)=>{
    let status = -1   
    for(let i = 0;i<users.length;i++){
        if(users[i].id === id){
        status = i;
        break
        }
    }
    return status;
    }

    export const checkUserNameExists=(name:string)=>{
        let status = false   
        for(let i = 0;i<users.length;i++){
            if(users[i].name === name){
            status = true
            break
            }
    }
     return status;
        }    

export const removeUser = (id:string) => {
    // const index = users.findIndex((user) => {
    //     user.id === id
    // });
    const index = userExistsIndex(users,id)
    //console.log(index)
    if(index !== -1) {
    //console.log("user ",users[index].name ,"disconected , removing them")
    return users.splice(index,1)[0];
    }

}


export const getUser = (id:string) => users .find((user) => user.id === id);

export const getUsersInRoom = (room:string) => users.filter((user) => user.room === room);

export const userCount =()=>users.length
Войти в полноэкранный режим Выход из полноэкранного режима
export const makeTimeStamp=()=>{

    const hour = new Date(Date.now()).getHours()    
    const min = new Date(Date.now()).getMinutes()
    const sec =  new Date(Date.now()).getSeconds()

    let mins=  min+''
    let secs=':'+sec
     if(min<10){
     mins = '0'+ min
     }

     if(sec<10){
     secs = '0' + sec
     }

     return hour+':'+ mins + secs
     }  
Ввести полноэкранный режим Выход из полноэкранного режима

Это обычный экспресс-сервер node js, который подключается и слушает сокетное соединение, затем он добавляет пользователя во временный список и возвращает имя комнаты и количество пользователей в ней,

Есть конечная точка post /users rest, которая обычно используется для аутентификации и добавления пользователя в базу данных, но в данном случае она просто проверяет, не используется ли уже указанное имя пользователя, что вызывает странные проблемы, когда два человека используют одинаковые имена в одной комнате.

Экземпляр socket io также будет слушать новые сообщения, отправляемые клиентами, и транслировать их всем в комнате.

Наконец, он прослушивает разъединения, в этом случае он удаляет имя пользователя из временного списка и сообщает об этом всем в комнате.

Чтобы следить за изменениями в папке index.ts, скомпилировать его в dist/index.js и запустить сервер .
используйте две команды ниже, каждую в своем экземпляре терминала

npm run watch
npm start
Войти в полноэкранный режим Выйти из полноэкранного режима

и вернемся к клиенту,

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

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


Общие типы будут выглядеть примерно так

export type Chat ={ newMessage:{message: string; time: string,user:string }};
export interface User{username:string ; room:string}
export interface Room{users:number ; room:string}
export interface Message{
  message: string;
  time: string;
  user: string;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Внутри context.ts мы создаем UserContext

mport React from 'react';
import { User } from './../App';

const user_data = { user:{username:"",room:"general"}, updateUser:(user:User)=>{}}


const UserContext = React.createContext(user_data);
export default UserContext;
Вход в полноэкранный режим Выход из полноэкранного режима

затем мы сделаем пользовательский хук для обработки клиента socketio

import { Room, User } from "./types"
import { useRef,useState,useEffect } from 'react';
import socketIOClient,{ Socket } from 'socket.io-client';

const NEW_MESSAGE_ADDAED = "new_message_added";
const ROOM_DATA = "room_data";

const devUrl="http://localhost:4000"



const useChats=(user:User)=>{

const socketRef = useRef<Socket>();
const [messages, setMessages] = useState<any>([]);
const [room, setRoom] = useState<Room>({users:0,room:""});


useEffect(() => {
socketRef.current = socketIOClient(devUrl, {
query: { room:user.room,user:user.username },
transports: ["websocket"],
withCredentials: true,
extraHeaders:{"my-custom-header": "abcd"}

})


socketRef.current?.on(NEW_MESSAGE_ADDAED, (msg:any) => {
// //console.log("new message  added==== ",msg)
setMessages((prev: any) => [msg,...prev]);
 });

socketRef.current?.on(ROOM_DATA, (msg:any) => {
//console.log("room data  ==== ",msg)
 setRoom(msg)});

return () => {socketRef.current?.disconnect()};
}, [])

const sendMessage = (message:any) => {
//console.log("sending message ..... === ",message)
 socketRef.current?.emit("new_message", message)
};




return {room,messages,sendMessage}
}
export default useChats
Вход в полноэкранный режим Выход из полноэкранного режима

Мы храним экземпляр сокета в useRef, потому что мы не хотим, чтобы он инициализировался заново при каждом повторном рендере, затем мы помещаем его в useEffect, чтобы инициализировать только при монтировании компонента, мы возвращаем новые сообщения, данные комнаты и функцию sendmessage.

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

import { JoinRoom } from './components/JoinRoom';
import React from 'react'
import UserContext from "./utils/context";
import { Chats } from './components/Chats';


export interface User{username:string ; room:string}

let the_user:User
const user_room= localStorage.getItem("user-room");
if(user_room)
the_user = JSON.parse(user_room); 

function App() {

  const [user, setUser] = React.useState<User>(the_user);
  const updateUser = (new_user:User) => {setUser(new_user)};
  const user_exists = user && user.username !==""



return (
<div className="scroll-bar flex flex-col justify-between h-screen w-screen ">

<UserContext.Provider  value ={{user,updateUser}}>
{user_exists?<Chats />:<JoinRoom/>}  
</UserContext.Provider>

  </div>
  );
}

export default App;
Входим в полноэкранный режим Выйти из полноэкранного режима

для пользовательских классов tailwind добавьте это в index.css

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


body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

.flex-center{
  @apply flex justify-center items-center
}

.flex-center-col{
  @apply flex flex-col justify-center items-center
}
.scroll-bar{
  @apply scrollbar-thin scrollbar-thumb-purple-900 scrollbar-track-gray-400
}

Войти в полноэкранный режим Выйти из полноэкранного режима

класс полосы прокрутки — это расширение tailwind, которое можно установить и добавить в конфигурацию tailwind

npm install -D tailwind-scrollbar
Войти в полноэкранный режим Выйти из полноэкранного режима

затем добавьте его в файл tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [
    require('tailwind-scrollbar'),
  ],
}
Войти в полноэкранный режим Выход из полноэкранного режима

компонент загрузки

import React from 'react'

interface LoadingProps {

}

export const Loading: React.FC<LoadingProps> = ({}) => {
return (
 <div className='h-full w-full flex-center bg-slate-300 text-lg'>
 loading ...
 </div>
);
}
Вход в полноэкранный режим Выход из полноэкранного режима

JoinRoom.tsx, который будет показан, если пользователь не найден в локальном хранилище,

import React, {useContext, useState} from "react";
import UserContext from './../utils/context';
import axios from 'axios';


interface JoinRoomProps {


}

export const JoinRoom: React.FC<JoinRoomProps> = () => {

const [input, setInput] = useState({ username: "", room: "general" });
const [error, setError] = useState({ name:"", message:"" });


const devUrl="http://localhost:4000"




const client = axios.create({ baseURL:devUrl});
const user = useContext(UserContext);
//console.log("JoinRoom.tsx  user ==== ",user.user)

const handleChange = async (e: any) => {
        const { value } = e.target;
        setInput({
          ...input,
          [e.target.id]: value,
        });

      };


  const handleSubmit = async (e: any) => {
  //console.log("inputon submit ==== ",input)
  e.preventDefault();


  if(input.username !== ""){
  const roomname = input.room.toLowerCase()
  const username = input.username.toLowerCase()
  const room_data = {username,room:roomname}
  // localStorage.setItem("user-room",JSON.stringify(room_data));
  // user.updateUser(room_data)

  client.post('/users', {user:room_data})
  .then( (response)=> {
  const user_exist =response.data.data
  //console.log("user exists? === ",user_exist)

  if(!user_exist){

  localStorage.setItem("user-room",JSON.stringify(room_data));
  user.updateUser(room_data)  
  }
  else{
    setError({name:"username",message:"username exists"})
  }
  })
  .catch(function (error) {
    //console.log("error logging in === ",error)
    setError({name:"username",message:"connection error"})
  });


  }
  else{
  setError({name:"username",message:"nick name needed"})
  }

  };   
  const isError=()=>{
  if(error.name === "") return false
  return true}


return (
 <div className="h-full w-full flex-center-col bg-gradient-to-l from-cyan-900 to-purple-900">
        <form className="w-[95%] md:w-[50%] p-3 bg-slate-700 rounded-lg text-white shadow-lg
         shadow-purple-500 ">
        <div className="flex-center-col">
            <label className="text-lg font-bold">Join</label>
          <input
            style={{borderColor:isError()?"red":""}}
            className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
            id="username"
            placeholder="nick name"
            onChange={handleChange}
            value={input.username}
          />

         <input
            className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
            id="room"
            placeholder="room name"
            onChange={handleChange}
            value={input.room}
          />
         {isError()?<div className="text-md p-1m-1 text-red-300">{error.message}</div>:null}
      <button 
      onClick={handleSubmit}
      className="p-2 m-1 w-[30%] bg-purple-800 shadow-md 
       hover:shadow-purple-400 rounded-md">Join</button>
      </div>
      </form>

 </div>
);
}
Вход в полноэкранный режим Выход из полноэкранного режима

при отправке будет сделан post запрос, который проверит, используется ли имя пользователя в настоящее время, и добавит пользователя в локальное хранилище и контекст пользователя, если нет,

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

import React from 'react'
import {BiExit} from 'react-icons/bi'
import { User } from './../App';

interface ToolbarProps {
room:any
updateUser: (user: User) => void
}

export const Toolbar: React.FC<ToolbarProps> = ({room,updateUser}) => {
return (
 <div className='bg-slate-600 text-white p-1 w-full flex justify-between items-center h-12'>
  <div className='p-2 m-1 text-xl font-bold flex'>
  <div className='p-2  text-xl font-bold '>  {room?.room}</div>

{room.room?<div className='p-1 m-1  text-base font-normal shadow shadow-white hover:shadow-red-400
   flex-center cursor-pointer'
   onClick={()=>{
    localStorage.removeItem('user-room') ;
    updateUser({username:"",room:""})
    }}><BiExit/></div>:null}

    </div>
  <div className='p-2 m-1 font-bold'>{room?.users}{room?.users?" online ":""}</div>
 </div>
);
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Chats.tsx

import React, { useState, useRef,useEffect,useContext } from "react";
import {AiOutlineSend } from 'react-icons/ai';
import { IconContext } from "react-icons";
import { makeTimeStamp } from './../utils/utils';
import { Toolbar } from './Toolbar';
import { Chat, Message } from './../utils/types';
import UserContext from "../utils/context";
import useChats from "../utils/useChats";
import { Loading } from './Loading';



interface ChatsProps {

}


export const Chats: React.FC<ChatsProps> = ({}) => {


const user = useContext(UserContext);
//console.log("Text.tsx  user ==== ",user.user)

const {room,messages,sendMessage} = useChats(user.user)


const room_loaded = room.users>0 && user.user.username !== ""

console.log("Text.tsx  room ==== ",room)

const [input, setInput] = useState({ message: "", time:makeTimeStamp() });
const [error, setError] = useState({ name:"", message:"" });

 const [size, setSize] = useState({x: window.innerWidth,y: window.innerHeight});
 const updateSize = () =>setSize({x: window.innerWidth,y: window.innerHeight });

 useEffect(() => {
   window.onresize = updateSize
    })

  const handleChange = async (e: any) => {
    const { value } = e.target;
    setInput({
      ...input,
      [e.target.id]: value,
    });
  };

  const handleSubmit = async (e: any) => {
    e.preventDefault();
    // //console.log("submit in form ====",input.message)
    if (input.message !== "" && user.user.username !=="") {
      const message = { message: input.message, time:input.time,user:user.user.username };
      // //console.log("message ====",message)
      sendMessage(message)
      setError({name:"",message:""})
    }else{
      //console.log("error ====",input,user)
      setError({name:"username",message:"type something"})
    }
  };

  const isError=()=>{
    if(error.name === "") return false
    return true}

  if(!room_loaded){
      return <Loading/>
  }
  return (
    <div 
    style={{maxHeight:size.y}}
    className="h-full overflow-x-hidden overflow-y-hiddenflex flex-col justify-between ">

      <div className="fixed top-[0px] w-[100%] z-60">
      <Toolbar room={room}  updateUser={user.updateUser}/>
      </div>
      {/* <div className="fixed top-[10%] right-[40%] p-1 z-60 text-3xl font-bold">{size.y}</div> */}

      <div
        className="mt-10 w-full h-[55vh] md:h-[80vh] flex flex-col-reverse items-center overflow-y-scroll  p-2 scroll-bar"
      >
        {messages &&
          messages.map((chat: Chat, index: number) => {
            return <Chatcard  key={index} chat={chat} user={user.user}/>;
          })}
      </div>

      <form 
      onSubmit={handleSubmit}
      className="w-full p-1 fixed bottom-1 ">
        <div className="flex-center">
          <input
             style={{borderColor:isError()?"red":""}}
            className="w-[80%] md:w-[50%] p-2 m-1 border-black border-2 rounded-sm "
            id="message"
            placeholder="type.."
            onChange={handleChange}
            value={input.message}

            autoComplete={"off"}
          />
          <button type="submit">
            <IconContext.Provider value={{
              size: "30px",
              className: "mx-1",

            }}>
           <AiOutlineSend  /></IconContext.Provider>
               </button>
    </div>

      </form>
    </div>
  );
};

interface ChatCardProps {
  chat: Chat;
  user: { username: string; room: string;}

}

export const Chatcard: React.FC<ChatCardProps> = ({ chat,user }) => {

const isMe = chat.newMessage.user === user.username
  // //console.log("chat in chat card ==== ",chat)
  return (
    <div className="flex-center w-full m-2">

    <div 
    style={{backgroundColor:isMe?"purple":"white",color:isMe?"white":""}}
    className="capitalize p-5 h-6 w-6 text-xl font-bold mr-1 border-2 border-slate-400
    rounded-[50%] flex-center"> {chat?.newMessage.user[0]}</div>

      <div className="w-[80%] h-full border border-slate-800  rounded-md
     m-1 p-2 flex justify-between items-center">

      <div className="max-w-[80%] h-fit break-words whitespace-normal text-mdfont-normal">
        {chat?.newMessage.message}
      </div>

        <div className="w-fit font-medium h-full flex flex-col justify-end items-stretch text-sm ">
        <div className="w-full ">{chat?.newMessage.user}</div>
        <div className="w-full ">{chat?.newMessage.time}</div>
      </div>
    </div>

    </div>
  );
};
Вход в полноэкранный режим Выход из полноэкранного режима

и это все.

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

Во время написания этой статьи я уже заметил возможную ошибку, которая может привести к столкновению имен пользователей.
Если пользователь успешно присоединился к серверу с именем пользователя «john», а затем вышел и закрыл браузер, отключившись от сервера, если он вернется и другой пользователь присоединился к серверу с именем пользователя john, клиент получит значение локального хранилища, которое является john, и присоединится к комнате с другим пользователем в комнате с тем же именем пользователя.
Я позволю вам решить эту проблему, пожалуйста, отправьте мне запрос на исправление, если вы это сделаете или найдете какую-либо другую ошибку.

окончательный код клиента
финальный код сервера
код react native

Оцените статью
devanswers.ru
Добавить комментарий