ChatPlus는 React, NodeJS, Firebase 및 기타 서비스로 개발된 진보적인 웹 앱입니다.
모든 친구들과 실시간으로 대화할 수 있습니다 🗣️✨🧑🤝🧑❤️
친구에게 전화를 걸고 영상 및 음성 통화를 할 수 있습니다 🎥🔉pies
친구에게 이미지를 보내고 오디오 메시지도 보내면 프랑스어, 영어, 스페인어 등 어떤 언어를 사용하든 음성을 텍스트로 변환하는 AI가 있습니다 🤖✨
웹 앱은 모든 기기에 설치될 수 있으며 알림을 받을 수 있습니다 ⬇️🔔🎉
여러분의 지원에 진심으로 감사드립니다. Github 저장소에 별표를 남겨주시고 친구들과 공유해 주세요 ⭐✨
전체 설치 및 배포 문서를 보려면 이 Github 저장소를 확인하세요: https://github.com/aladinyo/ChatPlus
ChatPlus는 제가 만든 최고의 애플리케이션 중 하나입니다. 생생한 사용자 인터페이스와 여러 메시징 및 통화 기능이 모두 웹에서 구현되어 관심을 끌고 있으며, 프런트엔드가 PWA 애플리케이션으로 설계되었기 때문에 크로스 플랫폼 경험을 제공합니다. 어디에나 설치할 수 있고 독립형 앱처럼 작동할 수 있으며 푸시 알림과 같은 기능을 갖춘 ChatPlus는 웹 기술을 통해 모바일 앱의 모든 기능을 제공할 수 있는 경량 앱의 정의입니다.
우리 앱은 MVC 소프트웨어 아키텍처를 따릅니다. MVC(Model-View-Controller)는 사용자 인터페이스, 데이터 및 제어 논리를 구현하는 데 일반적으로 사용되는 소프트웨어 디자인의 패턴입니다. 이는 소프트웨어의 비즈니스 로직과 디스플레이 간의 분리를 강조합니다. 이러한 "관심사 분리"는 더 나은 업무 분담과 향상된 유지 관리를 제공합니다.
이는 여러 React 인터페이스 구성 요소로 구성됩니다.
MaterialUI: Material UI는 Google의 Material Design을 구현하는 오픈 소스 React 구성 요소 라이브러리입니다. 이는 포괄적이며 즉시 제작에 사용할 수 있습니다. 아이콘 버튼과 오디오 슬라이더와 같은 외부 인터페이스의 일부 요소에 사용할 것입니다.
LoginView: 사용자가 사용자 이름과 비밀번호를 입력하거나 Google로 로그인할 수 있는 간단한 로그인 보기입니다.
SidebarViews : 'sidebar__header', 'sidebar__search', 'sidebar menu' 및 'SidebarChats'와 같은 다중 보기 구성 요소는 사용자 정보 헤더, 사용자 검색용 검색 창, 채팅 간 탐색을 위한 사이드바 메뉴, 그룹으로 구성됩니다. 사용자 및 SidebarChats 구성 요소를 사용하면 최근 메시지를 보낸 모든 채팅을 표시할 수 있으며 사용자 이름과 사진, 마지막 메시지가 표시됩니다.
ChatViews: 다음과 같은 많은 구성 요소로 구성됩니다.
1.'chat__header '는 대화 중인 사용자의 정보, 온라인 상태, 프로필 사진을 포함하고 음성 통화 및 영상 통화 버튼을 표시하며 사용자가 입력 중인지 여부도 표시합니다.
2 .'chat__body--container' 여기에는 다른 사용자와의 메시지 정보가 포함되어 있으며 문자 메시지, 메시지가 포함된 이미지, 오디오 시간 및 오디오 재생 여부와 같은 정보가 포함된 오디오 메시지를 표시하는 메시지 구성 요소가 있습니다. 이 구성 요소의 끝에는 메시지가 표시되었는지 여부를 표시하는 'seen' 요소가 있습니다.
3 .'AudioPlayer': 탐색을 위해 슬라이더를 사용하여 오디오를 표시하고 오디오의 전체 시간과 현재 시간을 표시할 수 있는 React 구성 요소입니다. 이 보기 구성 요소는 'chat__body-- 컨테이너' 내에 로드됩니다.
4.'ChatFooter ': 메시지를 입력하기 위한 입력, 입력 시 메시지를 보내는 버튼, 그렇지 않으면 버튼을 사용하여 오디오를 녹음할 수 있고, 이미지와 파일을 가져올 수 있는 버튼이 포함되어 있습니다.
5 .'MediaPreview': 채팅에서 보내기로 선택한 이미지나 파일을 미리 볼 수 있는 React 구성 요소로, 회전식 슬라이드쇼에 표시되며 사용자는 이미지나 파일을 슬라이드하고 각각에 대해 특정 메시지를 입력할 수 있습니다.
6.'ImagePreview ': 채팅에 이미지가 전송되면 이 구성 요소는 이미지를 부드러운 애니메이션으로 전체 화면에 표시하고 이미지를 클릭하면 구성 요소가 마운트됩니다.
scalePage: 풀 HD 화면, 4K 화면과 같은 대형 화면에 표시될 때 웹 앱의 크기를 늘리는 보기 기능입니다.
CallViews: 모든 호출 보기 요소를 포함하는 일련의 반응 구성 요소로, 화면 전체로 드래그할 수 있는 기능이 있으며 다음으로 구성됩니다.
1 '버튼': 빨간색 버전의 통화 버튼과 녹색 영상 통화 버튼입니다.
2 'AudioCallView': 수신되는 오디오 전화에 응답하고 타이머와 함께 통화를 표시하며 통화를 취소할 수 있는 보기 구성 요소입니다.
3 'StartVideoCallView': 로컬 MediaAPI에 연결하여 우리 자신의 비디오를 표시하고 다른 사용자가 전화를 수락할 때까지 기다리거나 들어오는 비디오 전화에 응답할 수 있는 버튼을 표시하는 보기 구성 요소입니다.
4 'VideoCallView': 우리와 다른 사용자의 비디오를 표시하는 보기 구성 요소로, 카메라 전환, 카메라 및 오디오 비활성화를 허용하며 전체 화면으로 전환할 수도 있습니다.
RouteViews: 로컬 구성 요소 탐색을 생성하기 위해 모든 뷰가 포함된 React 구성 요소에는 'VideoCallRoute', 'SideBarMenuRoute' 및 'ChatsRoute'가 있습니다.
클라이언트 측 모델은 프런트엔드가 데이터베이스, 여러 로컬 및 서버 측 API와 상호 작용할 수 있도록 하는 논리이며 다음으로 구성됩니다.
이는 뷰를 특정 모델과 연결하는 React 구성 요소로 구성됩니다.
우리가 사용한 SDK에는 일부 서버 측 기능이 필요하고 다음으로 구성되어 있으므로 모든 기능이 프런트엔드에서 수행되는 것은 아닙니다.
우리 웹 앱은 Firebase NoSql 데이터베이스인 데이터베이스를 저장하기 위해 Firestore를 사용합니다. 사용자 정보, 모든 메시지 목록, 채팅 목록을 저장하고 방에 채팅도 저장합니다. 이러한 데이터는 우리 데이터 베이스:
다음 장에서는 ChatPlus의 특정 기능에 대한 빠른 설명과 튜토리얼을 제공하고 JS 코드를 보여주고 그 뒤에 있는 알고리즘을 설명하며 코드를 연결하는 올바른 통합 도구도 제공할 것입니다. 데이터베이스.
사용자의 온라인 상태는 프런트엔드의 ".info/connected"에 연결하고 이에 따라 Firestore와 데이터베이스를 모두 업데이트함으로써 Firebase 데이터베이스 연결 기능을 사용하여 구현되었습니다.
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); } };
또한 백엔드에서 데이터베이스의 변경 사항을 수신하고 이에 따라 Firestore를 업데이트하는 리스너를 설정합니다. 이 함수는 사용자의 온라인 상태를 실시간으로 제공하여 내부에서 다른 기능도 실행할 수 있습니다.
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) } }
알림은 훌륭한 기능이며 Firebase 메시징을 사용하여 구현됩니다. 사용자의 브라우저가 알림을 지원하는 경우 프런트엔드에서 알림을 구성하고 사용자의 Firebase 메시징 토큰을 검색합니다.
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 }); }); }
사용자가 메시지를 보낼 때마다 데이터베이스에 알림을 추가합니다.
db.collection("notifications").add({ userID: user.uid, title: user.displayName, body: inputText, photoURL: user.photoURL, token: token, });
백엔드에서는 알림 컬렉션을 듣고 Firebase 메시징을 사용하여 이를 사용자에게 보냅니다.
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); }; }; }; }); }; });
우리 웹 애플리케이션을 사용하면 사용자가 서로 오디오 메시지를 보낼 수 있으며 그 기능 중 하나는 이 오디오를 영어, 프랑스어, 스페인어로 녹음된 오디오용 텍스트로 변환하는 기능이며 이 기능은 Google Cloud Speech to Text 기능으로 구현되었습니다. 백엔드는 Firestore에 추가된 새로운 기록을 수신하고 이를 기록한 다음 데이터베이스에 씁니다.
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); }; }; }; }); }; });
분명히 여러분의 눈은 textToAudio 함수를 보고 있고 제가 어떻게 만들었는지 궁금해하실 것입니다. 걱정하지 마세요.
// 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;
우리 웹 앱은 Daily API를 사용하여 실시간 웹 RTC 연결을 구현하고 사용자가 서로 화상 통화를 할 수 있도록 허용하므로 먼저 Daily에서 룸을 생성하고 삭제하기 위한 많은 API 진입점이 있는 백엔드 호출 서버를 설정합니다.
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();
프런트엔드에는 이 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) }); };
좋습니다. 이제 통화 룸을 만들고 일일 JS SDK를 사용하여 이러한 룸에 연결하고 데이터를 보내고 받을 시간입니다.
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 및 UserQuery는 단지 Firebase Firestore 문서 경로일 뿐입니다. 이제 앱의 나머지 부분에는 위의 이 함수에 의해 트리거되는 상태 변경에 반응하는 보기 구성 요소가 있으며 이에 따라 호출 UI 요소가 표시됩니다.
다음 기능은 Call 요소를 페이지 전체로 드래그할 수 있는 Magic입니다.
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(); }; };
채팅에 이미지를 끌어다 놓아 다른 사용자에게 보낼 수 있습니다. 이 기능은 다음을 실행하여 가능해집니다.
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는 우리가 제공하는 이미지 덩어리를 색인화하는 간단한 함수입니다.
function mediaIndexer(files) { const imagesSrc = []; const filesArray = Array.from(files); filesArray.forEach((file, index) => { imagesSrc[index] = URL.createObjectURL(file); }); return { imagesSrc, imageFiles: filesArray }; }