From 187c7b2589a77bfffee995162e72377ee6c2189d Mon Sep 17 00:00:00 2001 From: Sulaiman AlRomaih Date: Wed, 6 May 2026 21:57:57 +0300 Subject: [PATCH 1/7] Web: Improve session navigation and state handling - Introduces reset functionality for session-scoped state during transitions. - Utilizes `enterSession()` for cleaner session code navigation and session teardown. - Replaces URL fragment navigation with `routerLink` --- .../services/room-management/room.service.ts | 14 ++ .../src/app/features/chat/chat.component.html | 3 +- .../src/app/features/chat/chat.component.ts | 217 ++++++++++++------ .../chat-sidebar/chat-sidebar.component.html | 3 +- 4 files changed, 160 insertions(+), 77 deletions(-) diff --git a/client/web/src/app/core/services/room-management/room.service.ts b/client/web/src/app/core/services/room-management/room.service.ts index c09b53c..91963c5 100644 --- a/client/web/src/app/core/services/room-management/room.service.ts +++ b/client/web/src/app/core/services/room-management/room.service.ts @@ -45,6 +45,20 @@ export class RoomService implements IRoomService { this.wsService.send('[UserCommand] /list'); } + /** + * Clears all session-scoped state. Called during session transitions + * (entering/leaving a private session) so the singleton's BehaviorSubjects + * don't replay stale members/rooms from the previous session into the + * newly-mounted view. + */ + public reset(): void { + this.ngZone.run(() => { + this.rooms$.next([]); + this.members$.next([]); + this.currentRoom = 'main'; + }); + } + public joinRoom(room: string): void { const sanitizedRoom = room .replace(/[^a-zA-Z0-9\-_ ]/g, '') diff --git a/client/web/src/app/features/chat/chat.component.html b/client/web/src/app/features/chat/chat.component.html index 8b47f33..45dafd9 100644 --- a/client/web/src/app/features/chat/chat.component.html +++ b/client/web/src/app/features/chat/chat.component.html @@ -295,9 +295,8 @@
diff --git a/client/web/src/app/features/chat/chat.component.ts b/client/web/src/app/features/chat/chat.component.ts index 73577fb..19a2266 100644 --- a/client/web/src/app/features/chat/chat.component.ts +++ b/client/web/src/app/features/chat/chat.component.ts @@ -148,6 +148,7 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { activeDownloads: FileDownload[] = []; private isNavigatingIntentionally = false; + private isInitialBootstrap = true; private lastMessagesLength: number = 0; private connectionInitTimeouts: ReturnType[] = []; private navigationTimeout: ReturnType | null = null; @@ -220,15 +221,8 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { } if (!isPlatformBrowser(this.platformId)) { - this.route.paramMap.subscribe((params) => { - const privateSession = params.get('code'); - - if (privateSession) { - this.metaService.updateChatMetadata(true); - } else { - this.metaService.updateChatMetadata(false); - } - }); + const privateSession = this.route.snapshot.paramMap.get('code'); + this.metaService.updateChatMetadata(!!privateSession); return; } @@ -248,33 +242,59 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { this.logger.debug('ngOnInit', `Flowbite loaded`); }); - // Check if route has a session code in URL but don't connect yet - this.route.paramMap.subscribe((params) => { - this.ngZone.run(() => { - const sessionCode = params.get('code'); - const storedSessionCode = localStorage.getItem(SESSION_CODE_KEY); - - if (sessionCode && this.sessionService.isValidSessionCode(sessionCode)) { - this.SessionCode = this.sessionService.sanitizeSessionCode(sessionCode); - this.metaService.updateChatMetadata(true); - } else if (storedSessionCode && this.sessionService.isValidSessionCode(storedSessionCode)) { - this.SessionCode = this.sessionService.sanitizeSessionCode(storedSessionCode); - this.metaService.updateChatMetadata(true); - } else { - if (sessionCode && !this.sessionService.isValidSessionCode(sessionCode)) { - this.logger.warn('ngOnInit', 'Invalid session code in URL, clearing'); + // Check if route has a session code in URL but don't connect yet. + // First emission seeds SessionCode for ngAfterViewInit's initial connect(); + // subsequent emissions (from in-app navigation) trigger a full session + // transition via enterSession(). + this.subscriptions.push( + this.route.paramMap.subscribe((params) => { + this.ngZone.run(() => { + const sessionCode = params.get('code'); + const storedSessionCode = localStorage.getItem(SESSION_CODE_KEY); + + if (this.isInitialBootstrap) { + this.isInitialBootstrap = false; + + if (sessionCode && this.sessionService.isValidSessionCode(sessionCode)) { + const sanitized = this.sessionService.sanitizeSessionCode(sessionCode); + this.SessionCode = sanitized; + localStorage.setItem(SESSION_CODE_KEY, sanitized); + this.metaService.updateChatMetadata(true); + } else if ( + storedSessionCode && + this.sessionService.isValidSessionCode(storedSessionCode) + ) { + this.SessionCode = this.sessionService.sanitizeSessionCode(storedSessionCode); + this.metaService.updateChatMetadata(true); + } else { + if (sessionCode && !this.sessionService.isValidSessionCode(sessionCode)) { + this.logger.warn('ngOnInit', 'Invalid session code in URL, clearing'); + } + if (storedSessionCode && !this.sessionService.isValidSessionCode(storedSessionCode)) { + this.logger.warn('ngOnInit', 'Invalid session code in localStorage, clearing'); + this.clearSessionCode(); + } + this.chatService.clearMessages(); + this.messages = []; + this.metaService.updateChatMetadata(false); + } + this.cdr.detectChanges(); + return; } - if (storedSessionCode && !this.sessionService.isValidSessionCode(storedSessionCode)) { - this.logger.warn('ngOnInit', 'Invalid session code in localStorage, clearing'); - this.clearSessionCode(); + + // Subsequent emission: in-app navigation between sessions. + const newCode = + sessionCode && this.sessionService.isValidSessionCode(sessionCode) + ? this.sessionService.sanitizeSessionCode(sessionCode) + : null; + const currentCode = this.SessionCode || null; + if (newCode !== currentCode) { + this.isNavigatingIntentionally = true; + void this.enterSession(newCode); } - this.chatService.clearMessages(); - this.messages = []; - this.metaService.updateChatMetadata(false); - } - this.cdr.detectChanges(); - }); - }); + }); + }) + ); // Listen to changes in the user's name this.subscriptions.push( @@ -791,6 +811,74 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { }); } + /** + * ========================================================== + * ENTER SESSION + * Single point of state-transition: tears down everything related + * to the current session, updates SessionCode + storage + metadata, + * then connects to the new one. Called by the paramMap subscription + * whenever the route's :code segment changes after initial bootstrap. + * ========================================================== + */ + private async enterSession(code: string | null): Promise { + if (!isPlatformBrowser(this.platformId)) return; + + this.logger.info('enterSession', `Switching to session: ${code ?? 'public'}`); + + // ---- Tear down previous session ---- + this.webrtcService.closeAllConnections(); + this.wsConnectionService.disconnect(); + + this.connectionInitTimeouts.forEach((id) => clearTimeout(id)); + this.connectionInitTimeouts = []; + this.connectionWarningTimeouts.forEach((id) => clearTimeout(id)); + this.connectionWarningTimeouts.clear(); + if (this.statusCheckIntervalId) { + clearInterval(this.statusCheckIntervalId); + this.statusCheckIntervalId = null; + } + + this.roomService.reset(); + this.chatService.clearMessages(); + + this.messages = []; + this.members = []; + this.rooms = []; + this.activeUploads = []; + this.activeDownloads = []; + this.memberConnectionStatus.clear(); + this.showConnectionWarning = false; + this.connectionWarningDismissed = false; + this.currentRoom = 'main'; + this.overrideRecipients = null; + this.lastMessagesLength = 0; + + // ---- Update session code (memory + storage + meta) ---- + if (code) { + const sanitized = this.sessionService.sanitizeSessionCode(code); + this.SessionCode = sanitized; + localStorage.setItem(SESSION_CODE_KEY, sanitized); + this.metaService.updateChatMetadata(true); + } else { + this.SessionCode = ''; + localStorage.removeItem(SESSION_CODE_KEY); + this.metaService.updateChatMetadata(false); + } + + this.cdr.detectChanges(); + + // ---- Connect to the new session ---- + try { + await this.connect(this.SessionCode || undefined); + } catch (err) { + this.logger.error('enterSession', `Failed to connect to new session: ${err}`); + } finally { + // Settle the navigation intent so beforeUnload semantics remain correct + // for whatever the user does next. + this.isNavigatingIntentionally = false; + } + } + /** * ========================================================== * SEND MESSAGE @@ -1252,17 +1340,20 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { requestAnimationFrame(() => { this.isOpenEndSessionPopup = false; this.cdr.detectChanges(); - this.clearSessionCode(); - this.wsConnectionService.disconnect(); this.toaster.success(this.translate.instant('SESSION_ENDED_SUCCESS')); - this.router.navigate([`/`]); + // paramMap subscription detects the code change and calls enterSession(null), + // which tears down WebRTC/WS and reconnects to public. + void this.router.navigateByUrl('/'); }); } /** * ========================================================== * OPEN CHAT SESSION - * Redirects the user to /private/:code in the same browser tab. + * Validates the code and triggers SPA navigation to /private/:code. + * The actual session-state teardown and reconnection is handled by + * the paramMap subscription -> enterSession() flow, so this method + * is only responsible for the navigation itself. * ========================================================== */ private openChatSession(code: string): void { @@ -1272,46 +1363,26 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { return; } - if (this.SessionCode) { - // Close all WebRTC connections - this.webrtcService.closeAllConnections(); - - // Clear all state - this.clearSessionCode(); - this.clearMessages(); - this.members = []; - this.rooms = []; - this.activeUploads = []; - this.activeDownloads = []; - this.overrideRecipients = null; - - // Disconnect WebSocket - this.wsConnectionService.disconnect(); + if (!isPlatformBrowser(this.platformId)) return; - // Reset room to default - this.currentRoom = 'main'; + const sanitizedCode = this.sessionService.sanitizeSessionCode(code); + if (sanitizedCode === this.SessionCode) { + this.logger.debug('openChatSession', `Already in session: ${sanitizedCode}`); + return; } - if (isPlatformBrowser(this.platformId)) { - this.logger.debug('openChatSession', `Opening chat session with code: ${code}`); - this.ngZone.run(() => { - this.isNavigatingIntentionally = true; - this.cdr.detectChanges(); - }); - - const sanitizedCode = this.sessionService.sanitizeSessionCode(code); - localStorage.setItem(SESSION_CODE_KEY, sanitizedCode); - - // Clear any existing navigation timeout - if (this.navigationTimeout) { - clearTimeout(this.navigationTimeout); - } + this.logger.debug('openChatSession', `Opening chat session with code: ${sanitizedCode}`); - this.navigationTimeout = setTimeout(() => { - this.navigationTimeout = null; - window.open(`/private/${sanitizedCode}`, '_self'); - }, NAVIGATION_DELAY_MS); + if (this.navigationTimeout) { + clearTimeout(this.navigationTimeout); } + + // Small delay so the popup close animation and toast can render before + // the URL change kicks off the session-transition pipeline. + this.navigationTimeout = setTimeout(() => { + this.navigationTimeout = null; + void this.router.navigateByUrl(`/private/${sanitizedCode}`); + }, NAVIGATION_DELAY_MS); } /** diff --git a/client/web/src/app/features/chat/components/chat-sidebar/chat-sidebar.component.html b/client/web/src/app/features/chat/components/chat-sidebar/chat-sidebar.component.html index f37667e..16f6dbd 100644 --- a/client/web/src/app/features/chat/components/chat-sidebar/chat-sidebar.component.html +++ b/client/web/src/app/features/chat/components/chat-sidebar/chat-sidebar.component.html @@ -1064,9 +1064,8 @@
From 73b279e8fb6e6815006ace4f78b5af0efa7563f8 Mon Sep 17 00:00:00 2001 From: Sulaiman AlRomaih Date: Wed, 6 May 2026 22:14:57 +0300 Subject: [PATCH 2/7] Web: Enhance session connections - Added handling for session join failures with UI feedback. - Improved WebRTC connection management for smoother chat experience. - Optimized chat member monitoring for better performance. - Updated Arabic translations to support new error messages. - Enhanced session navigation with fallback to public rooms. --- .../src/app/core/i18n/localizations/ar.json | 1 + .../src/app/core/i18n/localizations/en.json | 1 + .../app/core/interfaces/webrtc.interface.ts | 1 + .../communication/webrtc-signaling.service.ts | 27 +++- .../services/communication/webrtc.service.ts | 8 ++ .../websocket-connection.service.ts | 3 - .../src/app/features/chat/chat.component.ts | 119 +++++++++++++----- 7 files changed, 122 insertions(+), 38 deletions(-) diff --git a/client/web/src/app/core/i18n/localizations/ar.json b/client/web/src/app/core/i18n/localizations/ar.json index 6943c3f..09266ee 100644 --- a/client/web/src/app/core/i18n/localizations/ar.json +++ b/client/web/src/app/core/i18n/localizations/ar.json @@ -134,6 +134,7 @@ "CANNOT_CONNECT_TO_USER": "تعذّر الاتصال بالمستخدم {{userName}}. قد لا يكون متصلاً.", "CONNECTION_CLOSED_WITH_USER": "تم إغلاق الاتصال مع المستخدم {{userName}}.", "SERVER_CONNECTION_FAILED": "فشل الاتصال بالخادم. يرجى التحقق من اتصال الإنترنت.", + "SESSION_JOIN_FAILED": "تعذر الانضمام إلى الجلسة. قد يكون الرمز غير صالح أو منتهي الصلاحية، أو أن الخادم غير متاح.", "SERVER_CONNECTION_LOST_PERMANENTLY": "تم فقد الاتصال بالخادم. يرجى تحديث الصفحة.", "RECONNECTING_TO_SERVER": "جارٍ إعادة الاتصال بالخادم...", "CANCELLED": "تم الإلغاء", diff --git a/client/web/src/app/core/i18n/localizations/en.json b/client/web/src/app/core/i18n/localizations/en.json index e49bbfc..ffe1ec3 100644 --- a/client/web/src/app/core/i18n/localizations/en.json +++ b/client/web/src/app/core/i18n/localizations/en.json @@ -134,6 +134,7 @@ "CANNOT_CONNECT_TO_USER": "Cannot connect to {{userName}}. They may be offline.", "CONNECTION_CLOSED_WITH_USER": "Connection with {{userName}} was closed.", "SERVER_CONNECTION_FAILED": "Failed to connect to server. Please check your internet connection.", + "SESSION_JOIN_FAILED": "Couldn't join the session. The code may be invalid or expired, or the server may be unreachable.", "SERVER_CONNECTION_LOST_PERMANENTLY": "Lost connection to server. Please refresh the page.", "RECONNECTING_TO_SERVER": "Reconnecting to server...", "CANCELLED": "Canceled", diff --git a/client/web/src/app/core/interfaces/webrtc.interface.ts b/client/web/src/app/core/interfaces/webrtc.interface.ts index ab0a7ea..240ba41 100644 --- a/client/web/src/app/core/interfaces/webrtc.interface.ts +++ b/client/web/src/app/core/interfaces/webrtc.interface.ts @@ -25,5 +25,6 @@ export interface IWebRTCService { initiateConnection(targetUser: string): void; sendData(data: DataChannelMessage, targetUser: string): void; + closeConnection(targetUser: string): void; closeAllConnections(): void; } 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 19e4a51..3659dc1 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 @@ -238,6 +238,28 @@ export class WebRTCSignalingService { } } + /** + * Fully stops WebRTC work for a peer that is no longer in the room. + * This is stronger than closing the current RTCPeerConnection because it + * also cancels pending retries and delayed connection requests. + * @param targetUser The user to stop connecting to + */ + public closeConnection(targetUser: string): void { + this.closePeerConnection(targetUser, true); + + this.connectionLocks.delete(targetUser); + this.reconnectAttempts.delete(targetUser); + this.lastSequences.delete(targetUser); + + const reconnectionTimeout = this.reconnectionTimeouts.get(targetUser); + if (reconnectionTimeout) { + clearTimeout(reconnectionTimeout); + this.reconnectionTimeouts.delete(targetUser); + } + + this.communicationService.deleteMessageQueue(targetUser); + } + /** * Closes a peer connection with the target user * @param targetUser The user to disconnect from @@ -246,8 +268,8 @@ export class WebRTCSignalingService { public closePeerConnection(targetUser: string, force = false) { const peerConnection = this.peerConnections.get(targetUser); if (peerConnection) { - peerConnection.close(); this.peerConnections.delete(targetUser); + peerConnection.close(); } // Clear connection request timeout @@ -286,7 +308,9 @@ export class WebRTCSignalingService { peerConnection.close(); }); this.peerConnections.clear(); + this.connectionLocks.clear(); this.reconnectAttempts.clear(); + this.lastSequences.clear(); this.candidateQueues.clear(); this.collectedCandidates.clear(); @@ -923,7 +947,6 @@ export class WebRTCSignalingService { this.logger.info('reconnect', `Reconnecting WebRTC with ${targetUser}...`); this.closePeerConnection(targetUser, true); - this.reconnectAttempts.set(targetUser, 0); this.initiateConnection(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 bc3a77e..b2e2840 100644 --- a/client/web/src/app/core/services/communication/webrtc.service.ts +++ b/client/web/src/app/core/services/communication/webrtc.service.ts @@ -171,6 +171,14 @@ export class WebRTCService implements IWebRTCService { return this.communicationService.isConnected(targetUser); } + /** + * Closes connection with a single target user and cancels pending retries + * @param targetUser The user to disconnect from + */ + public closeConnection(targetUser: string): void { + this.signalingService.closeConnection(targetUser); + } + /** * Closes all connections */ diff --git a/client/web/src/app/core/services/communication/websocket-connection.service.ts b/client/web/src/app/core/services/communication/websocket-connection.service.ts index f6965ff..9f86d98 100644 --- a/client/web/src/app/core/services/communication/websocket-connection.service.ts +++ b/client/web/src/app/core/services/communication/websocket-connection.service.ts @@ -265,9 +265,6 @@ export class WebSocketConnectionService implements OnDestroy { this.isConnecting = false; this.stopKeepAlive(); this.socket = undefined; - if (this.reconnectAttempts === 0 && !this.manualDisconnect) { - this.toaster.error(this.translate.instant('SERVER_CONNECTION_FAILED')); - } reject(error); }; }); diff --git a/client/web/src/app/features/chat/chat.component.ts b/client/web/src/app/features/chat/chat.component.ts index 19a2266..3719383 100644 --- a/client/web/src/app/features/chat/chat.component.ts +++ b/client/web/src/app/features/chat/chat.component.ts @@ -12,7 +12,8 @@ import { ViewChild, inject, } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { combineLatest, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { isPlatformBrowser, NgOptimizedImage, UpperCasePipe } from '@angular/common'; import { ThemeService } from '../../core/services/ui/theme.service'; @@ -296,13 +297,14 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { }) ); + this.initializeChat(); + // Listen to changes in the user's name this.subscriptions.push( this.userService.user$.subscribe((username: unknown) => { if (username) { this.ngZone.run(() => { this.logger.info('ngOnInit', `Username is set to: ${username}`); - this.initializeChat(); }); } }) @@ -547,31 +549,54 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { // Listen for current members in the room this.subscriptions.push( - this.roomService.members$.subscribe((allMembers: string[]) => { - this.ngZone.run(() => { - // Filter out the local user's own name - this.members = allMembers.filter((m) => m !== this.userService.user); - - // Clean up timeouts for members who left - for (const [member, timeoutId] of this.connectionWarningTimeouts.entries()) { - if (!this.members.includes(member)) { - clearTimeout(timeoutId); - this.connectionWarningTimeouts.delete(member); - } - } + combineLatest([this.roomService.members$, this.userService.user$]) + .pipe( + filter(([, currentUser]) => currentUser.trim().length > 0), + map(([allMembers, currentUser]) => allMembers.filter((m) => m !== currentUser)), + distinctUntilChanged( + (previous, current) => + previous.length === current.length && + previous.every((member, index) => member === current[index]) + ) + ) + .subscribe((nextMembers: string[]) => { + this.ngZone.run(() => { + const previousMembers = this.members; + const departedMembers = previousMembers.filter( + (member) => !nextMembers.includes(member) + ); + + this.members = nextMembers; - // For new members, start a warning timeout - this.members.forEach((member) => { - if (!this.memberConnectionStatus.has(member)) { - this.memberConnectionStatus.set(member, false); - this.scheduleConnectionWarning(member); + departedMembers.forEach((member) => { + this.logger.info( + 'members', + `Closing WebRTC connection for departed member: ${member}` + ); + this.webrtcService.closeConnection(member); + this.memberConnectionStatus.delete(member); + }); + + // Clean up timeouts for members who left + for (const [member, timeoutId] of this.connectionWarningTimeouts.entries()) { + if (!this.members.includes(member)) { + clearTimeout(timeoutId); + this.connectionWarningTimeouts.delete(member); + } } - }); - this.cdr.detectChanges(); - this.initiateConnectionsWithMembers(); - }); - }) + // For new members, start a warning timeout + this.members.forEach((member) => { + if (!this.memberConnectionStatus.has(member)) { + this.memberConnectionStatus.set(member, false); + this.scheduleConnectionWarning(member); + } + }); + + this.cdr.detectChanges(); + this.initiateConnectionsWithMembers(); + }); + }) ); // Listen for active file uploads @@ -620,6 +645,7 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { this.subscriptions.push( this.webrtcService.peerDisconnected$.subscribe((member) => { this.ngZone.run(() => { + if (!this.members.includes(member)) return; this.memberConnectionStatus.set(member, false); this.scheduleConnectionWarning(member); this.cdr.detectChanges(); @@ -631,6 +657,7 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { this.subscriptions.push( this.webrtcService.peerConnected$.subscribe((member) => { this.ngZone.run(() => { + if (!this.members.includes(member)) return; this.memberConnectionStatus.set(member, true); this.clearConnectionWarning(member); this.cdr.detectChanges(); @@ -724,11 +751,14 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { ngAfterViewInit(): void { if (!isPlatformBrowser(this.platformId)) return; - if (this.SessionCode) { - this.connect(this.SessionCode); - } else { - void this.connect(); - } + const attemptedCode = this.SessionCode; + this.connect(attemptedCode || undefined).catch(() => { + if (attemptedCode) { + this.fallbackToPublic(); + } else { + this.toaster.error(this.translate.instant('SERVER_CONNECTION_FAILED')); + } + }); document.addEventListener('visibilitychange', this.visibilityChangeListener); window.addEventListener('beforeunload', this.beforeUnloadHandler); @@ -872,6 +902,11 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { await this.connect(this.SessionCode || undefined); } catch (err) { this.logger.error('enterSession', `Failed to connect to new session: ${err}`); + if (code) { + this.fallbackToPublic(); + } else { + this.toaster.error(this.translate.instant('SERVER_CONNECTION_FAILED')); + } } finally { // Settle the navigation intent so beforeUnload semantics remain correct // for whatever the user does next. @@ -1445,6 +1480,18 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { this.SessionCode = ''; } + /** + * Called when joining a private session fails (invalid/expired code, + * network error, etc). The browser's WebSocket API doesn't expose the + * HTTP status of a failed upgrade, so we can't reliably distinguish + * "wrong code" from "server unreachable" — the toast covers both. + */ + private fallbackToPublic(): void { + this.clearSessionCode(); + this.toaster.error(this.translate.instant('SESSION_JOIN_FAILED')); + void this.router.navigateByUrl('/'); + } + /** * ========================================================== * WAIT FOR FILE TRANSFER CONNECTION @@ -1483,18 +1530,24 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { * ========================================================== */ private initiateConnectionsWithMembers(): void { + // Clear any existing connection initialization timeouts before reacting + // to the latest membership snapshot. + this.connectionInitTimeouts.forEach((timeout) => clearTimeout(timeout)); + this.connectionInitTimeouts = []; + if (!this.members || this.members.length === 0) { this.logger.info( 'initiateConnectionsWithMembers', 'No members in the room, skipping WebRTC.' ); + + if (this.statusCheckIntervalId) { + clearInterval(this.statusCheckIntervalId); + this.statusCheckIntervalId = null; + } return; } - // Clear any existing connection initialization timeouts - this.connectionInitTimeouts.forEach((timeout) => clearTimeout(timeout)); - this.connectionInitTimeouts = []; - this.logger.info('initiateConnectionsWithMembers', 'Initiating connections with other members'); // Filter out self AND already connected/connecting peers From 2a4e0ffbea2675932476e29214af03ccff023fde Mon Sep 17 00:00:00 2001 From: Sulaiman AlRomaih Date: Wed, 6 May 2026 22:21:00 +0300 Subject: [PATCH 3/7] Server: Update OpenSSL crate versions - Updated `openssl` to version 0.10.79 and `openssl-sys` to 0.9.115. --- server/Cargo.lock | 9 ++++----- server/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 3ee498c..133d3da 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1568,15 +1568,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1594,9 +1593,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", diff --git a/server/Cargo.toml b/server/Cargo.toml index 674d795..c30f0ab 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,7 +22,7 @@ actix-rt = "2.11.0" uuid = { version = "1.23.1", features = ["v4"] } fake = "5.1.0" derive_more = { version = "2.1.1", features = ["full"] } -openssl = { version = "0.10.78" } +openssl = { version = "0.10.79" } serde_json = "1.0.149" serde = { version = "1.0.228", features = ["derive"] } env_logger = "0.11.10" From 4bc63eab7e7b84b711de92ccb75de4dae634cdeb Mon Sep 17 00:00:00 2001 From: Sulaiman AlRomaih Date: Wed, 6 May 2026 22:23:47 +0300 Subject: [PATCH 4/7] Doc: Update README file. --- README.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9468720..135906a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ # PastePoint -PastePoint is a secure, feature-rich file-sharing service designed for local networks. It enables users to share files and communicate efficiently through peer-to-peer WebRTC connections. Built with a Rust-based backend using Actix Web and an Angular frontend with SSR support, PastePoint prioritizes security, performance, and usability. +PastePoint is a secure, feature-rich file-sharing service designed for local networks. It enables users to share files and communicate efficiently through peer-to-peer WebRTC connections. Built with a Rust-based backend using Actix Web and an Angular 21 frontend with SSR support, PastePoint prioritizes security, performance, and usability. ## Usage Disclaimer @@ -24,7 +24,10 @@ PastePoint is a secure, feature-rich file-sharing service designed for local net - Establish WebSocket-based local chat between computers on the same network - List available sessions, create new sessions, or join existing ones + - Multiple rooms within a session — create, list, and switch between rooms + - QR code session sharing — generate a code from one device and scan it from another to join instantly - Real-time messaging with emoji support and dark/light theme + - Resilient WebSocket signaling with automatic reconnect, heartbeat, and bfcache support - **File Sharing**: @@ -62,10 +65,10 @@ PastePoint is a secure, feature-rich file-sharing service designed for local net ### Server (Rust) -[![Actix](https://img.shields.io/badge/Actix-4.7-blue)](https://actix.rs/) +[![Actix](https://img.shields.io/badge/Actix-4.13-blue)](https://actix.rs/) [![OpenSSL](https://img.shields.io/badge/OpenSSL-0.10-yellow)](https://www.openssl.org/) -- **Framework**: Actix Web with WebSocket support +- **Framework**: Actix Web with WebSocket support (Rust edition 2024, toolchain 1.93.1) - **Security**: OpenSSL for TLS termination - **Utilities**: UUID generation, Serde serialization - **Rate Limiting**: Actix-governor for request throttling @@ -74,22 +77,24 @@ PastePoint is a secure, feature-rich file-sharing service designed for local net #### Web (Angular) -[![Angular](https://img.shields.io/badge/Angular-19-red)](https://angular.io/) +[![Angular](https://img.shields.io/badge/Angular-21-red)](https://angular.io/) [![Tailwind](https://img.shields.io/badge/Tailwind-3.4-blue)](https://tailwindcss.com/) -[![Flowbite](https://img.shields.io/badge/Flowbite-3.0-cyan)](https://flowbite.com/) +[![Flowbite](https://img.shields.io/badge/Flowbite-3.1-cyan)](https://flowbite.com/) -- **Rendering**: Server-Side Rendering with Angular Universal +- **Rendering**: Server-Side Rendering with Angular SSR - **State Management**: RxJS observables - **Styling**: Tailwind CSS with dark mode -- **I18n**: ngx-translate integration (English, Arabic) (WIP) +- **I18n**: ngx-translate integration (English, Arabic with RTL) - **WebRTC**: Native WebRTC API for file transfers +- **QR Sharing**: `qrcode` for generation, `jsqr` for camera-based scanning +- **Integrity**: `hash-wasm` for fast file hashing - **Notifications**: Hot-toast for real-time feedback ### Infrastructure [![Nginx](https://img.shields.io/badge/Nginx-Reverse_Proxy-green)](https://nginx.org) [![Docker](https://img.shields.io/badge/Docker-24.0-blue)](https://www.docker.com) -[![Express](https://img.shields.io/badge/Express-4.21-purple)](https://expressjs.com/) +[![Express](https://img.shields.io/badge/Express-4.22-purple)](https://expressjs.com/) - **Container Orchestration**: Docker Compose with multi-stage builds - **Reverse Proxy**: Nginx with enhanced security features @@ -103,8 +108,7 @@ PastePoint is a secure, feature-rich file-sharing service designed for local net pastepoint/ ├── client/ # Frontend clients │ ├── web/ # Angular SSR frontend -│ ├── ios/ # iOS client (WIP) -│ └── android/ # Android client (WIP) +│ └── ios/ # iOS client (WIP) ├── server/ # Rust backend with WebSockets ├── nginx/ # Reverse proxy & SSL termination ├── scripts/ # Development & deployment scripts @@ -131,7 +135,7 @@ pastepoint/ ##### Android: -- Work in progress +- Planned #### Deployment: @@ -162,7 +166,7 @@ pastepoint/ - Docker and Docker Compose - Node.js (v22.14.0 as specified in `.nvmrc`) -- Rust (stable, specified in `rust-toolchain`) +- Rust 1.93.1 (specified in `rust-toolchain`, edition 2024) #### Windows-Specific Requirements: @@ -205,9 +209,9 @@ pastepoint/ - Frontend: - Localhost: [https://localhost](https://localhost) - Local Network: `https://` - - Server API: - - Localhost: [https://localhost:9000](https://localhost:9000) - - Local Network: `https://:9000` + - WebSocket signaling: + - Localhost: `wss://localhost:9000/ws` + - Local Network: `wss://:9000/ws` ## Contributing From d2d0ad6ed6464a1b20e5d144eb432b8e0af5341b Mon Sep 17 00:00:00 2001 From: Sulaiman AlRomaih Date: Wed, 6 May 2026 22:24:32 +0300 Subject: [PATCH 5/7] Server: Bump version to 0.9.2 --- server/Cargo.lock | 2 +- server/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 133d3da..03debaa 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2031,7 +2031,7 @@ dependencies = [ [[package]] name = "server" -version = "0.9.1" +version = "0.9.2" dependencies = [ "actix", "actix-broker", diff --git a/server/Cargo.toml b/server/Cargo.toml index c30f0ab..33d08b9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "server" -version = "0.9.1" +version = "0.9.2" edition = "2024" authors = ["Sulaiman AlRomaih"] license = "GPL-3" From 029b40353492c4039a3f993b3cf7d232385d3acb Mon Sep 17 00:00:00 2001 From: Sulaiman AlRomaih Date: Wed, 6 May 2026 22:24:50 +0300 Subject: [PATCH 6/7] Web: Bump version to 0.16.2 --- client/web/package-lock.json | 4 ++-- client/web/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/web/package-lock.json b/client/web/package-lock.json index 2e65bb1..ff592d5 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "0.16.1", + "version": "0.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "0.16.1", + "version": "0.16.2", "dependencies": { "@angular/cdk": "^21.2.9", "@angular/common": "^21.2.11", diff --git a/client/web/package.json b/client/web/package.json index f997067..4a72333 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.16.1", + "version": "0.16.2", "scripts": { "ng": "ng", "start": "ng serve", From 7ddb25df2c872818de2cd00e1058e36749beeee8 Mon Sep 17 00:00:00 2001 From: Sulaiman AlRomaih Date: Wed, 6 May 2026 22:47:53 +0300 Subject: [PATCH 7/7] Web: Fix WS connect promise hang and serialize session transitions - Settle the establishConnection() promise from onclose when the socket closes before opening (e.g. concurrent disconnect or abrupt server-side close), so callers waiting on connect() never hang. - Track a monotonic transition id in enterSession() so a stale transition finishing late after a newer one started doesn't toast, navigate, or reset isNavigatingIntentionally on behalf of the active session. - short verbose comments --- .../communication/webrtc-signaling.service.ts | 6 ++-- .../services/communication/webrtc.service.ts | 4 --- .../websocket-connection.service.ts | 23 +++++++++++++-- .../services/room-management/room.service.ts | 6 ++-- .../src/app/features/chat/chat.component.ts | 28 ++++++++++--------- 5 files changed, 40 insertions(+), 27 deletions(-) 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 3659dc1..10a7540 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 @@ -239,10 +239,8 @@ export class WebRTCSignalingService { } /** - * Fully stops WebRTC work for a peer that is no longer in the room. - * This is stronger than closing the current RTCPeerConnection because it - * also cancels pending retries and delayed connection requests. - * @param targetUser The user to stop connecting to + * Stronger than closePeerConnection — also cancels pending retries and + * queued requests. Use when the peer is gone for good (left the room). */ public closeConnection(targetUser: string): void { this.closePeerConnection(targetUser, true); 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 b2e2840..6e80968 100644 --- a/client/web/src/app/core/services/communication/webrtc.service.ts +++ b/client/web/src/app/core/services/communication/webrtc.service.ts @@ -171,10 +171,6 @@ export class WebRTCService implements IWebRTCService { return this.communicationService.isConnected(targetUser); } - /** - * Closes connection with a single target user and cancels pending retries - * @param targetUser The user to disconnect from - */ public closeConnection(targetUser: string): void { this.signalingService.closeConnection(targetUser); } diff --git a/client/web/src/app/core/services/communication/websocket-connection.service.ts b/client/web/src/app/core/services/communication/websocket-connection.service.ts index 9f86d98..fecb1dc 100644 --- a/client/web/src/app/core/services/communication/websocket-connection.service.ts +++ b/client/web/src/app/core/services/communication/websocket-connection.service.ts @@ -178,6 +178,21 @@ export class WebSocketConnectionService implements OnDestroy { const socket = new WebSocket(wsUri); this.socket = socket; + // Settle once — onclose may fire without onopen/onerror, e.g. when a + // concurrent disconnect replaces this.socket and the stale-event guards + // below skip the natural reject path. + let settled = false; + const settleResolve = () => { + if (settled) return; + settled = true; + resolve(); + }; + const settleReject = (err: unknown) => { + if (settled) return; + settled = true; + reject(err); + }; + socket.onopen = () => { if (socket !== this.socket) return; this.logger.info('connect', 'WebSocket connected'); @@ -188,7 +203,7 @@ export class WebSocketConnectionService implements OnDestroy { this.reconnectDelay = 1000; this.isConnecting = false; this.startKeepAlive(); - resolve(); + settleResolve(); }; socket.onmessage = (ev) => { @@ -219,6 +234,10 @@ export class WebSocketConnectionService implements OnDestroy { }; socket.onclose = (event) => { + // Must run before the stale-event guard — the promise belongs to + // this socket, even if it's already been replaced. + settleReject(new Error(`WebSocket closed before opening (code ${event.code})`)); + // Ignore stale close events from a socket that has already been replaced. // Without this guard, a late-firing onclose from a previous socket would // null-out the current `this.socket` and trigger a phantom reconnect, @@ -265,7 +284,7 @@ export class WebSocketConnectionService implements OnDestroy { this.isConnecting = false; this.stopKeepAlive(); this.socket = undefined; - reject(error); + settleReject(error); }; }); } diff --git a/client/web/src/app/core/services/room-management/room.service.ts b/client/web/src/app/core/services/room-management/room.service.ts index 91963c5..7d505a0 100644 --- a/client/web/src/app/core/services/room-management/room.service.ts +++ b/client/web/src/app/core/services/room-management/room.service.ts @@ -46,10 +46,8 @@ export class RoomService implements IRoomService { } /** - * Clears all session-scoped state. Called during session transitions - * (entering/leaving a private session) so the singleton's BehaviorSubjects - * don't replay stale members/rooms from the previous session into the - * newly-mounted view. + * Resets singleton state on session transition so BehaviorSubjects don't + * replay the previous session's members/rooms into the new view. */ public reset(): void { this.ngZone.run(() => { diff --git a/client/web/src/app/features/chat/chat.component.ts b/client/web/src/app/features/chat/chat.component.ts index 3719383..d61b674 100644 --- a/client/web/src/app/features/chat/chat.component.ts +++ b/client/web/src/app/features/chat/chat.component.ts @@ -150,6 +150,7 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { private isNavigatingIntentionally = false; private isInitialBootstrap = true; + private currentTransitionId = 0; private lastMessagesLength: number = 0; private connectionInitTimeouts: ReturnType[] = []; private navigationTimeout: ReturnType | null = null; @@ -853,6 +854,7 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { private async enterSession(code: string | null): Promise { if (!isPlatformBrowser(this.platformId)) return; + const transitionId = ++this.currentTransitionId; this.logger.info('enterSession', `Switching to session: ${code ?? 'public'}`); // ---- Tear down previous session ---- @@ -900,7 +902,10 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { // ---- Connect to the new session ---- try { await this.connect(this.SessionCode || undefined); + // Superseded by a newer transition — leave its state alone. + if (transitionId !== this.currentTransitionId) return; } catch (err) { + if (transitionId !== this.currentTransitionId) return; this.logger.error('enterSession', `Failed to connect to new session: ${err}`); if (code) { this.fallbackToPublic(); @@ -908,9 +913,9 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { this.toaster.error(this.translate.instant('SERVER_CONNECTION_FAILED')); } } finally { - // Settle the navigation intent so beforeUnload semantics remain correct - // for whatever the user does next. - this.isNavigatingIntentionally = false; + if (transitionId === this.currentTransitionId) { + this.isNavigatingIntentionally = false; + } } } @@ -1376,8 +1381,8 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { this.isOpenEndSessionPopup = false; this.cdr.detectChanges(); this.toaster.success(this.translate.instant('SESSION_ENDED_SUCCESS')); - // paramMap subscription detects the code change and calls enterSession(null), - // which tears down WebRTC/WS and reconnects to public. + // Cross-route nav: Angular tears down this component, ngOnDestroy + // handles cleanup, and the new instance at '/' connects to public. void this.router.navigateByUrl('/'); }); } @@ -1385,10 +1390,9 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { /** * ========================================================== * OPEN CHAT SESSION - * Validates the code and triggers SPA navigation to /private/:code. - * The actual session-state teardown and reconnection is handled by - * the paramMap subscription -> enterSession() flow, so this method - * is only responsible for the navigation itself. + * Navigates to /private/:code. Cross-route transitions (public -> private) + * destroy/recreate the component; only same-route /private/A -> /private/B + * goes through the paramMap -> enterSession() path. * ========================================================== */ private openChatSession(code: string): void { @@ -1481,10 +1485,8 @@ export class ChatComponent implements OnInit, OnDestroy, AfterViewInit { } /** - * Called when joining a private session fails (invalid/expired code, - * network error, etc). The browser's WebSocket API doesn't expose the - * HTTP status of a failed upgrade, so we can't reliably distinguish - * "wrong code" from "server unreachable" — the toast covers both. + * The browser hides the HTTP status of a failed WS upgrade, so a join + * failure could be a bad code or an unreachable server — toast covers both. */ private fallbackToPublic(): void { this.clearSessionCode();