diff --git a/.cursor/commands/fix-typecheck-errors.md b/.cursor/commands/fix-typecheck-errors.md new file mode 100644 index 0000000..0ee66d9 --- /dev/null +++ b/.cursor/commands/fix-typecheck-errors.md @@ -0,0 +1 @@ +Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any. \ No newline at end of file diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc index 71390a4..1ced579 100644 --- a/.cursor/rules/ui.mdc +++ b/.cursor/rules/ui.mdc @@ -5,4 +5,8 @@ When you work with UI: 1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML. 3. The supporting text slot for MDUI lists is "description". -4. When working with lists/sets in states, use the "useImmer" hook. \ No newline at end of file +4. When working with lists/sets in states, use the "useImmer" hook. +5. Do NOT use inline styles in React components if they are static, instead write them in CSS. + The CSS is placed in `frontend/src/resources/css/`. Find the appropriate file to put the styles in. +6. In SCSS, for Material Design colors use `$color-dark-` variables. For all colors, refer + to `frontend/src/resources/css/common/_colors.scss`. \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7301bd5..b700cff 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,6 +44,8 @@ jobs: JWT_SECRET=${{ secrets.JWT_SECRET }} VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }} VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }} + TURN_USERNAME=${{ vars.TURN_USERNAME }} + TURN_PASSWORD=${{ secrets.TURN_PASSWORD }} EOF - name: Build container run: | diff --git a/backend/app.py b/backend/app.py index 91102fa..8be682e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -5,7 +5,7 @@ import subprocess import sys import os -from routes import account, messaging, profile, push +from routes import account, messaging, profile, push, webrtc @asynccontextmanager async def lifespan(app: FastAPI): @@ -56,4 +56,5 @@ app.add_middleware( app.include_router(account.router) app.include_router(messaging.router) app.include_router(profile.router) -app.include_router(push.router, prefix="/push") \ No newline at end of file +app.include_router(push.router, prefix="/push") +app.include_router(webrtc.router, prefix="/webrtc") \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index f99f8d7..e44eede 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -911,6 +911,29 @@ class MessaggingSocketManager: await websocket.send_json({"type": type, "data": response}) except HTTPException as e: await self.send_error(websocket, type, e) + elif type == "call_signaling": + # Forward WebRTC signaling between peers + try: + self.user_by_ws[websocket] = current_user.id + + payload = data.get("data") or {} + to_user_id = int(payload.get("toUserId") or 0) + if not to_user_id: + raise HTTPException(status_code=400, detail="Missing toUserId") + + # Ensure sender is set by the server + payload["fromUserId"] = current_user.id + payload["fromUsername"] = current_user.username + + await self.send_to_user(to_user_id, { + "type": "call_signaling", + "data": payload + }) + + # Optional ack + await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}}) + except HTTPException as e: + await self.send_error(websocket, type, e) else: await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}}) diff --git a/backend/routes/webrtc.py b/backend/routes/webrtc.py new file mode 100644 index 0000000..10b07d9 --- /dev/null +++ b/backend/routes/webrtc.py @@ -0,0 +1,89 @@ +import logging +import os +import hmac +import hashlib +import time +from fastapi import APIRouter, Depends +from dependencies import get_current_user +import traceback + +router = APIRouter() +logger = logging.getLogger("uvicorn.error") + + +def generate_turn_credentials(username: str, secret: str, expiration_minutes: int = 60): + """Generate time-limited TURN credentials using TURN REST API format. + + This creates temporary credentials that expire after the specified time. + The username format is: timestamp:username + The password is an HMAC hash of the username and secret. + """ + # Current timestamp (seconds since epoch) + timestamp = int(time.time()) + (expiration_minutes * 60) + + # Create temporary username: timestamp:original_username + temp_username = f"{timestamp}:{username}" + + # Generate password using HMAC-SHA1 + temp_password = hmac.new( + secret.encode('utf-8'), + temp_username.encode('utf-8'), + hashlib.sha1 + ).hexdigest() + + return temp_username, temp_password + + +@router.get("/ice") +async def get_ice_servers(current_user = Depends(get_current_user)): + """Return ICE server configuration (STUN/TURN) for WebRTC clients. + + Generates time-limited TURN credentials that expire in 1 hour. + """ + try: + # Prefer using your own coturn for both STUN and TURN + turn_domain = "fromchat.ru" + stun_urls = [ + f"stun:{turn_domain}:3478", + f"stuns:{turn_domain}:5349", + ] + + turn_urls = [ + f"turn:{turn_domain}:3478", + f"turns:{turn_domain}:5349", + ] + + # Get TURN configuration from environment + turn_username = os.getenv("TURN_USERNAME") + turn_secret = os.getenv("TURN_SECRET") + + # Check if required environment variables are set + if not turn_username: + logger.error("ERROR: TURN_USERNAME environment variable is not set") + raise ValueError("TURN_USERNAME environment variable is not set") + + if not turn_secret: + logger.error("ERROR: TURN_SECRET environment variable is not set") + raise ValueError("TURN_SECRET environment variable is not set") + + ice_servers: list[dict] = [{"urls": url} for url in stun_urls] + + temp_username, temp_password = generate_turn_credentials( + turn_username, + turn_secret, + expiration_minutes=60 # Expires in 1 hour + ) + + ice_servers.append({ + "urls": turn_urls, + "username": temp_username, + "credential": temp_password, + }) + + return {"iceServers": ice_servers} + + except Exception as e: + logger.error(f"ERROR in /api/webrtc/ice: {str(e)}") + logger.error(f"ERROR type: {type(e).__name__}") + traceback.print_exc() + raise \ No newline at end of file diff --git a/frontend/src/core/calls/signaling.ts b/frontend/src/core/calls/signaling.ts new file mode 100644 index 0000000..8280246 --- /dev/null +++ b/frontend/src/core/calls/signaling.ts @@ -0,0 +1,115 @@ +import type { WebSocketMessage, CallSignalingData, CallInvite } from "@/core/types"; +import * as WebRTC from "./webrtc"; + +export interface CallState { + receiveCall: (userId: number, username: string) => void; + endCall: () => void; +} + +export class CallSignalingHandler { + private getState: () => CallState; + + constructor(getState: () => CallState) { + this.getState = getState; + } + + handleWebSocketMessage(message: WebSocketMessage) { + if (message.type !== "call_signaling") { + return; + } + + const { data } = message; + if (!data) { + console.warn("Received call_signaling message with no data:", message); + return; + } + + console.log("Received signaling message:", data.type, "from user", data.fromUserId, "full data:", data); + + switch (data.type) { + case "call_invite": + this.handleCallInvite(data as CallInvite); + break; + case "call_accept": + this.handleCallAccept(data); + break; + case "call_reject": + this.handleCallReject(data); + break; + case "call_offer": + this.handleCallOffer(data); + break; + case "call_answer": + this.handleCallAnswer(data); + break; + case "call_ice_candidate": + this.handleIceCandidate(data); + break; + case "call_end": + this.handleCallEnd(data); + break; + } + } + + private async handleCallInvite(data: CallInvite) { + const { fromUserId, fromUsername } = data; + const state = this.getState(); + + // Show incoming call UI + state.receiveCall(fromUserId, fromUsername); + + // Handle incoming call in WebRTC service + await WebRTC.handleIncomingCall(fromUserId, fromUsername); + } + + private async handleCallAccept(data: any) { + const { fromUserId } = data; + // Initiator should create and send offer now + try { + await WebRTC.onRemoteAccepted(fromUserId); + } catch (error) { + console.error("Failed to proceed after accept:", error); + } + } + + private handleCallReject(data: any) { + const state = this.getState(); + const { fromUserId } = data; + + // Clean up WebRTC connection first + if (fromUserId) { + WebRTC.cleanupCall(fromUserId); + } + + // End the call + state.endCall(); + } + + private async handleCallOffer(data: any) { + const { fromUserId, data: offer } = data; + await WebRTC.handleCallOffer(fromUserId, offer); + } + + private async handleCallAnswer(data: any) { + const { fromUserId, data: answer } = data; + await WebRTC.handleCallAnswer(fromUserId, answer); + } + + private async handleIceCandidate(data: any) { + const { fromUserId, data: candidate } = data; + await WebRTC.handleIceCandidate(fromUserId, candidate); + } + + private handleCallEnd(data: any) { + const state = this.getState(); + const { fromUserId } = data; + + // Clean up WebRTC connection first + if (fromUserId) { + WebRTC.cleanupCall(fromUserId); + } + + // End the call + state.endCall(); + } +} diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts new file mode 100644 index 0000000..77d0397 --- /dev/null +++ b/frontend/src/core/calls/webrtc.ts @@ -0,0 +1,472 @@ +import { getAuthHeaders } from "@/core/api/authApi"; +import type { IceServersResponse } from "@/core/types"; +import { request } from "@/core/websocket"; + +export interface CallSignalingMessage { + type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject"; + fromUserId: number; + toUserId: number; + data?: any; +} + +export interface WebRTCCall { + peerConnection: RTCPeerConnection; + localStream: MediaStream | null; + remoteStream: MediaStream | null; + isInitiator: boolean; + remoteUserId: number; + remoteUsername: string; + isEnding?: boolean; + isMuted?: boolean; +} + +// Global state +export let authToken: string | null = null; +export let onCallStateChange: ((userId: number, state: string) => void) | null = null; +export let onRemoteStream: ((userId: number, stream: MediaStream) => void) | null = null; +const calls: Map = new Map(); + +export function setAuthToken(token: string) { + authToken = token; +} + +export function setCallStateChangeHandler(handler: (userId: number, state: string) => void) { + onCallStateChange = handler; +} + +export function setRemoteStreamHandler(handler: (userId: number, stream: MediaStream) => void) { + onRemoteStream = handler; +} + +async function sendSignalingMessage(message: CallSignalingMessage) { + if (!authToken) { + throw new Error("No auth token available"); + } + + console.log("Sending signaling message:", message.type, "to user", message.toUserId); + + await request({ + type: "call_signaling", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: message + }); +} + +async function getIceServers(): Promise { + const defaultIceServers = [{ urls: "stun:fromchat.ru:3478" }]; + + if (!authToken) { + console.warn("No auth token available for ICE servers"); + return defaultIceServers; + } + + try { + const response = await fetch("/api/webrtc/ice", { + headers: getAuthHeaders(authToken) + }); + + if (response.ok) { + const data = await response.json() as IceServersResponse; + console.log("Received ICE servers:", data.iceServers); + return data.iceServers || []; + } else { + console.warn("Failed to fetch ICE servers:", response.status, response.statusText); + } + } catch (error) { + console.warn("Failed to fetch ICE servers:", error); + } + + // Fallback to STUN only if backend fails + return defaultIceServers; +} + +async function createPeerConnection(userId: number): Promise { + const iceServers = await getIceServers(); + + const peerConnection = new RTCPeerConnection({ + iceServers + }); + + const call: WebRTCCall = { + peerConnection, + localStream: null, + remoteStream: null, + isInitiator: false, + remoteUserId: userId, + remoteUsername: "", + isMuted: false + }; + + calls.set(userId, call); + + peerConnection.addEventListener("icegatheringstatechange", () => { + console.log("ICE gathering state changed:", peerConnection.iceGatheringState); + }); + + // Add ICE candidate event listener for debugging and sending + peerConnection.addEventListener("icecandidate", async (event) => { + if (event.candidate) { + console.log("Local ICE candidate:", event.candidate.candidate); + + // Send ICE candidate to remote peer + try { + await sendSignalingMessage({ + type: "call_ice_candidate", + fromUserId: 0, // Will be set by server + toUserId: userId, + data: { + candidate: event.candidate.candidate, + sdpMLineIndex: event.candidate.sdpMLineIndex, + sdpMid: event.candidate.sdpMid + } + }); + } catch (error) { + console.error("Failed to send ICE candidate:", error); + } + } else { + console.log("ICE gathering complete"); + } + }); + + peerConnection.addEventListener("iceconnectionstatechange", () => { + console.log("ICE connection state changed:", peerConnection.iceConnectionState); + }); + + peerConnection.addEventListener("signalingstatechange", () => { + // Signaling state changed + }); + + // Handle remote stream + peerConnection.addEventListener("track", (event) => { + const [remoteStream] = event.streams; + const call = calls.get(userId); + if (call) { + call.remoteStream = remoteStream; + if (onRemoteStream) { + onRemoteStream(userId, remoteStream); + } + } + }); + + // Handle connection state changes + peerConnection.addEventListener("connectionstatechange", () => { + console.log("WebRTC connection state changed:", peerConnection.connectionState); + const call = calls.get(userId); + if (call) { + if (onCallStateChange) { + onCallStateChange(userId, peerConnection.connectionState); + } + + // Clean up if connection failed or closed + if (peerConnection.connectionState === "failed" || + peerConnection.connectionState === "closed" || + peerConnection.connectionState === "disconnected") { + // Only send end call message if we're not already cleaning up + const call = calls.get(userId); + if (call && !call.isEnding) { + call.isEnding = true; + endCall(userId); + } + } + } + }); + + return peerConnection; +} + +export async function initiateCall(userId: number, username: string): Promise { + try { + // Get user media + const localStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: false + }); + + // Create peer connection + await createPeerConnection(userId); + const call = calls.get(userId); + if (!call) return false; + + call.localStream = localStream; + call.remoteUsername = username; + call.isInitiator = true; + + // Add tracks to peer connection + localStream.getTracks().forEach(track => call.peerConnection.addTrack(track, localStream)); + + // Send call invite + await sendSignalingMessage({ + type: "call_invite", + fromUserId: 0, // Will be set by server + toUserId: userId, + data: { fromUsername: username } + }); + + return true; + } catch (error) { + console.error("Failed to initiate call:", error); + cleanupCall(userId); + return false; + } +} + +export async function acceptCall(userId: number): Promise { + try { + let call = calls.get(userId); + if (!call) { + // Create call object if it doesn't exist (for race conditions) + await createPeerConnection(userId); + call = calls.get(userId); + if (!call) return false; + } + + // Get user media and attach + const localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + call.localStream = localStream; + localStream.getTracks().forEach(track => call!.peerConnection.addTrack(track, localStream)); + + // Notify initiator that callee accepted; initiator will generate offer + await sendSignalingMessage({ + type: "call_accept", + fromUserId: 0, // Will be set by server + toUserId: userId, + data: {} + }); + + return true; + } catch (error) { + console.error("Failed to accept call:", error); + cleanupCall(userId); + return false; + } +} + +export async function rejectCall(userId: number): Promise { + await sendSignalingMessage({ + type: "call_reject", + fromUserId: 0, // Will be set by server + toUserId: userId, + data: {} + }); + + cleanupCall(userId); +} + +export async function endCall(userId: number): Promise { + const call = calls.get(userId); + if (call && !call.isEnding) { + call.isEnding = true; + + // Send call end message + await sendSignalingMessage({ + type: "call_end", + fromUserId: 0, // Will be set by server + toUserId: userId, + data: {} + }); + + cleanupCall(userId); + } +} + +export async function handleIncomingCall(userId: number, username: string): Promise { + try { + // Create peer connection for incoming call + await createPeerConnection(userId); + const call = calls.get(userId); + if (!call) return; + + call.remoteUsername = username; + call.isInitiator = false; + } catch (error) { + console.error("Failed to handle incoming call:", error); + cleanupCall(userId); + } +} + +export async function onRemoteAccepted(userId: number): Promise { + const call = calls.get(userId); + if (!call) { + throw new Error("No call found to accept"); + } + + try { + // Create offer + const offer = await call.peerConnection.createOffer(); + await call.peerConnection.setLocalDescription(offer); + + // Send offer to remote peer + await sendSignalingMessage({ + type: "call_offer", + fromUserId: 0, // Will be set by server + toUserId: userId, + data: offer + }); + } catch (error) { + console.error("Failed to create offer:", error); + throw error; + } +} + +export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise { + const call = calls.get(userId); + if (!call) { + throw new Error("No call found for offer"); + } + + try { + // Set remote description + await call.peerConnection.setRemoteDescription(offer); + + // Create answer + const answer = await call.peerConnection.createAnswer(); + await call.peerConnection.setLocalDescription(answer); + + // Send answer to remote peer + await sendSignalingMessage({ + type: "call_answer", + fromUserId: 0, // Will be set by server + toUserId: userId, + data: answer + }); + } catch (error) { + console.error("Failed to handle offer:", error); + throw error; + } +} + +export async function handleCallAnswer(userId: number, answer: RTCSessionDescriptionInit): Promise { + const call = calls.get(userId); + if (!call) { + throw new Error("No call found for answer"); + } + + try { + await call.peerConnection.setRemoteDescription(answer); + } catch (error) { + console.error("Failed to handle answer:", error); + throw error; + } +} + +export async function handleIceCandidate(userId: number, candidate: RTCIceCandidateInit): Promise { + const call = calls.get(userId); + if (!call) { + console.warn("No call found for ICE candidate from user", userId); + return; + } + + try { + console.log("Adding ICE candidate from user", userId, ":", candidate); + await call.peerConnection.addIceCandidate(candidate); + } catch (error) { + console.error("Failed to add ICE candidate:", error); + } +} + +export function toggleMute(userId: number): boolean { + const call = calls.get(userId); + if (!call || !call.localStream) { + return false; + } + + if (!call.isMuted) { + // Mute: Stop the track completely (no green dot) + const audioTrack = call.localStream.getAudioTracks()[0]; + if (audioTrack) { + audioTrack.stop(); + call.localStream.removeTrack(audioTrack); + } + + // Create a silent audio track using Web Audio API + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + // Set gain to 0 (silent) + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + + // Connect nodes + oscillator.connect(gainNode); + + // Create a MediaStreamDestination to get a MediaStream + const destination = audioContext.createMediaStreamDestination(); + gainNode.connect(destination); + + // Start the oscillator (but it's silent due to gain = 0) + oscillator.start(); + + // Add the silent track to maintain WebRTC connection + const silentTrack = destination.stream.getAudioTracks()[0]; + if (silentTrack) { + call.localStream.addTrack(silentTrack); + } + + call.isMuted = true; + return true; // Muted + } else { + // Unmute: Re-enable microphone by getting new audio stream + navigator.mediaDevices.getUserMedia({ audio: true, video: false }) + .then(newStream => { + // Remove any existing audio tracks from the stream + call.localStream!.getAudioTracks().forEach(track => track.stop()); + + // Get the new active track + const newAudioTrack = newStream.getAudioTracks()[0]; + + // Replace the track in the peer connection + const sender = call.peerConnection.getSenders().find(s => + s.track && s.track.kind === 'audio' + ); + + if (sender) { + // Replace the track in the existing sender + sender.replaceTrack(newAudioTrack); + } else { + // Add the track to the peer connection if no sender exists + call.peerConnection.addTrack(newAudioTrack, call.localStream!); + } + + // Add the track to the local stream + call.localStream!.addTrack(newAudioTrack); + + call.isMuted = false; + }) + .catch(error => { + console.error("Failed to re-enable microphone:", error); + }); + return false; // Unmuted + } +} + +export function getCall(userId: number): WebRTCCall | undefined { + return calls.get(userId); +} + +export function cleanupCall(userId: number): void { + const call = calls.get(userId); + if (call) { + // Close peer connection + if (call.peerConnection) { + call.peerConnection.close(); + } + + // Stop local stream + if (call.localStream) { + call.localStream.getTracks().forEach(track => track.stop()); + } + + calls.delete(userId); + } +} + +export function cleanup(): void { + // Clean up all calls + for (const userId of calls.keys()) { + cleanupCall(userId); + } + calls.clear(); +} \ No newline at end of file diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index c913612..0181e4a 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -247,6 +247,10 @@ export interface DmEncryptedJSON { } } +export interface IceServersResponse { + iceServers: RTCIceServer[]; +} + // --------------- // WebSocket types // --------------- @@ -428,4 +432,19 @@ export interface EncryptedMessageJson { export interface DialogProps { isOpen: boolean; onOpenChange: (value: boolean) => void; +} + +// Call types +export interface CallInvite extends CallSignalingData { + type: "call_invite"; + fromUserId: number; + fromUsername: string; + timestamp: string; +} + +export interface CallSignalingData { + type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject"; + fromUserId: number; + toUserId: number; + data?: any; } \ No newline at end of file diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 4ec865b..f8120c8 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -8,6 +8,7 @@ import { API_WS_BASE_URL } from "./config"; import type { WebSocketMessage } from "./types"; import { delay } from "@/utils/utils"; +import { CallSignalingHandler } from "@/core/calls/signaling"; /** * Creates a new WebSocket connection to the chat server @@ -35,6 +36,11 @@ export let websocket: WebSocket = create(); */ let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; +/** + * Call signaling handler + */ +let callSignalingHandler: CallSignalingHandler | null = null; + /** * Set the global WebSocket message handler * @param handler - Function to handle WebSocket messages @@ -43,6 +49,14 @@ export function setGlobalMessageHandler(handler: ((response: WebSocketMessage(payload: WebSocketMessage): Promise> { console.log("WebSocket request:", payload); return new Promise((resolve, reject) => { @@ -97,6 +111,11 @@ websocket.addEventListener("message", (e) => { try { const response: WebSocketMessage = JSON.parse(e.data); + // Handle call signaling messages + if (callSignalingHandler && response.type === "call_signaling") { + callSignalingHandler.handleWebSocketMessage(response); + } + // Route message to global handler if set if (globalMessageHandler) { globalMessageHandler(response); diff --git a/frontend/src/pages/chat/css/_callWindow.scss b/frontend/src/pages/chat/css/_callWindow.scss new file mode 100644 index 0000000..ccca703 --- /dev/null +++ b/frontend/src/pages/chat/css/_callWindow.scss @@ -0,0 +1,69 @@ +@use "../../../css/material" as *; + +.call-window { + position: fixed; + z-index: 1000; + width: 300px; + background-color: $color-dark-surface; + border: 1px solid $color-dark-outline; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + cursor: grab; + + &.dragging { + cursor: grabbing; + } +} + +.call-header { + padding: 16px; + border-bottom: 1px solid $color-dark-outline-variant; + cursor: grab; + + .user-info { + display: flex; + align-items: center; + gap: 12px; + + .avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + } + + .user-details { + flex: 1; + + .username { + margin: 0; + font-size: 16px; + font-weight: 500; + color: $color-dark-on-surface; + } + + .status { + margin: 4px 0 0 0; + font-size: 14px; + color: $color-dark-on-surface-variant; + } + } + } +} + +.call-controls { + padding: 16px; + display: flex; + justify-content: center; + gap: 12px; +} + +.remote-audio { + position: fixed; + bottom: 0px; + right: 0px; + width: 0; + height: 0; + opacity: 0; + visibility: hidden; +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/_right-panel.scss b/frontend/src/pages/chat/css/_right-panel.scss index f6ff746..3777b8b 100644 --- a/frontend/src/pages/chat/css/_right-panel.scss +++ b/frontend/src/pages/chat/css/_right-panel.scss @@ -23,6 +23,9 @@ .chat-header-info { display: flex; + justify-content: space-between; + align-items: center; + flex: 1; .info-chat { display: flex; diff --git a/frontend/src/pages/chat/css/chat.scss b/frontend/src/pages/chat/css/chat.scss index fb38357..1e0239e 100644 --- a/frontend/src/pages/chat/css/chat.scss +++ b/frontend/src/pages/chat/css/chat.scss @@ -8,4 +8,5 @@ @use "context-menu"; @use "profile-dialog"; @use "settings-dialog"; -@use "animations"; \ No newline at end of file +@use "animations"; +@use "callWindow"; \ No newline at end of file diff --git a/frontend/src/pages/chat/hooks/useAudioCall.ts b/frontend/src/pages/chat/hooks/useAudioCall.ts new file mode 100644 index 0000000..b5525f9 --- /dev/null +++ b/frontend/src/pages/chat/hooks/useAudioCall.ts @@ -0,0 +1,193 @@ +import { useAppState } from "../state"; +import * as WebRTC from "@/core/calls/webrtc"; +import { CallSignalingHandler } from "@/core/calls/signaling"; +import { setCallSignalingHandler } from "@/core/websocket"; +import { createRef, useEffect } from "react"; + +// Global audio ref shared across all instances +let globalRemoteAudioRef = createRef(); + +export default function useAudioCall() { + const { chat, startCall, endCall, setCallStatus, toggleMute, user } = useAppState(); + const remoteAudioRef = globalRemoteAudioRef; + + useEffect(() => { + if (user.authToken) { + WebRTC.setAuthToken(user.authToken); + } + + // Initialize call signaling handler + const signalingHandler = new CallSignalingHandler(() => ({ + receiveCall: (userId: number, username: string) => { + // Use the receiveCall function from state + const state = useAppState.getState(); + state.receiveCall(userId, username); + }, + endCall + })); + setCallSignalingHandler(signalingHandler); + + // Set up call state change handler + WebRTC.setCallStateChangeHandler((userId: number, state: string) => { + const call = chat.call; + if (call.remoteUserId === userId) { + switch (state) { + case "connecting": + setCallStatus("connecting"); + break; + case "connected": + setCallStatus("active"); + break; + case "disconnected": + case "failed": + case "closed": + endCall(); + break; + } + } + }); + + // Set up remote stream handler + WebRTC.setRemoteStreamHandler((_userId: number, stream: MediaStream) => { + if (!remoteAudioRef.current) { + return; + } + const el = remoteAudioRef.current; + try { + el.srcObject = stream; + el.muted = false; + el.volume = 1.0; + el.autoplay = true; + + // Handle audio events + el.addEventListener("error", () => { + console.warn("[AUDIO] element error", (el.error?.message) || el.error); + }); + + el.play().catch(() => { + // Try to play after user interaction if autoplay is blocked + const playAfterInteraction = () => { + el.play().catch(() => {}); + document.removeEventListener('click', playAfterInteraction); + document.removeEventListener('touchstart', playAfterInteraction); + }; + document.addEventListener('click', playAfterInteraction); + document.addEventListener('touchstart', playAfterInteraction); + }); + } catch (e) { + console.warn("failed to attach remote stream:", e); + } + }); + + return () => { + WebRTC.cleanup(); + setCallSignalingHandler(null); + }; + }, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall]); + + async function requestAudioPermissions(): Promise { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: false + }); + + // Stop the stream immediately as we just needed permission + stream.getTracks().forEach(track => track.stop()); + return true; + } catch (error) { + console.error("Failed to get audio permissions:", error); + return false; + } + }; + + async function initiateCall(userId: number, username: string) { + const hasPermission = await requestAudioPermissions(); + + if (!hasPermission) { + console.log("Audio permission denied"); + return; + } + + + // Start the call in state + startCall(userId, username); + setCallStatus("calling"); + + // Initiate WebRTC call + const success = await WebRTC.initiateCall(userId, username); + + if (!success) { + endCall(); + } + }; + + async function acceptCall() { + if (!chat.call.remoteUserId) { + return; + } + + setCallStatus("connecting"); + const success = await WebRTC.acceptCall(chat.call.remoteUserId); + + if (!success) { + endCall(); + } + }; + + async function rejectCall() { + if (!chat.call.remoteUserId) { + return; + } + + await WebRTC.rejectCall(chat.call.remoteUserId); + endCall(); + }; + + async function handleEndCall() { + if (chat.call.remoteUserId) { + await WebRTC.endCall(chat.call.remoteUserId); + } + endCall(); + }; + + function handleToggleMute() { + if (chat.call.remoteUserId) { + const isMuted = WebRTC.toggleMute(chat.call.remoteUserId); + // Update mute state in store + if (isMuted !== chat.call.isMuted) { + toggleMute(); + } + } + }; + + async function handleIncomingCall(userId: number, username: string) { + await WebRTC.handleIncomingCall(userId, username); + }; + + async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit) { + await WebRTC.handleCallOffer(userId, offer); + }; + + async function handleCallAnswer(userId: number, answer: RTCSessionDescriptionInit) { + await WebRTC.handleCallAnswer(userId, answer); + }; + + async function handleIceCandidate(userId: number, candidate: RTCIceCandidateInit) { + await WebRTC.handleIceCandidate(userId, candidate); + }; + + return { + call: chat.call, + initiateCall, + acceptCall, + rejectCall, + endCall: handleEndCall, + toggleMute: handleToggleMute, + handleIncomingCall, + handleCallOffer, + handleCallAnswer, + handleIceCandidate, + remoteAudioRef + }; +} diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 67bf059..1600abb 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -10,7 +10,9 @@ import { API_BASE_URL } from "@/core/config"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; -export type ChatTabs = "chats" | "channels" | "contacts" | "dms" +export type ChatTabs = "chats" | "channels" | "contacts" | "dms"; + +export type CallStatus = "calling" | "connecting" | "active" | "ended"; interface ActiveDM { userId: number; @@ -18,6 +20,16 @@ interface ActiveDM { publicKey: string | null } +interface CallState { + isActive: boolean; + status: CallStatus; + startTime: number | null; + isMuted: boolean; + remoteUserId: number | null; + remoteUsername: string | null; + isInitiator: boolean; +} + interface ChatState { messages: Message[]; currentChat: string; @@ -30,6 +42,7 @@ interface ChatState { publicChatPanel: PublicChatPanel | null; dmPanel: DMPanel | null; pendingPanel?: MessagePanel | null; + call: CallState; } export interface UserState { @@ -54,6 +67,13 @@ interface AppState { switchToPublicChat: (chatName: string) => Promise; switchToDM: (dmData: DMPanelData) => Promise; + // Call state + startCall: (userId: number, username: string) => void; + endCall: () => void; + setCallStatus: (status: CallStatus) => void; + toggleMute: () => void; + receiveCall: (userId: number, username: string) => void; + // User state user: UserState; setUser: (token: string, user: User) => void; @@ -79,7 +99,16 @@ export const useAppState = create((set, get) => ({ activePanel: null, publicChatPanel: null, dmPanel: null, - pendingPanel: null + pendingPanel: null, + call: { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false + } }, addMessage: (message: Message) => set((state) => { // Check if message already exists to prevent duplicates @@ -358,5 +387,72 @@ export const useAppState = create((set, get) => ({ // Let MessagePanelRenderer handle the animation timing completely // It will set isChatSwitching to false when the fadeInDown animation completes - } + }, + + // Call state management + startCall: (userId: number, username: string) => set((state) => ({ + chat: { + ...state.chat, + call: { + isActive: true, + status: "calling", + startTime: null, + isMuted: false, + remoteUserId: userId, + remoteUsername: username, + isInitiator: true + } + } + })), + + endCall: () => set((state) => ({ + chat: { + ...state.chat, + call: { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false + } + } + })), + + setCallStatus: (status: CallStatus) => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + status, + startTime: status === "active" && !state.chat.call.startTime ? Date.now() : state.chat.call.startTime + } + } + })), + + toggleMute: () => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isMuted: !state.chat.call.isMuted + } + } + })), + + receiveCall: (userId: number, username: string) => set((state) => ({ + chat: { + ...state.chat, + call: { + isActive: true, + status: "calling", + startTime: null, + isMuted: false, + remoteUserId: userId, + remoteUsername: username, + isInitiator: false + } + } + })) })); \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index b94bbc7..844ccfb 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -2,6 +2,7 @@ import { LeftPanel } from "./left/LeftPanel"; import { RightPanel } from "./right/RightPanel"; import "@/pages/chat/css/chat.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; +import { CallWindow } from "./right/CallWindow"; export default function ChatPage() { const { navigate: navigateDownloadApp } = useDownloadAppScreen(); @@ -13,6 +14,7 @@ export default function ChatPage() { + ); } diff --git a/frontend/src/pages/chat/ui/right/CallWindow.tsx b/frontend/src/pages/chat/ui/right/CallWindow.tsx new file mode 100644 index 0000000..44747ac --- /dev/null +++ b/frontend/src/pages/chat/ui/right/CallWindow.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect } from "react"; +import { useAppState } from "@/pages/chat/state"; +import useAudioCall from "@/pages/chat/hooks/useAudioCall"; +import defaultAvatar from "@/images/default-avatar.png"; + +export function CallWindow() { + const { chat, toggleMute } = useAppState(); + const { call } = chat; + const { acceptCall, rejectCall, remoteAudioRef, endCall } = useAudioCall(); + const [position, setPosition] = useState({ x: 100, y: 100 }); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [callDuration, setCallDuration] = useState(0); + + useEffect(() => { + let interval: NodeJS.Timeout; + + if (call.status === "active" && call.startTime) { + interval = setInterval(() => { + setCallDuration(Math.floor((Date.now() - call.startTime!) / 1000)); + }, 1000); + } else { + setCallDuration(0); + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [call.status, call.startTime]); + + function formatDuration(seconds: number) { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + + function getStatusText() { + switch (call.status) { + case "calling": + return "Calling..."; + case "connecting": + return "Connecting..."; + case "active": + return formatDuration(callDuration); + default: + return ""; + } + } + + + return ( + <> +