Fix issues with server navigation

This commit is contained in:
2026-04-01 18:18:31 +02:00
parent 8b6578da3c
commit fed270d28d
2 changed files with 140 additions and 94 deletions

View File

@@ -1,28 +1,38 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
DestroyRef,
computed, computed,
effect, effect,
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule, NgOptimizedImage } from '@angular/common'; import { CommonModule, NgOptimizedImage } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide'; 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 { Room, User } from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime';
import { VoiceSessionFacade } from '../../domains/voice-session'; import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
import { NotificationsFacade } from '../../domains/notifications'; 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 { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import { import {
ConfirmDialogComponent, ConfirmDialogComponent,
@@ -49,11 +59,12 @@ export class ServersRailComponent {
private store = inject(Store); private store = inject(Store);
private router = inject(Router); private router = inject(Router);
private voiceSession = inject(VoiceSessionFacade); private voiceSession = inject(VoiceSessionFacade);
private webrtc = inject(RealtimeSessionFacade);
private db = inject(DatabaseService); private db = inject(DatabaseService);
private notifications = inject(NotificationsFacade); private notifications = inject(NotificationsFacade);
private serverDirectory = inject(ServerDirectoryFacade); private serverDirectory = inject(ServerDirectoryFacade);
private destroyRef = inject(DestroyRef);
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -79,6 +90,13 @@ export class ServersRailComponent {
void this.refreshBannedLookup(rooms, currentUser ?? null); void this.refreshBannedLookup(rooms, currentUser ?? null);
}); });
this.savedRoomJoinRequests
.pipe(
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
} }
initial(name?: string): string { initial(name?: string): string {
@@ -102,7 +120,7 @@ export class ServersRailComponent {
this.router.navigate(['/search']); this.router.navigate(['/search']);
} }
async joinSavedRoom(room: Room): Promise<void> { joinSavedRoom(room: Room): void {
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
@@ -110,27 +128,14 @@ export class ServersRailComponent {
return; return;
} }
if (await this.isRoomBanned(room)) { if (this.isRoomMarkedBanned(room)) {
this.bannedServerName.set(room.name); this.bannedServerName.set(room.name);
this.showBannedDialog.set(true); this.showBannedDialog.set(true);
return; return;
} }
const roomWsUrl = this.serverDirectory.getWebSocketUrl({ this.activateSavedRoom(room);
sourceId: room.sourceId, this.savedRoomJoinRequests.next({ room });
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);
}
} }
closeBannedDialog(): void { closeBannedDialog(): void {
@@ -145,13 +150,15 @@ export class ServersRailComponent {
this.joinPasswordError.set(null); this.joinPasswordError.set(null);
} }
async confirmPasswordJoin(): Promise<void> { confirmPasswordJoin(): void {
const room = this.passwordPromptRoom(); const room = this.passwordPromptRoom();
if (!room) if (!room)
return; return;
await this.attemptJoinRoom(room, this.joinPassword()); this.joinPasswordError.set(null);
this.savedRoomJoinRequests.next({ room,
password: this.joinPassword() });
} }
isRoomMarkedBanned(room: Room): boolean { isRoomMarkedBanned(room: Room): boolean {
@@ -261,19 +268,6 @@ export class ServersRailComponent {
this.bannedRoomLookup.set(Object.fromEntries(entries)); this.bannedRoomLookup.set(Object.fromEntries(entries));
} }
private async isRoomBanned(room: Room): Promise<boolean> {
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 { private prepareVoiceContext(room: Room): void {
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
@@ -284,17 +278,24 @@ export class ServersRailComponent {
} }
} }
private async attemptJoinRoom(room: Room, password?: string): Promise<void> { 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 currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser(); const currentUser = this.currentUser();
if (!currentUserId) if (!currentUserId)
return; return EMPTY;
this.joinPasswordError.set(null); this.joinPasswordError.set(null);
try { return this.serverDirectory.requestJoin({
const response = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: room.id, roomId: room.id,
userId: currentUserId, userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId, userPublicKey: currentUser?.oderId || currentUserId,
@@ -303,48 +304,56 @@ export class ServersRailComponent {
}, { }, {
sourceId: room.sourceId, sourceId: room.sourceId,
sourceUrl: room.sourceUrl sourceUrl: room.sourceUrl
})); })
.pipe(
tap((response) => {
this.closePasswordDialog();
this.store.dispatch(
RoomsActions.updateRoom({
roomId: room.id,
changes: this.toRoomRefreshChanges(room, response.server)
})
);
this.closePasswordDialog(); if (this.currentRoom()?.id === room.id) {
this.store.dispatch( this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
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
} }
}),
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') { private handleBackgroundJoinError(room: Room, error: unknown): void {
this.passwordPromptRoom.set(room); const serverError = error as {
this.showPasswordDialog.set(true); error?: { error?: string; errorCode?: string };
this.joinPasswordError.set(message); status?: number;
return; };
} const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
if (errorCode === 'BANNED') { if (errorCode === 'PASSWORD_REQUIRED') {
this.bannedServerName.set(room.name); this.passwordPromptRoom.set(room);
this.showBannedDialog.set(true); this.showPasswordDialog.set(true);
return; this.joinPasswordError.set(message);
} return;
}
if (this.shouldFallbackToOfflineView(error)) { if (errorCode === 'BANNED') {
this.closePasswordDialog(); this.closePasswordDialog();
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); this.bannedRoomLookup.update((lookup) => ({
this.store.dispatch(RoomsActions.viewServer({ room, ...lookup,
skipBanCheck: true })); [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); || (typeof status === 'number' && status >= 500);
} }
private toServerInfo(room: Room) { private toRoomRefreshChanges(room: Room, server: ServerInfo): Partial<Room> {
return { return {
id: room.id, name: server.name,
name: room.name, description: server.description,
description: room.description, topic: server.topic ?? room.topic,
hostName: room.hostId || 'Unknown', hostId: server.ownerId || room.hostId,
userCount: room.userCount ?? 0, userCount: server.userCount,
maxUsers: room.maxUsers ?? 50, maxUsers: server.maxUsers,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password, hasPassword:
isPrivate: room.isPrivate, typeof server.hasPassword === 'boolean'
createdAt: room.createdAt, ? server.hasPassword
ownerId: room.hostId, : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
channels: room.channels, isPrivate: server.isPrivate,
sourceId: room.sourceId, createdAt: server.createdAt,
sourceName: room.sourceName, channels:
sourceUrl: room.sourceUrl 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
}; };
} }
} }

View File

@@ -167,6 +167,8 @@ export class RoomsEffects {
* and prevents false join/leave sounds during state re-syncs. * and prevents false join/leave sounds during state re-syncs.
*/ */
private knownVoiceUsers = new Set<string>(); private knownVoiceUsers = new Set<string>();
private roomNavigationRequestVersion = 0;
private latestNavigatedRoomId: string | null = null;
/** Loads all saved rooms from the local database. */ /** Loads all saved rooms from the local database. */
loadRooms$ = createEffect(() => loadRooms$ = createEffect(() =>
@@ -416,8 +418,11 @@ export class RoomsEffects {
user, user,
savedRooms savedRooms
]) => { ]) => {
const navigationRequestVersion = this.beginRoomNavigation(room.id);
void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, { void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
showCompatibilityError: true showCompatibilityError: true,
navigationRequestVersion
}); });
this.router.navigate(['/room', room.id]); this.router.navigate(['/room', room.id]);
@@ -478,9 +483,11 @@ export class RoomsEffects {
const activateViewedRoom = () => { const activateViewedRoom = () => {
const oderId = user.oderId || this.webrtc.peerId(); const oderId = user.oderId || this.webrtc.peerId();
const navigationRequestVersion = this.beginRoomNavigation(room.id);
void this.connectToRoomSignaling(room, user, oderId, savedRooms, { void this.connectToRoomSignaling(room, user, oderId, savedRooms, {
showCompatibilityError: true showCompatibilityError: true,
navigationRequestVersion
}); });
this.router.navigate(['/room', room.id]); this.router.navigate(['/room', room.id]);
@@ -1621,14 +1628,19 @@ export class RoomsEffects {
user: User | null, user: User | null,
resolvedOderId?: string, resolvedOderId?: string,
savedRooms: Room[] = [], savedRooms: Room[] = [],
options: { showCompatibilityError?: boolean } = {} options: { showCompatibilityError?: boolean; navigationRequestVersion?: number } = {}
): Promise<void> { ): Promise<void> {
const shouldShowCompatibilityError = options.showCompatibilityError ?? false; const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
const navigationRequestVersion = options.navigationRequestVersion;
const compatibilitySelector = this.resolveCompatibilitySelector(room); const compatibilitySelector = this.resolveCompatibilitySelector(room);
const isCompatible = compatibilitySelector === null const isCompatible = compatibilitySelector === null
? true ? true
: await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector); : await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector);
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
return;
}
if (!isCompatible) { if (!isCompatible) {
if (shouldShowCompatibilityError) { if (shouldShowCompatibilityError) {
this.store.dispatch( this.store.dispatch(
@@ -1653,6 +1665,10 @@ export class RoomsEffects {
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl); const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id); const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
const joinCurrentEndpointRooms = () => { const joinCurrentEndpointRooms = () => {
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
return;
}
this.webrtc.setCurrentServer(room.id); this.webrtc.setCurrentServer(room.id);
this.webrtc.identify(oderId, displayName, wsUrl); this.webrtc.identify(oderId, displayName, wsUrl);
@@ -1676,7 +1692,7 @@ export class RoomsEffects {
this.webrtc.connectToSignalingServer(wsUrl).subscribe({ this.webrtc.connectToSignalingServer(wsUrl).subscribe({
next: (connected) => { next: (connected) => {
if (!connected) if (!connected || !this.isCurrentRoomNavigation(room.id, navigationRequestVersion))
return; return;
joinCurrentEndpointRooms(); joinCurrentEndpointRooms();
@@ -1788,6 +1804,22 @@ export class RoomsEffects {
return roomMatch ? roomMatch[1] : null; 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 { private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId) if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
return 'host'; return 'host';