ChatPlus est une application web progressive développée avec React, NodeJS, Firebase et d'autres services.
Vous pouvez parler avec tous vos amis en temps réel 🗣️✨🧑🤝🧑❤️
Vous pouvez appeler vos amis et passer des appels vidéo et audio avec eux 🎥🔉🤩
Envoyez des images à vos amis ainsi que des messages audio et vous disposez d'une IA qui convertit votre parole en texte que vous parliez français, anglais ou espagnol 🤖✨
L'application Web peut être installée sur n'importe quel appareil et peut recevoir des notifications ⬇️🔔🎉
J'apprécierais beaucoup votre soutien, laissez-nous une étoile sur le référentiel Github et partagez-le avec vos amis ⭐✨
Consultez ce référentiel Github pour la documentation complète d'installation et de déploiement : https://github.com/aladinyo/ChatPlus
ChatPlus est l'une des meilleures applications que j'ai créées, elle attire l'attention avec son interface utilisateur dynamique et ses multiples fonctionnalités de messagerie et d'appel qui sont toutes implémentées sur le Web, elle offre une expérience multiplateforme car son interface est conçue pour être une application PWA qui a la capacité d'être installé partout et d'agir comme une application autonome, avec ses fonctionnalités telles que les notifications push, ChatPlus est la définition d'une application légère qui peut offrir toutes les fonctionnalités d'une application mobile avec les technologies Web.
Notre application suit l'architecture logicielle MVC, MVC (Model-View-Controller) est un modèle de conception logicielle couramment utilisé pour implémenter des interfaces utilisateur, des données et une logique de contrôle. Il met l'accent sur une séparation entre la logique métier du logiciel et l'affichage. Cette « séparation des préoccupations » permet une meilleure division du travail et une meilleure maintenance.
Il se compose de plusieurs composants d'interface React :
MaterialUI : Material UI est une bibliothèque de composants React open source qui implémente le Material Design de Google. Il est complet et peut être utilisé en production dès le départ, nous allons l'utiliser pour les boutons d'icônes et certains éléments de l'interface comme un curseur audio.
LoginView : une vue de connexion simple qui permet à l'utilisateur de saisir son nom d'utilisateur et son mot de passe ou de se connecter avec Google.
SidebarViews : composants d'affichage multiples comme 'sidebar__header', 'sidebar__search', 'sidebar menu' et 'SidebarChats', il se compose d'un en-tête pour les informations utilisateur, d'une barre de recherche pour rechercher des utilisateurs, d'un menu latéral pour naviguer entre vos discussions, vos groupes. et les utilisateurs, et un composant SidebarChats pour afficher toutes vos discussions récentes avec lesquelles vous avez envoyé un message, il affiche le nom d'utilisateur, la photo et le dernier message.
ChatViews : il se compose de nombreux composants comme suit :
1 .'chat__header' qui contient les informations de l'utilisateur à qui vous parlez, son statut en ligne, sa photo de profil et affiche les boutons d'appel audio et d'appel vidéo, il indique également si l'utilisateur est en train de taper.
2 .'chat__body--container' il contient les informations de nos messages avec les autres utilisateurs, il a un composant de messages qui affiche des messages texte, des images avec leur message et également des messages audio avec leurs informations comme la durée audio et si l'audio a été joué et à la fin de ce composant, nous avons l'élément « vu » qui indique si les messages ont été vus.
3 .'AudioPlayer' : un composant React qui peut nous afficher un audio avec un curseur pour y naviguer, affiche l'heure complète et l'heure actuelle de l'audio, ce composant de vue est chargé dans 'chat__body-- containers'.
4 .'ChatFooter' : il contient une entrée pour taper un message un bouton pour envoyer un message lors de la saisie sur l'entrée sinon le bouton vous permettra d'enregistrer l'audio, un bouton pour importer des images et des fichiers.
5 .'MediaPreview' : un composant React qui permet de prévisualiser les images ou les fichiers que nous avons sélectionnés pour envoyer dans notre chat, ils sont affichés sur un carrousel et l'utilisateur peut faire glisser les images ou les fichiers et taper un message spécifique pour chacun
6 .'ImagePreview' : Lorsque nous recevons des images envoyées sur notre chat, ce composant affichera les images en plein écran avec une animation fluide, le composant se monte après avoir cliqué sur une image.
scalePage : une fonction d'affichage qui augmente la taille de notre application Web lorsqu'elle est affichée sur de grands écrans comme les écrans Full HD et les écrans 4K.
CallViews : un ensemble de composants de réaction qui contiennent tous les éléments d'affichage des appels, ils ont la capacité d'être glissés sur tout notre écran et se composent de :
1 'Boutons' : un bouton d'appel avec une version rouge et un bouton d'appel vidéo vert.
2 'AudioCallView' : un composant d'affichage qui permet de répondre à un appel audio entrant et d'afficher l'appel avec une minuterie et qui permet d'annuler l'appel.
3 'StartVideoCallView' : un composant d'affichage qui affiche une vidéo de nous-mêmes en nous connectant au MediaAPI local et attend que l'autre utilisateur accepte l'appel ou affiche un bouton pour que nous puissions répondre à un appel vidéo entrant.
4 'VideoCallView' : un composant d'affichage qui affiche une vidéo de nous et de l'autre utilisateur, il permet de changer de caméra, de désactiver la caméra et l'audio, il peut également passer en plein écran.
RouteViews : composants React qui contiennent toutes nos vues afin de créer une navigation de composants locaux, nous avons 'VideoCallRoute', 'SideBarMenuRoute' et 'ChatsRoute'
Les modèles côté client sont la logique qui permet à notre interface d'interagir avec des bases de données et plusieurs API locales et côté serveur et se compose de :
Il se compose de composants React qui relient nos vues à leurs modèles spécifiques :
Toutes les fonctionnalités ne sont pas réalisées sur le frontend car les SDK que nous avons utilisés nécessitent certaines fonctionnalités côté serveur et comprennent :
Notre application Web utilise Firestore pour stocker notre base de données qui est une base de données Firebase NoSql, nous stockons les informations des utilisateurs, nous stockons une liste de tous les messages, nous stockons la liste des discussions, et nous stockons également les discussions dans les salles, ce sont les données stockées sur notre base de données:
Dans les prochains chapitres, je vais donner une explication rapide et des tutoriels sur certaines fonctionnalités de ChatPlus, je vais vous montrer le code JS et expliquer l'algorithme qui le sous-tend et également fournir le bon outil d'intégration pour lier votre code avec la base de données.
Le statut en ligne des utilisateurs a été implémenté à l'aide de la fonctionnalité de connectivité de la base de données Firebase en se connectant au « .info/connected » sur le frontend et en mettant à jour Firestore et la base de données en conséquence :
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); } };
Et sur notre backend, nous avons également configuré un écouteur qui écoute les modifications apportées à notre base de données et met à jour Firestore en conséquence. Cette fonction peut également nous donner le statut en ligne de l'utilisateur en temps réel afin que nous puissions également exécuter d'autres fonctions à l'intérieur :
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) } }
Les notifications sont une fonctionnalité intéressante et elles sont implémentées à l'aide de la messagerie Firebase. Sur notre interface, si le navigateur de l'utilisateur prend en charge les notifications, nous le configurons et récupérons le jeton de messagerie Firebase de l'utilisateur :
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 }); }); }
chaque fois qu'un utilisateur envoie un message, nous ajoutons une notification à notre base de données :
db.collection("notifications").add({ userID: user.uid, title: user.displayName, body: inputText, photoURL: user.photoURL, token: token, });
et sur notre backend nous écoutons la collection de notifications et nous utilisons la messagerie Firebase pour l'envoyer à l'utilisateur
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); }; }; }; }); }; });
Notre application Web permet aux utilisateurs de s'envoyer des messages audio, et l'une de ses fonctionnalités est la possibilité de convertir cet audio en texte pour un audio enregistré en anglais, français et espagnol. Cette fonctionnalité a été implémentée avec la fonctionnalité Google Cloud Speech to Text, Notre backend écoute les nouvelles transcriptions ajoutées à Firestore et les transcrit puis les écrit dans la base de données :
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); }; }; }; }); }; });
Évidemment, vos yeux regardent cette fonction textToAudio et vous vous demandez comment je l'ai créée, ne vous inquiétez pas, je vous ai compris :
// 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;
Notre application Web utilise l'API Daily pour implémenter des connexions Web RTC en temps réel. Elle permet aux utilisateurs de s'appeler par vidéo. Nous configurons donc d'abord un serveur d'appel back-end doté de nombreux points d'entrée API pour créer et supprimer des salles dans 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();
et sur notre frontend, nous avons plusieurs fonctions pour appeler cette 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) }); };
Génial, il est maintenant temps de créer des salles d'appel et d'utiliser le SDK JS quotidien pour vous connecter à ces salles et envoyer et recevoir des données :
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 et UserQuery ne sont que des chemins de documents Firebase Firestore, maintenant le reste de l'application a des composants de vue qui réagissent aux changements d'état déclenchés par cette fonction ci-dessus et nos éléments d'interface utilisateur d'appel apparaîtront en conséquence.
Cette fonction suivante est la Magic qui vous permet de faire glisser l'élément Call partout sur la page :
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(); }; };
Vous pouvez glisser et déposer des images sur votre chat et les envoyer à l'autre utilisateur, cette fonctionnalité est rendue possible en exécutant ceci :
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); }, []);
Le mediaIndexer est une fonction simple qui indexe le blob d'images que nous lui fournissons :
function mediaIndexer(files) { const imagesSrc = []; const filesArray = Array.from(files); filesArray.forEach((file, index) => { imagesSrc[index] = URL.createObjectURL(file); }); return { imagesSrc, imageFiles: filesArray }; }