diff --git a/app/src/components/AppLayout.tsx b/app/src/components/AppLayout.tsx index 833f86d6..0adac60d 100644 --- a/app/src/components/AppLayout.tsx +++ b/app/src/components/AppLayout.tsx @@ -4,6 +4,10 @@ import Main from "./Main"; import SideBar from "./side-bar/SideBar"; import { DESKTOP_VIEW, MOBILE_VIEW } from "@constants"; import { useRightSideBarContext } from "@features/groups/contexts/RightSideBarProvider"; +import { useEffect, useState } from "react"; +import { useSocket } from "@hooks/useSocket"; +import { callStatusEmitter } from "@features/calls/context/callStatusEmitter"; +import CallLayout from "@features/calls/CallLayout"; const StyledApp = styled.div<{ $isChatOpen: boolean; @@ -40,7 +44,17 @@ function AppLayout() { const { chatId } = useParams(); const isChatOpen = !!chatId; const { isRightSideBarOpen } = useRightSideBarContext(); + const [isCollapsed, setIsCollapsed] = useState(false); + const [callStatus, setCallStatus] = useState< + "inactive" | "active" | "calling" | "incoming" | "ended" + >("inactive"); + useEffect(() => { + const handler = (status: typeof callStatus) => setCallStatus(status); + callStatusEmitter.on("update", handler); + + return () => callStatusEmitter.off("update", handler); + }, []); return (
+ {callStatus != "inactive" && ( + + )}
{isRightSideBarOpen && }
diff --git a/app/src/components/side-bar/groups/AddMoreMembers.tsx b/app/src/components/side-bar/groups/AddMoreMembers.tsx index 102cf7f2..dbd24ca5 100644 --- a/app/src/components/side-bar/groups/AddMoreMembers.tsx +++ b/app/src/components/side-bar/groups/AddMoreMembers.tsx @@ -30,7 +30,6 @@ function AddMoreMembers() { const dispatch = useDispatch(); if (isPending) return; - console.log(chatType); function handleClick() { addGroupMembers({ diff --git a/app/src/constants.ts b/app/src/constants.ts index a6a2f2c3..66f674b9 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -5,6 +5,9 @@ const { VITE_PORT: PORT, VITE_ENV: ENV, VITE_BACKEND_STORGAE: STATIC_MEDIA_URL, + TURN_URL, + VITE_TURN_USERNAME: TURN_USERNAME, + VITE_TURN_PASSWORD: TURN_PASSWORD } = import.meta.env; const MOBILE_VIEW = "(max-width: 800px)"; @@ -21,4 +24,7 @@ export { MOBILE_VIEW, DESKTOP_VIEW, MAX_STORY_SIZE, + TURN_URL, + TURN_USERNAME, + TURN_PASSWORD }; diff --git a/app/src/features/authentication/signup/hooks/useSignup.ts b/app/src/features/authentication/signup/hooks/useSignup.ts index 66ad2bf3..e594a0b6 100644 --- a/app/src/features/authentication/signup/hooks/useSignup.ts +++ b/app/src/features/authentication/signup/hooks/useSignup.ts @@ -6,19 +6,15 @@ export function useSignup() { mutate: signup, isSuccess, isPending, - isError, + isError } = useMutation({ - mutationFn: Signup, - - onSuccess: (email) => { - console.log(email); - }, + mutationFn: Signup }); return { signup, isPending, isSuccess, - isError, + isError }; } diff --git a/app/src/features/calls/CallLayout.tsx b/app/src/features/calls/CallLayout.tsx index c2c4a2ef..99470a8b 100644 --- a/app/src/features/calls/CallLayout.tsx +++ b/app/src/features/calls/CallLayout.tsx @@ -7,6 +7,7 @@ import { useCallContext } from "./hooks/useCallContext"; import { getChatByID } from "@features/chats/utils/helpers"; import { useAppSelector } from "@hooks/useGlobalState"; import { useSocket } from "@hooks/useSocket"; +import { EnableSpeaker } from "./SpeakerEnable"; const ModalContainer = styled.div` position: fixed; @@ -193,21 +194,19 @@ const ActiveHeaderOpen = styled.div` type PropsType = { isCollapsed: boolean; setIsCollapsed: (arg0: boolean) => void; - chatId: string | undefined; callStatus: string | undefined; }; export default function CallLayout({ isCollapsed, setIsCollapsed, - chatId, callStatus }: PropsType) { - const { endCall } = useCallContext(); - const { acceptCall } = useSocket(); + const { acceptCall, finishCall } = useSocket(); + const { endCall, chatId } = useCallContext(); const chats = useAppSelector((state) => state.chats.chats); const chat = getChatByID({ - chatID: chatId ?? "", + chatID: chatId.current ?? "", chats: chats }); @@ -249,16 +248,30 @@ export default function CallLayout({ accept )} - - endCall()} - $bgColor="var(--color-error)" - $bgColorHover="var(--color-error-shade)" - > - {getIcon("EndCall")} - - end call - + + {callStatus === "incoming" ? ( + + endCall()} + $bgColor="var(--color-error)" + $bgColorHover="var(--color-error-shade)" + > + {getIcon("EndCall")} + + end call + + ) : ( + + finishCall()} + $bgColor="var(--color-error)" + $bgColorHover="var(--color-error-shade)" + > + {getIcon("EndCall")} + + end call + + )} diff --git a/app/src/features/calls/SpeakerEnable.tsx b/app/src/features/calls/SpeakerEnable.tsx new file mode 100644 index 00000000..16f32892 --- /dev/null +++ b/app/src/features/calls/SpeakerEnable.tsx @@ -0,0 +1,48 @@ +import React, { useEffect } from "react"; +import { useCallContext } from "./hooks/useCallContext"; + +interface EnableSpeakerProps { + children?: React.ReactNode; +} + +export const EnableSpeaker: React.FC = ({ children }) => { + const { clientId } = useCallContext(); + + useEffect(() => { + const playStreamsToSpeaker = () => { + if (!clientId?.current) { + console.warn("clientId is not available or has no current value."); + return; + } + + clientId.current.forEach((clientData, clientIdKey) => { + if (!clientData || !clientData.connection) { + console.warn(`No connection found for client: ${clientIdKey}`); + return; + } + + // Get all tracks from the remote connection + const receivers = clientData.connection.getReceivers(); + const remoteStream = new MediaStream( + receivers.map((receiver) => receiver.track).filter(Boolean) + ); + + if (remoteStream && remoteStream.getTracks().length > 0) { + const audio = new Audio(); + audio.srcObject = remoteStream; + audio.play().catch((err) => { + console.error( + `Failed to play audio for client: ${clientIdKey}`, + err + ); + }); + console.log(`Playing audio for client: ${clientIdKey}`); + } + }); + }; + + playStreamsToSpeaker(); + }, [clientId]); + + return
{children}
; +}; diff --git a/app/src/features/calls/call.ts b/app/src/features/calls/call.ts deleted file mode 100644 index c7d9f28f..00000000 --- a/app/src/features/calls/call.ts +++ /dev/null @@ -1,100 +0,0 @@ -export let callState = "idle"; -const stunServers = { - iceServers: [ - { urls: ["stun:stun.l.google.com:19302", "stun:stun.l.google.com:5349"] } - ] -}; -export async function getVoiceInput(): Promise { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - return stream; - } catch (error) { - console.error("Error accessing microphone:", error); - return null; - } -} - -const RemoteStream: MediaStream = new MediaStream(); -export const connectToPeer = async ( - peerConnection: RTCPeerConnection | null, - localStream: MediaStream | null -) => { - callState = "connecting"; - peerConnection = new RTCPeerConnection(stunServers); - if (!localStream) localStream = await getVoiceInput(); - console.log(localStream); - localStream?.getTracks().forEach((t) => { - peerConnection.addTrack(t, localStream); - }); - - peerConnection.ontrack = (e) => { - e.streams[0]?.getTracks().forEach((track) => RemoteStream.addTrack(track)); - }; - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve(null); - }, 10000); - - peerConnection.onicecandidate = (event) => { - if (!event.candidate) { - clearTimeout(timeout); - resolve(null); - } - }; - }); - const offer = await peerConnection.createOffer(); - await peerConnection.setLocalDescription(offer); - return JSON.stringify(offer); -}; -export const createOffer = async ( - peerConnection: RTCPeerConnection | null, - localStream: MediaStream | null -) => { - peerConnection = new RTCPeerConnection(stunServers); - if (!localStream) localStream = await getVoiceInput(); - console.log(localStream); - localStream?.getTracks().forEach((t) => { - peerConnection.addTrack(t, localStream); - }); - - peerConnection.ontrack = (e) => { - e.streams[0]?.getTracks().forEach((track) => RemoteStream.addTrack(track)); - }; - - const offer = await peerConnection.createOffer(); - await peerConnection.setLocalDescription(offer); - - return JSON.stringify(offer); -}; -export const handleIceCandidates = async ( - peerConnection: RTCPeerConnection, - sendCandidate: (candidate: string) => void -) => { - return new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve(null); - }, 10000); - - peerConnection.onicecandidate = (event) => { - if (!event.candidate) { - clearTimeout(timeout); - resolve(null); - } - sendCandidate(JSON.stringify(event.candidate)); - }; - }); -}; -export const createAnswer = async (offer: string) => { - await connectToPeer(); - const offer_parsed = JSON.parse(offer); - await peerConnection.setRemoteDescription(offer_parsed); - const answer = await peerConnection.createAnswer(); - await peerConnection.setLocalDescription(answer); - return JSON.stringify(answer); -}; -export const startCall = async (answer: string) => { - callState = "ongoing"; - const offer_parsed = JSON.parse(answer); - await peerConnection.setRemoteDescription(offer_parsed); -}; diff --git a/app/src/features/calls/context/CallProvider.tsx b/app/src/features/calls/context/CallProvider.tsx index c35b6dd6..fb4d6320 100644 --- a/app/src/features/calls/context/CallProvider.tsx +++ b/app/src/features/calls/context/CallProvider.tsx @@ -1,34 +1,92 @@ import React, { ReactNode, useCallback, useRef } from "react"; import { CallContext } from "./CallContext"; import { useAppSelector } from "@hooks/useGlobalState"; -import { createOffer, getVoiceInput } from "../call"; import { callStatusEmitter } from "./callStatusEmitter"; import { CallStatus } from "types/calls"; - +import { TURN_USERNAME, TURN_PASSWORD } from "@constants"; +const Servers = { + iceServers: [ + { urls: ["stun:stun.l.google.com:19302", "stun:stun.l.google.com:5349"] }, + { + urls: ["stun:stun1.l.google.com:19302", "stun:stun2.l.google.com:19302"] + }, + { + urls: "turn:global.relay.metered.ca:80", + username: TURN_USERNAME, + credential: TURN_PASSWORD + }, + { + urls: "turn:global.relay.metered.ca:80?transport=tcp", + username: TURN_USERNAME, + credential: TURN_PASSWORD + }, + { + urls: "turn:global.relay.metered.ca:443", + username: TURN_USERNAME, + credential: TURN_PASSWORD + }, + { + urls: "turns:global.relay.metered.ca:443?transport=tcp", + username: TURN_USERNAME, + credential: TURN_PASSWORD + } + ] +}; export const CallProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const userId = useAppSelector((state) => state.user.userInfo.id); - - const callAccepted = useRef(null); const callIdRef = useRef(null); const senderIdRef = useRef(null); const chatIdRef = useRef(null); - const peerConnection = useRef(null); const localStream = useRef(null); - const offer = useRef(null); - const answer = useRef(null); + const offer = useRef(null); const callStatus = useRef("inactive"); + const clientIdRef = useRef< + Map + >(new Map()); - const setCallStatus = (status: typeof callStatus.current) => { + const addClientId = useCallback( + ( + clientId: string, + connection: RTCPeerConnection | null, + offerSent: boolean + ) => { + clientIdRef.current.set(clientId, { connection, offerSent }); + }, + [] + ); + + const removeClientId = useCallback((clientId: string) => { + if (clientIdRef.current.has(clientId)) { + clientIdRef.current.delete(clientId); + } + }, []); + + const clearClientIds = useCallback(() => { + clientIdRef.current.clear(); + }, []); + const setChatId = useCallback((chatId: string) => { + chatIdRef.current = chatId; + }, []); + const hasClientId = useCallback( + (clientId: string, offerSent: boolean | null = null) => { + const clientData = clientIdRef.current.get(clientId); + return ( + !!clientData && + (offerSent === null || clientData.offerSent === offerSent) + ); + }, + [] + ); + + const setCallStatus = useCallback((status: CallStatus) => { callStatus.current = status; - console.log(status); callStatusEmitter.emit("update", status); - }; + }, []); const joinCall = useCallback( (newSenderId: string, newChatId: string, newCallId: string) => { - console.log(callIdRef.current, newCallId); if (!callIdRef.current) { if (newSenderId === userId) { setCallStatus("calling"); @@ -40,7 +98,7 @@ export const CallProvider: React.FC<{ children: ReactNode }> = ({ chatIdRef.current = newChatId; } }, - [userId] + [setCallStatus, userId] ); const endCall = useCallback(() => { @@ -48,52 +106,194 @@ export const CallProvider: React.FC<{ children: ReactNode }> = ({ callIdRef.current = null; senderIdRef.current = null; chatIdRef.current = null; - callAccepted.current = null; - peerConnection.current?.close(); - peerConnection.current = null; + if (localStream.current) { + localStream.current.getTracks().forEach((track) => { + track.stop(); + }); + } + clientIdRef.current.forEach((clientData, clientId) => { + if (clientData.connection) { + clientData.connection.close(); + } + + removeClientId(clientId); + }); + const remoteAudioElements = document.querySelectorAll("audio"); + remoteAudioElements.forEach((audio) => audio.remove()); + localStream.current = null; - }, []); + clearClientIds(); + }, [clearClientIds, removeClientId, setCallStatus]); const acceptCall = useCallback(() => { - setCallStatus("active"); - callAccepted.current = callIdRef.current; - }, []); + if (callIdRef.current && chatIdRef.current) { + if ( + senderIdRef.current === userId && + (!clientIdRef.current || clientIdRef.current.size === 0) + ) { + setCallStatus("calling"); + } else { + setCallStatus("active"); + } + } + }, [setCallStatus, userId]); - const startPeerConnection = useCallback(async () => { - localStream.current = await getVoiceInput(); - offer.current = await createOffer( - peerConnection.current, - localStream.current - ); - }, []); + const startPeerConnection = useCallback( + async (clientId: string) => { + if (!clientId) return null; + if (!localStream.current) { + localStream.current = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: false + }); + } + + if (!localStream.current) { + alert("Failed to get voice input."); + endCall(); + return null; + } + const peerConnection = new RTCPeerConnection(Servers); + localStream.current.getTracks().forEach((track) => { + peerConnection?.addTrack(track, localStream.current); + }); + peerConnection.ontrack = (event) => { + if (event.track.kind === "audio") { + const remoteAudio = document.createElement("audio"); + remoteAudio.srcObject = event.streams[0]; + remoteAudio.autoplay = true; + remoteAudio.controls = true; + document.body.appendChild(remoteAudio); + } + }; + const offer = await peerConnection.createOffer(); + if (!hasClientId(clientId) || hasClientId(clientId, false)) { + await peerConnection.setLocalDescription(offer); + addClientId(clientId, peerConnection, true); + setCallStatus("active"); + return offer; + } + return null; + }, + [addClientId, endCall, hasClientId, setCallStatus] + ); + + const recieveICE = useCallback( + async (candidate: RTCIceCandidateInit, senderId: string) => { + if (hasClientId(senderId, true)) { + const clientData = clientIdRef.current.get(senderId); + if (clientData) { + if (!clientData.connection) return; + clientData.connection.addIceCandidate(candidate); + } + } + }, + [hasClientId] + ); + + const recieveAnswer = useCallback( + (data: RTCSessionDescriptionInit, senderId: string) => { + if (hasClientId(senderId, true)) { + const clientData = clientIdRef.current.get(senderId); + if (clientData && clientData.connection) { + clientData.connection.setRemoteDescription(data); + setCallStatus("active"); + } + } + }, + [hasClientId, setCallStatus] + ); + const createAnswer = useCallback( + async (data: RTCSessionDescriptionInit, senderId: string) => { + if (!senderId || !data) { + console.warn("Invalid senderId or data provided."); + return null; + } - const recieveICE = useCallback(async (candidate: RTCIceCandidateInit) => { - if (peerConnection.current) { try { - await peerConnection.current.addIceCandidate(candidate); - console.log("ICE candidate added successfully."); + const peerConnection = new RTCPeerConnection(Servers); + if (!localStream.current) { + localStream.current = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: false + }); + } + + if (!localStream.current) { + alert("Failed to get voice input."); + endCall(); + return null; + } + localStream.current.getTracks().forEach((track) => { + peerConnection.addTrack(track, localStream.current); + }); + await peerConnection.setRemoteDescription(data); + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + peerConnection.ontrack = (event) => { + if (event.track.kind === "audio") { + const remoteAudio = document.createElement("audio"); + remoteAudio.srcObject = event.streams[0]; + remoteAudio.autoplay = true; + remoteAudio.controls = true; + document.body.appendChild(remoteAudio); + } + }; + peerConnection.oniceconnectionstatechange = () => { + const state = peerConnection.iceConnectionState; + + switch (state) { + case "connected": + console.log("Peer connection established."); + break; + case "disconnected": + console.warn("Peer connection disconnected."); + break; + case "failed": + console.error("Peer connection failed. Restarting ICE?"); + break; + case "closed": + console.log("Peer connection closed."); + break; + default: + console.log("ICE connection state:", state); + break; + } + }; + + addClientId(senderId, peerConnection, false); + return answer; } catch (error) { - console.error("Failed to add ICE candidate:", error); + console.error("Error creating answer:", error); + return null; } + }, + [addClientId, endCall] + ); + + const getPeerConnection = useCallback((clientId: string) => { + const clientData = clientIdRef.current.get(clientId); + if (clientData) { + return clientData.connection; } + return null; }, []); - const recieveAnswer = useCallback(() => {}, []); - const contextValue = { - peerConnection: peerConnection, callId: callIdRef, senderId: senderIdRef, chatId: chatIdRef, startPeerConnection, - offer: offer, - answer: answer, + offer, + clientId: clientIdRef, + setChatId, acceptCall, - callAccepted: callAccepted, joinCall, + createAnswer, endCall, recieveICE, - recieveAnswer + recieveAnswer, + getPeerConnection }; return ( diff --git a/app/src/features/chats/ChatInput.tsx b/app/src/features/chats/ChatInput.tsx index 831f6814..eb7d7192 100644 --- a/app/src/features/chats/ChatInput.tsx +++ b/app/src/features/chats/ChatInput.tsx @@ -96,7 +96,6 @@ function ChatInput() { setError(""); } - console.log(currChat?.messagingPermission); const isGroupWithoutPostPermission = currChat?.type === "group" && @@ -115,7 +114,7 @@ function ChatInput() { return ( <> {isFilePreviewOpen && file && ( - + )} {isBlocked ? null : ( diff --git a/app/src/features/chats/Topbar.tsx b/app/src/features/chats/Topbar.tsx index 7baebe61..f9c435aa 100644 --- a/app/src/features/chats/Topbar.tsx +++ b/app/src/features/chats/Topbar.tsx @@ -1,29 +1,22 @@ import { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import styled from "styled-components"; - import Avatar from "@components/Avatar"; import Icon from "@components/Icon"; import SearchBar from "@features/search/components/SearchBar"; import PinnedMessages from "@features/pin-messages/components/PinnedMessages"; -import CallLayout from "@features/calls/CallLayout"; - import { getIcon } from "@data/icons"; import { setChatIsBlocked } from "@state/messages/chats"; - import { useSocket } from "@hooks/useSocket"; import { useAppDispatch, useAppSelector } from "@hooks/useGlobalState"; import { useChatMembers } from "./hooks/useChatMembers"; import { useBlock } from "@features/privacy-settings/hooks/useBlock"; import { useRightSideBarContext } from "@features/groups/contexts/RightSideBarProvider"; - import { getElapsedTime } from "@utils/helpers"; import { getChatByID } from "./utils/helpers"; import { updateSideBarView } from "@state/side-bar/sideBar"; import { sideBarPages } from "types/sideBar"; - import { resetActiveThread } from "@state/messages/channels"; -import { callStatusEmitter } from "@features/calls/context/callStatusEmitter"; const Container = styled.div<{ $hasMargin?: boolean }>` position: absolute; @@ -126,18 +119,10 @@ function Topbar() { const dispatch = useAppDispatch(); const { activeThread } = useAppSelector((state) => state.channelsThreads); const [isSearching, setIsSearching] = useState(false); - const [isCollapsed, setIsCollapsed] = useState(false); const { createVoiceCall } = useSocket(); const [callStatus, setCallStatus] = useState< "inactive" | "active" | "calling" | "incoming" | "ended" >("inactive"); - - useEffect(() => { - const handler = (status: typeof callStatus) => setCallStatus(status); - callStatusEmitter.on("update", handler); - - return () => callStatusEmitter.off("update", handler); - }, []); const startCall = () => { if (chatId && callStatus === "inactive") { createVoiceCall({ chatId }); @@ -215,15 +200,7 @@ function Topbar() { return ( <> - {callStatus != "inactive" && ( - - )} - + {!activeThread && ( ) => { - console.log("file"); if (e.target.files) { console.log("file", e.target.files[0]); setFile(e.target.files[0]); - event.target.value = ""; + e.target.value = ""; } setIsFilePreviewOpen(true); diff --git a/app/src/features/chats/utils/helpers.ts b/app/src/features/chats/utils/helpers.ts index e2dc5db7..1308bb23 100644 --- a/app/src/features/chats/utils/helpers.ts +++ b/app/src/features/chats/utils/helpers.ts @@ -151,6 +151,6 @@ export const decryptMessage = async ({ return new TextDecoder().decode(decryptedBuffer); } catch (err: any) { - return Error(`Decryption error: ${err.message}`); + console.error(`Decryption error: ${err.message}`); } }; diff --git a/app/src/main.tsx b/app/src/main.tsx index 6496bcc5..81323678 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,4 +1,4 @@ -// import { StrictMode } from "react"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; @@ -16,10 +16,10 @@ async function enableMocking() { enableMocking().then(() => { createRoot(document.getElementById("root")!).render( - // - - - - // + + + + + ); }); diff --git a/app/src/sockets/SocketProvider.tsx b/app/src/sockets/SocketProvider.tsx index f7fde1b4..219014bf 100644 --- a/app/src/sockets/SocketProvider.tsx +++ b/app/src/sockets/SocketProvider.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, ReactNode, useCallback } from "react"; +import { useEffect, ReactNode, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; import { Dispatch } from "redux"; @@ -61,17 +61,17 @@ interface AckCreateGroup { } function SocketProvider({ children }: SocketProviderProps) { - const [isConnected, setIsConnected] = useState(false); const { callId, joinCall, recieveICE, recieveAnswer, - peerConnection, + setChatId, + getPeerConnection, + createAnswer, startPeerConnection, offer, - acceptCall: setAcceptedCall, - callAccepted + acceptCall: setAcceptedCall } = useCallContext(); const navigate = useNavigate(); const dispatch = useDispatch(); @@ -84,82 +84,93 @@ function SocketProvider({ children }: SocketProviderProps) { const { decrypt } = useEncryptDecrypt(); const { chat } = useChat(); - const handleIceCandidates = useCallback(async () => { - return new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve(null); - }, 10000); - if (peerConnection && isConnected && socket && callId) { - peerConnection.onicecandidate = (event) => { - if (!event.candidate) { - clearTimeout(timeout); - resolve(null); - } - socket.emit("SIGNAL-SERVER", { - type: "ICE", - voiceCallId: callId, - data: event.candidate - }); - }; - } - }); - }, [peerConnection, isConnected, socket, callId]); - const sendOffer = useCallback(() => { - if (offer && isConnected && socket && callId) { - socket.emit("SIGNAL-SERVER", { - type: "OFFER", - voiceCallId: callId, - data: offer + const handleIceCandidates = useCallback( + async (clientId: string) => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(null); + }, 10000); + if (socket?.connected && socket && callId.current) { + const peerConnection = getPeerConnection(clientId); + if (!peerConnection) return; + peerConnection.onicecandidate = (event) => { + if (!event.candidate) { + clearTimeout(timeout); + resolve(null); + } + socket.emit("SIGNAL-SERVER", { + type: "ICE", + voiceCallId: callId.current, + data: event.candidate, + targetId: clientId + }); + }; + } }); - } - }, [offer, isConnected, socket, callId]); + }, + [socket, callId, getPeerConnection] + ); + const sendOffer = useCallback( + (clientId: string, offer: RTCSessionDescriptionInit) => { + if ( + socket && + offer && + socket?.connected && + socket && + callId.current && + clientId + ) { + socket.emit("SIGNAL-SERVER", { + type: "OFFER", + voiceCallId: callId.current, + data: offer, + targetId: clientId + }); + } + }, + [socket, callId] + ); const acceptCall = useCallback(() => { - console.log(callId, callAccepted, isConnected, socket); - if (isConnected && socket && callId.current && !callAccepted.current) { - console.log("acceptCall", callId.current); + if (socket?.connected && socket && callId.current) { setAcceptedCall(); socket.emit("JOIN-CALL", { voiceCallId: callId.current }); } - }, [callAccepted, isConnected, socket, callId, setAcceptedCall]); + }, [socket, callId, setAcceptedCall]); + const finishCall = useCallback(() => { + if (socket?.connected && socket && callId.current) { + socket.emit("LEAVE", { voiceCallId: callId.current }); + endCall(); + } + }, [socket, callId, endCall]); const sendAnswer = useCallback( - ( - answer: string, - callback?: (response: { success: boolean; error?: string }) => void - ) => { - if (isConnected && socket) { - socket.emit( - "SEND_ANSWER", - { answer }, - (response: { success: boolean; error?: string }) => { - if (callback) { - callback(response); - } else if (!response.success) { - console.error("Failed to send answer:", response.error); - } else { - console.log("Answer sent successfully"); - } - } - ); - } else { - console.warn("Cannot send answer: not connected to socket server"); + (clientId: string, answer: RTCSessionDescriptionInit) => { + if ( + socket && + offer && + socket?.connected && + socket && + callId.current && + clientId + ) { + socket.emit("SIGNAL-SERVER", { + type: "ANSWER", + voiceCallId: callId.current, + data: answer, + targetId: clientId + }); } }, - [isConnected, socket] + [socket, callId, offer] ); useEffect(() => { if (!socket) return; - if (!socket.connected) socket.connect(); const onConnect = () => { - setIsConnected(true); - console.log("Socket connected"); - socket.io.engine.on("close", (reason) => { console.log("Socket connection closed:", reason); }); }; const onReceiveMessage = (message: MessageInterface) => { - console.log("Received message:", message); if (!chat) { console.warn("No chat context available for decryption"); return; @@ -226,17 +237,6 @@ function SocketProvider({ children }: SocketProviderProps) { socket.on("typing", (isTyping, message) => handleIsTyping(dispatch, isTyping, message.chatId) ); - - // socket.on("RECIEVE_OFFER", async (offer) => { - // console.log(offer); - // const answer = await createAnswer(offer); - // sendAnswer(answer); - // }); - // socket.on("RECEIVE_ANSWER", async (answer: string) => { - // console.log(answer); - // startCall(answer); - // }); - socket.on( "DELETE_MESSAGE_SERVER", ({ chatId, id }: { chatId: string; id: string }) => { @@ -282,22 +282,43 @@ function SocketProvider({ children }: SocketProviderProps) { voiceCallId: string; chatId: string; }) => { - console.log("call id", voiceCallId); joinCall(snederId, chatId, voiceCallId); - console.log(snederId, userId); if (snederId === userId) acceptCall(); } ); - socket.on("CLIENT-JOINED", () => { - console.log("CLIENT-JOINED"); - startPeerConnection(); - sendOffer(); - handleIceCandidates(); + socket.on("CLIENT-JOINED", async ({ clientId }) => { + const offer = await startPeerConnection(clientId); + try { + if (offer && clientId) { + sendOffer(clientId, offer); + handleIceCandidates(clientId); + } else throw new Error("Failed to send offer"); + } catch { + console.error("Failed to send offer"); + } }); - socket.on("SIGNAL-SERVER", async ({ type, data }) => { - console.log(typeof data); - if (type === "ANSWER") recieveAnswer(data); - if (type === "ICE") recieveICE(data); + socket.on( + "SIGNAL-CLIENT", + async ({ type, voiceCallId, data, senderId }) => { + if (voiceCallId == callId.current) { + if (type === "OFFER") { + const answer = await createAnswer(data, senderId); + if (answer) { + sendAnswer(senderId, answer); + handleIceCandidates(senderId); + } else { + console.error("Failed to create answer"); + } + } + if (type === "ANSWER") recieveAnswer(data, senderId); + if (type === "ICE") recieveICE(data, senderId); + } + } + ); + socket.on("CLIENT-LEFT", ({ voiceCallId, clientId }) => { + if (voiceCallId === callId.current) { + endCall(clientId); + } }); socket.emit("typing"); @@ -313,10 +334,10 @@ function SocketProvider({ children }: SocketProviderProps) { socket.off("typing"); // socket.io.engine.off("close"); }; - }, [socket, chat, decrypt, dispatch, queryClient, user]); + }, [socket, chat]); const sendMessage = (sentMessage: MessageInterface) => { - if (isConnected && socket) { + if (socket?.connected && socket) { console.log("messageToSend", sentMessage); socket.emit( "SEND_MESSAGE", @@ -377,7 +398,7 @@ function SocketProvider({ children }: SocketProviderProps) { content: string, chatId: string ) => { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "EDIT_MESSAGE_CLIENT", { messageId, content, chatId }, @@ -402,13 +423,12 @@ function SocketProvider({ children }: SocketProviderProps) { ); } }; - const pinMessageSocket = ( chatId: string, messageId: string, userId: string ) => { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit("PIN_MESSAGE_CLIENT", { messageId, chatId, userId }); } else { console.warn("Cannot pin message: not connected to socket server"); @@ -420,29 +440,20 @@ function SocketProvider({ children }: SocketProviderProps) { messageId: string, userId: string ) => { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit("UNPIN_MESSAGE_CLIENT", { messageId, chatId, userId }); } else { console.warn("Cannot unpin message: not connected to socket server"); } }; const createVoiceCall = ({ chatId }: { chatId: string }) => { - console.log("create call", isConnected, socket, chatId, callId.current); - if (isConnected && socket && !callId.current) { + if (socket?.connected && socket && !callId.current) { + setChatId(chatId); socket.emit("CREATE-CALL", { chatId }); } }; - // const startConnection = async (callId: string) => { - // const offer = await connectToPeer(); - // if (isConnected && socket) { - // console.log(offer); - // socket.emit("SEND_OFFER", { offer }); - // } else { - // console.warn("Cannot unpin message: not connected to socket server"); - // } - // }; function createGroupOrChannel({ type, @@ -453,13 +464,12 @@ function SocketProvider({ children }: SocketProviderProps) { name: string; members: string[]; }) { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "CREATE_GROUP_CHANNEL", { type, name, members }, ({ success, data, error }: AckCreateGroup) => { if (success) { - console.log("Group/Channel ID:", data?._id); navigate(`/${data?._id}`); } else { toast.error(error || `Failed to create ${type}`); @@ -480,7 +490,7 @@ function SocketProvider({ children }: SocketProviderProps) { chatId: string; users: string[]; }) { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "ADD_MEMBERS_CLIENT", { chatId, users }, @@ -506,8 +516,7 @@ function SocketProvider({ children }: SocketProviderProps) { chatId: string; members: string[]; }) { - console.log(chatId, members); - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "ADD_ADMINS_CLIENT", { chatId, members }, @@ -533,7 +542,7 @@ function SocketProvider({ children }: SocketProviderProps) { messageId: string; chatId: string; }) { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "DELETE_MESSAGE_CLIENT", { messageId, chatId }, @@ -551,12 +560,11 @@ function SocketProvider({ children }: SocketProviderProps) { } function leaveGroup({ chatId }: { chatId: string }) { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "LEAVE_GROUP_CHANNEL_CLIENT", { chatId }, ({ success, message, error }: AckCreateGroup) => { - console.log(message, error, success); if (success) { toast.success(message); queryClient.invalidateQueries({ queryKey: ["chats"] }); @@ -580,7 +588,7 @@ function SocketProvider({ children }: SocketProviderProps) { members: string[]; }) { console.log(chatId, members); - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "REMOVE_MEMBERS_CLIENT", { chatId, members }, @@ -608,7 +616,7 @@ function SocketProvider({ children }: SocketProviderProps) { type: "post" | "download"; who: "admins" | "everyone"; }) { - if (isConnected && socket) { + if (socket?.connected && socket) { socket.emit( "SET_PERMISSION_CLIENT", { chatId, type, who }, @@ -662,7 +670,6 @@ function SocketProvider({ children }: SocketProviderProps) { "SET_PRIVACY_CLIENT", { chatId, privacy }, ({ success, message, error }: AckCreateGroup) => { - console.log(message, error, success); if (success) { toast.success(message); queryClient.invalidateQueries({ queryKey: ["chats"] }); @@ -680,7 +687,7 @@ function SocketProvider({ children }: SocketProviderProps) { return ( {children} diff --git a/app/src/types/calls.ts b/app/src/types/calls.ts index 4c75782f..a877a533 100644 --- a/app/src/types/calls.ts +++ b/app/src/types/calls.ts @@ -2,15 +2,40 @@ export interface CallContextType { callId: React.RefObject; senderId: React.RefObject; chatId: React.RefObject; - callAccepted: React.RefObject; + clientId: React.RefObject< + Map< + string, + { + connection: RTCPeerConnection | null; + offerSent: boolean; + } + > + >; + // callAccepted: React.RefObject; joinCall: (newCallId: string, newSenderId: string, newChatId: string) => void; endCall: () => void; + acceptCall: () => void; - startPeerConnection: () => Promise; - offer: React.RefObject; - answer: React.RefObject; - peerConnection: React.RefObject; + startPeerConnection: ( + clientId: string + ) => Promise; + offer: React.RefObject; + getPeerConnection: (clientId: string) => RTCPeerConnection | null; //recieveAnswer: (answer: string) => Promise; - recieveICE: (candidate: RTCIceCandidateInit) => Promise; + recieveICE: ( + candidate: RTCIceCandidateInit, + senderId: string + ) => Promise; + createAnswer: ( + data: RTCSessionDescriptionInit, + senderId: string + ) => Promise; + setChatId: (chatId: string) => void; + recieveAnswer: (answer: RTCSessionDescriptionInit, senderId: string) => void; } -export type CallStatus = "inactive" | "active" | "calling" | "incoming" | "ended"; \ No newline at end of file +export type CallStatus = + | "inactive" + | "active" + | "calling" + | "incoming" + | "ended"; diff --git a/app/src/types/socket.ts b/app/src/types/socket.ts index 36624587..5b544ac4 100644 --- a/app/src/types/socket.ts +++ b/app/src/types/socket.ts @@ -65,6 +65,7 @@ export interface SocketContextType { deleteGroup: ({ chatId }: { chatId: string }) => void; createVoiceCall: ({ chatId }: { chatId: string }) => void; acceptCall: (callId: string | null) => void; + finishCall: () => void; } export interface SocketProviderProps { diff --git a/app/src/utils/socket.tsx b/app/src/utils/socket.tsx index 17c07f52..b5250a09 100644 --- a/app/src/utils/socket.tsx +++ b/app/src/utils/socket.tsx @@ -3,24 +3,20 @@ import { io, Socket } from "socket.io-client"; import { useUser } from "@features/authentication/login/hooks/useUser"; import { DefaultEventsMap } from "@socket.io/component-emitter"; -let socket: Socket | null = null; - function initializeSocket( userId: string, sessionId: string | null ): Socket { - if (!socket) { - socket = io(`${import.meta.env.VITE_SOCKET_BACKEND_API}`, { - query: { - userId - }, - auth: { - sessionId - } - }); - - console.log("Socket initialized"); - } + const socket = io(`${import.meta.env.VITE_SOCKET_BACKEND_API}`, { + query: { + userId + }, + auth: { + sessionId + } + }); + + console.log("Socket initialized"); return socket; } @@ -37,9 +33,9 @@ function useSocket() { socketRef.current = initializeSocket(user._id, sessionId); return () => { - console.log("Disconnecting socket"); + ("Disconnecting socket"); socketRef.current?.disconnect(); - socket = null; + socketRef.current = null; }; } }, [user, isPending]);