Merge branch 'feature/calls'

This commit is contained in:
2025-10-14 21:04:28 +03:00
Unverified
29 changed files with 3679 additions and 29 deletions
+1
View File
@@ -0,0 +1 @@
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
+5 -1
View File
@@ -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. 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". 3. The supporting text slot for MDUI lists is "description".
4. When working with lists/sets in states, use the "useImmer" hook. 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-<color-name>` variables. For all colors, refer
to `frontend/src/resources/css/common/_colors.scss`.
+2
View File
@@ -44,6 +44,8 @@ jobs:
JWT_SECRET=${{ secrets.JWT_SECRET }} JWT_SECRET=${{ secrets.JWT_SECRET }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }} VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }} VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
TURN_USERNAME=${{ vars.TURN_USERNAME }}
TURN_PASSWORD=${{ secrets.TURN_PASSWORD }}
EOF EOF
- name: Build container - name: Build container
run: | run: |
+3 -2
View File
@@ -5,7 +5,7 @@ import subprocess
import sys import sys
import os import os
from routes import account, messaging, profile, push from routes import account, messaging, profile, push, webrtc
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -56,4 +56,5 @@ app.add_middleware(
app.include_router(account.router) app.include_router(account.router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push") app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc")
+84
View File
@@ -911,6 +911,90 @@ class MessaggingSocketManager:
await websocket.send_json({"type": type, "data": response}) await websocket.send_json({"type": type, "data": response})
except HTTPException as e: except HTTPException as e:
await self.send_error(websocket, type, 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: else:
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}}) await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
+89
View File
@@ -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
+4
View File
@@ -139,4 +139,8 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
export function restoreKeys() { export function restoreKeys() {
currentPublicKey = ub64(localStorage.getItem("publicKey")!); currentPublicKey = ub64(localStorage.getItem("publicKey")!);
currentPrivateKey = ub64(localStorage.getItem("privateKey")!); currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
}
export function getAuthToken(): string | null {
return localStorage.getItem("authToken");
} }
+145
View File
@@ -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<EncodedFrame>) {
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);
});
+238
View File
@@ -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<CallSessionKey> {
// 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<CallSessionKey> {
// 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<CallSessionKey> {
// 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<CallSessionKey> {
// 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<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
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<Record<string, unknown>> {
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<WrappedSessionKeyPayload> {
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<CallSessionKey> {
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<Uint8Array> {
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);
}
+170
View File
@@ -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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { MINIMUM_WIDTH } from "../config"; import { MINIMUM_WIDTH } from "@/core/config";
import useWindowSize from "./useWindowSize"; import useWindowSize from "./useWindowSize";
export default function useDownloadAppScreen() { export default function useDownloadAppScreen() {
+99
View File
@@ -247,6 +247,10 @@ export interface DmEncryptedJSON {
} }
} }
export interface IceServersResponse {
iceServers: RTCIceServer[];
}
// --------------- // ---------------
// WebSocket types // WebSocket types
// --------------- // ---------------
@@ -428,4 +432,99 @@ export interface EncryptedMessageJson {
export interface DialogProps { export interface DialogProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (value: boolean) => void; 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;
} }
+19
View File
@@ -8,6 +8,7 @@
import { API_WS_BASE_URL } from "./config"; import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types"; import type { WebSocketMessage } from "./types";
import { delay } from "@/utils/utils"; import { delay } from "@/utils/utils";
import { CallSignalingHandler } from "./calls/signaling";
/** /**
* Creates a new WebSocket connection to the chat server * Creates a new WebSocket connection to the chat server
@@ -35,6 +36,11 @@ export let websocket: WebSocket = create();
*/ */
let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null; let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
/**
* Call signaling handler
*/
let callSignalingHandler: CallSignalingHandler | null = null;
/** /**
* Set the global WebSocket message handler * Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages * @param handler - Function to handle WebSocket messages
@@ -43,6 +49,14 @@ export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<an
globalMessageHandler = handler; globalMessageHandler = handler;
} }
/**
* Set the call signaling handler
* @param handler - Call signaling handler instance
*/
export function setCallSignalingHandler(handler: CallSignalingHandler | null): void {
callSignalingHandler = handler;
}
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> { export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload); console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -97,6 +111,11 @@ websocket.addEventListener("message", (e) => {
try { try {
const response: WebSocketMessage<any> = JSON.parse(e.data); const response: WebSocketMessage<any> = 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 // Route message to global handler if set
if (globalMessageHandler) { if (globalMessageHandler) {
globalMessageHandler(response); globalMessageHandler(response);
@@ -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;
}
}
@@ -23,6 +23,9 @@
.chat-header-info { .chat-header-info {
display: flex; display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
.info-chat { .info-chat {
display: flex; display: flex;
+2 -1
View File
@@ -8,4 +8,5 @@
@use "context-menu"; @use "context-menu";
@use "profile-dialog"; @use "profile-dialog";
@use "settings-dialog"; @use "settings-dialog";
@use "animations"; @use "animations";
@use "callWindow";
+359
View File
@@ -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<HTMLAudioElement>();
let globalLocalVideoRef = createRef<HTMLVideoElement>();
let globalRemoteVideoRef = createRef<HTMLVideoElement>();
let globalLocalScreenShareRef = createRef<HTMLVideoElement>();
let globalRemoteScreenShareRef = createRef<HTMLVideoElement>();
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<boolean> {
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
};
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {
fetchDMHistory, fetchDMHistory,
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket
} from "../../../core/api/dmApi"; } from "@/core/api/dmApi";
import type { User, Message, DmEncryptedJSON } from "@/core/types"; import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
+222 -3
View File
@@ -10,7 +10,9 @@ import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; 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 { interface ActiveDM {
userId: number; userId: number;
@@ -18,6 +20,23 @@ interface ActiveDM {
publicKey: string | null 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 { interface ChatState {
messages: Message[]; messages: Message[];
currentChat: string; currentChat: string;
@@ -30,6 +49,7 @@ interface ChatState {
publicChatPanel: PublicChatPanel | null; publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null; dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null; pendingPanel?: MessagePanel | null;
call: CallState;
} }
export interface UserState { export interface UserState {
@@ -54,6 +74,21 @@ interface AppState {
switchToPublicChat: (chatName: string) => Promise<void>; switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>; switchToDM: (dmData: DMPanelData) => Promise<void>;
// 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 state
user: UserState; user: UserState;
setUser: (token: string, user: User) => void; setUser: (token: string, user: User) => void;
@@ -79,7 +114,23 @@ export const useAppState = create<AppState>((set, get) => ({
activePanel: null, activePanel: null,
publicChatPanel: null, publicChatPanel: null,
dmPanel: 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) => { addMessage: (message: Message) => set((state) => {
// Check if message already exists to prevent duplicates // Check if message already exists to prevent duplicates
@@ -358,5 +409,173 @@ export const useAppState = create<AppState>((set, get) => ({
// Let MessagePanelRenderer handle the animation timing completely // Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes // 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
}
}
}))
})); }));
+2
View File
@@ -2,6 +2,7 @@ import { LeftPanel } from "./left/LeftPanel";
import { RightPanel } from "./right/RightPanel"; import { RightPanel } from "./right/RightPanel";
import "@/pages/chat/css/chat.scss"; import "@/pages/chat/css/chat.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { CallWindow } from "./right/calls/CallWindow";
export default function ChatPage() { export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const { navigate: navigateDownloadApp } = useDownloadAppScreen();
@@ -13,6 +14,7 @@ export default function ChatPage() {
<LeftPanel /> <LeftPanel />
<RightPanel /> <RightPanel />
</div> </div>
<CallWindow />
</div> </div>
); );
} }
+7 -13
View File
@@ -3,35 +3,29 @@ import useProfile from "@/pages/chat/hooks/useProfile";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react"; import { useState } from "react";
import { ProfileDialog } from "./profile/ProfileDialog"; import { ProfileDialog } from "./profile/ProfileDialog";
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
export function ChatHeader() { export function ChatHeader() {
const { profileData } = useProfile(); const { profileData } = useProfile();
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const handleProfileClick = () => { const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
setIsProfileOpen(true);
};
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
return ( return (
<> <>
<header className="chat-header-left"> <header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div> <div className="product-name">{PRODUCT_NAME}</div>
<div className="profile"> <div className="profile">
<a href="#" id="profile-open" onClick={handleProfileClick}> <a href="#" id="profile-open" onClick={() => setIsProfileOpen(true)}>
<img <img
src={profilePictureUrl} src={profilePictureUrl}
alt="" alt=""
id="preview1" id="preview1"
onError={(e) => { onError={() => setProfilePictureUrl(defaultAvatar)} />
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</a> </a>
</div> </div>
</header> </header>
<MinimizedCallBar />
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} /> <ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
</> </>
); );
@@ -8,6 +8,7 @@ import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity"; import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
import type { DMPanel } from "./panels/DMPanel"; import type { DMPanel } from "./panels/DMPanel";
import useCall from "@/pages/chat/hooks/useCall";
interface MessagePanelRendererProps { interface MessagePanelRendererProps {
panel: MessagePanel | null; panel: MessagePanel | null;
@@ -26,6 +27,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const [editMessage, setEditMessage] = useState<Message | null>(null); const [editMessage, setEditMessage] = useState<Message | null>(null);
const [editVisible, setEditVisible] = useState(Boolean(editMessage)); const [editVisible, setEditVisible] = useState(Boolean(editMessage));
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null); const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
const { initiateCall } = useCall();
// Drag & drop // Drag & drop
@@ -156,6 +158,18 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
previousMessageCountRef.current = currentMessageCount; previousMessageCountRef.current = currentMessageCount;
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]); }, [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 ( return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}> <div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div <div
@@ -208,15 +222,15 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<p> <p>
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span> <span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
{panelState ? ( {panelState ? (
<> panelState.online ? "Online" : "Offline"
{panelState.online ? "Online" : "Offline"}
{panelState.isTyping && " • Typing..."}
</>
) : ( ) : (
"Выберите чат, чтобы начать переписку" "Выберите чат, чтобы начать переписку"
)} )}
</p> </p>
</div> </div>
{panel?.isDm() && (
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
)}
</div> </div>
</div> </div>
@@ -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(
<>
<audio
ref={remoteAudioRef}
className="remote-audio"
autoPlay
playsInline
controls />
{shouldRender && (
<div
className={`call-window ${(call.isActive ? call.isMinimized : wasMinimized) ? "minimized" : "maximized"} ${isDragging ? "dragging" : ""} ${getGradientClass()} ${isVisible ? "visible" : "hidden"}`}
style={(call.isActive ? call.isMinimized : wasMinimized) ? {
left: pipPosition.x,
top: pipPosition.y
} : undefined}
onMouseDown={(e) => {
if (call.isMinimized) {
// Only start dragging if not clicking on a button
const target = e.target as HTMLElement;
if (!target.closest("mdui-button-icon")) {
setIsDragging(true);
setDragOffset({
x: e.clientX - pipPosition.x,
y: e.clientY - pipPosition.y
});
}
}
}}
>
<div className="call-header">
<div className="window-controls">
<mdui-button-icon
onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn"
/>
</div>
<div className="call-header-info">
<h3 className="username">{remoteUsername}</h3>
<p className="status">{getStatusText()}</p>
{!call.isMinimized && call.encryptionEmojis.length > 0 && (
<div className="encryption-emojis">
{call.encryptionEmojis.map((emoji, index) => (
<span key={index} className="encryption-emoji">
{emoji}
</span>
))}
</div>
)}
</div>
</div>
<div className={`call-content ${(call.isSharingScreen || call.isRemoteScreenSharing) ? "with-screen-share" : ""}`}>
{/* Main screen share area - takes most space when active */}
<div className="screen-share-area">
{/* Local screen share */}
<div
className="video-tile screen-share-tile local-screen-share"
style={{ display: call.isSharingScreen ? "flex" : "none" }}>
<video
ref={localScreenShareRef}
className="video-element screen-share-video"
autoPlay
playsInline
muted />
<div className="tile-label">Your Screen</div>
</div>
{/* Remote screen share */}
<div
className="video-tile screen-share-tile remote-screen-share"
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
<video
ref={remoteScreenShareRef}
className="video-element screen-share-video"
autoPlay
playsInline />
<div className="tile-label">{remoteUsername}&apos;s Screen</div>
</div>
</div>
{/* Video tiles sidebar - appears on right when screen share is active */}
<div className="video-tiles-sidebar">
{/* Local video tile */}
<div className="video-tile local-video">
<video
ref={localVideoRef}
className="video-element"
autoPlay
playsInline
muted
style={{ display: call.isVideoEnabled ? "block" : "none" }} />
{!call.isVideoEnabled && (
<div className="video-placeholder">
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" />
<span className="placeholder-username">{user.currentUser?.username || "You"}</span>
</div>
)}
<div className="tile-label">You</div>
</div>
{/* Remote video tile */}
<div className="video-tile remote-video">
<video
ref={remoteVideoRef}
className="video-element"
autoPlay
playsInline
style={{ display: call.isRemoteVideoEnabled ? "block" : "none" }} />
{!call.isRemoteVideoEnabled && (
<div className="video-placeholder">
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" />
<span className="placeholder-username">{remoteUsername}</span>
</div>
)}
<div className="tile-label">{remoteUsername}</div>
</div>
</div>
</div>
<div className="call-controls">
{status === "calling" && !isInitiator ? (
<>
<mdui-button-icon onClick={acceptCall} icon="call" />
<mdui-button-icon onClick={rejectCall} icon="call_end" />
</>
) : (
<>
<mdui-button-icon onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
<mdui-button-icon onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
<mdui-button-icon onClick={endCall} icon="call_end" />
</>
)}
</div>
</div>
)}
</>,
id("root")
)
);
}
@@ -0,0 +1,62 @@
import { useAppState } from "@/pages/chat/state";
import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png";
export function MinimizedCallBar() {
const { chat, toggleCallMinimize } = useAppState();
const { call } = chat;
const { endCall, toggleMute } = useCall();
function getGradientClass() {
switch (call.status) {
case "calling":
return "gradient-calling";
case "connecting":
return "gradient-connecting";
case "active":
return "gradient-active";
default:
return "gradient-default";
}
}
function getStatusText() {
switch (call.status) {
case "calling":
return "Calling...";
case "connecting":
return "Connecting...";
case "active":
return "Active";
default:
return "";
}
}
if (!call.isActive || !call.isMinimized) {
return null;
}
return (
<div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimize}>
<div className="call-info">
<img src={defaultAvatar} alt="Avatar" className="avatar" />
<div className="user-details">
<span className="username">{call.remoteUsername}</span>
<span className="status">{getStatusText()}</span>
</div>
</div>
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
{call.status === "calling" && !call.isInitiator ? (
<mdui-button-icon onClick={endCall} icon="call_end" />
) : (
<>
<mdui-button-icon onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={endCall} icon="call_end" />
</>
)}
</div>
</div>
);
}
@@ -6,7 +6,7 @@ import {
sendDmWithFiles, sendDmWithFiles,
editDmEnvelope, editDmEnvelope,
deleteDmEnvelope deleteDmEnvelope
} from "../../../../../core/api/dmApi"; } from "@/core/api/dmApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState } from "@/pages/chat/state"; import type { UserState } from "@/pages/chat/state";
@@ -263,6 +263,16 @@ export class DMPanel extends MessagePanel {
this.currentUser.authToken = authToken; this.currentUser.authToken = authToken;
} }
// Get DM user ID for call functionality
getDMUserId(): number | null {
return this.dmData?.userId || null;
}
// Get DM username for call functionality
getDMUsername(): string | null {
return this.dmData?.username || null;
}
// Helper functions for localStorage // Helper functions for localStorage
private getLastReadId(userId: number): number { private getLastReadId(userId: number): number {
try { try {
+4 -1
View File
@@ -26,6 +26,9 @@ export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer
} }
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> { export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey; // Normalize to a contiguous ArrayBuffer slice to avoid offset/length issues
const keyBuffer = rawKey instanceof Uint8Array
? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength)
: (rawKey as ArrayBuffer);
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
} }
+30
View File
@@ -43,4 +43,34 @@ export function ub64(s: string): Uint8Array {
export function id<T extends Element = HTMLElement>(id: string): T { export function id<T extends Element = HTMLElement>(id: string): T {
return document.getElementById(id) as unknown as T return document.getElementById(id) as unknown as T
}
/**
* Runs the specified callback after `click` or `touchstart` event is triggered.
*
* @param action The action to perform after interaction
* @returns A function to clean up the event listeners.
*/
export function doAfterInteraction<T>(action?: () => (T | Promise<T>)): Promise<T> {
return new Promise((resolve, reject) => {
function doAfterInteractionInner() {
document.removeEventListener("click", doAfterInteractionInner);
document.removeEventListener("touchstart", doAfterInteractionInner);
try {
const result = action?.();
if (result instanceof Promise) {
result.then(resolve);
} else {
resolve(result as T);
}
} catch (error) {
reject(error);
}
}
document.addEventListener("click", doAfterInteractionInner);
document.addEventListener("touchstart", doAfterInteractionInner);
setTimeout(reject, 10000);
});
} }
+1 -1
View File
@@ -29,7 +29,7 @@
"clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean", "clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean",
"install": "npm run backend:dependencies && npm run generate:env", "install": "npm run backend:dependencies && npm run generate:env",
"prepare": "husky", "prepare": "husky",
"generate:env": "echo \"JWT_SECRET=\\\"$(openssl rand -base64 32)\\\"\" > deployment/.env && ./.venv/bin/python3 backend/generate_vapid_keys.py 1>>deployment/.env" "generate:env": "echo \"JWT_SECRET=\\\"$(openssl rand -base64 32)\\\"\" > deployment/.env && ./.venv/bin/python3 backend/generate_vapid_keys.py 1>>deployment/.env && echo \"TURN_USERNAME=\\\"set-your-username\\\"\\nTURN_SECRET=\\\"set-your-secret\\\"\" >> deployment/.env"
}, },
"files": [ "files": [
"frontend/build/electron" "frontend/build/electron"