Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/web/src/app/core/i18n/localizations/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
"TERMS_GOVERNING_LAW": "تُفسَّر هذه الشروط وتُطبَّق وفقاً لقوانين الولاية القضائية التي توجد فيها جهة تشغيل الخدمة، دون اعتبار لتنازع القوانين.",
"LEGAL_NOTICE": "هذه الوثيقة لأغراض المعلومات فقط ولا تُعد استشارة قانونية. يُرجى استشارة محامٍ مختص لملابساتك الخاصة.",

"_CONNECTION_WARNING_SECTION": "==== CONNECTION WARNING ====",
"CONNECTION_WARNING_TITLE": "تعذّر الاتصال ببعض الأعضاء",
"CONNECTION_WARNING_DESC": "قد تمنع شبكتك الاتصال المباشر بين الأجهزة. جرّب تحديث الصفحة، أو التبديل إلى شبكة أخرى، أو تعطيل VPN أو جدار الحماية.",
"CONNECTION_WARNING_REFRESH": "تحديث",
"CONNECTION_WARNING_DISMISS": "تجاهل",

"_STATUS_ERRORS_SECTION": "==== STATUS & ERRORS ====",
"CONNECTION_LOST": "تم فقد الاتصال",
"ERROR": "حدث خطأ",
Expand Down
6 changes: 6 additions & 0 deletions client/web/src/app/core/i18n/localizations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
"TERMS_GOVERNING_LAW": "These Terms shall be governed by and construed in accordance with the laws of the jurisdiction where the Service operator is established, without regard to its conflict of law provisions.",
"LEGAL_NOTICE": "This document is provided for informational purposes only and does not constitute legal advice. Consult a qualified attorney for advice specific to your situation.",

"_CONNECTION_WARNING_SECTION": "==== CONNECTION WARNING ====",
"CONNECTION_WARNING_TITLE": "Unable to connect to some members",
"CONNECTION_WARNING_DESC": "Your network may be blocking peer-to-peer connections. Try refreshing the page, switching to a different network, or disabling your VPN/firewall.",
"CONNECTION_WARNING_REFRESH": "Refresh",
"CONNECTION_WARNING_DISMISS": "Dismiss",

"_STATUS_ERRORS_SECTION": "==== STATUS & ERRORS ====",
"CONNECTION_LOST": "Connection lost",
"ERROR": "Something went wrong",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ export class WebRTCCommunicationService {
};

channel.onerror = (ev: Event) => {
// Ignore events from stale data channels that have been replaced
if (this.dataChannels.get(targetUser) !== channel) return;

if ('error' in ev) {
const rtcErrorEvent = ev as RTCErrorEvent;
const error = rtcErrorEvent.error;
Expand All @@ -151,6 +154,9 @@ export class WebRTCCommunicationService {
};

channel.onclose = () => {
// Ignore events from stale data channels that have been replaced
if (this.dataChannels.get(targetUser) !== channel) return;

this.logger.info('setupDataChannel', `Data channel with ${targetUser} is closed`);
this.dataChannelOpen$.next(false);
this.dataChannels.delete(targetUser);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TranslateService } from '@ngx-translate/core';
import { NGXLogger } from 'ngx-logger';
import { WebRTCCommunicationService } from './webrtc-communication.service';
import { HotToastService } from '@ngxpert/hot-toast';
import { Subject } from 'rxjs';

@Injectable({
providedIn: 'root',
Expand All @@ -30,6 +31,9 @@ export class WebRTCSignalingService {
private communicationService = inject(WebRTCCommunicationService);

// =============== Properties ===============
public peerDisconnected$ = new Subject<string>();
public peerConnected$ = new Subject<string>();
Comment thread
SloMR marked this conversation as resolved.

private peerConnections = new Map<string, RTCPeerConnection>();
private reconnectAttempts = new Map<string, number>();
private connectionLocks = new Set<string>();
Expand All @@ -44,7 +48,12 @@ export class WebRTCSignalingService {
constructor() {
this.initializeSignalMessageHandler();
this.communicationService.dataChannelClosed$.subscribe((targetUser) => {
if (this.wsService.isConnected() && this.peerConnections.has(targetUser)) {
if (
this.wsService.isConnected() &&
this.peerConnections.has(targetUser) &&
!this.connectionLocks.has(targetUser) &&
!this.reconnectionTimeouts.has(targetUser)
) {
this.logger.info(
'handleDataChannelClose',
`Data channel closed with ${targetUser}, attempting reconnection`
Expand Down Expand Up @@ -131,9 +140,9 @@ export class WebRTCSignalingService {
'initiateConnection',
`Initiating connection with ${targetUser} (role: caller)`
);
const peerConnection = this.createPeerConnection(targetUser);

try {
const peerConnection = this.createPeerConnection(targetUser);
if (!peerConnection) {
this.logger.error(
'initiateConnection',
Expand All @@ -148,15 +157,24 @@ export class WebRTCSignalingService {
dataChannel.onopen = () => {
this.connectionLocks.delete(targetUser);
this.communicationService.sendQueuedMessages(targetUser);
if (peerConnection.connectionState === 'connected') {
this.reconnectAttempts.delete(targetUser);
this.peerConnected$.next(targetUser);
}
};
dataChannel.onerror = () => {
this.connectionLocks.delete(targetUser);
this.closePeerConnection(targetUser, true);
this.handleDisconnection(targetUser);
if (this.peerConnections.get(targetUser) === peerConnection) {
this.closePeerConnection(targetUser, true);
this.handleDisconnection(targetUser);
}
};
dataChannel.onclose = () => {
this.connectionLocks.delete(targetUser);
if (this.wsService.isConnected()) {
if (
this.wsService.isConnected() &&
this.peerConnections.get(targetUser) === peerConnection
) {
this.closePeerConnection(targetUser, true);
this.handleDisconnection(targetUser);
}
Expand Down Expand Up @@ -246,13 +264,6 @@ export class WebRTCSignalingService {
this.connectionRequestDelays.delete(targetUser);
}

// Clear reconnection timeout
const reconnectionTimeout = this.reconnectionTimeouts.get(targetUser);
if (reconnectionTimeout) {
clearTimeout(reconnectionTimeout);
this.reconnectionTimeouts.delete(targetUser);
}

// Clear state mismatch timeout
const stateMismatchTimeout = this.stateMismatchTimeouts.get(targetUser);
if (stateMismatchTimeout) {
Expand Down Expand Up @@ -426,6 +437,9 @@ export class WebRTCSignalingService {
};

peerConnection.onconnectionstatechange = () => {
// Ignore events from stale connections that have been replaced
if (this.peerConnections.get(targetUser) !== peerConnection) return;

const state = peerConnection.connectionState;

if (state === 'connected') {
Expand All @@ -434,7 +448,17 @@ export class WebRTCSignalingService {
clearTimeout(iceGatheringTimeout);
iceGatheringTimeout = null;
}
this.logger.info('createPeerConnection', `Successfully connected to ${targetUser}`);
// Only clear retry counter and emit connected when data channel is also open
if (this.communicationService.isConnected(targetUser)) {
this.reconnectAttempts.delete(targetUser);
this.logger.info('createPeerConnection', `Successfully connected to ${targetUser}`);
this.peerConnected$.next(targetUser);
} else {
this.logger.info(
'createPeerConnection',
`Peer connection connected to ${targetUser}, waiting for data channel`
);
}
} else if (state === 'failed' || state === 'disconnected') {
// Clear timeout on failure
if (iceGatheringTimeout) {
Expand All @@ -446,6 +470,9 @@ export class WebRTCSignalingService {
};

peerConnection.oniceconnectionstatechange = () => {
// Ignore events from stale connections that have been replaced
if (this.peerConnections.get(targetUser) !== peerConnection) return;

const iceState = peerConnection.iceConnectionState;

if (iceState === 'connected' || iceState === 'completed') {
Expand Down Expand Up @@ -475,6 +502,11 @@ export class WebRTCSignalingService {
* @param targetUser The user to handle disconnection for
*/
private handleDisconnection(targetUser: string) {
// Skip if a reconnection is already scheduled for this user
if (this.reconnectionTimeouts.has(targetUser)) {
return;
}

const attempts = this.reconnectAttempts.get(targetUser) ?? 0;

// Log diagnostic info on first failure
Expand Down Expand Up @@ -507,7 +539,7 @@ export class WebRTCSignalingService {

const timeoutId = setTimeout(() => {
this.reconnectionTimeouts.delete(targetUser);
if (!this.peerConnections.has(targetUser)) {
if (!this.communicationService.isConnected(targetUser)) {
this.logger.debug(
'handleDisconnection',
`Attempting reconnection ${attempts + 1} to ${targetUser}`
Expand All @@ -516,8 +548,9 @@ export class WebRTCSignalingService {
} else {
this.logger.debug(
'handleDisconnection',
`Connection already exists for ${targetUser}, skipping reconnect`
`Connection healthy for ${targetUser}, skipping reconnect`
);
this.reconnectAttempts.delete(targetUser);
}
}, delay);

Expand All @@ -537,6 +570,7 @@ export class WebRTCSignalingService {
);
}
this.closePeerConnection(targetUser, true);
this.peerDisconnected$.next(targetUser);
}
}

Expand Down Expand Up @@ -888,14 +922,9 @@ export class WebRTCSignalingService {
private reconnect(targetUser: string) {
this.logger.info('reconnect', `Reconnecting WebRTC with ${targetUser}...`);

const peerConnection = this.peerConnections.get(targetUser);
if (peerConnection) {
peerConnection.close();
this.peerConnections.delete(targetUser);
}

this.initiateConnection(targetUser);
this.closePeerConnection(targetUser, true);
this.reconnectAttempts.set(targetUser, 0);
this.initiateConnection(targetUser);
}

/**
Expand Down Expand Up @@ -996,29 +1025,38 @@ export class WebRTCSignalingService {

// Temporarily bypass role checking and initiate connection
this.connectionLocks.add(targetUser);
const peerConnection = this.createPeerConnection(targetUser);

if (!peerConnection) {
this.logger.error('forceInitiateConnection', `PeerConnection missing for ${targetUser}`);
return;
}

try {
const peerConnection = this.createPeerConnection(targetUser);
if (!peerConnection) {
this.logger.error('forceInitiateConnection', `PeerConnection missing for ${targetUser}`);
throw new Error(`Failed to create peer connection for ${targetUser}`);
}

const dataChannel = peerConnection.createDataChannel('data', DATA_CHANNEL_OPTIONS);
this.communicationService.setupDataChannel(dataChannel, targetUser);

dataChannel.onopen = () => {
this.connectionLocks.delete(targetUser);
this.communicationService.sendQueuedMessages(targetUser);
if (peerConnection.connectionState === 'connected') {
this.reconnectAttempts.delete(targetUser);
this.peerConnected$.next(targetUser);
}
};
dataChannel.onerror = () => {
this.connectionLocks.delete(targetUser);
this.closePeerConnection(targetUser, true);
this.handleDisconnection(targetUser);
if (this.peerConnections.get(targetUser) === peerConnection) {
this.closePeerConnection(targetUser, true);
this.handleDisconnection(targetUser);
}
};
dataChannel.onclose = () => {
this.connectionLocks.delete(targetUser);
if (this.wsService.isConnected()) {
if (
this.wsService.isConnected() &&
this.peerConnections.get(targetUser) === peerConnection
) {
this.closePeerConnection(targetUser, true);
this.handleDisconnection(targetUser);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { ChatMessage, DataChannelMessage } from '../../../utils/constants';
import { IWebRTCService } from '../../interfaces/webrtc.interface';
import { WebRTCSignalingService } from './webrtc-signaling.service';
Expand All @@ -15,6 +15,14 @@ export class WebRTCService implements IWebRTCService {
private logger = inject(NGXLogger);

// =============== Public Properties ===============
public get peerDisconnected$(): Observable<string> {
return this.signalingService.peerDisconnected$.asObservable();
}

public get peerConnected$(): Observable<string> {
return this.signalingService.peerConnected$.asObservable();
}
Comment thread
SloMR marked this conversation as resolved.

/**
* Gets the data channel open subject
*/
Expand Down
Loading
Loading