diff --git a/client/src/App.js b/client/src/App.js index 583b751..ba74456 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -65,9 +65,28 @@ function App() { showNotification({ title: `${message.sender.username} send a message`, message: message.message }) } }) + + // Global voice call event listeners + socket.on('call-offer', ({ from, conversationId }) => { + const currentPath = window.location.pathname; + const currentConversationId = currentPath.split('messages/')[1]; + + // Only show notification if user is not in the conversation where call is happening + if (!currentConversationId || currentConversationId !== conversationId) { + showNotification({ + title: `Incoming voice call`, + message: 'Click to view the conversation', + onClick: () => { + window.location.href = `/messages/${conversationId}`; + } + }); + } + }); + return () => { socket.off('notificaiton'); socket.off('message'); + socket.off('call-offer'); }; // eslint-disable-next-line }, [socket]) diff --git a/client/src/Components/VoiceCall/VoiceCallModal.jsx b/client/src/Components/VoiceCall/VoiceCallModal.jsx new file mode 100644 index 0000000..7c13b42 --- /dev/null +++ b/client/src/Components/VoiceCall/VoiceCallModal.jsx @@ -0,0 +1,371 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Modal, Button, Group, Text, Avatar, Stack, ActionIcon } from '@mantine/core'; +import { IconPhone, IconPhoneOff, IconMicrophone, IconMicrophoneOff } from '@tabler/icons'; +import { socket } from '../../App'; + +const VoiceCallModal = ({ + isOpen, + onClose, + callData, + currentUserId, + isIncoming = false +}) => { + const [callState, setCallState] = useState('connecting'); // connecting, ringing, connected, ended + const [isMuted, setIsMuted] = useState(false); + const [callDuration, setCallDuration] = useState(0); + + const localAudioRef = useRef(null); + const remoteAudioRef = useRef(null); + const peerConnectionRef = useRef(null); + const localStreamRef = useRef(null); + const callTimerRef = useRef(null); + + const rtcConfig = React.useMemo(() => ({ + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' } + ] + }), []); + + const handleCallAnswer = React.useCallback(async ({ answer }) => { + try { + await peerConnectionRef.current.setRemoteDescription(answer); + } catch (error) { + console.error('Error handling call answer:', error); + } + }, []); + + const handleIceCandidate = React.useCallback(async ({ candidate }) => { + try { + await peerConnectionRef.current.addIceCandidate(candidate); + } catch (error) { + console.error('Error handling ICE candidate:', error); + } + }, []); + + const cleanupCall = React.useCallback(() => { + if (callTimerRef.current) { + clearInterval(callTimerRef.current); + callTimerRef.current = null; + } + + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach(track => track.stop()); + localStreamRef.current = null; + } + + if (peerConnectionRef.current) { + peerConnectionRef.current.close(); + peerConnectionRef.current = null; + } + }, []); + + const handleCallEnd = React.useCallback(() => { + setCallState('ended'); + cleanupCall(); + setTimeout(() => onClose(), 1000); + }, [onClose, cleanupCall]); + + const handleCallReject = React.useCallback(() => { + setCallState('ended'); + cleanupCall(); + setTimeout(() => onClose(), 1000); + }, [onClose, cleanupCall]); + + const startCallTimer = React.useCallback(() => { + callTimerRef.current = setInterval(() => { + setCallDuration(prev => prev + 1); + }, 1000); + }, []); + + useEffect(() => { + if (isOpen && callData) { + if (isIncoming) { + setCallState('ringing'); + } else { + initializeCall(); + } + } + + return () => { + cleanupCall(); + }; + }, [isOpen, callData, isIncoming, initializeCall, cleanupCall]); + + useEffect(() => { + // Socket event listeners for WebRTC signaling + socket.on('call-answer', handleCallAnswer); + socket.on('ice-candidate', handleIceCandidate); + socket.on('call-end', handleCallEnd); + socket.on('call-reject', handleCallReject); + + return () => { + socket.off('call-answer', handleCallAnswer); + socket.off('ice-candidate', handleIceCandidate); + socket.off('call-end', handleCallEnd); + socket.off('call-reject', handleCallReject); + }; + }, [handleCallEnd, handleCallReject]); + + const initializeCall = React.useCallback(async () => { + try { + // Get user media + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + localStreamRef.current = stream; + + if (localAudioRef.current) { + localAudioRef.current.srcObject = stream; + } + + // Create peer connection + peerConnectionRef.current = new RTCPeerConnection(rtcConfig); + + // Add local stream to peer connection + stream.getTracks().forEach(track => { + peerConnectionRef.current.addTrack(track, stream); + }); + + // Handle remote stream + peerConnectionRef.current.ontrack = (event) => { + if (remoteAudioRef.current) { + remoteAudioRef.current.srcObject = event.streams[0]; + setCallState('connected'); + startCallTimer(); + } + }; + + // Handle ICE candidates + peerConnectionRef.current.onicecandidate = (event) => { + if (event.candidate && callData) { + socket.emit('ice-candidate', { + to: callData.otherUserId, + candidate: event.candidate + }); + } + }; + + if (!isIncoming) { + // Create and send offer + const offer = await peerConnectionRef.current.createOffer(); + await peerConnectionRef.current.setLocalDescription(offer); + + socket.emit('call-offer', { + to: callData.otherUserId, + from: currentUserId, + offer: offer, + conversationId: callData.conversationId + }); + + setCallState('ringing'); + } + + } catch (error) { + console.error('Error initializing call:', error); + setCallState('ended'); + } + }, [callData, currentUserId, isIncoming, rtcConfig, startCallTimer]); + + const acceptCall = async () => { + try { + // Get user media + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + localStreamRef.current = stream; + + if (localAudioRef.current) { + localAudioRef.current.srcObject = stream; + } + + // Create peer connection + peerConnectionRef.current = new RTCPeerConnection(rtcConfig); + + // Add local stream to peer connection + stream.getTracks().forEach(track => { + peerConnectionRef.current.addTrack(track, stream); + }); + + // Handle remote stream + peerConnectionRef.current.ontrack = (event) => { + if (remoteAudioRef.current) { + remoteAudioRef.current.srcObject = event.streams[0]; + setCallState('connected'); + startCallTimer(); + } + }; + + // Handle ICE candidates + peerConnectionRef.current.onicecandidate = (event) => { + if (event.candidate && callData) { + socket.emit('ice-candidate', { + to: callData.otherUserId, + candidate: event.candidate + }); + } + }; + + // Set remote description and create answer + await peerConnectionRef.current.setRemoteDescription(callData.offer); + const answer = await peerConnectionRef.current.createAnswer(); + await peerConnectionRef.current.setLocalDescription(answer); + + socket.emit('call-answer', { + to: callData.otherUserId, + from: currentUserId, + answer: answer + }); + + setCallState('connecting'); + + } catch (error) { + console.error('Error accepting call:', error); + rejectCall(); + } + }; + + const rejectCall = () => { + socket.emit('call-reject', { + to: callData?.otherUserId, + from: currentUserId + }); + setCallState('ended'); + setTimeout(() => onClose(), 1000); + }; + + const endCall = () => { + socket.emit('call-end', { + to: callData?.otherUserId, + from: currentUserId + }); + setCallState('ended'); + cleanupCall(); + setTimeout(() => onClose(), 1000); + }; + + const toggleMute = () => { + if (localStreamRef.current) { + const audioTracks = localStreamRef.current.getAudioTracks(); + audioTracks.forEach(track => { + track.enabled = !track.enabled; + }); + setIsMuted(!isMuted); + } + }; + + const formatDuration = (seconds) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const getCallStateText = () => { + switch (callState) { + case 'connecting': + return 'Connecting...'; + case 'ringing': + return isIncoming ? 'Incoming call' : 'Calling...'; + case 'connected': + return formatDuration(callDuration); + case 'ended': + return 'Call ended'; + default: + return ''; + } + }; + + return ( + {}} + centered + withCloseButton={false} + size="sm" + radius="lg" + > + + + +
+ + {callData?.otherUser?.name || callData?.otherUser?.username} + + + {getCallStateText()} + +
+ + {callState === 'ringing' && isIncoming && ( + + + + + + + + + )} + + {(callState === 'connecting' || callState === 'connected') && ( + + + {isMuted ? : } + + + + + + )} + + {(callState === 'ringing' && !isIncoming) && ( + + + + )} + + {callState === 'ended' && ( + + )} +
+ + {/* Hidden audio elements */} +
+ ); +}; + +export default VoiceCallModal; \ No newline at end of file diff --git a/client/src/Pages/MessagePage.jsx b/client/src/Pages/MessagePage.jsx index 8448d4e..7c2544f 100644 --- a/client/src/Pages/MessagePage.jsx +++ b/client/src/Pages/MessagePage.jsx @@ -16,11 +16,16 @@ import Header from "./../Components/Navigations/Header"; import { useParams } from "react-router-dom"; import { getConverstionName } from "../Services/Services"; import { socket } from "../App"; -import { IconSend } from "@tabler/icons"; +import { IconSend, IconPhone } from "@tabler/icons"; +import VoiceCallModal from "../Components/VoiceCall/VoiceCallModal"; +import { showNotification } from "@mantine/notifications"; function MessagePage() { const [messages, setMessages] = useState(null); const [converstion, setConverstion] = useState(null); + const [showCallModal, setShowCallModal] = useState(false); + const [incomingCallData, setIncomingCallData] = useState(null); + const [isIncomingCall, setIsIncomingCall] = useState(false); const { _id, username, avatar } = useSelector((state) => state.auth.user); const lastMessageRef = useRef(null); const params = useParams(); @@ -38,6 +43,29 @@ function MessagePage() { }, ]); }; + + const startVoiceCall = () => { + if (converstion && converstion.users.length === 2) { + const otherUser = converstion.users.find(user => user._id !== _id); + + if (otherUser) { + const callData = { + conversationId: converstion._id, + otherUserId: otherUser._id, + otherUser: otherUser + }; + + setIncomingCallData(callData); + setIsIncomingCall(false); + setShowCallModal(true); + } + } else { + showNotification({ + title: "Voice calls are only available in direct messages", + color: "yellow" + }); + } + }; useEffect(() => { (async () => { const { data } = await API.get(`/converstion/${params.id}/messages`); @@ -53,21 +81,38 @@ function MessagePage() { setMessages((m) => [...m, message]); } }); - // socket.on("typing", (message) => { - // if (message.converstion === params.id) { - // setTyping(message.message); - // setTimeout(() => { - // setTyping(null); - // }, 1000); - // } - // }); + + // Voice call event listeners + socket.on("call-offer", ({ from, offer, conversationId }) => { + if (conversationId === params.id) { + // Find the caller info from conversation + const otherUser = converstion?.users?.find(user => user._id === from); + if (otherUser) { + const callData = { + conversationId, + otherUserId: from, + otherUser, + offer + }; + + setIncomingCallData(callData); + setIsIncomingCall(true); + setShowCallModal(true); + + showNotification({ + title: `Incoming call from ${otherUser.username}`, + color: "blue" + }); + } + } + }); return () => { socket.off("message"); - socket.off("typing"); + socket.off("call-offer"); }; // eslint-disable-next-line - }, [socket]); + }, [socket, params.id, converstion]); useEffect(() => { lastMessageRef.current?.scrollIntoView({ behavior: "smooth", @@ -80,8 +125,31 @@ function MessagePage() { if (!converstion) return Loading ...; return ( <> + { + setShowCallModal(false); + setIncomingCallData(null); + setIsIncomingCall(false); + }} + callData={incomingCallData} + currentUserId={_id} + isIncoming={isIncomingCall} + /> -
+ +
+ {converstion.users.length === 2 && ( + + + + )} + {messages && @@ -159,9 +227,9 @@ function MessagePage() {
- + - + diff --git a/server/server.js b/server/server.js index b4a48a8..77d1480 100644 --- a/server/server.js +++ b/server/server.js @@ -40,6 +40,38 @@ io.on('connection', (socket) => { socket.on('add user', (id) => { id ? global.users[id] = socket : null }) + + // Voice call signaling events + socket.on('call-offer', ({ to, from, offer, conversationId }) => { + if (global.users[to]) { + global.users[to].emit('call-offer', { from, offer, conversationId }); + } + }); + + socket.on('call-answer', ({ to, from, answer }) => { + if (global.users[to]) { + global.users[to].emit('call-answer', { from, answer }); + } + }); + + socket.on('ice-candidate', ({ to, candidate }) => { + if (global.users[to]) { + global.users[to].emit('ice-candidate', { candidate }); + } + }); + + socket.on('call-end', ({ to, from }) => { + if (global.users[to]) { + global.users[to].emit('call-end', { from }); + } + }); + + socket.on('call-reject', ({ to, from }) => { + if (global.users[to]) { + global.users[to].emit('call-reject', { from }); + } + }); + // socket.on('typing', async ({ converstion: id, sender }) => { // const c = await Converstion.findById(id) // c.users?.map(u => u !== sender && global.users[u]?.emit('typing', { converstion: c._id, message: 'typing' }))