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..d76a28d 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -911,6 +911,90 @@ 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: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + 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) + elif type == "call_video_toggle": + # Forward video toggle state between peers + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + 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 + + await self.send_to_user(to_user_id, { + "type": "call_signaling", + "data": { + "type": "call_video_toggle", + "fromUserId": current_user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + } + }) + + await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "call_screen_share_toggle": + # Forward screen share toggle state between peers + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + 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 + + await self.send_to_user(to_user_id, { + "type": "call_signaling", + "data": { + "type": "call_screen_share_toggle", + "fromUserId": current_user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + } + }) + + await websocket.send_json({"type": "call_screen_share_toggle", "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/api/authApi.ts b/frontend/src/core/api/authApi.ts index 28311a7..ed9ea77 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/authApi.ts @@ -139,4 +139,8 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis export function restoreKeys() { currentPublicKey = ub64(localStorage.getItem("publicKey")!); currentPrivateKey = ub64(localStorage.getItem("privateKey")!); +} + +export function getAuthToken(): string | null { + return localStorage.getItem("authToken"); } \ No newline at end of file diff --git a/frontend/src/core/calls/e2eeWorker.ts b/frontend/src/core/calls/e2eeWorker.ts new file mode 100644 index 0000000..d12a771 --- /dev/null +++ b/frontend/src/core/calls/e2eeWorker.ts @@ -0,0 +1,145 @@ +/** + * E2EE Worker for WebRTC Insertable Streams + * Encrypts/decrypts encoded audio and video frames using AES-GCM + * Uses RTP timestamps for IVs to handle out-of-order and dropped frames + */ + +export interface FrameMetadata { + contributingSources?: number[]; + mimeType?: string; + payloadType?: number; + rtpTimestamp: number; + synchronizationSource: number; + dependencies?: number[]; + frameId?: number; + spatialIndex?: number; + temporalIndex?: number; +} + +export interface EncodedFrame { + data: Uint8Array | ArrayBuffer; + timestamp?: number; + type?: string; + getMetadata?: () => FrameMetadata; +} + +export interface WorkerOptions { + key: CryptoKey; + mode: 'encrypt' | 'decrypt'; + sessionId?: string; +} + +/** + * Extract sequence number from encoded frame + * For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available, + * otherwise fall back to extracting from RTP header + */ +function makeIV(encodedFrame: EncodedFrame): ArrayBuffer { + // Create IV using ONLY RTP metadata - this ensures sender and receiver use identical IVs + // Frame data can differ between sender/receiver due to encoding differences + const ivBuffer = new ArrayBuffer(12); + const view = new DataView(ivBuffer); + + if (encodedFrame.getMetadata) { + try { + const metadata = encodedFrame.getMetadata(); + if (metadata && typeof metadata.rtpTimestamp === 'number') { + // Use ONLY RTP timestamp + sync source - these are identical on both sides + view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes + view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes + view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV) + + return ivBuffer; + } + } catch (e) { + console.error("Failed to get metadata:", e); + } + } + + // Fallback: use timestamp only (no random to avoid desync) + view.setUint32(0, Date.now() & 0xFFFFFFFF, false); + view.setUint32(4, 0, false); + view.setUint32(8, 0, false); + return ivBuffer; +} + +addEventListener("rtctransform", (event) => { + const { transformer } = event; + const { readable, writable } = transformer; + const { key, mode } = transformer.options as WorkerOptions; + + const isEncrypting = mode === 'encrypt'; + + let frameCount = 0; + + async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController) { + try { + const data = new Uint8Array(encodedFrame.data); + + // Increment frame counter + frameCount++; + + // Create IV using RTP timestamp from metadata (synchronized between peers) + const iv = makeIV(encodedFrame); + + // Ensure IV is properly typed + const ivArray = new Uint8Array(iv); + const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray }; + + // COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility + // This prevents most visual leakage while maintaining decodability + let headerSize = 0; + let payloadData: Uint8Array; + + if (data.length > 20) { + // For video frames, preserve first 8 bytes for better codec compatibility + // This includes frame type, keyframe info, and basic header structure + headerSize = Math.min(8, Math.floor(data.length / 10)); + payloadData = data.slice(headerSize); + } else { + // For small frames (likely audio), encrypt everything + payloadData = data; + } + + // Encrypt the payload data + const payloadBuffer = new ArrayBuffer(payloadData.byteLength); + new Uint8Array(payloadBuffer).set(payloadData); + + let encryptedPayload: ArrayBuffer; + if (isEncrypting) { + encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer); + } else { + try { + encryptedPayload = await crypto.subtle.decrypt(params, key, payloadBuffer); + } catch (error) { + console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, error); + return; // Drop the frame + } + } + + // Reconstruct frame: minimal headers + encrypted payload + const encryptedArray = new Uint8Array(encryptedPayload); + const result = new Uint8Array(headerSize + encryptedArray.length); + + if (headerSize > 0) { + result.set(data.slice(0, headerSize), 0); // Copy minimal headers + result.set(encryptedArray, headerSize); // Add encrypted payload + } else { + result.set(encryptedArray, 0); + } + + // CRITICAL: Video frames need ArrayBuffer, not Uint8Array + encodedFrame.data = result.buffer; + controller.enqueue(encodedFrame); + } catch (e) { + // FAIL SECURELY: Never send unencrypted frames + const data = new Uint8Array(encodedFrame.data); + console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e); + return; // Drop the frame completely + } + } + + readable + .pipeThrough(new TransformStream({ transform })) + .pipeTo(writable); +}); diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts new file mode 100644 index 0000000..304768c --- /dev/null +++ b/frontend/src/core/calls/encryption.ts @@ -0,0 +1,238 @@ +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { randomBytes } from "@/utils/crypto/kdf"; +import { b64, ub64 } from "@/utils/utils"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { getCurrentKeys } from "@/core/api/authApi"; +import type { WrappedSessionKeyPayload } from "@/core/types"; + +export interface CallSessionKey { + key: Uint8Array; + hash: string; // For emoji display +} + +export interface CallKeyExchange { + type: "call_key_exchange"; + sessionKeyHash: string; + encryptedSessionKey: EncryptedCallMessage; +} + +export interface EncryptedCallMessage { + iv: string; + ciphertext: string; + salt: string; + iv2: string; + wrappedSessionKey: string; +} + +/** + * Generates a new call session key for end-to-end encryption + * @returns Promise that resolves to a session key with its hash for display + */ +export async function generateCallSessionKey(): Promise { + // Generate session key material + const sessionKeyMaterial = randomBytes(32); + + // Generate hash for emoji display (first 4 bytes of SHA-256 hash) + const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer); + const hash = b64(new Uint8Array(hashBuffer.slice(0, 4))); + + return { + key: sessionKeyMaterial, + hash + }; +} + +/** + * Rotate a session key by generating a completely new key + * This provides forward secrecy for long-running calls + */ +export async function rotateCallSessionKey(): Promise { + // Generate new session key material (completely independent of current key) + const newSessionKeyMaterial = randomBytes(32); + + // Generate new hash for emoji display + const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer); + const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4))); + + return { + key: newSessionKeyMaterial, + hash: newHash + }; +} + +/** + * Create session key from hash (for backward compatibility) + * @deprecated Use deriveCallSessionKeyFromSharedSecret instead + */ +export async function createCallSessionKeyFromHash(hash: string): Promise { + // For backward compatibility, generate a deterministic key from the hash + const hashBytes = ub64(hash); + const sessionKey = new Uint8Array(32); + + // Repeat the hash bytes to fill 32 bytes + for (let i = 0; i < 32; i++) { + sessionKey[i] = hashBytes[i % hashBytes.length]; + } + + return { + key: sessionKey, + hash + }; +} + +/** + * Derive session key from ECDH shared secret and session key hash + * This creates a deterministic but cryptographically secure key + */ +export async function deriveCallSessionKeyFromSharedSecret( + sharedSecret: Uint8Array, + sessionKeyHash: string, + isInitiator: boolean +): Promise { + // Use HKDF to derive the session key from the shared secret + // Include the session key hash and role to ensure uniqueness + const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`); + const salt = new Uint8Array(32); // Zero salt for deterministic derivation + + // Import the shared secret as a raw key for HKDF + const sharedKey = await crypto.subtle.importKey( + 'raw', + sharedSecret.buffer as ArrayBuffer, + { name: 'HKDF' }, + false, + ['deriveKey'] + ); + + // Derive the session key using HKDF + const sessionKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: salt, + info: info + }, + sharedKey, + { name: 'AES-GCM', length: 256 }, + true, // Make the key extractable so we can export it + ['encrypt', 'decrypt'] + ); + + // Export the raw key material + const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey); + + return { + key: new Uint8Array(sessionKeyMaterial), + hash: sessionKeyHash + }; +} + +/** + * Encrypt a call signaling message with the session key + */ +export async function encryptCallMessage(message: Record, sessionKey: Uint8Array): Promise { + const messageKey = await importAesGcmKey(sessionKey); + const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message))); + + return { + iv: b64(encrypted.iv), + ciphertext: b64(encrypted.ciphertext), + salt: "", // Not used for message encryption, only for key wrapping + iv2: "", + wrappedSessionKey: "" + }; +} + +/** + * Decrypt a call signaling message + */ +export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise> { + const messageKey = await importAesGcmKey(sessionKey); + const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext)); + return JSON.parse(new TextDecoder().decode(decrypted)); +} + +/** + * Generate 4 emojis representing the call session key + */ +export function generateCallEmojis(sessionKeyHash: string): string[] { + // Convert hash to numbers and map to emoji ranges + const hashBytes = new Uint8Array(ub64(sessionKeyHash)); + const emojis: string[] = []; + + // Different emoji categories for variety + const emojiSets = [ + ["๐ŸŽต", "๐ŸŽถ", "๐ŸŽค", "๐ŸŽง", "๐ŸŽผ", "๐ŸŽน", "๐Ÿฅ", "๐ŸŽบ", "๐ŸŽธ", "๐ŸŽป"], // Music + ["๐Ÿ”ฅ", "๐Ÿ’ซ", "โญ", "โœจ", "๐ŸŒŸ", "๐Ÿ’ฅ", "โšก", "๐ŸŒˆ", "๐ŸŽ†", "๐ŸŽ‡"], // Energy + ["๐Ÿš€", "๐Ÿ›ธ", "๐Ÿ›ฐ๏ธ", "๐ŸŒŒ", "๐Ÿ”ญ", "โš™๏ธ", "๐Ÿ”ง", "โšก", "๐Ÿ’ก", "๐Ÿ”ฌ"], // Tech/Space + ["๐ŸŽญ", "๐ŸŽช", "๐ŸŽจ", "๐ŸŽฌ", "๐Ÿ“ท", "๐ŸŽฅ", "๐Ÿ“บ", "๐ŸŽฎ", "๐Ÿ•น๏ธ", "๐ŸŽฏ"] // Entertainment + ]; + + for (let i = 0; i < 4; i++) { + const set = emojiSets[i % emojiSets.length]; + const index = hashBytes[i % hashBytes.length] % set.length; + emojis.push(set[index]); + } + + return emojis; +} + +// HKDF info for CALL key wrapping (distinct from DM's info) +const CALL_INFO = new Uint8Array([2]); + +/** + * Wraps a call session key for a specific recipient using ECDH key exchange + * @param recipientPublicKeyB64 - The recipient's public key in base64 format + * @param sessionKey - The session key to wrap + * @returns Promise that resolves to the wrapped session key payload + */ +export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const salt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO); + const wk = await importAesGcmKey(wkRaw); + const wrap = await aesGcmEncrypt(wk, sessionKey); + return { + salt: b64(salt), + iv2: b64(wrap.iv), + wrapped: b64(wrap.ciphertext) + }; +} + +/** + * Create a shared secret and derive session key for the receiver + */ +export async function createSharedSecretAndDeriveSessionKey( + senderPublicKeyB64: string, + sessionKeyHash: string, + isInitiator: boolean +): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Create shared secret using ECDH + const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + + // Derive the session key from the shared secret + return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator); +} + +/** + * Unwraps a call session key received from a sender using ECDH key exchange + * @param senderPublicKeyB64 - The sender's public key in base64 format + * @param payload - The wrapped session key payload + * @returns Promise that resolves to the unwrapped session key + */ +export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const salt = ub64(payload.salt); + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO); + const wk = await importAesGcmKey(wkRaw); + const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped)); + return new Uint8Array(sessionKey); +} diff --git a/frontend/src/core/calls/signaling.ts b/frontend/src/core/calls/signaling.ts new file mode 100644 index 0000000..bc158c4 --- /dev/null +++ b/frontend/src/core/calls/signaling.ts @@ -0,0 +1,170 @@ +import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types"; +import * as WebRTC from "./webrtc"; + +export interface CallState { + receiveCall: (userId: number, username: string) => void; + endCall: () => void; + setCallSessionKeyHash: (sessionKeyHash: string) => void; + setRemoteVideoEnabled: (enabled: boolean) => void; + setRemoteScreenSharing: (enabled: boolean) => void; +} + +/** + * Handles incoming WebSocket messages related to call signaling + */ +export class CallSignalingHandler { + private getState: () => CallState; + + constructor(getState: () => CallState) { + this.getState = getState; + } + + /** + * Routes incoming call signaling messages to appropriate handlers + */ + handleWebSocketMessage(message: CallSignalingMessage) { + const { data } = message; + if (!data) { + return; + } + + switch (message.type) { + case "call_invite": + this.handleCallInvite(message, data as CallInviteMessageData); + break; + case "call_accept": + this.handleCallAccept(data as CallAcceptData); + break; + case "call_reject": + this.handleCallReject(data as CallRejectData); + break; + case "call_offer": + this.handleCallOffer(message, data as CallOfferData); + break; + case "call_answer": + this.handleCallAnswer(message, data as CallAnswerData); + break; + case "call_ice_candidate": + this.handleIceCandidate(message, data as CallIceCandidateData); + break; + case "call_end": + this.handleCallEnd(data as CallEndData); + break; + case "call_session_key": + this.handleCallSessionKey(message); + break; + case "call_video_toggle": + this.handleVideoToggle(message, data as CallVideoToggleData); + break; + case "call_screen_share_toggle": + this.handleScreenShareToggle(message, data as CallScreenShareToggleData); + break; + } + } + + /** + * Handles incoming call invitation + */ + private async handleCallInvite(message: CallSignalingMessage, data: CallInviteMessageData) { + const { fromUsername } = data; + const fromUserId = message.fromUserId; + const state = this.getState(); + + // First, create the peer connection in WebRTC service + await WebRTC.handleIncomingCall(fromUserId, fromUsername); + + // Then show incoming call UI + state.receiveCall(fromUserId, fromUsername); + } + + /** + * Handles call acceptance from remote peer + */ + private async handleCallAccept(data: CallAcceptData) { + 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); + } + } + + /** + * Handles call rejection from remote peer + */ + private handleCallReject(data: CallRejectData) { + 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(message: CallSignalingMessage, data: CallOfferData) { + await WebRTC.handleCallOffer(message.fromUserId, data); + } + + private async handleCallAnswer(message: CallSignalingMessage, data: CallAnswerData) { + await WebRTC.handleCallAnswer(message.fromUserId, data); + } + + private async handleIceCandidate(message: CallSignalingMessage, data: CallIceCandidateData) { + await WebRTC.handleIceCandidate(message.fromUserId, data); + } + + private handleCallEnd(data: CallEndData) { + const state = this.getState(); + const { fromUserId } = data; + + // Clean up WebRTC connection first + if (fromUserId) { + WebRTC.cleanupCall(fromUserId); + } + + // End the call + state.endCall(); + } + + private handleCallSessionKey(message: CallSignalingMessage) { + const state = this.getState(); + const { sessionKeyHash, data } = message; + if (sessionKeyHash) { + state.setCallSessionKeyHash(sessionKeyHash); + } + if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) { + WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash); + } + } + + private handleVideoToggle(message: CallSignalingMessage, data: CallVideoToggleData) { + const state = this.getState(); + + if (data && typeof data.enabled === "boolean" && message.fromUserId) { + // Update Zustand state (for UI) + state.setRemoteVideoEnabled(data.enabled); + // Update WebRTC internal state (for track routing) + WebRTC.setRemoteVideoEnabled(message.fromUserId, data.enabled); + } else { + console.warn("Invalid toggle data:", data); + } + } + + private handleScreenShareToggle(message: CallSignalingMessage, data: CallScreenShareToggleData) { + const state = this.getState(); + + if (data && typeof data.enabled === "boolean" && message.fromUserId) { + // Update Zustand state (for UI) + state.setRemoteScreenSharing(data.enabled); + // Update WebRTC internal state (for track routing) + WebRTC.setRemoteScreenSharing(message.fromUserId, data.enabled); + } else { + console.warn("Invalid toggle data:", data); + } + } +} diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts new file mode 100644 index 0000000..9e46562 --- /dev/null +++ b/frontend/src/core/calls/webrtc.ts @@ -0,0 +1,1199 @@ +import { getAuthHeaders, getAuthToken } from "@/core/api/authApi"; +import type { CallSignalingMessage, IceServersResponse, WrappedSessionKeyPayload } from "@/core/types"; +import { request } from "@/core/websocket"; +import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; +import { fetchUserPublicKey } from "@/core/api/dmApi"; +import { importAesGcmKey } from "@/utils/crypto/symmetric"; +import E2EEWorker from "./e2eeWorker?worker"; +import { delay } from "@/utils/utils"; + +// Constants +const DEFAULT_ICE_SERVERS = [{ urls: "stun:fromchat.ru:3478" }]; +const KEY_ROTATION_INTERVAL = 10 * 60 * 1000; // 10 minutes +const NEGOTIATION_DELAY = 100; // ms + +/** + * WebRTC Call class for managing individual call instances + */ +export class WebRTCCall { + private _peerConnection!: RTCPeerConnection; + localStream: MediaStream | null = null; + private localVideoStream: MediaStream | null = null; + private screenShareStream: MediaStream | null = null; + readonly remoteUserId: number; + remoteUsername: string = ""; + isEnding?: boolean = false; + private isMuted?: boolean = false; + private isLocalVideoEnabled: boolean = false; + private isScreenSharing: boolean = false; + private isRemoteScreenSharing: boolean = false; // Track remote screen share state from signaling + private isRemoteVideoEnabled: boolean = false; // Track remote video state from signaling + isNegotiating?: boolean = false; + // Insertable Streams E2EE + private _sessionKey: Uint8Array | null = null; + private _sessionId: string; + private keyRotationTimer?: NodeJS.Timeout; + private transformedSenders: Set = new Set(); + private transformedReceivers: Set = new Set(); + // Track specific senders for proper routing when both video and screen share are active + private videoSender?: RTCRtpSender | null = null; + private screenShareSender?: RTCRtpSender | null = null; + // Track the number of video tracks received for each type + private receivedVideoTrackCount: number = 0; + private receivedScreenShareTrackCount: number = 0; + + // ------------------- + // Getters and setters + // ------------------- + + get peerConnection(): RTCPeerConnection { + return this._peerConnection; + } + + private set peerConnection(value: RTCPeerConnection) { + this._peerConnection = value; + } + + get sessionId(): string { + return this._sessionId; + } + + private set sessionId(value: string) { + this._sessionId = value; + } + + get sessionKey(): Uint8Array | null { + return this._sessionKey; + } + + private set sessionKey(value: Uint8Array | null) { + this._sessionKey = value; + } + + + // ------------------- + // Core initialization + // ------------------- + + constructor(userId: number) { + this.remoteUserId = userId; + this._sessionId = crypto.randomUUID(); + } + + /** + * Initializes the peer connection with proper ICE servers and sets up event listeners + */ + async initialize(): Promise { + const iceServers = await this.getIceServers(); + + // Create peer connection with proper ICE servers + this.peerConnection = new RTCPeerConnection({ + iceServers + }); + + this.setupEventListeners(); + } + + /** + * Gets ICE servers from backend with fallback + */ + private async getIceServers(): Promise { + try { + const response = await fetch("/api/webrtc/ice", { + headers: getAuthHeaders(getAuthToken()!) + }); + + if (response.ok) { + const data = await response.json() as IceServersResponse; + 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 DEFAULT_ICE_SERVERS; + } + + /** + * Sets up all peer connection event listeners + */ + private setupEventListeners(): void { + // Add ICE candidate event listener for sending + this.peerConnection.addEventListener("icecandidate", async (event) => { + if (event.candidate) { + const { candidate, sdpMLineIndex, sdpMid } = event.candidate; + + try { + await sendSignalingMessage({ + type: "call_ice_candidate", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { candidate, sdpMLineIndex, sdpMid } + }); + } catch (error) { + console.error("Failed to send ICE candidate:", error); + } + } + }); + + this.peerConnection.addEventListener("iceconnectionstatechange", () => { + // ICE connection state changed + }); + + this.peerConnection.addEventListener("signalingstatechange", () => { + // Signaling state changed + }); + + // Handle renegotiation when tracks are added/removed + this.peerConnection.addEventListener("negotiationneeded", async () => { + try { + // Prevent multiple simultaneous negotiations + if (this.isNegotiating) { + return; + } + + // Skip if we're in "stable" state and haven't finished the initial handshake + if (this.peerConnection.signalingState !== "stable") { + return; + } + + this.isNegotiating = true; + + const offer = await this.peerConnection.createOffer(); + await this.peerConnection.setLocalDescription(offer); + + await sendSignalingMessage({ + type: "call_offer", + fromUserId: 0, + toUserId: this.remoteUserId, + data: offer + }); + + this.isNegotiating = false; + } catch (error) { + console.error("Failed to handle negotiation:", error); + this.isNegotiating = false; + } + }); + + // Handle remote stream + this.peerConnection.addEventListener("track", async (event) => { + const [remoteStream] = event.streams; + if (remoteStream) { + const track = event.track; + + // Apply E2EE transform to all tracks - video now uses header-preserving encryption + if (this.sessionKey && window.RTCRtpScriptTransform) { + try { + const receiver = this.peerConnection.getReceivers().find(r => r.track === track); + if (receiver && !this.transformedReceivers.has(receiver)) { + const key = await importAesGcmKey(this.sessionKey); + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: this.sessionId }); + this.transformedReceivers.add(receiver); + } + } catch (error) { + console.error("Failed to apply E2EE to received track:", error); + } + } + + // Determine stream type based on track kind and signaling state + if (track.kind === "video") { + let isScreenShare = false; + let isVideo = false; + + if (this.isRemoteScreenSharing && this.isRemoteVideoEnabled) { + // Both active - route based on which one we haven't received yet + // Simple logic: if we haven't received video yet, this is video + // if we haven't received screen share yet, this is screen share + if (this.receivedVideoTrackCount === 0) { + isVideo = true; + this.receivedVideoTrackCount++; + } else if (this.receivedScreenShareTrackCount === 0) { + isScreenShare = true; + this.receivedScreenShareTrackCount++; + } else { + // Both already received - this might be a track replacement + isScreenShare = true; + } + } else if (this.isRemoteScreenSharing) { + isScreenShare = true; + this.receivedScreenShareTrackCount++; + } else if (this.isRemoteVideoEnabled) { + isVideo = true; + this.receivedVideoTrackCount++; + } + + if (isScreenShare) { + if (callbacks.onRemoteScreenShare) { + callbacks.onRemoteScreenShare(this.remoteUserId, remoteStream); + } + } else if (isVideo) { + if (callbacks.onRemoteVideoStream) { + callbacks.onRemoteVideoStream(this.remoteUserId, remoteStream); + } + } + } else if (track.kind === "audio") { + // Handle remote audio (existing behavior) + if (callbacks.onRemoteStream) { + callbacks.onRemoteStream(this.remoteUserId, remoteStream); + } + } + } + }); + + // Handle connection state changes + this.peerConnection.addEventListener("connectionstatechange", () => { + // WebRTC connection state changed + if (callbacks.onCallStateChange) { + callbacks.onCallStateChange(this.remoteUserId, this.peerConnection.connectionState); + } + + // Clean up only on permanent failures + // Don't end on "disconnected" - ICE can recover from temporary disconnections + if (this.peerConnection.connectionState === "failed" || + this.peerConnection.connectionState === "closed") { + // Only send end call message if we're not already cleaning up + if (!this.isEnding) { + this.isEnding = true; + endCall(this.remoteUserId); + } + } + }); + } + + + // ---------- + // Management + // ---------- + + /** + * Toggles mute state for this call + */ + toggleMute(): boolean { + if (!this.localStream) { + return false; + } + + if (!this.isMuted) { + // Mute: Stop the track completely (no green dot) + const audioTrack = this.localStream.getAudioTracks()[0]; + if (audioTrack) { + audioTrack.stop(); + this.localStream.removeTrack(audioTrack); + } + + // Create a silent audio track using Web Audio API + const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + const audioContext = new AudioContextClass(); + 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) { + this.localStream.addTrack(silentTrack); + } + + this.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 + this.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 = this.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 + this.peerConnection.addTrack(newAudioTrack, this.localStream!); + } + + // Add the track to the local stream + this.localStream!.addTrack(newAudioTrack); + + this.isMuted = false; + }) + .catch(error => { + console.error("Failed to re-enable microphone:", error); + }); + return false; // Unmuted + } + } + + /** + * Toggles video for this call + */ + async toggleVideo(): Promise { + if (!this.isLocalVideoEnabled) { + // Enable video + try { + const videoStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false + }); + + this.localVideoStream = videoStream; + this.isLocalVideoEnabled = true; + + // Add video track to peer connection + const videoTrack = videoStream.getVideoTracks()[0]; + const sender = this.peerConnection.addTrack(videoTrack, videoStream); + this.videoSender = sender; + + // Apply E2EE transform with header-preserving encryption for video + if (this.sessionKey && window.RTCRtpScriptTransform) { + try { + const key = await importAesGcmKey(this.sessionKey); + const sender = this.peerConnection.getSenders().find(s => s.track === videoTrack); + if (sender && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: this.sessionId }); + this.transformedSenders.add(sender); + } + } catch (error) { + console.error("Failed to apply E2EE to video:", error); + throw error; // Fail securely + } + } + + // Notify local video stream handler + if (callbacks.onLocalVideoStream) { + callbacks.onLocalVideoStream(this.remoteUserId, videoStream); + } + + // Send signaling message to notify remote peer + await sendSignalingMessage({ + type: "call_video_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: true } + }); + + return true; + } catch (error) { + console.error("Failed to enable video:", error); + return false; + } + } else { + // Disable video + if (this.localVideoStream) { + this.localVideoStream.getTracks().forEach(track => { + track.stop(); + // Remove track from peer connection + const senders = this.peerConnection.getSenders(); + const videoSender = senders.find(s => s.track === track); + if (videoSender) { + this.peerConnection.removeTrack(videoSender); + this.transformedSenders.delete(videoSender); + // Clear sender reference + if (this.videoSender === videoSender) { + this.videoSender = null; + } + } + }); + this.localVideoStream = null; + } + + this.isLocalVideoEnabled = false; + + // Notify local video stream handler + if (callbacks.onLocalVideoStream) { + callbacks.onLocalVideoStream(this.remoteUserId, null); + } + + // Send signaling message to notify remote peer + await sendSignalingMessage({ + type: "call_video_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: false } + }); + + return false; + } + } + + /** + * Toggles screen sharing for this call + */ + async toggleScreenShare(): Promise { + if (!this.isScreenSharing) { + // Enable screen sharing + try { + const screenStream = await navigator.mediaDevices.getDisplayMedia({ + video: { + width: { ideal: 1920, max: 3840 }, + height: { ideal: 1080, max: 2160 }, + frameRate: { ideal: 60, max: 60 } + }, + audio: false + }); + + // Set a special ID to identify screen share streams + try { + Object.defineProperty(screenStream, "id", { + value: `screen-${crypto.randomUUID()}`, + writable: false, + configurable: true + }); + } catch (e) { + // Use default stream ID + } + + this.screenShareStream = screenStream; + this.isScreenSharing = true; + + // Add screen share track to peer connection + const videoTrack = screenStream.getVideoTracks()[0]; + + // Handle when user stops sharing via browser UI + videoTrack.addEventListener("ended", async () => { + + // Clean up screen share state + if (this.screenShareStream) { + this.screenShareStream.getTracks().forEach(t => t.stop()); + this.screenShareStream = null; + } + this.isScreenSharing = false; + + // Remove screen share track from peer connection + const senders = this.peerConnection.getSenders(); + const screenSender = senders.find(sender => + sender.track && sender.track.kind === 'video' && + sender.track.readyState === 'ended' && + this.transformedSenders.has(sender) + ); + + if (screenSender) { + await this.peerConnection.removeTrack(screenSender); + this.transformedSenders.delete(screenSender); + } + + // Notify local screen share handler + if (callbacks.onLocalScreenShare) { + callbacks.onLocalScreenShare(this.remoteUserId, null); + } + + // Notify state change handler + if (callbacks.onScreenShareStateChange) { + callbacks.onScreenShareStateChange(this.remoteUserId, false); + } + + // Send signaling message to remote peer + await sendSignalingMessage({ + type: "call_screen_share_toggle", + fromUserId: 0, // Will be set by server + toUserId: this.remoteUserId, + data: { enabled: false } + }); + }); + + // Send signaling message FIRST to notify remote peer before adding track + // This ensures the receiver knows it's screen share before the track arrives + await sendSignalingMessage({ + type: "call_screen_share_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: true } + }); + + // Small delay to ensure signaling message is processed before track arrives + await delay(NEGOTIATION_DELAY); + + const sender = this.peerConnection.addTrack(videoTrack, screenStream); + this.screenShareSender = sender; + + // CRITICAL: Apply E2EE transform IMMEDIATELY after track is added + if (this.sessionKey && window.RTCRtpScriptTransform) { + try { + const key = await importAesGcmKey(this.sessionKey); + const sender = this.peerConnection.getSenders().find(s => s.track === videoTrack); + + if (sender && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: this.sessionId }); + this.transformedSenders.add(sender); + } + } catch (error) { + console.error("Failed to apply E2EE to screen share:", error); + throw error; // Fail securely + } + } + + // Notify local screen share handler + if (callbacks.onLocalScreenShare) { + callbacks.onLocalScreenShare(this.remoteUserId, screenStream); + } + + return true; + } catch (error) { + console.error("Failed to enable screen sharing:", error); + return false; + } + } else { + // Disable screen sharing + if (this.screenShareStream) { + this.screenShareStream.getTracks().forEach(track => { + track.stop(); + // Remove track from peer connection + const senders = this.peerConnection.getSenders(); + const screenSender = senders.find(s => s.track === track); + if (screenSender) { + this.peerConnection.removeTrack(screenSender); + this.transformedSenders.delete(screenSender); + // Clear sender reference + if (this.screenShareSender === screenSender) { + this.screenShareSender = null; + } + } + }); + this.screenShareStream = null; + } + + this.isScreenSharing = false; + + // Notify local screen share handler + if (callbacks.onLocalScreenShare) { + callbacks.onLocalScreenShare(this.remoteUserId, null); + } + + // Send signaling message to notify remote peer + await sendSignalingMessage({ + type: "call_screen_share_toggle", + fromUserId: 0, + toUserId: this.remoteUserId, + data: { enabled: false } + }); + + return false; + } + } + + + // --------- + // Lifecycle + // --------- + + /** + * Sets session key for this call + */ + async setSessionKey(keyBytes: Uint8Array): Promise { + this.sessionKey = keyBytes; + + await this.applyE2EETransforms(); + + // Start key rotation timer (rotate every 10 minutes for long calls) + if (this.keyRotationTimer) { + clearInterval(this.keyRotationTimer); + } + + this.keyRotationTimer = setInterval(async () => { + await this.rotateSessionKey(); + }, KEY_ROTATION_INTERVAL); + } + + /** + * Rotates session key for this call + */ + private async rotateSessionKey(): Promise { + if (!this.sessionKey) return; + + try { + // Generate new session key + const newSessionKey = await rotateCallSessionKey(); + + // Update the call with new session key + this.sessionKey = newSessionKey.key; + + // Reapply E2EE transforms with new key + await this.applyE2EETransforms(); + } catch (error) { + console.error("Failed to rotate session key:", error); + } + } + + /** + * Applies E2EE transforms to this call + */ + private async applyE2EETransforms(): Promise { + try { + if (!this.sessionKey || !window.RTCRtpScriptTransform) { + return; + } + + const key = await importAesGcmKey(this.sessionKey); + + // Apply to receivers that don't already have transforms + const receivers = this.peerConnection.getReceivers(); + for (const receiver of receivers) { + if (receiver.track && !this.transformedReceivers.has(receiver)) { + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId: this.sessionId }); + this.transformedReceivers.add(receiver); + } + } + + // Apply to senders that don't already have transforms + const senders = this.peerConnection.getSenders(); + for (const sender of senders) { + if (sender.track && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId: this.sessionId }); + this.transformedSenders.add(sender); + } + } + } catch (error) { + console.error("Failed to apply E2EE transforms:", error); + } + } + + /** + * Creates E2EE transform for this call + */ + async createE2EETransform(sessionKey: Uint8Array, sessionId?: string): Promise { + try { + if (!sessionKey || !window.RTCRtpScriptTransform) { + return; + } + + const key = await importAesGcmKey(sessionKey); + + // Apply to receivers that don't already have transforms + const receivers = this.peerConnection.getReceivers(); + for (const receiver of receivers) { + if (receiver.track && !this.transformedReceivers.has(receiver)) { + receiver.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'decrypt', sessionId }); + this.transformedReceivers.add(receiver); + } + } + + // Apply to senders that don't already have transforms + const senders = this.peerConnection.getSenders(); + for (const sender of senders) { + if (sender.track && !this.transformedSenders.has(sender)) { + sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: 'encrypt', sessionId }); + this.transformedSenders.add(sender); + } + } + } catch (error) { + console.error("Failed to create E2EE transform:", error); + // Fail securely - throw to prevent call from continuing without E2EE + throw error; + } + } + + /** + * Sets remote video enabled state + */ + setRemoteVideoEnabled(enabled: boolean): void { + this.isRemoteVideoEnabled = enabled; + // Reset counter when feature is disabled + if (!enabled) { + this.receivedVideoTrackCount = 0; + } + } + + /** + * Sets remote screen sharing state + */ + setRemoteScreenSharing(enabled: boolean): void { + this.isRemoteScreenSharing = enabled; + // Reset counter when feature is disabled + if (!enabled) { + this.receivedScreenShareTrackCount = 0; + } + } + + /** + * Cleans up this call + */ + cleanup(): void { + // Clear key rotation timer + if (this.keyRotationTimer) { + clearInterval(this.keyRotationTimer); + } + + // Close peer connection + if (this.peerConnection) { + this.peerConnection.close(); + } + + // Stop local stream + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()); + } + + // Stop local video stream + if (this.localVideoStream) { + this.localVideoStream.getTracks().forEach(track => track.stop()); + } + + // Stop screen share stream + if (this.screenShareStream) { + this.screenShareStream.getTracks().forEach(track => track.stop()); + } + } +} + +// Global state +export const callbacks = { + onCallStateChange: null as ((userId: number, state: string) => void) | null, + onRemoteStream: null as ((userId: number, stream: MediaStream) => void) | null, + onLocalVideoStream: null as ((userId: number, stream: MediaStream | null) => void) | null, + onRemoteVideoStream: null as ((userId: number, stream: MediaStream | null) => void) | null, + onLocalScreenShare: null as ((userId: number, stream: MediaStream | null) => void) | null, + onRemoteScreenShare: null as ((userId: number, stream: MediaStream | null) => void) | null, + onScreenShareStateChange: null as ((userId: number, isSharing: boolean) => void) | null, +} + +const calls: Map = new Map(); + +/** + * Sends a signaling message via WebSocket + */ +async function sendSignalingMessage(message: CallSignalingMessage) { + await request({ + type: "call_signaling", + credentials: { + scheme: "Bearer", + credentials: getAuthToken()! + }, + data: message + }); +} + + +async function createPeerConnection(userId: number): Promise { + const call = new WebRTCCall(userId); + await call.initialize(); + calls.set(userId, call); + return call; +} + +/** + * Initiates a call to the specified user + * @param userId - The ID of the user to call + * @param username - The username of the user to call + * @returns Promise that resolves to true if call was initiated successfully + */ +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 + const call = await createPeerConnection(userId); + if (!call) return false; + + call.localStream = localStream; + call.remoteUsername = username; + + // Add tracks to peer connection + localStream.getTracks().forEach(track => call.peerConnection.addTrack(track, localStream)); + + // Enable insertable streams encryption on sender side if supported + try { + if (call.peerConnection.getSenders().length > 0 && window.RTCRtpScriptTransform) { + const senders = call.peerConnection.getSenders(); + for (const sender of senders) { + if (!sender.track || sender.track.kind !== "audio") continue; + // just mark; actual key set after wrap/send + } + } + } catch {} + + // 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 sendCallSessionKey(userId: number, sessionKeyHash: string): Promise { + try { + await sendSignalingMessage({ + type: "call_session_key", + fromUserId: 0, // Will be set by server + toUserId: userId, + sessionKeyHash, + data: {} + }); + } catch (error) { + console.error("Failed to send call session key:", error); + } +} + +export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise { + try { + const recipientPublicKey = await fetchUserPublicKey(userId, getAuthToken()!); + if (!recipientPublicKey) { + console.warn("No recipient public key for", userId); + return; + } + const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey); + await sendSignalingMessage({ + type: "call_session_key", + fromUserId: 0, + toUserId: userId, + sessionKeyHash, + data: { wrappedSessionKey: wrapped } + }); + } catch (e) { + console.error("Failed to send wrapped session key:", e); + } +} + +export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promise { + const call = calls.get(userId); + if (!call) { + console.error("setSessionKey: No call found for user", userId); + return; + } + + await call.setSessionKey(keyBytes); +} + + +export async function receiveWrappedSessionKey( + fromUserId: number, + wrappedPayload: WrappedSessionKeyPayload, + sessionKeyHash?: string +): Promise { + try { + const senderPublicKey = await fetchUserPublicKey(fromUserId, getAuthToken()!); + if (!senderPublicKey) { + console.error("Failed to get sender public key"); + return; + } + if (!wrappedPayload || !sessionKeyHash) { + console.error("Missing wrapped payload or session key hash"); + return; + } + + // Unwrap the session key from the encrypted payload + const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, { + salt: wrappedPayload.salt, + iv2: wrappedPayload.iv2, + wrapped: wrappedPayload.wrapped + }); + + // Use the unwrapped session key directly (both sides should have the same key) + await setSessionKey(fromUserId, unwrappedSessionKey); + } catch (e) { + console.error("Failed to unwrap session key:", e); + } +} + +/** + * Accepts an incoming call from the specified user + * @param userId - The ID of the user who initiated the call + * @returns Promise that resolves to true if call was accepted successfully + */ +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) + call = await createPeerConnection(userId); + if (!call) return false; + } + + // Get user media and attach (only if not already attached) + if (!call.localStream) { + 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 + const call = await createPeerConnection(userId); + if (!call) return; + + call.remoteUsername = username; + } 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 { + // Small delay to ensure remote peer finishes processing the accept + // This prevents race conditions where our offer arrives before they're ready + await delay(NEGOTIATION_DELAY); + + // 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 { + let call = calls.get(userId); + + // Handle race condition - offer might arrive before peer connection is created + if (!call) { + call = await createPeerConnection(userId); + if (!call) { + throw new Error("Failed to create call for offer"); + } + } + + try { + // Ensure we have local media before answering + if (!call.localStream) { + try { + const localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + call.localStream = localStream; + localStream.getTracks().forEach(track => call!.peerConnection.addTrack(track, localStream)); + } catch (mediaError) { + console.error("Failed to get local media:", mediaError); + // Continue anyway - we can still receive media + } + } + + // Set remote description + await call.peerConnection.setRemoteDescription(offer); + + // Create answer + const answer = await call.peerConnection.createAnswer(); + await call.peerConnection.setLocalDescription(answer); + + // Attach transforms on callee side if session key is available + // If not available yet, setSessionKey will apply them when it arrives + if (call.sessionKey) { + await call.createE2EETransform(call.sessionKey, call.sessionId); + } + + // 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); + + // Reset negotiating flag + call.isNegotiating = false; + + // Attach transforms on initiator side if session key is available + // If not available yet, setSessionKey will apply them when it arrives + if (call.sessionKey) { + await call.createE2EETransform(call.sessionKey, call.sessionId); + } + + // Check if there are new receivers with tracks that haven't been notified yet + // This handles the case where tracks exist but the track event hasn't fired yet + const receivers = call.peerConnection.getReceivers(); + for (const receiver of receivers) { + if (receiver.track) { + const track = receiver.track; + + // Find the stream for this track + const transceiver = call.peerConnection.getTransceivers().find(t => t.receiver === receiver); + if (transceiver && transceiver.receiver.track) { + // Manually trigger stream handlers for tracks that didn't fire events + if (track.kind === "video" && callbacks.onRemoteVideoStream) { + // Create a MediaStream from the track + const stream = new MediaStream([track]); + callbacks.onRemoteVideoStream(userId, stream); + } + } + } + } + } catch (error) { + console.error("Failed to handle answer:", error); + throw error; + } +} + +export async function handleIceCandidate(userId: number, candidate: RTCIceCandidateInit): Promise { + let call = calls.get(userId); + if (!call) { + // Don't create peer connection here - ICE candidates will be gathered again after connection is established + return; + } + + try { + 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) { + return false; + } + return call.toggleMute(); +} + +export function getCall(userId: number): WebRTCCall | undefined { + return calls.get(userId); +} + +export async function toggleVideo(userId: number): Promise { + const call = calls.get(userId); + if (!call) { + return false; + } + return await call.toggleVideo(); +} + +export async function toggleScreenShare(userId: number): Promise { + const call = calls.get(userId); + if (!call) { + return false; + } + return await call.toggleScreenShare(); +} + +export function cleanupCall(userId: number): void { + const call = calls.get(userId); + if (call) { + call.cleanup(); + calls.delete(userId); + } +} + +/** + * Update the remote video enabled state (called when receiving signaling) + */ +export function setRemoteVideoEnabled(userId: number, enabled: boolean): void { + const call = calls.get(userId); + if (call) { + call.setRemoteVideoEnabled(enabled); + } +} + +/** + * Update the remote screen sharing state (called when receiving signaling) + */ +export function setRemoteScreenSharing(userId: number, enabled: boolean): void { + const call = calls.get(userId); + if (call) { + call.setRemoteScreenSharing(enabled); + } +} + +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/hooks/useDownloadAppScreen.tsx b/frontend/src/core/hooks/useDownloadAppScreen.tsx index bc058f2..b7999ab 100644 --- a/frontend/src/core/hooks/useDownloadAppScreen.tsx +++ b/frontend/src/core/hooks/useDownloadAppScreen.tsx @@ -1,5 +1,5 @@ import { Navigate } from "react-router-dom"; -import { MINIMUM_WIDTH } from "../config"; +import { MINIMUM_WIDTH } from "@/core/config"; import useWindowSize from "./useWindowSize"; export default function useDownloadAppScreen() { diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index c913612..940ba78 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,99 @@ export interface EncryptedMessageJson { export interface DialogProps { isOpen: boolean; onOpenChange: (value: boolean) => void; +} + +// Call types +export interface CallSignalingData { + fromUserId: number; + toUserId: number; +} + +export interface CallInviteData { + fromUsername: string; +} + +export interface CallInviteMessageData { + fromUsername: string; +} + +export type CallSignalingDataType = "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject" | "call_session_key" | "call_signaling" | "call_video_toggle" | "call_screen_share_toggle"; + +export interface CallSignalingMessage extends WebSocketMessage { + type: CallSignalingDataType; + fromUserId: number; + toUserId: number; + sessionKeyHash?: string; + data: CallSignalingMessageData; +} + +export type CallSignalingMessageData = + | CallInviteMessageData + | CallAcceptData + | CallRejectData + | CallOfferData + | CallAnswerData + | CallIceCandidateData + | CallEndData + | CallSessionKeyData + | CallVideoToggleData + | CallScreenShareToggleData; + +export interface CallAcceptData { + fromUserId: number; +} + +export interface CallRejectData { + fromUserId: number; +} + +export interface CallOfferData extends RTCSessionDescriptionInit { +} + +export interface CallAnswerData extends RTCSessionDescriptionInit { +} + +export interface CallIceCandidateData extends RTCIceCandidateInit { +} + +export interface CallEndData { + fromUserId: number; +} + +export interface CallSessionKeyData { + wrappedSessionKey?: WrappedSessionKeyPayload; +} + +export interface CallVideoToggleData { + enabled: boolean; +} + +export interface CallScreenShareToggleData { + enabled: boolean; +} + +export interface CallVideoToggleMessageData { + fromUserId: number; + data: CallVideoToggleData; +} + +export interface CallScreenShareToggleMessageData { + fromUserId: number; + data: CallScreenShareToggleData; +} + +export interface WrappedSessionKeyPayload { + salt: string; + iv2: string; + wrapped: string; +} + +export interface CallVideoToggleMessage extends CallSignalingMessage { + type: "call_video_toggle"; + data: CallVideoToggleData; +} + +export interface CallScreenShareToggleMessage extends CallSignalingMessage { + type: "call_screen_share_toggle"; + data: CallScreenShareToggleData; } \ No newline at end of file diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 4ec865b..1a6d736 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 "./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" && response.data) { + callSignalingHandler.handleWebSocketMessage(response.data); + } + // 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..b54e2ab --- /dev/null +++ b/frontend/src/pages/chat/css/_callWindow.scss @@ -0,0 +1,575 @@ +@use "../../../css/material" as *; +@use "sass:color"; + +.call-window { + $transition: cubic-bezier(0.4, 0, 0.2, 1); + + position: fixed; + z-index: 1000; + display: flex; + flex-direction: column; + user-select: none; + + // Base transition for all properties + transition: all 0.4s $transition; + + // Disable all transitions while dragging for immediate feedback + &.dragging { + transition: none !important; + } + + // Maximized mode (fullscreen) + &.maximized { + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + background-color: rgba($color-dark-surface, 0.98); + backdrop-filter: blur(40px); + -webkit-backdrop-filter: blur(40px); + border: none; + border-radius: 0; + cursor: default; + + &.visible { + opacity: 1; + transform: translateY(0); + } + + &.hidden { + opacity: 0; + transform: translateY(-100%); // Slide up when ending in fullscreen + pointer-events: none; + } + + .call-content { + padding: 32px; + gap: 24px; + + .video-tiles-grid { + gap: 24px; + min-height: 400px; + } + } + + .call-header { + .window-controls { + .window-control-btn { + color: $color-dark-on-surface-variant; + + &:hover { + color: $color-dark-on-surface; + background: rgba($color-dark-on-surface, 0.08); + } + } + } + } + } + + // Minimized mode (PiP) + &.minimized { + width: 400px; + height: 300px; + background-color: rgba($color-dark-surface, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 2px solid rgba($color-dark-outline, 0.4); + border-radius: 16px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + cursor: grab; + + &.visible { + opacity: 1; + transform: scale(1); + } + + &.hidden { + opacity: 0 !important; + transform: scale(0.7) !important; // Scale down and fade when ending in PiP + pointer-events: none; + transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important; + } + + &.dragging { + cursor: grabbing; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7); + } + + .call-header { + padding: 12px; + min-height: auto; + + .call-header-info { + .username { + font-size: 14px; + } + + .status { + font-size: 12px; + } + } + + .window-controls { + .window-control-btn { + color: $color-dark-primary; + + &:hover { + color: color.adjust($color-dark-primary, $lightness: 10%); + background: rgba($color-dark-primary, 0.1); + } + } + } + } + + .call-content { + padding: 8px; + gap: 8px; + + .video-tiles-grid { + gap: 8px; + min-height: 120px; + } + + .video-tile { + .tile-label { + font-size: 10px; + padding: 2px 6px; + } + + .video-placeholder { + .placeholder-avatar { + width: 40px; + height: 40px; + } + + .placeholder-username { + font-size: 12px; + } + } + } + + .screen-share-tile { + .screen-share-video { + object-fit: contain; + } + } + } + + .call-controls { + padding: 8px; + gap: 8px; + + mdui-button-icon { + --mdui-comp-icon-button-size: 36px; + } + } + } + + // Dynamic gradients based on call state + &.gradient-calling { + border-color: rgba(255, 193, 7, 0.5); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(255, 193, 7, 0.12) 0%, + rgba(255, 152, 0, 0.12) 50%, + rgba(255, 193, 7, 0.12) 100%); + border-radius: inherit; + animation: pulse-gradient 2s ease-in-out infinite; + pointer-events: none; + } + } + + &.gradient-connecting { + border-color: rgba(33, 150, 243, 0.5); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(33, 150, 243, 0.12) 0%, + rgba(63, 81, 181, 0.12) 50%, + rgba(33, 150, 243, 0.12) 100%); + border-radius: inherit; + animation: connecting-gradient 1.5s ease-in-out infinite; + pointer-events: none; + } + } + + &.gradient-active { + border-color: rgba(76, 175, 80, 0.5); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(76, 175, 80, 0.12) 0%, + rgba(56, 142, 60, 0.12) 50%, + rgba(76, 175, 80, 0.12) 100%); + border-radius: inherit; + animation: active-gradient 3s ease-in-out infinite; + pointer-events: none; + } + } + + &.gradient-default { + border-color: rgba(158, 158, 158, 0.3); + } +} + +.call-header { + padding: 20px; + border-bottom: 1px solid $color-dark-outline-variant; + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + + .window-controls { + display: flex; + gap: 8px; + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + + .window-control-btn { + transition: all 0.2s ease; + } + } + + .call-header-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + + .username { + margin: 0; + font-size: 20px; + font-weight: 600; + color: $color-dark-on-surface; + } + + .status { + margin: 0; + font-size: 14px; + color: $color-dark-on-surface-variant; + font-weight: 500; + } + + .encryption-emojis { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 8px; + + .encryption-emoji { + font-size: 24px; + display: inline-block; + animation: emoji-pulse 2s ease-in-out infinite; + + &:nth-child(1) { animation-delay: 0s; } + &:nth-child(2) { animation-delay: 0.2s; } + &:nth-child(3) { animation-delay: 0.4s; } + &:nth-child(4) { animation-delay: 0.6s; } + } + } + } +} + +.call-content { + padding: 24px; + display: flex; + flex-direction: row; + gap: 20px; + position: relative; + z-index: 1; + flex: 1; + overflow: hidden; + + // Reduce padding when screen share is active to maximize space + &.with-screen-share { + padding: 12px; + gap: 16px; + } + + // Default layout (no screen share): tiles in grid + .screen-share-area { + display: none; + } + + .video-tiles-sidebar { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + flex: 1; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + } + + // Layout with screen share: screen share on left, video tiles on right + &.with-screen-share { + .screen-share-area { + display: flex; + align-items: center; + justify-content: center; + flex: 4; // Give 4x more space than sidebar + min-width: 0; + max-width: 80vw; // Limit screen share to 80% of viewport width + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + // Ensure no padding/margin that could create blank space + padding: 0; + margin: 0; + } + + .video-tiles-sidebar { + display: flex; + flex-direction: column; + grid-template-columns: none; + flex: 1; // Give 1x space (4:1 ratio = 80:20) + min-width: 250px; // Ensure minimum 300px width + max-width: 20%; // Cap at 20% of container + flex-shrink: 0; + gap: 16px; + overflow-y: auto; + overflow-x: hidden; + padding: 5px; + + // Custom scrollbar + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: rgba($color-dark-surface-variant, 0.3); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: rgba($color-dark-primary, 0.5); + border-radius: 3px; + + &:hover { + background: rgba($color-dark-primary, 0.7); + } + } + + .video-tile { + min-height: 168px; + } + } + } + + .video-tile { + position: relative; + background: rgba($color-dark-surface-variant, 0.5); + border-radius: 16px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba($color-dark-outline, 0.3); + transition: all 0.3s ease; + + &:hover { + border-color: rgba($color-dark-outline, 0.5); + transform: scale(1.02); + } + + .video-element { + width: 100%; + height: 100%; + object-fit: cover; + } + + .video-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + width: 100%; + height: 100%; + background: rgba($color-dark-surface-variant, 0.6); + + .placeholder-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + } + + .placeholder-username { + font-size: 18px; + font-weight: 600; + color: $color-dark-on-surface; + } + } + + .tile-label { + position: absolute; + bottom: 12px; + left: 12px; + padding: 6px 12px; + background: rgba(0, 0, 0, 0.75); + color: white; + font-size: 13px; + font-weight: 600; + border-radius: 8px; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + + &.local-video { + .video-element { + transform: scaleX(-1); // Mirror local video + } + } + } + + .screen-share-tile { + position: relative; + border: 2px solid rgba($color-dark-primary, 0.6); + border-radius: 12px; + overflow: hidden; + background: rgba(0, 0, 0, 0.95); + display: inline-block; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + // Constrain to available space but size to content + max-width: 100%; + max-height: 100%; + + .screen-share-video { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + display: block; + object-fit: contain; + } + + .tile-label { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + font-size: 14px; + padding: 8px 16px; + background: rgba($color-dark-primary, 0.9); + color: $color-dark-on-primary; + border-radius: 8px; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + pointer-events: none; + z-index: 1; + } + } +} + +.call-controls { + padding: 20px; + display: flex; + justify-content: center; + gap: 16px; + border-top: 1px solid $color-dark-outline-variant; + position: relative; + z-index: 1; + flex-shrink: 0; + + mdui-button-icon { + transition: all 0.2s ease; + + &[icon="call_end"] { + background: rgba(244, 67, 54, 0.2); + color: rgb(244, 67, 54); + + &:hover { + background: rgba(244, 67, 54, 0.35); + transform: scale(1.1); + } + } + + &[icon="videocam"], + &[icon="videocam_off"], + &[icon="screen_share"], + &[icon="stop_screen_share"], + &[icon="mic"], + &[icon="mic_off"] { + &:hover { + background: rgba($color-dark-primary, 0.15); + transform: scale(1.1); + } + } + } +} + +.remote-audio { + position: fixed; + bottom: 0px; + right: 0px; + width: 0; + height: 0; + opacity: 0; + visibility: hidden; +} + +// Animations +@keyframes pulse-gradient { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 0.7; + } +} + +@keyframes connecting-gradient { + 0%, 100% { + opacity: 0.3; + } + 50% { + opacity: 0.6; + } +} + +@keyframes active-gradient { + 0%, 100% { + opacity: 0.2; + } + 50% { + opacity: 0.4; + } +} + +@keyframes emoji-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.15); + opacity: 1; + } +} 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/useCall.ts b/frontend/src/pages/chat/hooks/useCall.ts new file mode 100644 index 0000000..bf5e611 --- /dev/null +++ b/frontend/src/pages/chat/hooks/useCall.ts @@ -0,0 +1,359 @@ +import { useAppState } from "@/pages/chat/state"; +import * as WebRTC from "@/core/calls/webrtc"; +import { CallSignalingHandler } from "@/core/calls/signaling"; +import { setCallSignalingHandler } from "@/core/websocket"; +import { generateCallSessionKey, generateCallEmojis } from "@/core/calls/encryption"; +import { createRef, useEffect } from "react"; +import { doAfterInteraction } from "@/utils/utils"; + +// Global refs shared across all instances +let globalRemoteAudioRef = createRef(); +let globalLocalVideoRef = createRef(); +let globalRemoteVideoRef = createRef(); +let globalLocalScreenShareRef = createRef(); +let globalRemoteScreenShareRef = createRef(); + +export default function useCall() { + const { + chat, + startCall, + endCall, + setCallStatus, + toggleMute, + toggleVideo, + toggleScreenShare, + setCallEncryption, + setCallSessionKeyHash, + setRemoteVideoEnabled, + setRemoteScreenSharing, + user + } = useAppState(); + + const remoteAudioRef = globalRemoteAudioRef; + const localVideoRef = globalLocalVideoRef; + const remoteVideoRef = globalRemoteVideoRef; + const localScreenShareRef = globalLocalScreenShareRef; + const remoteScreenShareRef = globalRemoteScreenShareRef; + + useEffect(() => { + // 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, + setCallSessionKeyHash, + setRemoteVideoEnabled, + setRemoteScreenSharing + })); + setCallSignalingHandler(signalingHandler); + + // Set up call state change handler + WebRTC.callbacks.onCallStateChange = (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 audio stream handler + WebRTC.callbacks.onRemoteStream = (_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(() => { + doAfterInteraction(() => el.play()); + }); + } catch (e) { + console.warn("failed to attach remote stream:", e); + } + }; + + // Set up local video stream handler + WebRTC.callbacks.onLocalVideoStream = (_userId: number, stream: MediaStream | null) => { + if (!localVideoRef.current) { + return; + } + const el = localVideoRef.current; + try { + el.srcObject = stream; + el.muted = true; // Always mute local video to avoid feedback + el.autoplay = true; + if (stream) { + el.play().catch((err) => { + console.error("Failed to play local video:", err); + doAfterInteraction(() => el.play()).catch(() => {}); + }); + } + } catch (e) { + console.warn("failed to attach local video stream:", e); + } + }; + + // Set up remote video stream handler + WebRTC.callbacks.onRemoteVideoStream = (_userId: number, stream: MediaStream | null) => { + if (!remoteVideoRef.current) { + return; + } + const el = remoteVideoRef.current; + try { + el.srcObject = stream; + el.muted = false; + el.autoplay = true; + if (stream) { + el.play().catch((err) => { + console.error("Failed to play remote video:", err); + doAfterInteraction(() => el.play()).catch(() => {}); + }); + } + } catch (e) { + console.warn("failed to attach remote video stream:", e); + } + }; + + // Set up local screen share handler + WebRTC.callbacks.onLocalScreenShare = (_userId: number, stream: MediaStream | null) => { + if (!localScreenShareRef.current) { + return; + } + const el = localScreenShareRef.current; + try { + el.srcObject = stream; + el.muted = true; + el.autoplay = true; + if (stream) { + el.play().catch((err) => { + console.error("Failed to play local screen share:", err); + doAfterInteraction(() => el.play()).catch(() => {}); + }); + } + } catch (e) { + console.warn("failed to attach local screen share stream:", e); + } + }; + + // Set up remote screen share handler + WebRTC.callbacks.onRemoteScreenShare = (_userId: number, stream: MediaStream | null) => { + if (!remoteScreenShareRef.current) { + return; + } + const el = remoteScreenShareRef.current; + try { + el.srcObject = stream; + el.muted = false; + el.autoplay = true; + if (stream) { + el.play().catch((err) => { + console.error("Failed to play remote screen share:", err); + doAfterInteraction(() => el.play()).catch(() => {}); + }); + } + } catch (e) { + console.warn("failed to attach remote screen share stream:", e); + } + }; + + return () => { + WebRTC.cleanup(); + setCallSignalingHandler(null); + }; + }, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]); + + // Watch for session key hash changes and generate emojis + useEffect(() => { + if (chat.call.sessionKeyHash && chat.call.encryptionEmojis.length === 0) { + const emojis = generateCallEmojis(chat.call.sessionKeyHash); + setCallEncryption(chat.call.sessionKeyHash, emojis); + } + }, [chat.call.sessionKeyHash, chat.call.encryptionEmojis.length, setCallEncryption]); + + 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) { + return; + } + + let sessionKey; + try { + // Generate call session key and emojis + sessionKey = await generateCallSessionKey(); + const emojis = generateCallEmojis(sessionKey.hash); + + // Start the call in state + startCall(userId, username); + setCallStatus("calling"); + setCallEncryption(sessionKey.hash, emojis); + } catch (error) { + console.error("Failed to generate call encryption:", error); + endCall(); + return; + } + + // Initiate WebRTC call + const success = await WebRTC.initiateCall(userId, username); + + if (success && sessionKey) { + // Set the session key for ourselves (initiator) + await WebRTC.setSessionKey(userId, sessionKey.key); + + // Send session key hash to the receiver for visual verification + await WebRTC.sendCallSessionKey(userId, sessionKey.hash); + // Also wrap and send the actual session key for E2EE media + await WebRTC.sendWrappedCallSessionKey(userId, sessionKey.key, sessionKey.hash); + } else { + 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 handleToggleVideo() { + if (chat.call.remoteUserId) { + const isEnabled = await WebRTC.toggleVideo(chat.call.remoteUserId); + // Update video state in store + if (isEnabled !== chat.call.isVideoEnabled) { + toggleVideo(); + } + } + } + + async function handleToggleScreenShare() { + if (chat.call.remoteUserId) { + const isEnabled = await WebRTC.toggleScreenShare(chat.call.remoteUserId); + // Update screen share state in store + if (isEnabled !== chat.call.isSharingScreen) { + toggleScreenShare(); + } + } + } + + async function handleIncomingCall(userId: number, username: string) { + // Don't generate session key here - wait for it from the caller + 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); + } + + async function handleCallSessionKey(sessionKeyHash: string) { + try { + // Just generate and display the emojis from the hash + // The actual session key will arrive via the wrapped key mechanism + const emojis = generateCallEmojis(sessionKeyHash); + setCallEncryption(sessionKeyHash, emojis); + } catch (error) { + console.error("Failed to generate call emojis from hash:", error); + } + } + + return { + call: chat.call, + initiateCall, + acceptCall, + rejectCall, + endCall: handleEndCall, + toggleMute: handleToggleMute, + toggleVideo: handleToggleVideo, + toggleScreenShare: handleToggleScreenShare, + handleIncomingCall, + handleCallOffer, + handleCallAnswer, + handleIceCandidate, + handleCallSessionKey, + remoteAudioRef, + localVideoRef, + remoteVideoRef, + localScreenShareRef, + remoteScreenShareRef + }; +} + diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 189ef64..07029c3 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -6,7 +6,7 @@ import { fetchDMHistory, decryptDm, sendDMViaWebSocket -} from "../../../core/api/dmApi"; +} from "@/core/api/dmApi"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 67bf059..cf16791 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,23 @@ interface ActiveDM { publicKey: string | null } +interface CallState { + isActive: boolean; + status: CallStatus; + startTime: number | null; + isMuted: boolean; + remoteUserId: number | null; + remoteUsername: string | null; + isInitiator: boolean; + isMinimized: boolean; + sessionKeyHash: string | null; + encryptionEmojis: string[]; + isVideoEnabled: boolean; + isRemoteVideoEnabled: boolean; + isSharingScreen: boolean; + isRemoteScreenSharing: boolean; +} + interface ChatState { messages: Message[]; currentChat: string; @@ -30,6 +49,7 @@ interface ChatState { publicChatPanel: PublicChatPanel | null; dmPanel: DMPanel | null; pendingPanel?: MessagePanel | null; + call: CallState; } export interface UserState { @@ -54,6 +74,21 @@ 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; + toggleCallMinimize: () => void; + receiveCall: (userId: number, username: string) => void; + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; + setCallSessionKeyHash: (sessionKeyHash: string) => void; + toggleVideo: () => void; + toggleScreenShare: () => void; + setRemoteVideoEnabled: (enabled: boolean) => void; + setRemoteScreenSharing: (enabled: boolean) => void; + toggleCallMinimized: () => void; + // User state user: UserState; setUser: (token: string, user: User) => void; @@ -79,7 +114,23 @@ 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, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false + } }, addMessage: (message: Message) => set((state) => { // Check if message already exists to prevent duplicates @@ -358,5 +409,173 @@ 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, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false + } + } + })), + + endCall: () => set((state) => ({ + chat: { + ...state.chat, + call: { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: 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 + } + } + })), + + toggleCallMinimize: () => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isMinimized: !state.chat.call.isMinimized + } + } + })), + + 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, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false + } + } + })), + + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + sessionKeyHash, + encryptionEmojis + } + } + })), + + setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + sessionKeyHash + } + } + })), + + toggleVideo: () => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isVideoEnabled: !state.chat.call.isVideoEnabled + } + } + })), + + toggleScreenShare: () => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isSharingScreen: !state.chat.call.isSharingScreen + } + } + })), + + setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isRemoteVideoEnabled: enabled + } + } + })), + + setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isRemoteScreenSharing: enabled + } + } + })), + toggleCallMinimized: () => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isMinimized: !state.chat.call.isMinimized + } + } + })) })); \ 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..1183ab9 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/calls/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/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index a76bc15..b068269 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -3,35 +3,29 @@ import useProfile from "@/pages/chat/hooks/useProfile"; import defaultAvatar from "@/images/default-avatar.png"; import { useState } from "react"; import { ProfileDialog } from "./profile/ProfileDialog"; +import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; export function ChatHeader() { const { profileData } = useProfile(); const [isProfileOpen, setIsProfileOpen] = useState(false); - const handleProfileClick = () => { - setIsProfileOpen(true); - }; - - const profilePictureUrl = profileData?.profile_picture || defaultAvatar; + const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); return ( <>
{PRODUCT_NAME}
+ ); diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index c9bb259..b441f5b 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -8,6 +8,7 @@ import type { Message, WebSocketMessage } from "@/core/types"; import defaultAvatar from "@/images/default-avatar.png"; import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity"; import type { DMPanel } from "./panels/DMPanel"; +import useCall from "@/pages/chat/hooks/useCall"; interface MessagePanelRendererProps { panel: MessagePanel | null; @@ -26,6 +27,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { const [editMessage, setEditMessage] = useState(null); const [editVisible, setEditVisible] = useState(Boolean(editMessage)); const [pendingAction, setPendingAction] = useState(null); + const { initiateCall } = useCall(); // Drag & drop @@ -156,6 +158,18 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { previousMessageCountRef.current = currentMessageCount; }, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]); + function handleCallClick() { + if (panel && panelState && panel.isDm()) { + const dmPanel = panel as DMPanel; + const userId = dmPanel.getDMUserId(); + const username = dmPanel.getDMUsername(); + + if (userId && username) { + initiateCall(userId, username); + } + } + }; + return (
{panelState ? ( - <> - {panelState.online ? "Online" : "Offline"} - {panelState.isTyping && " โ€ข Typing..."} - + panelState.online ? "Online" : "Offline" ) : ( "ะ’ั‹ะฑะตั€ะธั‚ะต ั‡ะฐั‚, ั‡ั‚ะพะฑั‹ ะฝะฐั‡ะฐั‚ัŒ ะฟะตั€ะตะฟะธัะบัƒ" )}

+ {panel?.isDm() && ( + + )}
diff --git a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx new file mode 100644 index 0000000..7413a0a --- /dev/null +++ b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx @@ -0,0 +1,323 @@ +import { useState, useEffect } from "react"; +import { useAppState, type CallStatus } from "@/pages/chat/state"; +import useCall from "@/pages/chat/hooks/useCall"; +import defaultAvatar from "@/images/default-avatar.png"; +import { createPortal } from "react-dom"; +import { id } from "@/utils/utils"; + +export function CallWindow() { + const { chat, toggleCallMinimize, user } = useAppState(); + const { call } = chat; + const { + acceptCall, + rejectCall, + remoteAudioRef, + endCall, + toggleMute, + toggleVideo, + toggleScreenShare, + localVideoRef, + remoteVideoRef, + localScreenShareRef, + remoteScreenShareRef + } = useCall(); + const [pipPosition, setPipPosition] = useState({ x: window.innerWidth - 420, y: window.innerHeight - 320 }); + const [isDragging, setIsDragging] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [callDuration, setCallDuration] = useState(0); + const [isVisible, setIsVisible] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + const [wasMinimized, setWasMinimized] = useState(false); + const [callData, setCallData] = useState<{ + remoteUsername: string | null; + status: CallStatus; + isInitiator: boolean; + isMuted: boolean; + } | null>(null); + + const status = callData?.status || call.status; + const remoteUsername = callData?.remoteUsername || call.remoteUsername; + const isInitiator = callData?.isInitiator || call.isInitiator; + const isMuted = callData?.isMuted || call.isMuted; + + 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]); + + // Preserve call data during exit animation + useEffect(() => { + if (call.isActive) { + setCallData({ + remoteUsername: call.remoteUsername, + status: call.status, + isInitiator: call.isInitiator, + isMuted: call.isMuted + }); + } + }, [call.isActive, call.remoteUsername, call.status, call.isInitiator, call.isMuted]); + + // Track minimized state for exit animation + useEffect(() => { + if (call.isActive) { + setWasMinimized(call.isMinimized); + } + }, [call.isActive, call.isMinimized]); + + // Handle visibility animation with entrance and exit delays + useEffect(() => { + if (call.isActive) { + setShouldRender(true); + // Small delay to ensure DOM is ready, then trigger animation + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setIsVisible(true); + }); + }); + } else { + if (shouldRender) { + // Call ended - start exit animation + setIsVisible(false); + // After animation completes, stop rendering + const timer = setTimeout(() => { + setShouldRender(false); + setCallData(null); + setWasMinimized(false); + }, 400); // Match the CSS transition duration + return () => clearTimeout(timer); + } else { + // Call not active and not rendered - ensure clean state + setShouldRender(false); + setIsVisible(false); + setCallData(null); + setWasMinimized(false); + } + } + }, [call.isActive, shouldRender, wasMinimized]); + + // Handle dragging for PiP mode + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging && call.isMinimized) { + setPipPosition({ + x: e.clientX - dragOffset.x, + y: e.clientY - dragOffset.y + }); + } + }; + + const handleMouseUp = () => { + if (isDragging) { + setIsDragging(false); + } + }; + + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + } + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, call.isMinimized, dragOffset]); + + // Cleanup effect to reset state when component unmounts + useEffect(() => { + return () => { + setIsVisible(false); + setShouldRender(false); + setCallData(null); + }; + }, []); + + 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 (status) { + case "calling": + return "Calling..."; + case "connecting": + return "Connecting..."; + case "active": + return formatDuration(callDuration); + default: + return ""; + } + } + + function getGradientClass() { + switch (status) { + case "calling": + return "gradient-calling"; + case "connecting": + return "gradient-connecting"; + case "active": + return "gradient-active"; + default: + return "gradient-default"; + } + } + + + return ( + createPortal( + <> +