ChatPlus es una aplicación web progresiva desarrollada con React, NodeJS, Firebase y otros servicios.
Puedes hablar con todos tus amigos en tiempo real 🗣️✨🧑🤝🧑❤️
Puedes llamar a tus amigos y tener llamadas de video y audio con ellos 🎥🔉🤩
Envía imágenes a tus amigos y también mensajes de audio y tendrás una IA que convierte tu voz en texto, ya sea que hables francés, inglés o español 🤖✨
La aplicación web se puede instalar en cualquier dispositivo y puede recibir notificaciones ⬇️🔔🎉
Agradecería mucho tu apoyo, déjanos una estrella en el repositorio de Github y compártela con tus amigos ⭐✨
Consulte este repositorio de Github para obtener documentación completa de instalación e implementación: https://github.com/aladinyo/ChatPlus
ChatPlus es una de las mejores aplicaciones que creé, atrae la atención con su vibrante interfaz de usuario y múltiples funcionalidades de mensajería y llamadas, todas implementadas en la web, brinda una experiencia multiplataforma ya que su interfaz está diseñada para ser una aplicación PWA que tiene la capacidad de instalarse en todas partes y actuar como una aplicación independiente, con funciones como notificaciones automáticas, ChatPlus es la definición de una aplicación liviana que puede brindar todas las funciones de una aplicación móvil con tecnologías web.
Nuestra aplicación sigue la arquitectura de software MVC, MVC (Modelo-Vista-Controlador) es un patrón en el diseño de software comúnmente utilizado para implementar interfaces de usuario, datos y lógica de control. Enfatiza una separación entre la lógica empresarial y la visualización del software. Esta "separación de preocupaciones" permite una mejor división del trabajo y un mejor mantenimiento.
Consta de múltiples componentes de interfaz React:
MaterialUI: Material UI es una biblioteca de componentes React de código abierto que implementa Material Design de Google. Es completo y se puede usar en producción desde el primer momento, lo usaremos para botones de íconos y algunos elementos en nuestra interfaz, como un control deslizante de audio.
LoginView: una vista de inicio de sesión simple que permite al usuario ingresar su nombre de usuario y contraseña o iniciar sesión con Google.
SidebarViews : múltiples componentes de vista como 'sidebar__header', 'sidebar__search', 'sidebar menu' y 'SidebarChats', consta de un encabezado para información del usuario, una barra de búsqueda para buscar usuarios, un menú de barra lateral para navegar entre sus chats, sus grupos. y usuarios, y un componente SidebarChats para mostrar todos los chats recientes en los que envió mensajes, muestra el nombre de usuario, la foto y el último mensaje.
ChatViews: consta de muchos componentes de la siguiente manera:
1 .'chat__header' que contiene la información del usuario con el que estás hablando, su estado en línea, foto de perfil y muestra botones para llamada de audio y videollamada, también muestra si el usuario está escribiendo.
2 .'chat__body--container' contiene la información de nuestros mensajes con los otros usuarios, tiene un componente de mensajes que muestra mensajes de texto, imágenes con su mensaje y también mensajes de audio con su información, como la hora del audio y si el audio se reprodujo y Al final de este componente tenemos el elemento "visto" que muestra si se vieron los mensajes.
3 .'AudioPlayer': un componente de React que puede mostrarnos un audio con un control deslizante para navegar por él, muestra el tiempo completo y el tiempo actual del audio, este componente de vista se carga dentro de 'chat__body-- contenedor'.
4. 'ChatFooter': contiene una entrada para escribir un mensaje, un botón para enviar un mensaje cuando se escribe la entrada, de lo contrario, el botón le permitirá grabar el audio, un botón para importar imágenes y archivos.
5 .'MediaPreview': un componente de React que permite previsualizar las imágenes o archivos que hemos seleccionado para enviar en nuestro chat, se muestran en un carrusel y el usuario puede deslizar las imágenes o archivos y escribir un mensaje específico para cada uno.
6 .'ImagePreview': Cuando tenemos imágenes enviadas en nuestro chat, este componente mostrará las imágenes en pantalla completa con una animación suave, el componente se monta después de hacer clic en una imagen.
scalePage: una función de visualización que aumenta el tamaño de nuestra aplicación web cuando se muestra en pantallas grandes como pantallas Full HD y pantallas 4K.
CallViews: un conjunto de componentes de reacción que contienen todos los elementos de vista de llamadas, tienen la capacidad de arrastrarse por toda nuestra pantalla y consta de:
1 'Botones': un botón de llamada con una versión roja y un botón verde de videollamada.
2 'AudioCallView': un componente de visualización que permite responder llamadas de audio entrantes y mostrar la llamada con un temporizador y permite cancelar la llamada.
3 'StartVideoCallView': un componente de visualización que muestra un vídeo de nosotros mismos conectándonos a la MediaAPI local y espera a que el otro usuario acepte la llamada o muestra un botón para que respondamos una videollamada entrante.
4 'VideoCallView': un componente de visualización que muestra un video de nosotros y del otro usuario. Permite cambiar de cámara, desactivar la cámara y el audio, también puede ir a pantalla completa.
RouteViews: componentes de React que contienen todas nuestras vistas para crear componentes de navegación locales, tenemos 'VideoCallRoute', 'SideBarMenuRoute' y 'ChatsRoute'
Los modelos del lado del cliente son la lógica que permite que nuestro frontend interactúe con bases de datos y múltiples API locales y del lado del servidor y consta de:
Consta de componentes de React que vinculan nuestras vistas con sus modelos específicos:
No todas las funciones se realizan en la interfaz, ya que los SDK que utilizamos requieren algunas funcionalidades del lado del servidor y consisten en:
Nuestra aplicación web utiliza Firestore para almacenar nuestra base de datos, que es una base de datos Firebase NoSql, almacenamos información de los usuarios, almacenamos una lista de todos los mensajes, almacenamos una lista de chats y también almacenamos chats en salas, estos son los datos almacenados en nuestra base de datos:
En los próximos capítulos, daré una explicación rápida y tutoriales sobre ciertas funcionalidades en ChatPlus, le mostraré el código JS y le explicaré el algoritmo detrás de él y también le proporcionaré la herramienta de integración adecuada para vincular su código. la base de datos.
El estado en línea de los usuarios se implementó mediante la función de conectividad de la base de datos de Firebase conectándose a ".info/connected" en la interfaz y actualizando tanto Firestore como la base de datos en consecuencia:
var disconnectRef; function setOnlineStatus(uid) { try { console.log("setting up online status"); const isOfflineForDatabase = { state: 'offline', last_changed: createTimestamp2, id: uid, }; const isOnlineForDatabase = { state: 'online', last_changed: createTimestamp2, id: uid }; const userStatusFirestoreRef = db.collection("users").doc(uid); const userStatusDatabaseRef = db2.ref('/status/' + uid); // Firestore uses a different server timestamp value, so we'll // create two more constants for Firestore state. const isOfflineForFirestore = { state: 'offline', last_changed: createTimestamp(), }; const isOnlineForFirestore = { state: 'online', last_changed: createTimestamp(), }; disconnectRef = db2.ref('.info/connected').on('value', function (snapshot) { console.log("listening to database connected info") if (snapshot.val() === false) { // Instead of simply returning, we'll also set Firestore's state // to 'offline'. This ensures that our Firestore cache is aware // of the switch to 'offline.' userStatusFirestoreRef.set(isOfflineForFirestore, { merge: true }); return; }; userStatusDatabaseRef.onDisconnect().set(isOfflineForDatabase).then(function () { userStatusDatabaseRef.set(isOnlineForDatabase); // We'll also add Firestore set here for when we come online. userStatusFirestoreRef.set(isOnlineForFirestore, { merge: true }); }); }); } catch (error) { console.log("error setting onlins status: ", error); } };
Y en nuestro backend también configuramos un detector que escucha los cambios en nuestra base de datos y actualiza Firestore en consecuencia. Esta función también puede brindarnos el estado en línea del usuario en tiempo real para que podamos ejecutar otras funciones dentro de ella también:
async function handleOnlineStatus(data, event) { try { console.log("setting online status with event: ", event); // Get the data written to Realtime Database const eventStatus = data.val(); // Then use other event data to create a reference to the // corresponding Firestore document. const userStatusFirestoreRef = db.doc(`users/${eventStatus.id}`); // It is likely that the Realtime Database change that triggered // this event has already been overwritten by a fast change in // online / offline status, so we'll re-read the current data // and compare the timestamps. const statusSnapshot = await data.ref.once('value'); const status = statusSnapshot.val(); // If the current timestamp for this data is newer than // the data that triggered this event, we exit this function. if (eventStatus.state === "online") { console.log("event status: ", eventStatus) console.log("status: ", status) } if (status.last_changed <= eventStatus.last_changed) { // Otherwise, we convert the last_changed field to a Date eventStatus.last_changed = new Date(eventStatus.last_changed); //handle the call delete handleCallDelete(eventStatus); // ... and write it to Firestore. await userStatusFirestoreRef.set(eventStatus, { merge: true }); console.log("user: " + eventStatus.id + " online status was succesfully updated with data: " + eventStatus.state); } else { console.log("next status timestamp is newer for user: ", eventStatus.id); } } catch (error) { console.log("handle online status crashed with error :", error) } }
Las notificaciones son una característica excelente y se implementan mediante mensajería de Firebase. En nuestra interfaz, si el navegador del usuario admite notificaciones, lo configuramos y recuperamos el token de mensajería de Firebase del usuario:
const configureNotif = (docID) => { messaging.getToken().then((token) => { console.log(token); db.collection("users").doc(docID).set({ token: token }, { merge: true }) }).catch(e => { console.log(e.message); db.collection("users").doc(docID).set({ token: "" }, { merge: true }); }); }
Cada vez que un usuario envía un mensaje, agregamos una notificación a nuestra base de datos:
db.collection("notifications").add({ userID: user.uid, title: user.displayName, body: inputText, photoURL: user.photoURL, token: token, });
y en nuestro backend escuchamos la colección de notificaciones y usamos la mensajería de Firebase para enviársela al usuario.
let listening = false; db.collection("notifications").onSnapshot(snap => { if (!listening) { console.log("listening for notifications..."); listening = true; } const docs = snap.docChanges(); if (docs.length > 0) { docs.forEach(async change => { if (change.type === "added") { const data = change.doc.data(); if (data) { const message = { data: data, token: data.token }; await db.collection("notifications").doc(change.doc.id).delete(); try { const response = await messaging.send(message); console.log("notification successfully sent :", data); } catch (error) { console.log("error sending notification ", error); }; }; }; }); }; });
Nuestra aplicación web permite a los usuarios enviarse mensajes de audio entre sí, y una de sus características es la capacidad de convertir este audio en texto para audio grabado en inglés, francés y español y esta característica se implementó con la función Google Cloud Speech to Text. Nuestro backend escucha las nuevas transcripciones agregadas a Firestore, las transcribe y luego las escribe en la base de datos:
db.collection("transcripts").onSnapshot(snap => { const docs = snap.docChanges(); if (docs.length > 0) { docs.forEach(async change => { if (change.type === "added") { const data = change.doc.data(); if (data) { db.collection("transcripts").doc(change.doc.id).delete(); try { const text = await textToAudio(data.audioName, data.short, data.supportWebM); const roomRef = db.collection("rooms").doc(data.roomID).collection("messages").doc(data.messageID); db.runTransaction(async transaction => { const roomDoc = await transaction.get(roomRef); if (roomDoc.exists && !roomDoc.data()?.delete) { transaction.update(roomRef, { transcript: text }); console.log("transcript added with text: ", text); return; } else { console.log("room is deleted"); return; } }) db.collection("rooms").doc(data.roomID).collection("messages").doc(data.messageID).update({ transcript: text }); } catch (error) { console.log("error transcripting audio: ", error); }; }; }; }); }; });
Obviamente, tus ojos están mirando esa función textToAudio y te preguntas cómo la hice, no te preocupes, te entiendo:
// Imports the Google Cloud client library const speech = require('@google-cloud/speech').v1p1beta1; const { gcsUriLink } = require("./configKeys") // Creates a client const client = new speech.SpeechClient({ keyFilename: "./audio_transcript.json" }); async function textToAudio(audioName, isShort) { // The path to the remote LINEAR16 file const gcsUri = gcsUriLink + "/audios/" + audioName; // The audio file's encoding, sample rate in hertz, and BCP-47 language code const audio = { uri: gcsUri, }; const config = { encoding: "MP3", sampleRateHertz: 48000, languageCode: 'en-US', alternativeLanguageCodes: ['es-ES', 'fr-FR'] }; console.log("audio config: ", config); const request = { audio: audio, config: config, }; // Detects speech in the audio file if (isShort) { const [response] = await client.recognize(request); return response.results.map(result => result.alternatives[0].transcript).join('\n'); } const [operation] = await client.longRunningRecognize(request); const [response] = await operation.promise().catch(e => console.log("response promise error: ", e)); return response.results.map(result => result.alternatives[0].transcript).join('\n'); }; module.exports = textToAudio;
Nuestra aplicación web utiliza Daily API para implementar conexiones Web RTC en tiempo real, permite a los usuarios realizar videollamadas entre sí, por lo que primero configuramos un servidor de llamadas backend que tiene muchos puntos de entrada de API para crear y eliminar salas en Daily:
const app = express(); app.use(cors()); app.use(express.json()); app.delete("/delete-call", async (req, res) => { console.log("delete call data: ", req.body); deleteCallFromUser(req.body.id1); deleteCallFromUser(req.body.id2); try { fetch("https://api.daily.co/v1/rooms/" + req.body.roomName, { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "DELETE" }); } catch(e) { console.log("error deleting room for call delete!!"); console.log(e); } res.status(200).send("delete-call success !!"); }); app.post("/create-room/:roomName", async (req, res) => { var room = await fetch("https://api.daily.co/v1/rooms/", { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "POST", body: JSON.stringify({ name: req.params.roomName }) }); room = await room.json(); console.log(room); res.json(room); }); app.delete("/delete-room/:roomName", async (req, res) => { var deleteResponse = await fetch("https://api.daily.co/v1/rooms/" + req.params.roomName, { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "DELETE" }); deleteResponse = await deleteResponse.json(); console.log(deleteResponse); res.json(deleteResponse); }) app.listen(process.env.PORT || 7000, () => { console.log("call server is running"); }); const deleteCallFromUser = userID => db.collection("users").doc(userID).collection("call").doc("call").delete();
y en nuestra interfaz tenemos múltiples funciones para llamar a esta API:
import { callAPI as api } from "../../configKeys"; //const api = "http://localhost:7000" export async function createRoom(roomName) { var room = await fetch(`${api}/create-room/${roomName}`, { method: "POST", }); room = await room.json(); console.log(room); return room; } export async function deleteRoom(roomName) { var deletedRoom = await fetch(`${api}/delete-room/${roomName}`, { method: "DELETE", }); deletedRoom = await deletedRoom.json(); console.log(deletedRoom); console.log("deleted"); }; export function deleteCall() { window.callDelete && fetch(`${api}/delete-call`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify(window.callDelete) }); };
Genial, ahora es el momento de crear salas de llamadas y usar el SDK JS diario para conectarse a estas salas y enviar y recibir datos de ellas:
export default async function startVideoCall(dispatch, receiverQuery, userQuery, id, otherID, userName, otherUserName, sendNotif, userPhoto, otherPhoto, audio) { var room = null; const call = new DailyIframe.createCallObject(); const roomName = nanoid(); window.callDelete = { id1: id, id2: otherID, roomName } dispatch({ type: "set_other_user_name", otherUserName }); console.log("audio: ", audio); if (audio) { dispatch({ type: "set_other_user_photo", photo: otherPhoto }); dispatch({ type: "set_call_type", callType: "audio" }); } else { dispatch({ type: "set_other_user_photo", photo: null }); dispatch({ type: "set_call_type", callType: "video" }); } dispatch({ type: "set_caller", caller: true }); dispatch({ type: "set_call", call }); dispatch({ type: "set_call_state", callState: "state_creating" }); try { room = await createRoom(roomName); console.log("created room: ", room); dispatch({ type: "set_call_room", callRoom: room }); } catch (error) { room = null; console.log('Error creating room', error); await call.destroy(); dispatch({ type: "set_call_room", callRoom: null }); dispatch({ type: "set_call", call: null }); dispatch({ type: "set_call_state", callState: "state_idle" }); window.callDelete = null; //destroy the call object; }; if (room) { dispatch({ type: "set_call_state", callState: "state_joining" }); dispatch({ type: "set_call_queries", callQueries: { userQuery, receiverQuery } }); try { await db.runTransaction(async transaction => { console.log("runing transaction"); var userData = (await transaction.get(receiverQuery)).data(); //console.log("user data: ", userData); if (!userData || !userData?.callerID || userData?.otherUserLeft) { console.log("runing set"); transaction.set(receiverQuery, { room, callType: audio ? "audio" : "video", isCaller: false, otherUserLeft: false, callerID: id, otherID, otherUserName: userName, otherUserRatio: window.screen.width / window.screen.height, photo: audio ? userPhoto : "" }); transaction.set(userQuery, { room, callType: audio ? "audio" : "video", isCaller: true, otherUserLeft: false, otherUserJoined: false, callerID: id, otherID }); } else { console.log('transaction failed'); throw userData; } }); if (sendNotif) { sendNotif(); const notifTimeout = setInterval(() => { sendNotif(); }, 1500); dispatch({ type: "set_notif_tiemout", notifTimeout }); } call.join({ url: room.url, videoSource: !audio }); } catch (userData) { //delete the room we made deleteRoom(roomName); await call.destroy(); if (userData.otherID === id) { console.log("you and the other user are calling each other at the same time"); joinCall(dispatch, receiverQuery, userQuery, userData.room, userName, audio ? userPhoto : "", userData.callType); } else { console.log("other user already in a call"); dispatch({ type: "set_call_room", callRoom: null }); dispatch({ type: "set_call", call: null }); dispatch({ type: "set_call_state", callState: "state_otherUser_calling" }); } }; }; };
OtherUserQuery y UserQuery son solo rutas de documentos de Firebase Firestore, ahora el resto de la aplicación tiene componentes de vista que reaccionan a los cambios de estado que activa esta función anterior y nuestros elementos de la interfaz de usuario de llamada aparecerán en consecuencia.
La siguiente función es la Magia que te permite arrastrar el elemento Llamada por toda la página:
export function dragElement(elmnt, page) { var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0, top, left, prevTop = 0, prevLeft = 0, x, y, maxTop, maxLeft; const widthRatio = page.width / window.innerWidth; const heightRatio = page.height / window.innerHeight; //clear element's mouse listeners closeDragElement(); // setthe listener elmnt.addEventListener("mousedown", dragMouseDown); elmnt.addEventListener("touchstart", dragMouseDown, { passive: false }); function dragMouseDown(e) { e = e || window.event; // get the mouse cursor position at startup: if (e.type === "touchstart") { if (typeof(e.target.className) === "string") { if (!e.target.className.includes("btn")) { e.preventDefault(); } } else if (!typeof(e.target.className) === "function") { e.stopPropagation(); } pos3 = e.touches[0].clientX * widthRatio; pos4 = e.touches[0].clientY * heightRatio; } else { e.preventDefault(); pos3 = e.clientX * widthRatio; pos4 = e.clientY * heightRatio; }; maxTop = elmnt.offsetParent.offsetHeight - elmnt.offsetHeight; maxLeft = elmnt.offsetParent.offsetWidth - elmnt.offsetWidth; document.addEventListener("mouseup", closeDragElement); document.addEventListener("touchend", closeDragElement, { passive: false }); // call a function whenever the cursor moves: document.addEventListener("mousemove", elementDrag); document.addEventListener("touchmove", elementDrag, { passive: false }); } function elementDrag(e) { e = e || window.event; e.preventDefault(); // calculate the new cursor position: if (e.type === "touchmove") { x = e.touches[0].clientX * widthRatio; y = e.touches[0].clientY * heightRatio; } else { e.preventDefault(); x = e.clientX * widthRatio; y = e.clientY * heightRatio; }; pos1 = pos3 - x; pos2 = pos4 - y; pos3 = x pos4 = y; // set the element's new position: top = elmnt.offsetTop - pos2; left = elmnt.offsetLeft - pos1; //prevent the element from overflowing the viewport if (top >= 0 && top <= maxTop) { elmnt.style.top = top + "px"; } else if ((top > maxTop && pos4 < prevTop) || (top < 0 && pos4 > prevTop)) { elmnt.style.top = top + "px"; }; if (left >= 0 && left <= maxLeft) { elmnt.style.left = left + "px"; } else if ((left > maxLeft && pos3 < prevLeft) || (left < 0 && pos3 > prevLeft)) { elmnt.style.left = left + "px"; }; prevTop = y; prevLeft = x; } function closeDragElement() { // stop moving when mouse button is released: document.removeEventListener("mouseup", closeDragElement); document.removeEventListener("touchend", closeDragElement); document.removeEventListener("mousemove", elementDrag); document.removeEventListener("touchmove", elementDrag); }; return function() { elmnt.removeEventListener("mousedown", dragMouseDown); elmnt.removeEventListener("touchstart", dragMouseDown); closeDragElement(); }; };
Puedes arrastrar y soltar imágenes en tu chat y enviarlas al otro usuario. Esta funcionalidad es posible ejecutando esto:
useEffect(() => { const dropArea = document.querySelector(".chat"); ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, e => { e.preventDefault(); e.stopPropagation(); }, false); }); ['dragenter', 'dragover'].forEach(eventName => { dropArea.addEventListener(eventName, () => setShowDrag(true), false) }); ['dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, () => setShowDrag(false), false) }); dropArea.addEventListener('drop', e => { if (window.navigator.onLine) { if (e.dataTransfer?.files) { const dropedFile = e.dataTransfer.files; console.log("dropped file: ", dropedFile); const { imageFiles, imagesSrc } = mediaIndexer(dropedFile); setSRC(prevImages => [...prevImages, ...imagesSrc]); setImage(prevFiles => [...prevFiles, ...imageFiles]); setIsMedia("images_dropped"); }; }; }, false); }, []);
mediaIndexer es una función simple que indexa el blob de imágenes que le proporcionamos:
function mediaIndexer(files) { const imagesSrc = []; const filesArray = Array.from(files); filesArray.forEach((file, index) => { imagesSrc[index] = URL.createObjectURL(file); }); return { imagesSrc, imageFiles: filesArray }; }