diff --git a/toju-app/src/app/features/servers/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail.component.ts index dc3de66..589bdb0 100644 --- a/toju-app/src/app/features/servers/servers-rail.component.ts +++ b/toju-app/src/app/features/servers/servers-rail.component.ts @@ -1,28 +1,38 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { Component, + DestroyRef, computed, effect, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule, NgOptimizedImage } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePlus } from '@ng-icons/lucide'; -import { firstValueFrom } from 'rxjs'; +import { + EMPTY, + Subject, + catchError, + switchMap, + tap +} from 'rxjs'; import { Room, User } from '../../shared-kernel'; -import { RealtimeSessionFacade } from '../../core/realtime'; import { VoiceSessionFacade } from '../../domains/voice-session'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors'; import { RoomsActions } from '../../store/rooms/rooms.actions'; import { DatabaseService } from '../../infrastructure/persistence'; import { NotificationsFacade } from '../../domains/notifications'; -import { ServerDirectoryFacade } from '../../domains/server-directory'; +import { + type ServerInfo, + ServerDirectoryFacade +} from '../../domains/server-directory'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; import { ConfirmDialogComponent, @@ -49,11 +59,12 @@ export class ServersRailComponent { private store = inject(Store); private router = inject(Router); private voiceSession = inject(VoiceSessionFacade); - private webrtc = inject(RealtimeSessionFacade); private db = inject(DatabaseService); private notifications = inject(NotificationsFacade); private serverDirectory = inject(ServerDirectoryFacade); + private destroyRef = inject(DestroyRef); private banLookupRequestVersion = 0; + private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>(); savedRooms = this.store.selectSignal(selectSavedRooms); currentRoom = this.store.selectSignal(selectCurrentRoom); @@ -79,6 +90,13 @@ export class ServersRailComponent { void this.refreshBannedLookup(rooms, currentUser ?? null); }); + + this.savedRoomJoinRequests + .pipe( + switchMap(({ room, password }) => this.requestJoinInBackground(room, password)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } initial(name?: string): string { @@ -102,7 +120,7 @@ export class ServersRailComponent { this.router.navigate(['/search']); } - async joinSavedRoom(room: Room): Promise { + joinSavedRoom(room: Room): void { const currentUserId = localStorage.getItem('metoyou_currentUserId'); if (!currentUserId) { @@ -110,27 +128,14 @@ export class ServersRailComponent { return; } - if (await this.isRoomBanned(room)) { + if (this.isRoomMarkedBanned(room)) { this.bannedServerName.set(room.name); this.showBannedDialog.set(true); return; } - const roomWsUrl = this.serverDirectory.getWebSocketUrl({ - sourceId: room.sourceId, - sourceUrl: room.sourceUrl - }); - const currentWsUrl = this.webrtc.getCurrentSignalingUrl(); - - this.prepareVoiceContext(room); - - if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) { - this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); - this.store.dispatch(RoomsActions.viewServer({ room, - skipBanCheck: true })); - } else { - await this.attemptJoinRoom(room); - } + this.activateSavedRoom(room); + this.savedRoomJoinRequests.next({ room }); } closeBannedDialog(): void { @@ -145,13 +150,15 @@ export class ServersRailComponent { this.joinPasswordError.set(null); } - async confirmPasswordJoin(): Promise { + confirmPasswordJoin(): void { const room = this.passwordPromptRoom(); if (!room) return; - await this.attemptJoinRoom(room, this.joinPassword()); + this.joinPasswordError.set(null); + this.savedRoomJoinRequests.next({ room, + password: this.joinPassword() }); } isRoomMarkedBanned(room: Room): boolean { @@ -261,19 +268,6 @@ export class ServersRailComponent { this.bannedRoomLookup.set(Object.fromEntries(entries)); } - private async isRoomBanned(room: Room): Promise { - const currentUser = this.currentUser(); - const persistedUserId = localStorage.getItem('metoyou_currentUserId'); - - if (!currentUser && !persistedUserId) { - return false; - } - - const bans = await this.db.getBansForRoom(room.id); - - return hasRoomBanForUser(bans, currentUser, persistedUserId); - } - private prepareVoiceContext(room: Room): void { const voiceServerId = this.voiceSession.getVoiceServerId(); @@ -284,17 +278,24 @@ export class ServersRailComponent { } } - private async attemptJoinRoom(room: Room, password?: string): Promise { + private activateSavedRoom(room: Room): void { + this.prepareVoiceContext(room); + this.closePasswordDialog(); + this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); + this.store.dispatch(RoomsActions.viewServer({ room, + skipBanCheck: true })); + } + + private requestJoinInBackground(room: Room, password?: string) { const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUser = this.currentUser(); if (!currentUserId) - return; + return EMPTY; this.joinPasswordError.set(null); - try { - const response = await firstValueFrom(this.serverDirectory.requestJoin({ + return this.serverDirectory.requestJoin({ roomId: room.id, userId: currentUserId, userPublicKey: currentUser?.oderId || currentUserId, @@ -303,48 +304,56 @@ export class ServersRailComponent { }, { sourceId: room.sourceId, sourceUrl: room.sourceUrl - })); + }) + .pipe( + tap((response) => { + this.closePasswordDialog(); + this.store.dispatch( + RoomsActions.updateRoom({ + roomId: room.id, + changes: this.toRoomRefreshChanges(room, response.server) + }) + ); - this.closePasswordDialog(); - this.store.dispatch( - RoomsActions.joinRoom({ - roomId: room.id, - serverInfo: { - ...this.toServerInfo(room), - ...response.server, - channels: - Array.isArray(response.server.channels) && response.server.channels.length > 0 - ? response.server.channels - : room.channels + if (this.currentRoom()?.id === room.id) { + this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); } + }), + catchError((error: unknown) => { + this.handleBackgroundJoinError(room, error); + return EMPTY; }) ); - } catch (error: unknown) { - const serverError = error as { - error?: { error?: string; errorCode?: string }; - }; - const errorCode = serverError?.error?.errorCode; - const message = serverError?.error?.error || 'Failed to join server'; + } - if (errorCode === 'PASSWORD_REQUIRED') { - this.passwordPromptRoom.set(room); - this.showPasswordDialog.set(true); - this.joinPasswordError.set(message); - return; - } + private handleBackgroundJoinError(room: Room, error: unknown): void { + const serverError = error as { + error?: { error?: string; errorCode?: string }; + status?: number; + }; + const errorCode = serverError?.error?.errorCode; + const message = serverError?.error?.error || 'Failed to join server'; - if (errorCode === 'BANNED') { - this.bannedServerName.set(room.name); - this.showBannedDialog.set(true); - return; - } + if (errorCode === 'PASSWORD_REQUIRED') { + this.passwordPromptRoom.set(room); + this.showPasswordDialog.set(true); + this.joinPasswordError.set(message); + return; + } - if (this.shouldFallbackToOfflineView(error)) { - this.closePasswordDialog(); - this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); - this.store.dispatch(RoomsActions.viewServer({ room, - skipBanCheck: true })); - } + if (errorCode === 'BANNED') { + this.closePasswordDialog(); + this.bannedRoomLookup.update((lookup) => ({ + ...lookup, + [room.id]: true + })); + this.bannedServerName.set(room.name); + this.showBannedDialog.set(true); + return; + } + + if (this.shouldFallbackToOfflineView(error) && this.currentRoom()?.id === room.id) { + this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); } } @@ -362,22 +371,27 @@ export class ServersRailComponent { || (typeof status === 'number' && status >= 500); } - private toServerInfo(room: Room) { + private toRoomRefreshChanges(room: Room, server: ServerInfo): Partial { return { - id: room.id, - name: room.name, - description: room.description, - hostName: room.hostId || 'Unknown', - userCount: room.userCount ?? 0, - maxUsers: room.maxUsers ?? 50, - hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password, - isPrivate: room.isPrivate, - createdAt: room.createdAt, - ownerId: room.hostId, - channels: room.channels, - sourceId: room.sourceId, - sourceName: room.sourceName, - sourceUrl: room.sourceUrl + name: server.name, + description: server.description, + topic: server.topic ?? room.topic, + hostId: server.ownerId || room.hostId, + userCount: server.userCount, + maxUsers: server.maxUsers, + hasPassword: + typeof server.hasPassword === 'boolean' + ? server.hasPassword + : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password), + isPrivate: server.isPrivate, + createdAt: server.createdAt, + channels: + Array.isArray(server.channels) && server.channels.length > 0 + ? server.channels + : room.channels, + sourceId: server.sourceId ?? room.sourceId, + sourceName: server.sourceName ?? room.sourceName, + sourceUrl: server.sourceUrl ?? room.sourceUrl }; } } diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index eb6ccb2..14ac561 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -167,6 +167,8 @@ export class RoomsEffects { * and prevents false join/leave sounds during state re-syncs. */ private knownVoiceUsers = new Set(); + private roomNavigationRequestVersion = 0; + private latestNavigatedRoomId: string | null = null; /** Loads all saved rooms from the local database. */ loadRooms$ = createEffect(() => @@ -416,8 +418,11 @@ export class RoomsEffects { user, savedRooms ]) => { + const navigationRequestVersion = this.beginRoomNavigation(room.id); + void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, { - showCompatibilityError: true + showCompatibilityError: true, + navigationRequestVersion }); this.router.navigate(['/room', room.id]); @@ -478,9 +483,11 @@ export class RoomsEffects { const activateViewedRoom = () => { const oderId = user.oderId || this.webrtc.peerId(); + const navigationRequestVersion = this.beginRoomNavigation(room.id); void this.connectToRoomSignaling(room, user, oderId, savedRooms, { - showCompatibilityError: true + showCompatibilityError: true, + navigationRequestVersion }); this.router.navigate(['/room', room.id]); @@ -1621,14 +1628,19 @@ export class RoomsEffects { user: User | null, resolvedOderId?: string, savedRooms: Room[] = [], - options: { showCompatibilityError?: boolean } = {} + options: { showCompatibilityError?: boolean; navigationRequestVersion?: number } = {} ): Promise { const shouldShowCompatibilityError = options.showCompatibilityError ?? false; + const navigationRequestVersion = options.navigationRequestVersion; const compatibilitySelector = this.resolveCompatibilitySelector(room); const isCompatible = compatibilitySelector === null ? true : await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector); + if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { + return; + } + if (!isCompatible) { if (shouldShowCompatibilityError) { this.store.dispatch( @@ -1653,6 +1665,10 @@ export class RoomsEffects { const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl); const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id); const joinCurrentEndpointRooms = () => { + if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { + return; + } + this.webrtc.setCurrentServer(room.id); this.webrtc.identify(oderId, displayName, wsUrl); @@ -1676,7 +1692,7 @@ export class RoomsEffects { this.webrtc.connectToSignalingServer(wsUrl).subscribe({ next: (connected) => { - if (!connected) + if (!connected || !this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) return; joinCurrentEndpointRooms(); @@ -1788,6 +1804,22 @@ export class RoomsEffects { return roomMatch ? roomMatch[1] : null; } + private beginRoomNavigation(roomId: string): number { + this.roomNavigationRequestVersion += 1; + this.latestNavigatedRoomId = roomId; + + return this.roomNavigationRequestVersion; + } + + private isCurrentRoomNavigation(roomId: string, navigationRequestVersion?: number): boolean { + if (typeof navigationRequestVersion !== 'number') { + return true; + } + + return navigationRequestVersion === this.roomNavigationRequestVersion + && roomId === this.latestNavigatedRoomId; + } + private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null { if (room.hostId === currentUser.id || room.hostId === currentUser.oderId) return 'host';