ChatPlus é um aplicativo web progressivo desenvolvido com React, NodeJS, Firebase e outros serviços.
Você pode conversar com todos os seus amigos em tempo real 🗣️✨🧑🤝🧑❤️
Você pode ligar para seus amigos e fazer chamadas de vídeo e áudio com eles 🎥🔉🤩
Envie imagens para seus amigos e também mensagens de áudio e você terá uma IA que converte sua fala em texto, quer você fale francês, inglês ou espanhol 🤖✨
O aplicativo web pode ser instalado em qualquer dispositivo e pode receber notificações ⬇️🔔🎉
Eu agradeceria muito o seu apoio, deixe-nos uma estrela no repositório do Github e compartilhe com seus amigos ⭐✨
Confira este repositório Github para documentação completa de instalação e implantação: https://github.com/aladinyo/ChatPlus
ChatPlus é um dos melhores aplicativos que fiz, atrai a atenção com sua interface de usuário vibrante e múltiplas funcionalidades de mensagens e chamadas implementadas na web, oferece uma experiência multiplataforma, pois seu frontend foi projetado para ser um aplicativo PWA que tem a capacidade de ser instalado em qualquer lugar e funcionar como um aplicativo independente, com recursos como notificações push, ChatPlus é a definição de um aplicativo leve que pode fornecer todos os recursos de um aplicativo móvel com tecnologias web.
Nosso aplicativo segue a arquitetura de software MVC, MVC (Model-View-Controller) é um padrão em design de software comumente usado para implementar interfaces de usuário, dados e lógica de controle. Ele enfatiza a separação entre a lógica de negócios e a exibição do software. Esta “separação de preocupações” proporciona uma melhor divisão do trabalho e uma melhor manutenção.
Consiste em vários componentes da interface React:
MaterialUI: Material UI é uma biblioteca de componentes React de código aberto que implementa o Material Design do Google. É abrangente e pode ser usado imediatamente na produção, vamos usá-lo para botões de ícones e alguns elementos em nossa interface, como um controle deslizante de áudio.
LoginView: Uma visualização de login simples que permite ao usuário colocar seu nome de usuário e senha ou fazer login no google.
SidebarViews : vários componentes de visualização como 'sidebar__header', 'sidebar__search', 'sidebar menu' e 'SidebarChats', consiste em um cabeçalho para informações do usuário, uma barra de pesquisa para pesquisar usuários, um menu da barra lateral para navegar entre seus chats, seus grupos e usuários, e um componente SidebarChats para exibir todos os bate-papos recentes que você enviou, mostra o nome de usuário e a foto e a última mensagem.
ChatViews: consiste em vários componentes como segue:
1 .'chat__header' que contém as informações do usuário com quem você está conversando, seu status online, foto do perfil e exibe botões para chamada de áudio e videochamada, também mostra se o usuário está digitando.
2 .'chat__body--container' contém as informações de nossas mensagens com os demais usuários possui componente de mensagens que exibe mensagens de texto, imagens com sua mensagem e também mensagens de áudio com suas informações como tempo de áudio e se o áudio foi reproduzido e no final deste componente temos o elemento 'visto' que mostra se as mensagens foram vistas.
3.'AudioPlayer ': um componente React que pode nos exibir um áudio com um controle deslizante para navegar nele, exibe o tempo completo e o horário atual do áudio, este componente de visualização é carregado dentro de 'chat__body-- container'.
4 .'ChatFooter': contém uma entrada para digitar uma mensagem, um botão para enviar uma mensagem ao digitar a entrada, caso contrário o botão permitirá gravar o áudio, um botão para importar imagens e arquivos.
5.'MediaPreview ': um componente React que permite visualizar as imagens ou arquivos que selecionamos para enviar em nosso chat, eles são exibidos em um carrossel e o usuário pode deslizar as imagens ou arquivos e digitar uma mensagem específica para cada um
6 .'ImagePreview': Quando temos imagens enviadas em nosso chat este componente irá exibir as imagens em tela cheia com uma animação suave, o componente é montado após clicar em uma imagem.
scalePage: uma função de visualização que aumenta o tamanho do nosso aplicativo da web quando exibido em telas grandes, como telas full HD e telas 4K.
CallViews: um monte de componentes de reação que contêm todos os elementos de visualização de chamadas, eles podem ser arrastados por toda a tela e consistem em:
1 'Botões': um botão de chamada com uma versão vermelha e um botão verde de videochamada.
2 'AudioCallView': um componente de visualização que permite atender chamadas de áudio recebidas e exibir a chamada com um temporizador e permite cancelar a chamada.
3 'StartVideoCallView': um componente de visualização que exibe um vídeo nosso conectando-se ao MediaAPI local e espera que o outro usuário aceite a chamada ou exibe um botão para atendermos uma chamada de vídeo recebida.
4 'VideoCallView': um componente de visualização que exibe um vídeo nosso e do outro usuário que permite trocar de câmera, desabilitar câmera e áudio, também pode ir para tela cheia.
RouteViews: Componentes React que contém todas as nossas visualizações para criar navegação de componentes locais, temos 'VideoCallRoute', 'SideBarMenuRoute' e 'ChatsRoute'
Os modelos do lado do cliente são a lógica que permite que nosso frontend interaja com bancos de dados e múltiplas APIs locais e do servidor e consiste em:
Consiste em componentes React que vinculam nossas visualizações aos seus modelos específicos:
Nem todos os recursos são feitos no frontend, pois os SDKs que usamos exigem algumas funcionalidades do lado do servidor e consistem em:
Nosso aplicativo Web usa o Firestore para armazenar nosso banco de dados que é um banco de dados Firebase NoSql, armazenamos informações dos usuários, armazenamos uma lista de todas as mensagens, armazenamos lista de chats e também armazenamos chats em salas, esses são os dados armazenados em nosso base de dados:
Nos próximos capítulos darei uma explicação rápida e tutoriais sobre certas funcionalidades do ChatPlus, mostrarei o código JS e explicarei o algoritmo por trás dele e também fornecerei a ferramenta de integração certa para vincular seu código com o banco de dados.
O status online dos usuários foi implementado usando o recurso de conectividade do banco de dados do Firebase, conectando-se ao “.info/connected” no frontend e atualizando o Firestore e o banco de dados de acordo:
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); } };
E em nosso backend também configuramos um ouvinte que escuta as alterações em nosso banco de dados e atualiza o firestore de acordo. Esta função também pode nos fornecer o status online do usuário em tempo real para que possamos executar outras funções dentro dele também:
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) } }
As notificações são um ótimo recurso e são implementadas usando mensagens do Firebase. Em nosso frontend, se o navegador do usuário suportar notificações, nós o configuramos e recuperamos o token de mensagens do Firebase do usuário:
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 }); }); }
sempre que um usuário envia uma mensagem, adicionamos uma notificação ao nosso banco de dados:
db.collection("notifications").add({ userID: user.uid, title: user.displayName, body: inputText, photoURL: user.photoURL, token: token, });
e em nosso backend ouvimos a coleção de notificações e usamos as mensagens do Firebase para enviá-las ao usuário
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); }; }; }; }); }; });
Nosso aplicativo web permite que os usuários enviem mensagens de áudio entre si, e um de seus recursos é a capacidade de converter esse áudio em texto para áudio gravado em inglês, francês e espanhol e esse recurso foi implementado com o recurso Google Cloud Speech to Text, Nosso back-end escuta novas transcrições adicionadas ao Firestore e as transcreve e depois as grava no banco de dados:
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, seus olhos estão olhando para a função textToAudio e você está se perguntando como eu fiz isso, não se preocupe, peguei você:
// 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;
Nosso aplicativo da web usa API diária para implementar conexões Web RTC em tempo real, permitindo que os usuários façam videochamadas entre si. Primeiro, configuramos um servidor de chamada de back-end que possui muitos pontos de entrada de API para criar e excluir salas no 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();
e em nosso frontend temos múltiplas funções para chamar 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) }); };
Ótimo, agora é hora de criar salas de chamada e usar o JS SDK diário para conectar-se a essas salas e enviar e receber dados delas:
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 e UserQuery são apenas caminhos de documentos do Firebase Firestore, agora o resto do aplicativo tem componentes de visualização que reagem às mudanças de estado que são acionadas por esta função acima e nossos elementos de UI de chamada aparecerão de acordo.
A próxima função é o Magic que permite arrastar o elemento Call por toda a 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(); }; };
Você pode arrastar e soltar imagens em seu chat e enviá-las para outro usuário, esta funcionalidade é possível executando isto:
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); }, []);
O mediaIndexer é uma função simples que indexa o blob de imagens que fornecemos a ele:
function mediaIndexer(files) { const imagesSrc = []; const filesArray = Array.from(files); filesArray.forEach((file, index) => { imagesSrc[index] = URL.createObjectURL(file); }); return { imagesSrc, imageFiles: filesArray }; }