Bom dia amigos!
Gostaria de compartilhar com vocês minha experiência no desenvolvimento de um chat simples no React usando a biblioteca Socket.IO .
Presume-se que você esteja familiarizado com a biblioteca nomeada. Se você não estiver familiarizado, aqui está um guia relacionado com exemplos de criação de um tudushka e bate-papo em JavaScript vanilla .
Ele também assume que você tem pelo menos superficialmente familiarizado com Node.js .
Neste artigo, vou me concentrar nos aspectos práticos do uso de Socket.IO, React e Node.js.
Nosso chat terá os seguintes recursos principais:
- Seleção de sala
- Enviando mensagens
- Excluir mensagens do remetente
- Armazenamento de mensagens em um banco de dados local no formato JSON
- Armazenar o nome de usuário e ID no armazenamento local do navegador
- Exibindo o número de usuários ativos
- Exibindo uma lista de usuários com um indicador online
Também implementaremos a capacidade de enviar emoji .
Se você estiver interessado, por favor me siga.
Para quem está interessado apenas no código: aqui está o link para o repositório .
Caixa de areia:
Estrutura e dependências do projeto
Vamos começar a criar um projeto:
mkdir react-chat
cd react-chat
Crie um cliente usando Create React App :
yarn create react-app client
#
npm init react-app client
#
npx create-react-app client
No futuro, usarei yarn : para instalar dependências
yarn add = npm i, yarn start = npm start, yarn dev = npm run dev
.
Vá para o diretório "cliente" e instale dependências adicionais:
cd client
yarn add socket.io-client react-router-dom styled-components bootstrap react-bootstrap react-icons emoji-mart react-timeago
- socket.io-client - lado do cliente Socket.IO
- react-router-dom - roteamento
- componentes estilizados - estilização (CSS-in-JS)
- bootstrap , react-bootstrap - estilo
- react-icons - ícones
- emoji-mart - emoji
- react-timeago - formatação de data e hora
A seção "dependências" do arquivo "package.json":
{
"bootstrap": "^4.6.0",
"emoji-mart": "^3.0.0",
"react": "^17.0.1",
"react-bootstrap": "^1.5.0",
"react-dom": "^17.0.1",
"react-icons": "^4.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.1",
"react-timeago": "^5.2.0",
"socket.io-client": "^3.1.0",
"styled-components": "^5.2.1"
}
Volte para o diretório raiz (react-chat), crie o diretório "server", vá até ele, inicialize o projeto e instale as dependências:
cd ..
mkdir server
cd server
yarn init -yp
yarn add socket.io lowdb supervisor
- socket.io - back-end Socket.IO
- lowdb - banco de dados local em formato JSON
- supervisor - servidor de desenvolvimento (alternativa ao nodemon , que não funciona corretamente com a última versão estável do Node.js; tem algo a ver com início / interrupção incorreta de processos filho)
Adicione o comando "start" para iniciar o servidor de produção e o comando "dev" para iniciar o servidor de desenvolvimento. package.json:
{
"name": "server",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"private": true,
"dependencies": {
"lowdb": "^1.0.0",
"socket.io": "^3.1.0",
"supervisor": "^0.12.0"
},
"scripts": {
"start": "node index.js",
"dev": "supervisor index.js"
}
}
Volte para o diretório raiz (react-chat), inicialize o projeto e instale as dependências:
cd .. yarn init -yp yarn add nanoid concurrently
- identificadores geradores de nanoides (serão usados tanto no cliente quanto no servidor)
- concorrentemente - execução simultânea de dois ou mais comandos
react-chat / package.json (observe que os comandos para npm são diferentes; consulte os documentos simultâneos):
{
"name": "react-chat",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"private": true,
"dependencies": {
"concurrently": "^6.0.0",
"nanoid": "^3.1.20"
},
"scripts": {
"server": "yarn --cwd server dev",
"client": "yarn --cwd client start",
"start": "concurrently \"yarn server\" \"yarn client\""
}
}
Ótimo, terminamos com a formação da estrutura principal do projeto e instalação das dependências necessárias. Vamos começar a implementar o servidor.
Implementação de servidor
A estrutura do diretório "server":
|--server |--db - |--handlers |--messageHandlers.js |--userHandlers.js |--index.js ...
No arquivo "index.js", fazemos o seguinte:
- Construindo um servidor HTTP
- Nós conectamos Socket.IO a ele
- Iniciamos o servidor na porta 5000
- Registrando manipuladores de eventos ao conectar um soquete
index.js:
// HTTP-
const server = require('http').createServer()
// Socket.IO
const io = require('socket.io')(server, {
cors: {
origin: '*'
}
})
const log = console.log
//
const registerMessageHandlers = require('./handlers/messageHandlers')
const registerUserHandlers = require('./handlers/userHandlers')
// (, = )
const onConnection = (socket) => {
//
log('User connected')
// ""
const { roomId } = socket.handshake.query
//
socket.roomId = roomId
// ( )
socket.join(roomId)
//
//
registerMessageHandlers(io, socket)
registerUserHandlers(io, socket)
// -
socket.on('disconnect', () => {
//
log('User disconnected')
//
socket.leave(roomId)
})
}
//
io.on('connection', onConnection)
//
const PORT = process.env.PORT || 5000
server.listen(PORT, () => {
console.log(`Server ready. Port: ${PORT}`)
})
No arquivo "handlers / messageHandlers.js" fazemos o seguinte:
- Configurando um banco de dados local no formato JSON usando lowdb
- Nós gravamos os dados iniciais no banco de dados
- Criação de funções para receber, adicionar e excluir mensagens
- Registramos o processamento dos eventos correspondentes:
- mensagem: obter - receber mensagens
- mensagem: adicionar - adicionar uma mensagem
- mensagem: remover - excluir uma mensagem
As mensagens são objetos com as seguintes propriedades:
- messageId (string) - identificador de mensagem
- userId (string) - ID do usuário
- senderName (string) - nome do remetente
- messageText (string) - texto da mensagem
- createdAt (data) - data de criação
handlers / messageHandlers.js:
const { nanoid } = require('nanoid')
//
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
// "db" "messages.json"
const adapter = new FileSync('db/messages.json')
const db = low(adapter)
//
db.defaults({
messages: [
{
messageId: '1',
userId: '1',
senderName: 'Bob',
messageText: 'What are you doing here?',
createdAt: '2021-01-14'
},
{
messageId: '2',
userId: '2',
senderName: 'Alice',
messageText: 'Go back to work!',
createdAt: '2021-02-15'
}
]
}).write()
module.exports = (io, socket) => {
//
const getMessages = () => {
//
const messages = db.get('messages').value()
// ,
// - , ,
io.in(socket.roomId).emit('messages', messages)
}
//
//
const addMessage = (message) => {
db.get('messages')
.push({
// nanoid, 8 - id
messageId: nanoid(8),
createdAt: new Date(),
...message
})
.write()
//
getMessages()
}
//
// id
const removeMessage = (messageId) => {
db.get('messages').remove({ messageId }).write()
getMessages()
}
//
socket.on('message:get', getMessages)
socket.on('message:add', addMessage)
socket.on('message:remove', removeMessage)
}
No arquivo "handlers / userHandlers.js" fazemos o seguinte:
- Crie uma estrutura normalizada com usuários
- Criamos funções para obter, adicionar e remover usuários
- Registramos o processamento dos eventos correspondentes:
- usuário: obter - obter usuários
- usuário: adicionar - adicionar um usuário
- usuário: deixar - excluir um usuário
Também poderíamos usar lowdb para trabalhar com a lista de usuários. Você pode fazer isso se quiser. Eu, com sua permissão, vou me limitar ao objeto.
A estrutura normalizada (objeto) dos usuários possui o seguinte formato:
{ id (string) - : { username (string) - , online (boolean) - } }
Na verdade, não estamos excluindo usuários, mas transferindo seu status para offline (atribuindo a propriedade "online" para "false").
handlers / userHandlers.js:
//
//
const users = {
1: { username: 'Alice', online: false },
2: { username: 'Bob', online: false }
}
module.exports = (io, socket) => {
//
// "roomId" ,
// ,
//
const getUsers = () => {
io.in(socket.roomId).emit('users', users)
}
//
// id
const addUser = ({ username, userId }) => {
// ,
if (!users[userId]) {
// ,
users[userId] = { username, online: true }
} else {
// ,
users[userId].online = true
}
//
getUsers()
}
//
const removeUser = (userId) => {
// ,
// (O(1))
// ()
// redux, , immer,
users[userId].online = false
getUsers()
}
//
socket.on('user:get', getUsers)
socket.on('user:add', addUser)
socket.on('user:leave', removeUser)
}
Iniciamos o servidor para verificar seu desempenho:
yarn dev
Se virmos a mensagem “Servidor pronto. Porta: 5000 ", e um arquivo" messages.json "com os dados iniciais apareceu no diretório" db ", o que significa que o servidor está funcionando conforme o esperado, e você pode prosseguir com a implementação da parte cliente.
Implementação do cliente
Com o cliente tudo fica um pouco mais complicado. A estrutura do diretório "cliente":
|--client |--public |--index.html |--src |--components |--ChatRoom |--MessageForm |--MessageForm.js |--package.json |--MessageList |--MessageList.js |--MessageListItem.js |--package.json |--UserList |--UserList.js |--package.json |--ChatRoom.js |--package.json |--Home |--Home.js |--package.json |--index.js |--hooks |--useBeforeUnload.js |--useChat.js |--useLocalStorage.js App.js index.js |--jsconfig.json ( src) ...
Como o nome indica, o diretório "componentes" contém os componentes do aplicativo (partes da interface do usuário, módulos), e o diretório "ganchos" contém ganchos do usuário ("custom"), o principal dos quais é useChat ().
Os arquivos "package.json" nos diretórios do componente têm um único campo "main" com o valor do caminho para o arquivo JS, por exemplo:
{
"main": "./Home"
}
Isso permite que você importe um componente de um diretório sem especificar um nome de arquivo, por exemplo:
import { Home } from './Home'
//
import { Home } from './Home/Home'
Os arquivos "components / index.js" e "hooks / index.js" são usados para agregar e reexportar componentes e ganchos, respectivamente.
components / index.js:
export { Home } from './Home'
export { ChatRoom } from './ChatRoom'
hooks / index.js:
export { useChat } from './useChat'
export { useLocalStorage } from './useLocalStorage'
export { useBeforeUnload } from './useBeforeUnload'
Isso novamente permite importar componentes e ganchos por diretório e ao mesmo tempo. A agregação e a reexportação causam o uso de exportações de componentes nomeados (a documentação do React recomenda o uso da exportação padrão).
O arquivo jsconfig.json tem a seguinte aparência:
{
"compilerOptions": {
"baseUrl": "src"
}
}
Isso "diz" ao compilador que a importação de módulos começa a partir do diretório "src", portanto, os componentes, por exemplo, podem ser importados assim:
//
import { Home, ChatRoom } from 'components'
//
import { Home, ChatRoom } from './components'
Vamos começar examinando os ganchos personalizados.
Você pode usar soluções prontas. Por exemplo, aqui estão os ganchos oferecidos pela biblioteca react-use :
#
yarn add react-use
#
import { useLocalStorage } from 'react-use'
import { useBeforeUnload } from 'react-use'
O gancho useLocalStorage () permite que você armazene (escreva e recupere) valores no armazenamento local do navegador. Vamos usá-lo para armazenar o nome de usuário e a ID do usuário entre as sessões do navegador. Não queremos forçar o usuário a inserir seu nome todas as vezes, mas o ID é necessário para determinar as mensagens pertencentes a esse usuário. O gancho leva um nome para a chave e, opcionalmente, um valor inicial.
hooks / useLocalstorage.js:
import { useState, useEffect } from 'react'
export const useLocalStorage = (key, initialValue) => {
const [value, setValue] = useState(() => {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
})
useEffect(() => {
const item = JSON.stringify(value)
window.localStorage.setItem(key, item)
// , key, useEffect, ,
// useEffect
// eslint-disable-next-line
}, [value])
return [value, setValue]
}
O gancho "useBeforeUnload ()" é usado para exibir uma mensagem ou executar uma função quando a página (guia do navegador) é recarregada ou fechada. Vamos usá-lo para enviar um evento "user: leave" ao servidor para alternar o status do usuário. Uma tentativa de implementar o despacho do evento especificado usando o retorno de chamada retornado pelo gancho "useEffect ()" não foi bem-sucedida. O gancho leva um parâmetro, um primitivo ou uma função.
hooks / useBeforeUnload.js:
import { useEffect } from 'react'
export const useBeforeUnload = (value) => {
const handleBeforeunload = (e) => {
let returnValue
if (typeof value === 'function') {
returnValue = value(e)
} else {
returnValue = value
}
if (returnValue) {
e.preventDefault()
e.returnValue = returnValue
}
return returnValue
}
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeunload)
return () => window.removeEventListener('beforeunload', handleBeforeunload)
// eslint-disable-next-line
}, [])
}
O gancho useChat () é o gancho principal para nosso aplicativo. Será mais fácil se eu comentar linha por linha.
hooks / useChat.js:
import { useEffect, useRef, useState } from 'react'
// IO
import io from 'socket.io-client'
import { nanoid } from 'nanoid'
//
import { useLocalStorage, useBeforeUnload } from 'hooks'
//
// -
const SERVER_URL = 'http://localhost:5000'
//
export const useChat = (roomId) => {
//
const [users, setUsers] = useState([])
//
const [messages, setMessages] = useState([])
//
const [userId] = useLocalStorage('userId', nanoid(8))
//
const [username] = useLocalStorage('username')
// useRef() DOM-,
//
const socketRef = useRef(null)
useEffect(() => {
// ,
// ""
// socket.handshake.query.roomId
socketRef.current = io(SERVER_URL, {
query: { roomId }
})
// ,
// id
socketRef.current.emit('user:add', { username, userId })
//
socketRef.current.on('users', (users) => {
//
setUsers(users)
})
//
socketRef.current.emit('message:get')
//
socketRef.current.on('messages', (messages) => {
// , ,
// "userId" id ,
// "currentUser" "true",
// ,
const newMessages = messages.map((msg) =>
msg.userId === userId ? { ...msg, currentUser: true } : msg
)
//
setMessages(newMessages)
})
return () => {
//
socketRef.current.disconnect()
}
}, [roomId, userId, username])
//
//
const sendMessage = ({ messageText, senderName }) => {
// id
socketRef.current.emit('message:add', {
userId,
messageText,
senderName
})
}
// id
const removeMessage = (id) => {
socketRef.current.emit('message:remove', id)
}
// "user:leave"
useBeforeUnload(() => {
socketRef.current.emit('user:leave', userId)
})
// ,
return { users, messages, sendMessage, removeMessage }
}
Por padrão, todas as solicitações do cliente são enviadas para localhost: 3000 (a porta na qual o servidor de desenvolvimento está sendo executado). Para redirecionar as solicitações para a porta na qual o servidor "servidor" está sendo executado, o proxy deve ser executado. Para fazer isso, adicione a seguinte linha ao arquivo "src / package.json":
"proxy": "http://localhost:5000"
Resta implementar os componentes do aplicativo.
O componente Home é a primeira coisa que o usuário vê ao iniciar o aplicativo. Ele contém um formulário no qual o usuário é solicitado a inserir seu nome e selecionar uma sala. Na realidade, no caso de um quarto, o usuário não tem escolha, apenas uma opção (gratuita) está disponível. A segunda opção (desabilitada) (trabalho) é a capacidade de dimensionar o aplicativo. A exibição do botão para iniciar um chat depende do campo com o nome do usuário (quando este campo está vazio, o botão não é exibido). O botão é na verdade um link para a página de bate-papo.
componentes / Home.js:
import { useState, useRef } from 'react'
// react-router-dom
import { Link } from 'react-router-dom'
//
import { useLocalStorage } from 'hooks'
// react-bootstrap
import { Form, Button } from 'react-bootstrap'
export function Home() {
//
//
const [username, setUsername] = useLocalStorage('username', 'John')
//
const [roomId, setRoomId] = useState('free')
const linkRef = useRef(null)
//
const handleChangeName = (e) => {
setUsername(e.target.value)
}
//
const handleChangeRoom = (e) => {
setRoomId(e.target.value)
}
//
const handleSubmit = (e) => {
e.preventDefault()
//
linkRef.current.click()
}
const trimmed = username.trim()
return (
<Form
className='mt-5'
style={{ maxWidth: '320px', margin: '0 auto' }}
onSubmit={handleSubmit}
>
<Form.Group>
<Form.Label>Name:</Form.Label>
<Form.Control value={username} onChange={handleChangeName} />
</Form.Group>
<Form.Group>
<Form.Label>Room:</Form.Label>
<Form.Control as='select' value={roomId} onChange={handleChangeRoom}>
<option value='free'>Free</option>
<option value='job' disabled>
Job
</option>
</Form.Control>
</Form.Group>
{trimmed && (
<Button variant='success' as={Link} to={`/${roomId}`} ref={linkRef}>
Chat
</Button>
)}
</Form>
)
}
O componente UserList, como o nome sugere, é uma lista de usuários. Ele contém um acordeão, a própria lista e indicadores da presença online dos usuários.
components / UserList.js:
//
import { Accordion, Card, Button, Badge } from 'react-bootstrap'
// -
import { RiRadioButtonLine } from 'react-icons/ri'
// -
export const UserList = ({ users }) => {
//
const usersArr = Object.entries(users)
// ( )
// [ ['1', { username: 'Alice', online: false }], ['2', {username: 'Bob', online: false}] ]
//
const activeUsers = Object.values(users)
//
// [ {username: 'Alice', online: false}, {username: 'Bob', online: false} ]
.filter((u) => u.online).length
return (
<Accordion className='mt-4'>
<Card>
<Card.Header bg='none'>
<Accordion.Toggle
as={Button}
variant='info'
eventKey='0'
style={{ textDecoration: 'none' }}
>
Active users{' '}
<Badge variant='light' className='ml-1'>
{activeUsers}
</Badge>
</Accordion.Toggle>
</Card.Header>
{usersArr.map(([userId, obj]) => (
<Accordion.Collapse eventKey='0' key={userId}>
<Card.Body>
<RiRadioButtonLine
className={`mb-1 ${
obj.online ? 'text-success' : 'text-secondary'
}`}
size='0.8em'
/>{' '}
{obj.username}
</Card.Body>
</Accordion.Collapse>
))}
</Card>
</Accordion>
)
}
O componente MessageForm é um formulário padrão para enviar mensagens. Picker é um componente de emoji fornecido pela biblioteca de emoji-mart. Este componente é mostrado / oculto ao pressionar um botão.
components / MessageForm.js:
import { useState } from 'react'
//
import { Form, Button } from 'react-bootstrap'
//
import { Picker } from 'emoji-mart'
//
import { FiSend } from 'react-icons/fi'
import { GrEmoji } from 'react-icons/gr'
//
export const MessageForm = ({ username, sendMessage }) => {
//
const [text, setText] = useState('')
//
const [showEmoji, setShowEmoji] = useState(false)
//
const handleChangeText = (e) => {
setText(e.target.value)
}
// /
const handleEmojiShow = () => {
setShowEmoji((v) => !v)
}
//
// ,
const handleEmojiSelect = (e) => {
setText((text) => (text += e.native))
}
//
const handleSendMessage = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
sendMessage({ messageText: text, senderName: username })
setText('')
}
}
return (
<>
<Form onSubmit={handleSendMessage}>
<Form.Group className='d-flex'>
<Button variant='primary' type='button' onClick={handleEmojiShow}>
<GrEmoji />
</Button>
<Form.Control
value={text}
onChange={handleChangeText}
type='text'
placeholder='Message...'
/>
<Button variant='success' type='submit'>
<FiSend />
</Button>
</Form.Group>
</Form>
{/* */}
{showEmoji && <Picker onSelect={handleEmojiSelect} emojiSize={20} />}
</>
)
}
O componente MessageListItem é um item da lista de mensagens. TimeAgo é um componente para formatar data e hora. Leva uma data e retorna uma string como "1 mês atrás". Esta linha é atualizada em tempo real. Apenas o usuário que as enviou pode excluir mensagens.
components / MessageListItem.js:
//
import TimeAgo from 'react-timeago'
//
import { ListGroup, Card, Button } from 'react-bootstrap'
//
import { AiOutlineDelete } from 'react-icons/ai'
//
export const MessageListItem = ({ msg, removeMessage }) => {
//
const handleRemoveMessage = (id) => {
removeMessage(id)
}
const { messageId, messageText, senderName, createdAt, currentUser } = msg
return (
<ListGroup.Item
className={`d-flex ${currentUser ? 'justify-content-end' : ''}`}
>
<Card
bg={`${currentUser ? 'primary' : 'secondary'}`}
text='light'
style={{ width: '55%' }}
>
<Card.Header className='d-flex justify-content-between align-items-center'>
{/* TimeAgo */}
<Card.Text as={TimeAgo} date={createdAt} className='small' />
<Card.Text>{senderName}</Card.Text>
</Card.Header>
<Card.Body className='d-flex justify-content-between align-items-center'>
<Card.Text>{messageText}</Card.Text>
{/* */}
{currentUser && (
<Button
variant='none'
className='text-warning'
onClick={() => handleRemoveMessage(messageId)}
>
<AiOutlineDelete />
</Button>
)}
</Card.Body>
</Card>
</ListGroup.Item>
)
}
O componente "MessageList" é uma lista de mensagens. Ele usa o componente "MessageListItem".
components / MessageList.js:
import { useRef, useEffect } from 'react'
//
import { ListGroup } from 'react-bootstrap'
//
import { MessageListItem } from './MessageListItem'
// (inline styles)
const listStyles = {
height: '80vh',
border: '1px solid rgba(0,0,0,.4)',
borderRadius: '4px',
overflow: 'auto'
}
//
// "MessageListItem"
export const MessageList = ({ messages, removeMessage }) => {
// ""
const messagesEndRef = useRef(null)
// ,
useEffect(() => {
messagesEndRef.current?.scrollIntoView({
behavior: 'smooth'
})
}, [messages])
return (
<>
<ListGroup variant='flush' style={listStyles}>
{messages.map((msg) => (
<MessageListItem
key={msg.messageId}
msg={msg}
removeMessage={removeMessage}
/>
))}
<span ref={messagesEndRef}></span>
</ListGroup>
</>
)
}
O componente App é o principal componente do aplicativo. Ele define rotas e monta a interface.
src / App.js:
//
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'
//
import { Container } from 'react-bootstrap'
//
import { Home, ChatRoom } from 'components'
//
const routes = [
{ path: '/', name: 'Home', Component: Home },
{ path: '/:roomId', name: 'ChatRoom', Component: ChatRoom }
]
export const App = () => (
<Router>
<Container style={{ maxWidth: '512px' }}>
<h1 className='mt-2 text-center'>React Chat App</h1>
<Switch>
{routes.map(({ path, Component }) => (
<Route key={path} path={path} exact>
<Component />
</Route>
))}
</Switch>
</Container>
</Router>
)
Finalmente, o arquivo "src / index.js" é o ponto de entrada JavaScript para Webpack. Ele faz estilização e renderização globais do componente App.
src / index.js:
import React from 'react'
import { render } from 'react-dom'
import { createGlobalStyle } from 'styled-components'
//
import 'bootstrap/dist/css/bootstrap.min.css'
import 'emoji-mart/css/emoji-mart.css'
//
import { App } from './App'
// ""
const GlobalStyles = createGlobalStyle`
.card-header {
padding: 0.25em 0.5em;
}
.card-body {
padding: 0.25em 0.5em;
}
.card-text {
margin: 0;
}
`
const root = document.getElementById('root')
render(
<>
<GlobalStyles />
<App />
</>,
root
)
Bem, terminamos de desenvolver nosso pequeno aplicativo.
É hora de ter certeza de que funciona. Para isso, no diretório raiz do projeto (react-chat), execute o comando "yarn start". Depois disso, na guia do navegador que se abre, você deve ver algo assim:
Em vez de uma conclusão
Se você deseja melhorar o aplicativo, aqui estão algumas idéias:
- Adicionar banco de dados para usuários (usando o mesmo lowdb)
- Adicione uma segunda sala - para isso é suficiente implementar o processamento separado de listas de mensagens no servidor
- ( ) —
- — MongoDB Cloud Mongoose; Express
- : (, , ..) — react-filepond, — multer; WebRTC
- Do mais exótico: adicione voz ao texto e traduza mensagens de voz em texto - você pode usar o kit react-speech para isso
Algumas dessas ideias estão incluídas em meus planos para melhorar o chat.
Obrigado pela atenção e tenha um bom dia.