diff --git a/client/web/src/app/core/i18n/localizations/ar.json b/client/web/src/app/core/i18n/localizations/ar.json index 32b72d7..38f5416 100644 --- a/client/web/src/app/core/i18n/localizations/ar.json +++ b/client/web/src/app/core/i18n/localizations/ar.json @@ -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": "حدث خطأ", diff --git a/client/web/src/app/core/i18n/localizations/en.json b/client/web/src/app/core/i18n/localizations/en.json index f4b5a8a..66f5561 100644 --- a/client/web/src/app/core/i18n/localizations/en.json +++ b/client/web/src/app/core/i18n/localizations/en.json @@ -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", diff --git a/client/web/src/app/core/services/communication/webrtc-communication.service.ts b/client/web/src/app/core/services/communication/webrtc-communication.service.ts index 2782cb5..d15408b 100644 --- a/client/web/src/app/core/services/communication/webrtc-communication.service.ts +++ b/client/web/src/app/core/services/communication/webrtc-communication.service.ts @@ -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; @@ -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); diff --git a/client/web/src/app/core/services/communication/webrtc-signaling.service.ts b/client/web/src/app/core/services/communication/webrtc-signaling.service.ts index 6ee8858..19e4a51 100644 --- a/client/web/src/app/core/services/communication/webrtc-signaling.service.ts +++ b/client/web/src/app/core/services/communication/webrtc-signaling.service.ts @@ -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', @@ -30,6 +31,9 @@ export class WebRTCSignalingService { private communicationService = inject(WebRTCCommunicationService); // =============== Properties =============== + public peerDisconnected$ = new Subject(); + public peerConnected$ = new Subject(); + private peerConnections = new Map(); private reconnectAttempts = new Map(); private connectionLocks = new Set(); @@ -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` @@ -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', @@ -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); } @@ -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) { @@ -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') { @@ -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) { @@ -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') { @@ -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 @@ -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}` @@ -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); @@ -537,6 +570,7 @@ export class WebRTCSignalingService { ); } this.closePeerConnection(targetUser, true); + this.peerDisconnected$.next(targetUser); } } @@ -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); } /** @@ -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); } diff --git a/client/web/src/app/core/services/communication/webrtc.service.ts b/client/web/src/app/core/services/communication/webrtc.service.ts index 3748497..bc3a77e 100644 --- a/client/web/src/app/core/services/communication/webrtc.service.ts +++ b/client/web/src/app/core/services/communication/webrtc.service.ts @@ -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'; @@ -15,6 +15,14 @@ export class WebRTCService implements IWebRTCService { private logger = inject(NGXLogger); // =============== Public Properties =============== + public get peerDisconnected$(): Observable { + return this.signalingService.peerDisconnected$.asObservable(); + } + + public get peerConnected$(): Observable { + return this.signalingService.peerConnected$.asObservable(); + } + /** * Gets the data channel open subject */ diff --git a/client/web/src/app/features/chat/chat.component.html b/client/web/src/app/features/chat/chat.component.html index 7da701a..b0c3fdb 100644 --- a/client/web/src/app/features/chat/chat.component.html +++ b/client/web/src/app/features/chat/chat.component.html @@ -228,7 +228,8 @@

{{ 'MEMBERS' | translate }}

+ + + + + + + } + -
+
@if (messages.length === 0) { @@ -1925,30 +1994,45 @@
class="flex items-center gap-6 px-2" [ngClass]="isRTL ? 'flex-row-reverse' : 'flex-row'" > - Attach Files - Emoji + + +