Un chat creado con NodeJS, Socket.io, Express.js y Vanilla JS

Un chat creado con NodeJS, Socket.io, Express.js y Vanilla JS

Con esta nueva entrada quiero mostrar como he creado un chat usando NodeJS Socket.io. Para realizar el chat he usado la guía que ofrece la librería socket.io y su extensa documentación.

El código del proyecto

Para realizar este pequeño proyecto no he usado ningún framework basado en Javascript, ya que en principio iba a ser una prueba de concepto con pocas líneas para entender como funciona la librería socket.io.

Revisando la documentación oficial de socket.io se puede desarrollar un chat básico sin problema, pero como la idea era investigar, he probado algunas cosas añadiendo las siguientes funcionalidades:

  • Subida de ficheros
  • Chat privados
  • Información visual sobre los nuevos mensajes.
  • Cerrar y tener salas privadas

A continuación veremos el código correspondiente a la vista que he realizado usando bulma, el cual aconsejo echar un ojo ya que además de ligero, es bastante completo tanto en la documentación como en la cantidad de ejemplos de código.

index.html

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CHAT</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/all.min.css" />
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <div class="notification hidden">
    <p></p>
  </div>
  <div class="container">
    <div class="columns">
      <div class="column is-one-quarter mt-6">
        <div class="card  has-background-black">
          <div class="card-content">
            <div class="media mb-0">
              <div class="media-content">
                <p class="title is-6 has-text-white">Usuarios conectados</p>
                <hr>
              </div>
            </div>
            <div class="content has-text-white" id="userConnected">
            </div>
          </div>
        </div>
      </div>
      <div class="column">
        <div class="tabs is-boxed pt-6 mb-0">
          <ul>
            <li id="tabGeneral" data-content="chatGeneral">
              <a class="active">
                <span class="has-text-white">General</span>
                <i class="fas fa-comment newMessage hide"></i>
              </a>
            </li>
          </ul>
        </div>
        <div class="columns">
          <div class="column containerChats pb-0">
            <div id="chatGeneral" class="chat textarea contentTab"></div>
            <div class="infoInput"></div>
          </div>
        </div>
        <div class="columns is-mobile">
          <div class="column is-one-quarter pr-0 pt-1">
            <input class="input has-text-white has-background-black" type="text" id="user" placeholder="Usuario">
          </div>
          <div class="column ml-1 mt-1 mr-3">
            <div class="columns is-mobile">
              <input class="input has-text-white has-background-black" type="text" id="message" placeholder="Mensaje" title="Subir fichero">
              <form>
                <button class="button is-info" id="btnUploadFile">
                  <i class="fas fa-file-upload"></i>
                  <input class="file-input" type="file" name="uploadFile" accept="image/x-png,image/gif,image/jpeg">
                </button>
              </form>
            </div>
          </div>
        </div>
        <div class="columns is-mobile hidden" id="containerProgress">
          <div class="column">
            <progress id="upload-progress-bar" max="100" value="0">0</progress>
            <div class="info-progress" />
          </div>
        </div>
      </div>
    </div>
  </div>
  <script src="/socket.io/socket.io.js"></script>
  <script src="./index.js"></script>
</body>
</html>

style.css

@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300&display=swap');
html {
  overflow: hidden;
}
body {
  font-family: 'Montserrat', sans-serif;
  height: 100vh;
  width: 100vw;
  margin: 0;
  padding: 0;
  background: #354b52;
  color: #ffffff;
  overflow: hidden;
}
input {
  padding: 10px;
  font-size: 14px;
}
.chat {
  width: 100%;
  height: 250px;
  padding: 10px;
  font-size: 14px;
  overflow-y: auto;
  border: 1px solid #dddddd;
  background: #000000;
  color: #ffffff;
  resize: none !important;
}
.infoInput {
  font-size: 11px;
  position: relative;
  left: 10px;
  top: -5px;
}
.disabled {
  pointer-events: none;
  color: #aaaaaa !important;
}
.chatUser {
  font-weight: bold;
  margin-right: 5px;
}
::placeholder {
  color: #ffffff !important;
  opacity: .3 !important;
}
.chat::-webkit-scrollbar,
#userConnected::-webkit-scrollbar,
.tabs ul::-webkit-scrollbar {
  width: 10px;
}
.chat::-webkit-scrollbar-track,
#userConnected::-webkit-scrollbar-track,
.tabs ul::-webkit-scrollbar-track {
  border-radius: 10px;
}
.chat::-webkit-scrollbar-thumb,
#userConnected::-webkit-scrollbar-thumb,
.tabs ul::-webkit-scrollbar-thumb {
  background: #777777;       
  border-radius: 3px;
}
.chat::-webkit-scrollbar-thumb:hover,
#userConnected::-webkit-scrollbar-thumb:hover,
.tabs ul::-webkit-scrollbar-thumb:hover {
  background: #ffffff; 
  cursor: pointer;
}
.tabs ul {
  border-bottom: 0 !important;
  font-size: 14px;
  overflow: auto;
}
.tabs.is-boxed a:hover, .active {
  background-color: #000000;
  border: 1px solid #ffffff;
}
.containerChats .infoInput {
  text-align: right;
  left: 0;
  top: 0px;
  height: 15px;
}
.hide {
  display: none;
}
.card {
  border: 1px solid #dddddd;
  max-height: 343px;
  height: 343px;
}
.notification {
  transition: 300ms ease-in-out;
  position: fixed;
  bottom: -24px;
  width: 100%;
  opacity: 1;
  z-index: 10;
}
.notification.hidden {
  transition: 300ms ease-in-out;
  bottom: -100px;
  opacity: 0;
  z-index: -1;
}
#userConnected {
  overflow-y: auto;
  max-height: 225px;
  height: 225px;
}
#userConnected .chatUser {
  cursor: pointer !important;
}
@media (max-width: 768px) {
  .tabs  {
    padding-top: 0px !important;
  }
}
.newMessage {
  font-size: 11px;
  left: 5px;
  position: relative;
  color: #2196F3;
}
.closeChat {
  position: relative;
  left: 12px;
  top: -11px;
  color: #d46a10;
  font-size: 10px;
}
@media all and (max-width: 1023px) {
  .container {
    max-width: 960px;
    margin-left: 32px !important;
    margin-right: 32px !important;
  }
}
progress {
  width: 100%;
  position: relative;
  bottom: 25px;
  transition: 200ms ease-in-out;
  opacity: 1;
}
.info-progress {
  font-size: 11px;
  text-align: right;
  width: 100%;
  bottom: 30px;
  position: relative;
}
.hidden {
  opacity: 0;
  transition: 200ms ease-in-out;
}

Sobre el código anterior no hay mucho que añadir, una página simple pero suficiente para la prueba de concepto.

El último trozo de código que veremos, es el encargado de que la aplicación funcione en nuestro navegador.

index.js

// Definición de constantes y variables para la gestión del chat
let socket = null;
let user = null;
let message = null;
let usersPanel = null;
let notification = null;
let uploadFile = null;
let webrtc = null;
const CHAT_GENERAL = 'chatGeneral';
const LENGTH_MIN_USERNAME = 3;
const EMPTY = 0;
const LITERAL = {
  sameUser: 'No puedes enviarte un mensaje a ti mismo',
  minSizeUser: `El nombre del usuario debe tener <b>mínimo ${LENGTH_MIN_USERNAME} caracteres</b>`,
  uploadFile: 'Ha compartido un fichero',
  uploadSuccess: 'El fichero se ha subido y se esta compartiendo correctamente',
};

/** 
 * @description Cierra una sala de chat privada
 * @param {string} selectorID ID de la sala a cerrar
 */
const closeChat = selectorID => {
  document.querySelector(`.tabs ul li[data-content='${selectorID}']`).removeEventListener('click', selectedChat);
  document.querySelector(`.tabs ul li[data-content='${selectorID}']`).remove();
  document.querySelector(`.containerChats #${selectorID}`).remove();
  document.querySelector(`li[data-content='chatGeneral']`).click();
};

/** 
 * @description Creamos una sala privada
 * @param {object} data Información para crear la sala
 */
const chatTo = data => {
  const selectorID = data.idHTML || data.idOrigenHTML;
  const selectorIdUser = data.idUser ||data.idOrigen;
  const selectorUser = data.user || data.userOrigen;
  const existChat = document.querySelectorAll(`#${selectorID}`).length;
  if (existChat === EMPTY) {
    if (user.dataset.idhtml !== selectorID || data.idDestinoHTML !== data.idOrigenHTML) {
      document.querySelectorAll('.tabs ul li').forEach(item => {
        item.children[0].classList.remove('active');
      });
      document.querySelectorAll('.containerChats .chat').forEach(item => {
        item.classList.add('hide');
      });
      const chat = document.createElement('div');
      const li = document.createElement('li');
      const a = document.createElement('a');
      const span = document.createElement('span');
      const iNewChat = document.createElement('i');
      const iCloseChat = document.createElement('i');
      chat.setAttribute('id', selectorID);
      chat.classList.add('chat','textarea', 'contentTab');
      document.querySelector('.containerChats').prepend(chat);
      li.setAttribute('data-content', selectorID.toString());
      li.setAttribute('data-idUser', selectorIdUser.toString());
      a.classList.add('active');
      span.style.color = data.color;
      span.classList.add('has-text-white', 'chatUser');
      span.innerHTML = selectorUser;
      a.appendChild(span);
      iNewChat.classList.add('fas', 'fa-comment', 'newMessage', 'hide');
      a.appendChild(iNewChat);
      iCloseChat.classList.add('fas', 'fa-times-circle', 'closeChat');
      iCloseChat.onclick = () => { closeChat(selectorID.toString()); };
      a.appendChild(iCloseChat);
      li.appendChild(a);
      document.querySelector('.tabs ul').appendChild(li);
      document.querySelector(`.tabs ul li[data-content='${selectorID}']`).addEventListener('click', selectedChat);
    } else {
      notify(LITERAL.sameUser, 'danger');
    }
  }
};

/** 
 * @description Seleccionar una sala para charlar
 * @param {object} evt Evento implicito en la acción ejecutada
 */
const selectedChat = evt => {
  document.querySelectorAll('.containerChats .chat').forEach(item => {
    item.classList.add('hide');
  });
  const showChat = evt.currentTarget.dataset['content'];
  const chatElement = document.querySelector(`#${showChat}`);
  if (chatElement) {
    chatElement.classList.remove('hide');
  }
  document.querySelectorAll('.tabs a').forEach(item => item.classList.remove('active'));
  evt.currentTarget.children[0].classList.add('active');
  if (!Array.from(evt.currentTarget.children[0].querySelector('i').classList).includes('hide')) {
    evt.currentTarget.children[0].querySelector('i').classList.add('hide')
  }
};

/** 
 * @description Envia mensajes al chat general y a un usuario privado
 * @param {object} evt Evento implicito en la acción ejecutada
 * @param {boolean} status Indicamos el estado para saber que usuario esta escribiendo
 */
const sendMessage = (evt, status) => {
  if (evt.key === 'Enter') {
    if (user.value.length >= LENGTH_MIN_USERNAME && message.value.trim().length > EMPTY) {
      const parent = document.querySelectorAll('.tabs a.active')[0].parentElement;
      const content = parent.dataset.content;
      const currentlyChat = document.querySelector(`#${content}`);
      const isChatGeneral = content === CHAT_GENERAL;
      const infoMensaje = {
        idOrigen: user.dataset.iduser,
        idOrigenHTML: user.dataset.idhtml,
        idDestino: isChatGeneral ? CHAT_GENERAL : parent.dataset.iduser,
        idDestinoHTML: content,
        userOrigen: user.value,
        userDestino: isChatGeneral ? CHAT_GENERAL : parent.innerText,
        message: message.value
      };
      if (content !== CHAT_GENERAL) {
        const color = document.querySelector(`#userConnected .chatUser[data-idhtml='${user.dataset.idhtml}']`).style.color;
        const messageSend = document.querySelector(`.containerChats #${content}`);
        const div = document.createElement('div');
        const span = document.createElement('span');
        span.style.color = color;
        span.classList.add('chatUser');
        span.appendChild(document.createTextNode(`${infoMensaje.userOrigen}:`));
        div.appendChild(span);
        div.innerHTML += infoMensaje.message;
        messageSend.appendChild(div);
      }
      socket.emit(`message-chat-${isChatGeneral ? 'general' : 'private'}`, infoMensaje);
      message.value = '';
      currentlyChat.scrollTo(0, currentlyChat.scrollHeight);
    } 
    user.classList[user.value.length >= LENGTH_MIN_USERNAME ? 'remove' : 'add']('has-background-danger');
  } else {
    if (user.value.length >= LENGTH_MIN_USERNAME) {
      if (evt.key !== 'Tab') {
        socket.emit('write-client', { data: status ? user.value : ''});
      }
    }
  }
};

/** 
 * @description Muestra texto indicando quien esta escribiendo
 * @param {object} payload Información que pinta cuando pulsamos un tecla
 */
const clientBeenWriting = payload => {
  document.querySelector('.containerChats .infoInput').innerHTML = payload;
};

/** 
 * @description Muestra texto en el chat general
 * @param {object} data Información relativa al usuario
 */
const reciveMessage = data => {
  const {
    color,
    idDestinoHTML,
    idOrigenHTML,
    userOrigen,
    message 
  } = data;
  let activeChatID = document.querySelector(`#${idDestinoHTML}`) || document.querySelector(`#${idOrigenHTML}`);
  if (!activeChatID) {
    chatTo(data);
    activeChatID = document.querySelector(`#${idDestinoHTML}`) || document.querySelector(`#${idOrigenHTML}`);
  }
  if (idDestinoHTML !== idOrigenHTML) {
    const div = document.createElement('div');
    const span = document.createElement('span');
    span.style.color = color;
    span.classList.add('chatUser');
    span.appendChild(document.createTextNode(`${userOrigen}:`));
    div.appendChild(span);
    div.innerHTML += message;
    activeChatID.appendChild(div);
    activeChatID.scrollTo(0, activeChatID.scrollHeight);
  }
  const newMessage = document.querySelector('.tabs a.active').parentElement.dataset.content
  if (newMessage !== activeChatID.id && newMessage !== idDestinoHTML) {
    const destino = document.querySelector(`.tabs li[data-content='${idDestinoHTML}'] i`);
    const origen = document.querySelector(`.tabs li[data-content='${idOrigenHTML}'] i`);
    (destino || origen).classList.remove('hide');
  }
};

/** 
 * @description Añade y actualiza el panel lateral con el listado de los usuarios
 * @param {object} payload Información de cada uno de los usuarios
 */
const registerUser = payload => {
  Array.from(usersPanel.children).forEach(item => item.remove())
  payload.forEach(item => {
    const div = document.createElement('div');
    div.style.color = item.color;
    div.classList.add('chatUser');
    div.onclick = () => { chatTo(item); };
    div.setAttribute('data-idUser', item.idUser);
    div.setAttribute('data-idHTML', item.idHTML);
    div.appendChild(document.createTextNode(item.user));
    usersPanel.appendChild(div);
    if (user.value === item.user) {
      user.setAttribute('data-idHTML', item.idHTML);
      user.setAttribute('data-idUser', item.idUser);
    }
  });
};

/** 
 * @description Muestra mensaje de error al registrar el usuario
 * @param {object} payload Información de cada uno de los usuarios
 * @param {object} evt Evento implicito en la acción ejecutada
 */
const errorRegisteredUser = (payload, evt) => {
  const { error } = payload;
  notify(error, 'danger');
  evt.target.classList.remove('disabled');
  user.classList.add('has-background-danger');
};

/** 
 * @description Realiza la conexion al servidor
 * @param {object} evt Evento implicito en la acción ejecutada
 */
const connectedToServer = evt => {
  const target = evt.target;
  if (target.value.length >= LENGTH_MIN_USERNAME) {
    target.classList.add('disabled');
    user.classList.remove('has-background-danger');
    socket = io.connect();
    socket.on('recive-message', reciveMessage);
    socket.on('registered-user', registerUser);
    socket.on('error-registered-user', payload => errorRegisteredUser(payload, evt));
    socket.on('client-been-writing', clientBeenWriting);
    socket.on('upload-progress', data => uploadProgress(data));
    const idHTML = generateIDHtml();
    socket.emit('connected-to-server', { user: target.value, idHTML });
  } else {
    notify(LITERAL.minSizeUser, 'danger');
  }
};

/** 
 * @description Mostramos el progreso de la subida
 * @param {object} data Información sobre la subida del archivo
 */
const uploadProgress = data => {
  const { recived, total, who } = data;
  const porcent = Math.floor((recived * 100) / total);
  const currentlySize = (recived / 1024) / 1024; // MB
  const totalSize = (total / 1024) / 1024; // MB
  const progress = document.querySelector('#upload-progress-bar');
  const infoProgress = document.querySelector('.info-progress');
  progress.value = Math.floor(porcent);
  progress.innerHTML = porcent.toFixed(2);
  infoProgress.innerHTML = `${currentlySize.toFixed(2)} MB ${totalSize.toFixed(2)} MB ${porcent} %`;
};

/** 
 * @description Cargamos un fichero y se sube al servidor
 * @param {object} evt Evento implicito en la acción ejecutada
 */
const upload = async evt => {
  if (user.value.length >= LENGTH_MIN_USERNAME) {
    const uploadProgress = document.querySelector('#containerProgress');
    uploadProgress.classList.remove('hidden');
    const btnUploadFile = document.querySelector('#btnUploadFile');
    btnUploadFile.classList.add('is-loading');
    btnUploadFile.setAttribute('disabled', true);
    const files = evt.target.files;
    const data = new FormData();
    data.append('archivo', files[0]);
    socket.emit('upload-file', { idUser: user.dataset.iduser });
    const result = await (await fetch('/upload-file', {
      method: 'POST',
      body: data
    })).json();
    btnUploadFile.classList.remove('is-loading');
    btnUploadFile.removeAttribute('disabled');
    const { statusCode, path, statusMessage } = result;
    if (statusCode === 200) {
      message.value = `${LITERAL.uploadFile} <a href='${statusMessage}' target='_blank'>[${files[0].name}]</a>`;
      sendMessage(evt = {key: 'Enter'}, true);
      notify(LITERAL.uploadSuccess, 'success', 4000);
    } else {
      notify(statusMessage, 'danger', 4000);
    }
    setTimeout(() => {
      uploadProgress.classList.add('hidden');
    }, 2000);
  } else {
    document.querySelector('form').reset();
    notify(LITERAL.minSizeUser, 'danger');
  }
};

/** 
 * @description Inicialización del chat
 */
const load = () => {
  const tabGeneral = document.querySelector('#tabGeneral');
  user = document.querySelector('#user');
  message = document.querySelector('#message');
  usersPanel = document.querySelector('#userConnected');
  notification = document.querySelector('.notification');
  message.addEventListener('keydown', evt => sendMessage(evt, true));
  message.addEventListener('keyup',  evt => sendMessage(evt, false));
  user.addEventListener('blur', connectedToServer);
  tabGeneral.addEventListener('click', selectedChat);
  uploadFile = document.querySelector('input[type=file]');
  uploadFile.addEventListener('change', upload);
};

/** 
 * @description Muestra notificaciones en el chat
 * @param {string} msn Mensaje que se muestra en la notificación
 * @param {string} type Tipo de mensaje (danger, info, success, warning)
 * @param {number} timeout Duración de la notificación
 */
const notify = (msn = '', type = 'info', timeout = 2000) => {
  notification.children[0].innerHTML = msn;
  notification.classList.add(`is-${type}`);
  notification.classList.remove('hidden');
  setTimeout(() => {
    notification.classList.add('hidden');
    notification.classList.remove(`is-${type}`);
  }, timeout);
};

/** 
 * @description Generamos un ID para cada usuario
 */
const generateIDHtml = () => new Date().getTime().toString().split('').map(i => String.fromCharCode(parseInt(i) + 65)).join('');

/** 
 * @description Espera a que este la página completamente cargada
 */
document.addEventListener("DOMContentLoaded", load);

En un futuro me gustaría realizar esta misma aplicación usando React Vue, entre otras cosas para aprovechar la magia que ofrecen y poder añadir mas características como, llamadas de audio y video, compartir pantalla, guardar conversaciones, stickers, gifs, etc…

Para finalizar esta entrada, si estás interesado en usar el código, lo puedes obtener desde Github y ademas puedes probar el chat en la siguiente url: https://chat.tecnops.es/