Implement audio calls

This commit is contained in:
2025-09-21 19:09:39 +03:00
Unverified
parent 27902cf092
commit a853164b50
20 changed files with 1265 additions and 12 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.
+4
View File
@@ -6,3 +6,7 @@ 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: |
+2 -1
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):
@@ -57,3 +57,4 @@ 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")
+23
View File
@@ -911,6 +911,29 @@ 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:
self.user_by_ws[websocket] = current_user.id
payload = data.get("data") or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
# Ensure sender is set by the server
payload["fromUserId"] = current_user.id
payload["fromUsername"] = current_user.username
await self.send_to_user(to_user_id, {
"type": "call_signaling",
"data": payload
})
# Optional ack
await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}})
except HTTPException as e:
await self.send_error(websocket, type, e)
else: 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
+115
View File
@@ -0,0 +1,115 @@
import type { WebSocketMessage, CallSignalingData, CallInvite } from "@/core/types";
import * as WebRTC from "./webrtc";
export interface CallState {
receiveCall: (userId: number, username: string) => void;
endCall: () => void;
}
export class CallSignalingHandler {
private getState: () => CallState;
constructor(getState: () => CallState) {
this.getState = getState;
}
handleWebSocketMessage(message: WebSocketMessage<CallSignalingData>) {
if (message.type !== "call_signaling") {
return;
}
const { data } = message;
if (!data) {
console.warn("Received call_signaling message with no data:", message);
return;
}
console.log("Received signaling message:", data.type, "from user", data.fromUserId, "full data:", data);
switch (data.type) {
case "call_invite":
this.handleCallInvite(data as CallInvite);
break;
case "call_accept":
this.handleCallAccept(data);
break;
case "call_reject":
this.handleCallReject(data);
break;
case "call_offer":
this.handleCallOffer(data);
break;
case "call_answer":
this.handleCallAnswer(data);
break;
case "call_ice_candidate":
this.handleIceCandidate(data);
break;
case "call_end":
this.handleCallEnd(data);
break;
}
}
private async handleCallInvite(data: CallInvite) {
const { fromUserId, fromUsername } = data;
const state = this.getState();
// Show incoming call UI
state.receiveCall(fromUserId, fromUsername);
// Handle incoming call in WebRTC service
await WebRTC.handleIncomingCall(fromUserId, fromUsername);
}
private async handleCallAccept(data: any) {
const { fromUserId } = data;
// Initiator should create and send offer now
try {
await WebRTC.onRemoteAccepted(fromUserId);
} catch (error) {
console.error("Failed to proceed after accept:", error);
}
}
private handleCallReject(data: any) {
const state = this.getState();
const { fromUserId } = data;
// Clean up WebRTC connection first
if (fromUserId) {
WebRTC.cleanupCall(fromUserId);
}
// End the call
state.endCall();
}
private async handleCallOffer(data: any) {
const { fromUserId, data: offer } = data;
await WebRTC.handleCallOffer(fromUserId, offer);
}
private async handleCallAnswer(data: any) {
const { fromUserId, data: answer } = data;
await WebRTC.handleCallAnswer(fromUserId, answer);
}
private async handleIceCandidate(data: any) {
const { fromUserId, data: candidate } = data;
await WebRTC.handleIceCandidate(fromUserId, candidate);
}
private handleCallEnd(data: any) {
const state = this.getState();
const { fromUserId } = data;
// Clean up WebRTC connection first
if (fromUserId) {
WebRTC.cleanupCall(fromUserId);
}
// End the call
state.endCall();
}
}
+472
View File
@@ -0,0 +1,472 @@
import { getAuthHeaders } from "@/core/api/authApi";
import type { IceServersResponse } from "@/core/types";
import { request } from "@/core/websocket";
export interface CallSignalingMessage {
type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject";
fromUserId: number;
toUserId: number;
data?: any;
}
export interface WebRTCCall {
peerConnection: RTCPeerConnection;
localStream: MediaStream | null;
remoteStream: MediaStream | null;
isInitiator: boolean;
remoteUserId: number;
remoteUsername: string;
isEnding?: boolean;
isMuted?: boolean;
}
// Global state
export let authToken: string | null = null;
export let onCallStateChange: ((userId: number, state: string) => void) | null = null;
export let onRemoteStream: ((userId: number, stream: MediaStream) => void) | null = null;
const calls: Map<number, WebRTCCall> = new Map();
export function setAuthToken(token: string) {
authToken = token;
}
export function setCallStateChangeHandler(handler: (userId: number, state: string) => void) {
onCallStateChange = handler;
}
export function setRemoteStreamHandler(handler: (userId: number, stream: MediaStream) => void) {
onRemoteStream = handler;
}
async function sendSignalingMessage(message: CallSignalingMessage) {
if (!authToken) {
throw new Error("No auth token available");
}
console.log("Sending signaling message:", message.type, "to user", message.toUserId);
await request({
type: "call_signaling",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: message
});
}
async function getIceServers(): Promise<RTCIceServer[]> {
const defaultIceServers = [{ urls: "stun:fromchat.ru:3478" }];
if (!authToken) {
console.warn("No auth token available for ICE servers");
return defaultIceServers;
}
try {
const response = await fetch("/api/webrtc/ice", {
headers: getAuthHeaders(authToken)
});
if (response.ok) {
const data = await response.json() as IceServersResponse;
console.log("Received ICE servers:", data.iceServers);
return data.iceServers || [];
} else {
console.warn("Failed to fetch ICE servers:", response.status, response.statusText);
}
} catch (error) {
console.warn("Failed to fetch ICE servers:", error);
}
// Fallback to STUN only if backend fails
return defaultIceServers;
}
async function createPeerConnection(userId: number): Promise<RTCPeerConnection> {
const iceServers = await getIceServers();
const peerConnection = new RTCPeerConnection({
iceServers
});
const call: WebRTCCall = {
peerConnection,
localStream: null,
remoteStream: null,
isInitiator: false,
remoteUserId: userId,
remoteUsername: "",
isMuted: false
};
calls.set(userId, call);
peerConnection.addEventListener("icegatheringstatechange", () => {
console.log("ICE gathering state changed:", peerConnection.iceGatheringState);
});
// Add ICE candidate event listener for debugging and sending
peerConnection.addEventListener("icecandidate", async (event) => {
if (event.candidate) {
console.log("Local ICE candidate:", event.candidate.candidate);
// Send ICE candidate to remote peer
try {
await sendSignalingMessage({
type: "call_ice_candidate",
fromUserId: 0, // Will be set by server
toUserId: userId,
data: {
candidate: event.candidate.candidate,
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid
}
});
} catch (error) {
console.error("Failed to send ICE candidate:", error);
}
} else {
console.log("ICE gathering complete");
}
});
peerConnection.addEventListener("iceconnectionstatechange", () => {
console.log("ICE connection state changed:", peerConnection.iceConnectionState);
});
peerConnection.addEventListener("signalingstatechange", () => {
// Signaling state changed
});
// Handle remote stream
peerConnection.addEventListener("track", (event) => {
const [remoteStream] = event.streams;
const call = calls.get(userId);
if (call) {
call.remoteStream = remoteStream;
if (onRemoteStream) {
onRemoteStream(userId, remoteStream);
}
}
});
// Handle connection state changes
peerConnection.addEventListener("connectionstatechange", () => {
console.log("WebRTC connection state changed:", peerConnection.connectionState);
const call = calls.get(userId);
if (call) {
if (onCallStateChange) {
onCallStateChange(userId, peerConnection.connectionState);
}
// Clean up if connection failed or closed
if (peerConnection.connectionState === "failed" ||
peerConnection.connectionState === "closed" ||
peerConnection.connectionState === "disconnected") {
// Only send end call message if we're not already cleaning up
const call = calls.get(userId);
if (call && !call.isEnding) {
call.isEnding = true;
endCall(userId);
}
}
}
});
return peerConnection;
}
export async function initiateCall(userId: number, username: string): Promise<boolean> {
try {
// Get user media
const localStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
// Create peer connection
await createPeerConnection(userId);
const call = calls.get(userId);
if (!call) return false;
call.localStream = localStream;
call.remoteUsername = username;
call.isInitiator = true;
// Add tracks to peer connection
localStream.getTracks().forEach(track => call.peerConnection.addTrack(track, localStream));
// Send call invite
await sendSignalingMessage({
type: "call_invite",
fromUserId: 0, // Will be set by server
toUserId: userId,
data: { fromUsername: username }
});
return true;
} catch (error) {
console.error("Failed to initiate call:", error);
cleanupCall(userId);
return false;
}
}
export async function acceptCall(userId: number): Promise<boolean> {
try {
let call = calls.get(userId);
if (!call) {
// Create call object if it doesn't exist (for race conditions)
await createPeerConnection(userId);
call = calls.get(userId);
if (!call) return false;
}
// Get user media and attach
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
call.localStream = localStream;
localStream.getTracks().forEach(track => call!.peerConnection.addTrack(track, localStream));
// Notify initiator that callee accepted; initiator will generate offer
await sendSignalingMessage({
type: "call_accept",
fromUserId: 0, // Will be set by server
toUserId: userId,
data: {}
});
return true;
} catch (error) {
console.error("Failed to accept call:", error);
cleanupCall(userId);
return false;
}
}
export async function rejectCall(userId: number): Promise<void> {
await sendSignalingMessage({
type: "call_reject",
fromUserId: 0, // Will be set by server
toUserId: userId,
data: {}
});
cleanupCall(userId);
}
export async function endCall(userId: number): Promise<void> {
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<void> {
try {
// Create peer connection for incoming call
await createPeerConnection(userId);
const call = calls.get(userId);
if (!call) return;
call.remoteUsername = username;
call.isInitiator = false;
} catch (error) {
console.error("Failed to handle incoming call:", error);
cleanupCall(userId);
}
}
export async function onRemoteAccepted(userId: number): Promise<void> {
const call = calls.get(userId);
if (!call) {
throw new Error("No call found to accept");
}
try {
// Create offer
const offer = await call.peerConnection.createOffer();
await call.peerConnection.setLocalDescription(offer);
// Send offer to remote peer
await sendSignalingMessage({
type: "call_offer",
fromUserId: 0, // Will be set by server
toUserId: userId,
data: offer
});
} catch (error) {
console.error("Failed to create offer:", error);
throw error;
}
}
export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise<void> {
const call = calls.get(userId);
if (!call) {
throw new Error("No call found for offer");
}
try {
// Set remote description
await call.peerConnection.setRemoteDescription(offer);
// Create answer
const answer = await call.peerConnection.createAnswer();
await call.peerConnection.setLocalDescription(answer);
// Send answer to remote peer
await sendSignalingMessage({
type: "call_answer",
fromUserId: 0, // Will be set by server
toUserId: userId,
data: answer
});
} catch (error) {
console.error("Failed to handle offer:", error);
throw error;
}
}
export async function handleCallAnswer(userId: number, answer: RTCSessionDescriptionInit): Promise<void> {
const call = calls.get(userId);
if (!call) {
throw new Error("No call found for answer");
}
try {
await call.peerConnection.setRemoteDescription(answer);
} catch (error) {
console.error("Failed to handle answer:", error);
throw error;
}
}
export async function handleIceCandidate(userId: number, candidate: RTCIceCandidateInit): Promise<void> {
const call = calls.get(userId);
if (!call) {
console.warn("No call found for ICE candidate from user", userId);
return;
}
try {
console.log("Adding ICE candidate from user", userId, ":", candidate);
await call.peerConnection.addIceCandidate(candidate);
} catch (error) {
console.error("Failed to add ICE candidate:", error);
}
}
export function toggleMute(userId: number): boolean {
const call = calls.get(userId);
if (!call || !call.localStream) {
return false;
}
if (!call.isMuted) {
// Mute: Stop the track completely (no green dot)
const audioTrack = call.localStream.getAudioTracks()[0];
if (audioTrack) {
audioTrack.stop();
call.localStream.removeTrack(audioTrack);
}
// Create a silent audio track using Web Audio API
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
// Set gain to 0 (silent)
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
// Connect nodes
oscillator.connect(gainNode);
// Create a MediaStreamDestination to get a MediaStream
const destination = audioContext.createMediaStreamDestination();
gainNode.connect(destination);
// Start the oscillator (but it's silent due to gain = 0)
oscillator.start();
// Add the silent track to maintain WebRTC connection
const silentTrack = destination.stream.getAudioTracks()[0];
if (silentTrack) {
call.localStream.addTrack(silentTrack);
}
call.isMuted = true;
return true; // Muted
} else {
// Unmute: Re-enable microphone by getting new audio stream
navigator.mediaDevices.getUserMedia({ audio: true, video: false })
.then(newStream => {
// Remove any existing audio tracks from the stream
call.localStream!.getAudioTracks().forEach(track => track.stop());
// Get the new active track
const newAudioTrack = newStream.getAudioTracks()[0];
// Replace the track in the peer connection
const sender = call.peerConnection.getSenders().find(s =>
s.track && s.track.kind === 'audio'
);
if (sender) {
// Replace the track in the existing sender
sender.replaceTrack(newAudioTrack);
} else {
// Add the track to the peer connection if no sender exists
call.peerConnection.addTrack(newAudioTrack, call.localStream!);
}
// Add the track to the local stream
call.localStream!.addTrack(newAudioTrack);
call.isMuted = false;
})
.catch(error => {
console.error("Failed to re-enable microphone:", error);
});
return false; // Unmuted
}
}
export function getCall(userId: number): WebRTCCall | undefined {
return calls.get(userId);
}
export function cleanupCall(userId: number): void {
const call = calls.get(userId);
if (call) {
// Close peer connection
if (call.peerConnection) {
call.peerConnection.close();
}
// Stop local stream
if (call.localStream) {
call.localStream.getTracks().forEach(track => track.stop());
}
calls.delete(userId);
}
}
export function cleanup(): void {
// Clean up all calls
for (const userId of calls.keys()) {
cleanupCall(userId);
}
calls.clear();
}
+19
View File
@@ -247,6 +247,10 @@ export interface DmEncryptedJSON {
} }
} }
export interface IceServersResponse {
iceServers: RTCIceServer[];
}
// --------------- // ---------------
// WebSocket types // WebSocket types
// --------------- // ---------------
@@ -429,3 +433,18 @@ export interface DialogProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
} }
// Call types
export interface CallInvite extends CallSignalingData {
type: "call_invite";
fromUserId: number;
fromUsername: string;
timestamp: string;
}
export interface CallSignalingData {
type: "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject";
fromUserId: number;
toUserId: number;
data?: any;
}
+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 "@/core/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") {
callSignalingHandler.handleWebSocketMessage(response);
}
// 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,69 @@
@use "../../../css/material" as *;
.call-window {
position: fixed;
z-index: 1000;
width: 300px;
background-color: $color-dark-surface;
border: 1px solid $color-dark-outline;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
cursor: grab;
&.dragging {
cursor: grabbing;
}
}
.call-header {
padding: 16px;
border-bottom: 1px solid $color-dark-outline-variant;
cursor: grab;
.user-info {
display: flex;
align-items: center;
gap: 12px;
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.user-details {
flex: 1;
.username {
margin: 0;
font-size: 16px;
font-weight: 500;
color: $color-dark-on-surface;
}
.status {
margin: 4px 0 0 0;
font-size: 14px;
color: $color-dark-on-surface-variant;
}
}
}
}
.call-controls {
padding: 16px;
display: flex;
justify-content: center;
gap: 12px;
}
.remote-audio {
position: fixed;
bottom: 0px;
right: 0px;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
}
@@ -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;
+1
View File
@@ -9,3 +9,4 @@
@use "profile-dialog"; @use "profile-dialog";
@use "settings-dialog"; @use "settings-dialog";
@use "animations"; @use "animations";
@use "callWindow";
@@ -0,0 +1,193 @@
import { useAppState } from "../state";
import * as WebRTC from "@/core/calls/webrtc";
import { CallSignalingHandler } from "@/core/calls/signaling";
import { setCallSignalingHandler } from "@/core/websocket";
import { createRef, useEffect } from "react";
// Global audio ref shared across all instances
let globalRemoteAudioRef = createRef<HTMLAudioElement>();
export default function useAudioCall() {
const { chat, startCall, endCall, setCallStatus, toggleMute, user } = useAppState();
const remoteAudioRef = globalRemoteAudioRef;
useEffect(() => {
if (user.authToken) {
WebRTC.setAuthToken(user.authToken);
}
// Initialize call signaling handler
const signalingHandler = new CallSignalingHandler(() => ({
receiveCall: (userId: number, username: string) => {
// Use the receiveCall function from state
const state = useAppState.getState();
state.receiveCall(userId, username);
},
endCall
}));
setCallSignalingHandler(signalingHandler);
// Set up call state change handler
WebRTC.setCallStateChangeHandler((userId: number, state: string) => {
const call = chat.call;
if (call.remoteUserId === userId) {
switch (state) {
case "connecting":
setCallStatus("connecting");
break;
case "connected":
setCallStatus("active");
break;
case "disconnected":
case "failed":
case "closed":
endCall();
break;
}
}
});
// Set up remote stream handler
WebRTC.setRemoteStreamHandler((_userId: number, stream: MediaStream) => {
if (!remoteAudioRef.current) {
return;
}
const el = remoteAudioRef.current;
try {
el.srcObject = stream;
el.muted = false;
el.volume = 1.0;
el.autoplay = true;
// Handle audio events
el.addEventListener("error", () => {
console.warn("[AUDIO] element error", (el.error?.message) || el.error);
});
el.play().catch(() => {
// Try to play after user interaction if autoplay is blocked
const playAfterInteraction = () => {
el.play().catch(() => {});
document.removeEventListener('click', playAfterInteraction);
document.removeEventListener('touchstart', playAfterInteraction);
};
document.addEventListener('click', playAfterInteraction);
document.addEventListener('touchstart', playAfterInteraction);
});
} catch (e) {
console.warn("failed to attach remote stream:", e);
}
});
return () => {
WebRTC.cleanup();
setCallSignalingHandler(null);
};
}, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall]);
async function requestAudioPermissions(): Promise<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) {
console.log("Audio permission denied");
return;
}
// Start the call in state
startCall(userId, username);
setCallStatus("calling");
// Initiate WebRTC call
const success = await WebRTC.initiateCall(userId, username);
if (!success) {
endCall();
}
};
async function acceptCall() {
if (!chat.call.remoteUserId) {
return;
}
setCallStatus("connecting");
const success = await WebRTC.acceptCall(chat.call.remoteUserId);
if (!success) {
endCall();
}
};
async function rejectCall() {
if (!chat.call.remoteUserId) {
return;
}
await WebRTC.rejectCall(chat.call.remoteUserId);
endCall();
};
async function handleEndCall() {
if (chat.call.remoteUserId) {
await WebRTC.endCall(chat.call.remoteUserId);
}
endCall();
};
function handleToggleMute() {
if (chat.call.remoteUserId) {
const isMuted = WebRTC.toggleMute(chat.call.remoteUserId);
// Update mute state in store
if (isMuted !== chat.call.isMuted) {
toggleMute();
}
}
};
async function handleIncomingCall(userId: number, username: string) {
await WebRTC.handleIncomingCall(userId, username);
};
async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit) {
await WebRTC.handleCallOffer(userId, offer);
};
async function handleCallAnswer(userId: number, answer: RTCSessionDescriptionInit) {
await WebRTC.handleCallAnswer(userId, answer);
};
async function handleIceCandidate(userId: number, candidate: RTCIceCandidateInit) {
await WebRTC.handleIceCandidate(userId, candidate);
};
return {
call: chat.call,
initiateCall,
acceptCall,
rejectCall,
endCall: handleEndCall,
toggleMute: handleToggleMute,
handleIncomingCall,
handleCallOffer,
handleCallAnswer,
handleIceCandidate,
remoteAudioRef
};
}
+98 -2
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,16 @@ 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;
}
interface ChatState { interface ChatState {
messages: Message[]; messages: Message[];
currentChat: string; currentChat: string;
@@ -30,6 +42,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 +67,13 @@ 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;
receiveCall: (userId: number, username: string) => void;
// User state // User state
user: UserState; user: UserState;
setUser: (token: string, user: User) => void; setUser: (token: string, user: User) => void;
@@ -79,7 +99,16 @@ 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
}
}, },
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 +387,72 @@ 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
} }
}
})),
endCall: () => set((state) => ({
chat: {
...state.chat,
call: {
isActive: false,
status: "ended",
startTime: null,
isMuted: false,
remoteUserId: null,
remoteUsername: null,
isInitiator: false
}
}
})),
setCallStatus: (status: CallStatus) => set((state) => ({
chat: {
...state.chat,
call: {
...state.chat.call,
status,
startTime: status === "active" && !state.chat.call.startTime ? Date.now() : state.chat.call.startTime
}
}
})),
toggleMute: () => set((state) => ({
chat: {
...state.chat,
call: {
...state.chat.call,
isMuted: !state.chat.call.isMuted
}
}
})),
receiveCall: (userId: number, username: string) => set((state) => ({
chat: {
...state.chat,
call: {
isActive: true,
status: "calling",
startTime: null,
isMuted: false,
remoteUserId: userId,
remoteUsername: username,
isInitiator: false
}
}
}))
})); }));
+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/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>
); );
} }
@@ -0,0 +1,120 @@
import { useState, useEffect } from "react";
import { useAppState } from "@/pages/chat/state";
import useAudioCall from "@/pages/chat/hooks/useAudioCall";
import defaultAvatar from "@/images/default-avatar.png";
export function CallWindow() {
const { chat, toggleMute } = useAppState();
const { call } = chat;
const { acceptCall, rejectCall, remoteAudioRef, endCall } = useAudioCall();
const [position, setPosition] = useState({ x: 100, y: 100 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [callDuration, setCallDuration] = useState(0);
useEffect(() => {
let interval: NodeJS.Timeout;
if (call.status === "active" && call.startTime) {
interval = setInterval(() => {
setCallDuration(Math.floor((Date.now() - call.startTime!) / 1000));
}, 1000);
} else {
setCallDuration(0);
}
return () => {
if (interval) clearInterval(interval);
};
}, [call.status, call.startTime]);
function formatDuration(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function getStatusText() {
switch (call.status) {
case "calling":
return "Calling...";
case "connecting":
return "Connecting...";
case "active":
return formatDuration(callDuration);
default:
return "";
}
}
return (
<>
<audio
ref={remoteAudioRef}
className="remote-audio"
autoPlay
playsInline
controls />
{call.isActive && (
<div
className={`call-window ${isDragging ? 'dragging' : ''}`}
style={{
left: position.x,
top: position.y
}}
onMouseDown={(e) => {
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y
});
}}
onMouseMove={(e) => {
if (isDragging) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
});
}
}}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
>
<div className="call-header">
<div className="user-info">
<img
src={defaultAvatar}
alt="Avatar"
className="avatar" />
<div className="user-details">
<h3 className="username">
{call.remoteUsername}
</h3>
<p className="status">
{getStatusText()}
</p>
</div>
</div>
</div>
<div className="call-controls">
{call.status === "calling" && !call.isInitiator ? (
<>
<mdui-button-icon onClick={acceptCall} icon="call" />
<mdui-button-icon onClick={rejectCall} icon="call_end" />
</>
) : (
<>
<mdui-button-icon onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={endCall} icon="call_end" />
</>
)}
</div>
</div>
)}
</>
);
}
@@ -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 useAudioCall from "@/pages/chat/hooks/useAudioCall";
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 } = useAudioCall();
// 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>
@@ -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 {
+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"