From 6800c732927b900e50e62381f26dc4445be4c97a Mon Sep 17 00:00:00 2001 From: Myx Date: Sat, 11 Apr 2026 13:07:46 +0200 Subject: [PATCH] refactor: Cleaning rooms store --- toju-app/src/app/app.config.ts | 6 +- .../app/store/rooms/room-settings.effects.ts | 346 ++++ .../store/rooms/room-signaling-connection.ts | 453 +++++ .../store/rooms/room-state-sync.effects.ts | 814 ++++++++ toju-app/src/app/store/rooms/rooms.effects.ts | 1737 +---------------- toju-app/src/app/store/rooms/rooms.helpers.ts | 186 ++ 6 files changed, 1843 insertions(+), 1699 deletions(-) create mode 100644 toju-app/src/app/store/rooms/room-settings.effects.ts create mode 100644 toju-app/src/app/store/rooms/room-signaling-connection.ts create mode 100644 toju-app/src/app/store/rooms/room-state-sync.effects.ts create mode 100644 toju-app/src/app/store/rooms/rooms.helpers.ts diff --git a/toju-app/src/app/app.config.ts b/toju-app/src/app/app.config.ts index 2b76c41..9613fcd 100644 --- a/toju-app/src/app/app.config.ts +++ b/toju-app/src/app/app.config.ts @@ -19,6 +19,8 @@ import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { UsersEffects } from './store/users/users.effects'; import { RoomsEffects } from './store/rooms/rooms.effects'; import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'; +import { RoomStateSyncEffects } from './store/rooms/room-state-sync.effects'; +import { RoomSettingsEffects } from './store/rooms/room-settings.effects'; import { STORE_DEVTOOLS_MAX_AGE } from './core/constants'; /** Root application configuration providing routing, HTTP, NgRx store, and devtools. */ @@ -38,7 +40,9 @@ export const appConfig: ApplicationConfig = { MessagesSyncEffects, UsersEffects, RoomsEffects, - RoomMembersSyncEffects + RoomMembersSyncEffects, + RoomStateSyncEffects, + RoomSettingsEffects ]), provideStoreDevtools({ maxAge: STORE_DEVTOOLS_MAX_AGE, diff --git a/toju-app/src/app/store/rooms/room-settings.effects.ts b/toju-app/src/app/store/rooms/room-settings.effects.ts new file mode 100644 index 0000000..ba46661 --- /dev/null +++ b/toju-app/src/app/store/rooms/room-settings.effects.ts @@ -0,0 +1,346 @@ +/* eslint-disable @typescript-eslint/member-ordering */ +import { Injectable, inject } from '@angular/core'; +import { + Actions, + createEffect, + ofType +} from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { of, EMPTY } from 'rxjs'; +import { + mergeMap, + withLatestFrom, + tap, + catchError +} from 'rxjs/operators'; +import { RoomsActions } from './rooms.actions'; +import { selectCurrentUser } from '../users/users.selectors'; +import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { DatabaseService } from '../../infrastructure/persistence'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; +import { + normalizeRoomAccessControl, + resolveRoomPermission, + withLegacyRoomPermissions +} from '../../domains/access-control'; +import { Room, RoomSettings } from '../../shared-kernel'; +import { + resolveRoom, + getUserRoleForRoom, + canManageChannelsInRoom +} from './rooms.helpers'; +import { defaultChannels } from './room-channels.defaults'; + +/** + * NgRx effects for room settings, permissions, channels, and icon updates. + */ +@Injectable() +export class RoomSettingsEffects { + private actions$ = inject(Actions); + private store = inject(Store); + private webrtc = inject(RealtimeSessionFacade); + private db = inject(DatabaseService); + private serverDirectory = inject(ServerDirectoryFacade); + + /** Updates room settings (host/admin-only) and broadcasts changes to all peers. */ + updateRoomSettings$ = createEffect(() => + this.actions$.pipe( + ofType(RoomsActions.updateRoomSettings), + withLatestFrom( + this.store.select(selectCurrentUser), + this.store.select(selectCurrentRoom), + this.store.select(selectSavedRooms) + ), + mergeMap(([ + { roomId, settings }, + currentUser, + currentRoom, + savedRooms + ]) => { + if (!currentUser) + return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not logged in' })); + + const room = resolveRoom(roomId, currentRoom, savedRooms); + + if (!room) + return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' })); + + const currentUserRole = getUserRoleForRoom(room, currentUser, currentRoom); + const isOwner = currentUserRole === 'host'; + const canManageRoom = isOwner || resolveRoomPermission(room, currentUser, 'manageServer'); + + if (!canManageRoom) { + return of( + RoomsActions.updateRoomSettingsFailure({ + error: 'Permission denied' + }) + ); + } + + const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(settings, 'password'); + const normalizedPassword = typeof settings.password === 'string' ? settings.password.trim() : undefined; + const nextHasPassword = typeof settings.hasPassword === 'boolean' + ? settings.hasPassword + : (hasPasswordUpdate + ? !!normalizedPassword + : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)); + const updatedSettings: RoomSettings = { + name: settings.name ?? room.name, + description: settings.description ?? room.description, + topic: settings.topic ?? room.topic, + isPrivate: settings.isPrivate ?? room.isPrivate, + password: hasPasswordUpdate ? (normalizedPassword || '') : room.password, + hasPassword: nextHasPassword, + maxUsers: settings.maxUsers ?? room.maxUsers + }; + const localRoomUpdates: Partial = { + ...updatedSettings, + password: hasPasswordUpdate ? (normalizedPassword || undefined) : room.password, + hasPassword: nextHasPassword + }; + const sharedSettings: RoomSettings = { + name: updatedSettings.name, + description: updatedSettings.description, + topic: updatedSettings.topic, + isPrivate: updatedSettings.isPrivate, + hasPassword: nextHasPassword, + maxUsers: updatedSettings.maxUsers, + password: hasPasswordUpdate ? (normalizedPassword || '') : undefined + }; + + this.db.updateRoom(room.id, localRoomUpdates); + + this.webrtc.broadcastMessage({ + type: 'room-settings-update', + roomId: room.id, + settings: sharedSettings + }); + + if (canManageRoom) { + this.serverDirectory.updateServer(room.id, { + currentOwnerId: currentUser.id, + actingRole: currentUserRole ?? undefined, + name: updatedSettings.name, + description: updatedSettings.description, + isPrivate: updatedSettings.isPrivate, + maxUsers: updatedSettings.maxUsers, + password: hasPasswordUpdate ? (normalizedPassword || null) : undefined + }, { + sourceId: room.sourceId, + sourceUrl: room.sourceUrl + }).subscribe({ + error: () => {} + }); + } + + return of(RoomsActions.updateRoomSettingsSuccess({ roomId: room.id, + settings: updatedSettings })); + }), + catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message }))) + ) + ); + + /** Persists and broadcasts channel add/remove/rename changes. */ + syncChannelChanges$ = createEffect( + () => + this.actions$.pipe( + ofType(RoomsActions.addChannel, RoomsActions.removeChannel, RoomsActions.renameChannel), + withLatestFrom( + this.store.select(selectCurrentUser), + this.store.select(selectCurrentRoom) + ), + tap(([ + , currentUser, + currentRoom + ]) => { + if (!currentUser || !currentRoom) { + return; + } + + const role = getUserRoleForRoom(currentRoom, currentUser, currentRoom); + + if (!canManageChannelsInRoom(currentRoom, currentUser, currentRoom, role)) { + return; + } + + const channels = currentRoom.channels ?? defaultChannels(); + + this.db.updateRoom(currentRoom.id, { channels }); + + this.webrtc.broadcastMessage({ + type: 'channels-update', + roomId: currentRoom.id, + channels + }); + + this.serverDirectory.updateServer(currentRoom.id, { + currentOwnerId: currentUser.id, + actingRole: role ?? undefined, + channels + }, { + sourceId: currentRoom.sourceId, + sourceUrl: currentRoom.sourceUrl + }).subscribe({ + error: () => {} + }); + }) + ), + { dispatch: false } + ); + + /** Updates room permission grants (host-only) and broadcasts to peers. */ + updateRoomPermissions$ = createEffect(() => + this.actions$.pipe( + ofType(RoomsActions.updateRoomPermissions), + withLatestFrom( + this.store.select(selectCurrentUser), + this.store.select(selectCurrentRoom), + this.store.select(selectSavedRooms) + ), + mergeMap(([ + { roomId, permissions }, + currentUser, + currentRoom, + savedRooms + ]) => { + if (!currentUser) + return EMPTY; + + const room = resolveRoom(roomId, currentRoom, savedRooms); + + if (!room) + return EMPTY; + + const nextRoom = withLegacyRoomPermissions(room, permissions); + + return of(RoomsActions.updateRoomAccessControl({ + roomId: room.id, + changes: { + roles: nextRoom.roles, + roleAssignments: nextRoom.roleAssignments, + channelPermissions: nextRoom.channelPermissions, + slowModeInterval: nextRoom.slowModeInterval + } + })); + }) + ) + ); + + /** Updates role-based access control and broadcasts to peers and directory. */ + updateRoomAccessControl$ = createEffect(() => + this.actions$.pipe( + ofType(RoomsActions.updateRoomAccessControl), + withLatestFrom( + this.store.select(selectCurrentUser), + this.store.select(selectCurrentRoom), + this.store.select(selectSavedRooms) + ), + mergeMap(([ + { roomId, changes }, + currentUser, + currentRoom, + savedRooms + ]) => { + if (!currentUser) + return EMPTY; + + const room = resolveRoom(roomId, currentRoom, savedRooms); + + if (!room) + return EMPTY; + + const requiresRoleManagement = !!changes.roles || !!changes.roleAssignments || !!changes.channelPermissions; + const requiresServerManagement = typeof changes.slowModeInterval === 'number'; + const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId; + const canManageRoles = isOwner || resolveRoomPermission(room, currentUser, 'manageRoles'); + const canManageServer = isOwner || resolveRoomPermission(room, currentUser, 'manageServer'); + + if ((requiresRoleManagement && !canManageRoles) || (requiresServerManagement && !canManageServer)) { + return EMPTY; + } + + const nextRoom = normalizeRoomAccessControl({ + ...room, + ...changes + }); + const nextChanges: Partial = { + roles: nextRoom.roles, + roleAssignments: nextRoom.roleAssignments, + channelPermissions: nextRoom.channelPermissions, + slowModeInterval: nextRoom.slowModeInterval, + permissions: nextRoom.permissions, + members: nextRoom.members + }; + + this.webrtc.broadcastMessage({ + type: 'room-permissions-update', + roomId: room.id, + permissions: nextRoom.permissions, + room: { + roles: nextRoom.roles, + roleAssignments: nextRoom.roleAssignments, + channelPermissions: nextRoom.channelPermissions, + slowModeInterval: nextRoom.slowModeInterval + } + }); + + this.serverDirectory.updateServer(room.id, { + currentOwnerId: currentUser.id, + roles: nextRoom.roles, + roleAssignments: nextRoom.roleAssignments, + channelPermissions: nextRoom.channelPermissions, + slowModeInterval: nextRoom.slowModeInterval + }, { + sourceId: room.sourceId, + sourceUrl: room.sourceUrl + }).subscribe({ + error: () => {} + }); + + return of(RoomsActions.updateRoom({ roomId: room.id, + changes: nextChanges })); + }) + ) + ); + + /** Updates the server icon (permission-enforced) and broadcasts to peers. */ + updateServerIcon$ = createEffect(() => + this.actions$.pipe( + ofType(RoomsActions.updateServerIcon), + withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), + mergeMap(([ + { roomId, icon }, + currentUser, + currentRoom + ]) => { + if (!currentUser || !currentRoom || currentRoom.id !== roomId) { + return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' })); + } + + const isOwner = currentRoom.hostId === currentUser.id; + const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon'); + + if (!isOwner && !canByRole) { + return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' })); + } + + const iconUpdatedAt = Date.now(); + const changes: Partial = { icon, + iconUpdatedAt }; + + this.db.updateRoom(roomId, changes); + this.webrtc.broadcastMessage({ + type: 'server-icon-update', + roomId, + icon, + iconUpdatedAt + }); + + return of(RoomsActions.updateServerIconSuccess({ roomId, + icon, + iconUpdatedAt })); + }) + ) + ); +} diff --git a/toju-app/src/app/store/rooms/room-signaling-connection.ts b/toju-app/src/app/store/rooms/room-signaling-connection.ts new file mode 100644 index 0000000..6e15d64 --- /dev/null +++ b/toju-app/src/app/store/rooms/room-signaling-connection.ts @@ -0,0 +1,453 @@ +import { Store } from '@ngrx/store'; +import { firstValueFrom } from 'rxjs'; +import { Room, User } from '../../shared-kernel'; +import { + type RoomSignalSource, + type ServerSourceSelector, + type ServerDirectoryFacade, + areRoomSignalSourcesEqual, + CLIENT_UPDATE_REQUIRED_MESSAGE +} from '../../domains/server-directory'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { RoomsActions } from './rooms.actions'; +import { resolveUserDisplayName, extractRoomIdFromUrl } from './rooms.helpers'; + +export interface RoomSignalConnectionPlan { + fallbackSources: RoomSignalSource[]; + primarySource: RoomSignalSource | null; + room: Room; +} + +/** + * Encapsulates signaling-server connection, fallback cascade, and + * navigation versioning logic that was previously inlined in + * {@link RoomsEffects}. + * + * Instantiated by `RoomsEffects` (not an Angular service) - it receives + * the required dependencies through its constructor. + */ +export class RoomSignalingConnection { + latestNavigatedRoomId: string | null = null; + private readonly roomSignalFallbackSources = new Map(); + private roomNavigationRequestVersion = 0; + + constructor( + private readonly webrtc: RealtimeSessionFacade, + private readonly serverDirectory: ServerDirectoryFacade, + private readonly store: Store + ) {} + + // ── Navigation versioning ────────────────────────────────────── + + beginRoomNavigation(roomId: string): number { + this.roomNavigationRequestVersion += 1; + this.latestNavigatedRoomId = roomId; + + return this.roomNavigationRequestVersion; + } + + isCurrentRoomNavigation(roomId: string, navigationRequestVersion?: number): boolean { + if (typeof navigationRequestVersion !== 'number') { + return true; + } + + return navigationRequestVersion === this.roomNavigationRequestVersion + && roomId === this.latestNavigatedRoomId; + } + + deleteRoomFallbackSource(roomId: string): void { + this.roomSignalFallbackSources.delete(roomId); + } + + // ── Primary connection entry points ──────────────────────────── + + async connectToRoomSignaling( + room: Room, + user: User | null, + resolvedOderId?: string, + savedRooms: Room[] = [], + options: { showCompatibilityError?: boolean; navigationRequestVersion?: number } = {} + ): Promise { + const shouldShowCompatibilityError = options.showCompatibilityError ?? false; + const navigationRequestVersion = options.navigationRequestVersion; + const isViewedRoom = () => room.id === this.latestNavigatedRoomId; + + await this.serverDirectory.awaitInitialServerHealthCheck(); + + if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { + return; + } + + const connectionPlan = await this.resolveRoomSignalConnectionPlan(room); + + if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { + return; + } + + const sessionFallbackSource = this.roomSignalFallbackSources.get(room.id); + const connectionCandidates: { + isExistingFallback?: boolean; + isFallback?: boolean; + isPrimary?: boolean; + source: RoomSignalSource; + }[] = []; + const pushConnectionCandidate = ( + source: RoomSignalSource | null | undefined, + flags: { isExistingFallback?: boolean; isFallback?: boolean; isPrimary?: boolean } = {} + ) => { + if (!source || !this.resolveRoomSignalSelector(source, room.name)) { + return; + } + + if (connectionCandidates.some((candidate) => areRoomSignalSourcesEqual(candidate.source, source))) { + return; + } + + connectionCandidates.push({ + ...flags, + source + }); + }; + + if (sessionFallbackSource && this.webrtc.hasJoinedServer(room.id)) { + pushConnectionCandidate(sessionFallbackSource, { isExistingFallback: true, isFallback: true }); + } + + pushConnectionCandidate(connectionPlan.primarySource, { isPrimary: true }); + + for (const fallbackSource of connectionPlan.fallbackSources) { + pushConnectionCandidate(fallbackSource, { isFallback: true }); + } + + let attemptedFallback = false; + + for (const candidate of connectionCandidates) { + const selector = this.resolveRoomSignalSelector(candidate.source, connectionPlan.room.name); + + if (!selector) { + continue; + } + + const isCompatible = await this.serverDirectory.ensureEndpointVersionCompatibility(selector); + + if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { + return; + } + + if (!isCompatible) { + if (candidate.isPrimary) { + if (shouldShowCompatibilityError) { + this.store.dispatch( + RoomsActions.setSignalServerCompatibilityError({ message: CLIENT_UPDATE_REQUIRED_MESSAGE }) + ); + } + + if (isViewedRoom()) { + this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); + } + + return; + } + + continue; + } + + if (candidate.isFallback && !candidate.isExistingFallback && !attemptedFallback) { + attemptedFallback = true; + + if (isViewedRoom()) { + this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); + } + } + + const connected = await this.connectRoomToSignalSource( + connectionPlan.room, + candidate.source, + user, + resolvedOderId, + savedRooms, + navigationRequestVersion + ); + + if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { + return; + } + + if (!connected) { + continue; + } + + if (candidate.isFallback) { + this.roomSignalFallbackSources.set(room.id, candidate.source); + } else { + this.roomSignalFallbackSources.delete(room.id); + } + + if (shouldShowCompatibilityError) { + this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null })); + } + + if (isViewedRoom()) { + this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); + } + + return; + } + + if (shouldShowCompatibilityError) { + this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null })); + } + + if (isViewedRoom()) { + this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); + } + } + + syncSavedRoomConnections( + user: User | null, + currentRoom: Room | null, + savedRooms: Room[], + currentUrl: string + ): void { + if (!user || savedRooms.length === 0) { + return; + } + + const watchedRoomId = extractRoomIdFromUrl(currentUrl); + const roomsToSync = currentRoom ? this.includeRoom(savedRooms, currentRoom) : savedRooms; + const roomsBySignalingUrl = new Map(); + + for (const room of roomsToSync) { + const wsUrl = this.resolveRoomSignalingUrl(room); + + if (!wsUrl) { + continue; + } + + const groupedRooms = roomsBySignalingUrl.get(wsUrl) ?? []; + + if (!groupedRooms.some((groupedRoom) => groupedRoom.id === room.id)) { + groupedRooms.push(room); + } + + roomsBySignalingUrl.set(wsUrl, groupedRooms); + } + + for (const groupedRooms of roomsBySignalingUrl.values()) { + const preferredRoom = groupedRooms.find((room) => room.id === watchedRoomId) + ?? (currentRoom && groupedRooms.some((room) => room.id === currentRoom.id) + ? currentRoom + : null) + ?? groupedRooms[0] + ?? null; + + if (!preferredRoom) { + continue; + } + + const shouldShowCompatibilityError = preferredRoom.id === watchedRoomId + || (!!currentRoom && preferredRoom.id === currentRoom.id); + + void this.connectToRoomSignaling(preferredRoom, user, user.oderId || this.webrtc.peerId(), roomsToSync, { + showCompatibilityError: shouldShowCompatibilityError + }); + } + } + + resolveRoomSignalingUrl(room: Room): string { + const selector = this.resolveRoomSignalSelector(this.getPreferredRoomSignalSource(room), room.name); + + return selector ? this.serverDirectory.getWebSocketUrl(selector) : ''; + } + + resolveRoomSignalSource( + room: Pick + ): RoomSignalSource { + return this.serverDirectory.normaliseRoomSignalSource({ + sourceId: room.sourceId, + sourceName: room.sourceName, + sourceUrl: room.sourceUrl, + fallbackName: room.sourceName ?? room.name + }, { + ensureEndpoint: !!room.sourceUrl + }); + } + + resolveRoomSignalSelector( + source: RoomSignalSource | null | undefined, + fallbackName: string + ): ServerSourceSelector | undefined { + if (!source) { + return undefined; + } + + return this.serverDirectory.buildRoomSignalSelector({ + ...source, + fallbackName: source.sourceName ?? fallbackName + }, { + ensureEndpoint: !!source.sourceUrl + }); + } + + // ── Internal helpers ─────────────────────────────────────────── + + private async resolveRoomSignalConnectionPlan(room: Room): Promise { + let resolvedRoom = this.repairRoomSignalSource(room, this.resolveRoomSignalSource(room)); + let primarySource = this.resolveRoomSignalSource(resolvedRoom); + + if (!this.webrtc.hasJoinedServer(room.id)) { + const selector = this.resolveRoomSignalSelector(primarySource, resolvedRoom.name); + const authoritativeServer = ( + selector + ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) + : null + ) ?? await firstValueFrom(this.serverDirectory.findServerAcrossActiveEndpoints(room.id, primarySource)); + + if (authoritativeServer) { + const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource({ + sourceId: authoritativeServer.sourceId ?? primarySource.sourceId, + sourceName: authoritativeServer.sourceName ?? primarySource.sourceName, + sourceUrl: authoritativeServer.sourceUrl ?? primarySource.sourceUrl, + fallbackName: authoritativeServer.sourceName ?? primarySource.sourceName ?? resolvedRoom.name + }, { + ensureEndpoint: !!(authoritativeServer.sourceUrl ?? primarySource.sourceUrl) + }); + + resolvedRoom = this.repairRoomSignalSource(resolvedRoom, authoritativeSource); + primarySource = authoritativeSource; + } + } + + const fallbackSources = this.serverDirectory.getFallbackRoomEndpoints(primarySource) + .map((endpoint) => this.serverDirectory.normaliseRoomSignalSource({ + sourceId: endpoint.id, + sourceName: endpoint.name, + sourceUrl: endpoint.url, + fallbackName: endpoint.name + })) + .filter((source, index, sources) => + sources.findIndex((candidate) => areRoomSignalSourcesEqual(candidate, source)) === index + ); + + return { + fallbackSources, + primarySource: this.resolveRoomSignalSelector(primarySource, resolvedRoom.name) ? primarySource : null, + room: resolvedRoom + }; + } + + private async connectRoomToSignalSource( + room: Room, + source: RoomSignalSource, + user: User | null, + resolvedOderId: string | undefined, + savedRooms: Room[], + navigationRequestVersion?: number + ): Promise { + const selector = this.resolveRoomSignalSelector(source, room.name); + + if (!selector) { + return false; + } + + const wsUrl = this.serverDirectory.getWebSocketUrl(selector); + const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId(); + const displayName = resolveUserDisplayName(user); + 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); + + for (const backgroundRoom of backgroundRooms) { + this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl); + } + + if (this.webrtc.hasJoinedServer(room.id)) { + this.webrtc.switchServer(room.id, oderId, wsUrl); + } else { + this.webrtc.joinRoom(room.id, oderId, wsUrl); + } + }; + + if (this.webrtc.isSignalingConnectedTo(wsUrl)) { + joinCurrentEndpointRooms(); + return true; + } + + try { + const connected = await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl)); + + if (!connected || !this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { + return false; + } + + joinCurrentEndpointRooms(); + return true; + } catch { + return false; + } + } + + private repairRoomSignalSource(room: Room, source: RoomSignalSource | null): Room { + if (!source || areRoomSignalSourcesEqual(room, source)) { + return room; + } + + const changes: Partial = { + sourceId: source.sourceId, + sourceName: source.sourceName, + sourceUrl: source.sourceUrl + }; + + this.store.dispatch(RoomsActions.updateRoom({ + roomId: room.id, + changes + })); + + return { + ...room, + ...changes + }; + } + + private getPreferredRoomSignalSource(room: Room): RoomSignalSource { + const fallbackSource = this.roomSignalFallbackSources.get(room.id); + + if (fallbackSource && this.webrtc.hasJoinedServer(room.id)) { + return fallbackSource; + } + + return this.resolveRoomSignalSource(room); + } + + private includeRoom(rooms: Room[], room: Room): Room[] { + return rooms.some((candidate) => candidate.id === room.id) + ? rooms + : [...rooms, room]; + } + + private getRoomsForSignalingUrl(rooms: Room[], wsUrl: string): Room[] { + const seenRoomIds = new Set(); + const matchingRooms: Room[] = []; + + for (const room of rooms) { + if (seenRoomIds.has(room.id)) { + continue; + } + + if (this.resolveRoomSignalingUrl(room) !== wsUrl) { + continue; + } + + seenRoomIds.add(room.id); + matchingRooms.push(room); + } + + return matchingRooms; + } +} diff --git a/toju-app/src/app/store/rooms/room-state-sync.effects.ts b/toju-app/src/app/store/rooms/room-state-sync.effects.ts new file mode 100644 index 0000000..6e59224 --- /dev/null +++ b/toju-app/src/app/store/rooms/room-state-sync.effects.ts @@ -0,0 +1,814 @@ +/* eslint-disable @typescript-eslint/member-ordering */ +import { Injectable, inject } from '@angular/core'; +import { + Actions, + createEffect, + ofType +} from '@ngrx/effects'; +import { Action, Store } from '@ngrx/store'; +import { + of, + from, + EMPTY +} from 'rxjs'; +import { + map, + mergeMap, + withLatestFrom, + tap, + switchMap, + catchError +} from 'rxjs/operators'; +import { RoomsActions } from './rooms.actions'; +import { UsersActions } from '../users/users.actions'; +import { selectCurrentUser, selectAllUsers } from '../users/users.selectors'; +import { + selectActiveChannelId, + selectCurrentRoom, + selectSavedRooms +} from './rooms.selectors'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { DatabaseService } from '../../infrastructure/persistence'; +import { resolveRoomPermission } from '../../domains/access-control'; +import { + ChatEvent, + Room, + RoomSettings, + RoomPermissions, + BanEntry, + User, + VoiceState +} from '../../shared-kernel'; +import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; +import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; +import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants'; +import { VoiceSessionFacade } from '../../domains/voice-session'; +import { + buildSignalingUser, + buildKnownUserExtras, + isWrongServer, + resolveRoom, + sanitizeRoomSnapshot, + normalizeIncomingBans, + getPersistedCurrentUserId, + RoomPresenceSignalingMessage +} from './rooms.helpers'; + +/** + * NgRx effects for real-time state synchronisation: signaling presence + * events (server_users, user_joined, user_left, access_denied), P2P + * room-state / icon sync, and voice/screen/camera state broadcasts. + */ +@Injectable() +export class RoomStateSyncEffects { + private actions$ = inject(Actions); + private store = inject(Store); + private webrtc = inject(RealtimeSessionFacade); + private db = inject(DatabaseService); + private audioService = inject(NotificationAudioService); + private voiceSessionService = inject(VoiceSessionFacade); + + /** + * Tracks user IDs we already know are in voice. Lives outside the + * NgRx store so it survives room switches and presence re-syncs, + * preventing false join/leave sounds during state refreshes. + */ + private knownVoiceUsers = new Set(); + /** + * When a user leaves (e.g. socket drops), record the timestamp so + * that a rapid re-join (reconnect) does not trigger a false + * join/leave sound within {@link RECONNECT_SOUND_GRACE_MS}. + */ + private recentlyLeftVoiceTimestamps = new Map(); + + // ── Signaling presence ───────────────────────────────────────── + + /** Handles WebRTC signaling events for user presence (join, leave, server_users). */ + signalingMessages$ = createEffect(() => + this.webrtc.onSignalingMessage.pipe( + withLatestFrom( + this.store.select(selectCurrentUser), + this.store.select(selectCurrentRoom), + this.store.select(selectSavedRooms) + ), + mergeMap(([ + message, + currentUser, + currentRoom, + savedRooms + ]) => { + const signalingMessage: RoomPresenceSignalingMessage = message; + const myId = currentUser?.oderId || currentUser?.id; + const viewedServerId = currentRoom?.id; + const room = resolveRoom(signalingMessage.serverId, currentRoom, savedRooms); + const shouldClearReconnectFlag = !isWrongServer(signalingMessage.serverId, viewedServerId); + + switch (signalingMessage.type) { + case 'server_users': { + if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) + return EMPTY; + + const syncedUsers = signalingMessage.users + .filter((user) => user.oderId !== myId) + .map((user) => + buildSignalingUser(user, { + ...buildKnownUserExtras(room, user.oderId), + presenceServerIds: [signalingMessage.serverId] + }) + ); + const actions: Action[] = [ + UsersActions.syncServerPresence({ + roomId: signalingMessage.serverId, + users: syncedUsers + }) + ]; + + if (shouldClearReconnectFlag) { + actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); + } + + return actions; + } + + case 'user_joined': { + if (!signalingMessage.serverId || signalingMessage.oderId === myId) + return EMPTY; + + if (!signalingMessage.oderId) + return EMPTY; + + const joinedUser = { + oderId: signalingMessage.oderId, + displayName: signalingMessage.displayName + }; + const actions: Action[] = [ + UsersActions.userJoined({ + user: buildSignalingUser(joinedUser, { + ...buildKnownUserExtras(room, joinedUser.oderId), + presenceServerIds: [signalingMessage.serverId] + }) + }) + ]; + + if (shouldClearReconnectFlag) { + actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); + } + + return actions; + } + + case 'user_left': { + if (!signalingMessage.oderId) + return EMPTY; + + const remainingServerIds = Array.isArray(signalingMessage.serverIds) + ? signalingMessage.serverIds + : undefined; + + if (!remainingServerIds || remainingServerIds.length === 0) { + if (this.knownVoiceUsers.has(signalingMessage.oderId)) { + this.recentlyLeftVoiceTimestamps.set(signalingMessage.oderId, Date.now()); + } + + this.knownVoiceUsers.delete(signalingMessage.oderId); + } + + const actions: Action[] = [ + UsersActions.userLeft({ + userId: signalingMessage.oderId, + serverId: signalingMessage.serverId, + serverIds: remainingServerIds + }) + ]; + + if (shouldClearReconnectFlag) { + actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); + } + + return actions; + } + + case 'access_denied': { + if (isWrongServer(signalingMessage.serverId, viewedServerId)) + return EMPTY; + + if (signalingMessage.reason !== 'SERVER_NOT_FOUND') + return EMPTY; + + // When multiple signal URLs are configured, the room may already + // be successfully joined on a different signal server. Only show + // the reconnect notice when the room is not reachable at all. + if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) + return EMPTY; + + return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })]; + } + + default: + return EMPTY; + } + }) + ) + ); + + // ── P2P state sync ───────────────────────────────────────────── + + /** Request a full room-state snapshot whenever a peer data channel opens. */ + peerConnectedServerStateSync$ = createEffect( + () => + this.webrtc.onPeerConnected.pipe( + withLatestFrom(this.store.select(selectCurrentRoom)), + tap(([peerId, room]) => { + if (!room) + return; + + this.webrtc.sendToPeer(peerId, { + type: 'server-state-request', + roomId: room.id + }); + }) + ), + { dispatch: false } + ); + + /** Re-request the latest room-state snapshot whenever the user enters or views a server. */ + roomEntryServerStateSync$ = createEffect( + () => + this.actions$.pipe( + ofType( + RoomsActions.createRoomSuccess, + RoomsActions.joinRoomSuccess, + RoomsActions.viewServerSuccess + ), + tap(({ room }) => { + for (const peerId of this.webrtc.getConnectedPeers()) { + try { + this.webrtc.sendToPeer(peerId, { + type: 'server-state-request', + roomId: room.id + }); + } catch { + /* peer may have disconnected */ + } + } + }) + ), + { dispatch: false } + ); + + /** Processes incoming P2P room-state, room-sync, and icon-sync events. */ + incomingRoomEvents$ = createEffect(() => + this.webrtc.onMessageReceived.pipe( + withLatestFrom( + this.store.select(selectCurrentRoom), + this.store.select(selectSavedRooms), + this.store.select(selectAllUsers), + this.store.select(selectCurrentUser), + this.store.select(selectActiveChannelId) + ), + mergeMap(([ + event, + currentRoom, + savedRooms, + allUsers, + currentUser, + activeChannelId + ]) => { + switch (event.type) { + case 'voice-state': + return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice'); + case 'voice-channel-move': + return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null); + case 'screen-state': + return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen'); + case 'camera-state': + return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera'); + case 'server-state-request': + return this.handleServerStateRequest(event, currentRoom, savedRooms); + case 'server-state-full': + return this.handleServerStateFull(event, currentRoom, savedRooms, currentUser ?? null); + case 'room-settings-update': + return this.handleRoomSettingsUpdate(event, currentRoom, savedRooms); + case 'room-permissions-update': + return this.handleRoomPermissionsUpdate(event, currentRoom, savedRooms); + case 'channels-update': + return this.handleChannelsUpdate(event, currentRoom, savedRooms, activeChannelId); + case 'server-icon-summary': + return this.handleIconSummary(event, currentRoom, savedRooms); + case 'server-icon-request': + return this.handleIconRequest(event, currentRoom, savedRooms); + case 'server-icon-full': + case 'server-icon-update': + return this.handleIconData(event, currentRoom, savedRooms); + default: + return EMPTY; + } + }) + ) + ); + + /** Broadcasts the local server icon summary to peers when a new peer connects. */ + peerConnectedIconSync$ = createEffect( + () => + this.webrtc.onPeerConnected.pipe( + withLatestFrom(this.store.select(selectCurrentRoom)), + tap(([_peerId, room]) => { + if (!room) + return; + + const iconUpdatedAt = room.iconUpdatedAt || 0; + + this.webrtc.broadcastMessage({ + type: 'server-icon-summary', + roomId: room.id, + iconUpdatedAt + }); + }) + ), + { dispatch: false } + ); + + // ── Voice / Screen / Camera handlers ─────────────────────────── + + private handleVoiceOrScreenState( + event: ChatEvent, + allUsers: User[], + currentUser: User | null, + kind: 'voice' | 'screen' | 'camera' + ) { + const userId: string | undefined = event.fromPeerId ?? event.oderId; + + if (!userId) + return EMPTY; + + const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId); + const userExists = !!existingUser; + + if (kind === 'voice') { + const vs = event.voiceState as Partial | undefined; + + if (!vs) + return EMPTY; + + const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId) + ? UsersActions.userJoined({ + user: buildSignalingUser( + { oderId: userId, + displayName: event.displayName || existingUser?.displayName || 'User' }, + { presenceServerIds: [vs.serverId] } + ) + }) + : null; + // Detect voice-connection transitions to play join/leave sounds. + const weAreInVoice = this.webrtc.isVoiceConnected(); + const nowConnected = vs.isConnected ?? false; + const wasKnown = this.knownVoiceUsers.has(userId); + const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState); + const mergedVoiceState = { ...existingUser?.voiceState, ...vs }; + const isInCurrentVoiceRoom = this.isSameVoiceRoom(mergedVoiceState, currentUser?.voiceState); + + if (weAreInVoice) { + const isReconnect = this.consumeRecentLeave(userId); + + if (!isReconnect) { + if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) { + this.audioService.play(AppSound.Joining); + } else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) { + this.audioService.play(AppSound.Leave); + } + } + } + + if (nowConnected) { + this.knownVoiceUsers.add(userId); + } else { + this.knownVoiceUsers.delete(userId); + } + + if (!userExists) { + return of( + UsersActions.userJoined({ + user: buildSignalingUser( + { oderId: userId, + displayName: event.displayName || 'User' }, + { + presenceServerIds: vs.serverId ? [vs.serverId] : undefined, + voiceState: { + isConnected: vs.isConnected ?? false, + isMuted: vs.isMuted ?? false, + isDeafened: vs.isDeafened ?? false, + isSpeaking: vs.isSpeaking ?? false, + isMutedByAdmin: vs.isMutedByAdmin, + volume: vs.volume, + roomId: vs.roomId, + serverId: vs.serverId + } + } + ) + }) + ); + } + + const actions: Action[] = []; + + if (presenceRefreshAction) { + actions.push(presenceRefreshAction); + } + + actions.push(UsersActions.updateVoiceState({ userId, + voiceState: vs })); + + return actions; + } + + if (kind === 'screen') { + const isSharing = event.isScreenSharing as boolean | undefined; + + if (isSharing === undefined) + return EMPTY; + + if (!userExists) { + return of( + UsersActions.userJoined({ + user: buildSignalingUser( + { oderId: userId, + displayName: event.displayName || 'User' }, + { screenShareState: { isSharing } } + ) + }) + ); + } + + return of( + UsersActions.updateScreenShareState({ + userId, + screenShareState: { isSharing } + }) + ); + } + + const isCameraEnabled = event.isCameraEnabled as boolean | undefined; + + if (isCameraEnabled === undefined) + return EMPTY; + + if (!userExists) { + return of( + UsersActions.userJoined({ + user: buildSignalingUser( + { oderId: userId, + displayName: event.displayName || 'User' }, + { cameraState: { isEnabled: isCameraEnabled } } + ) + }) + ); + } + + return of( + UsersActions.updateCameraState({ + userId, + cameraState: { isEnabled: isCameraEnabled } + }) + ); + } + + private handleVoiceChannelMove( + event: ChatEvent, + currentRoom: Room | null, + savedRooms: Room[], + currentUser: User | null + ) { + const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null; + const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId; + const nextVoiceState = event.voiceState as Partial | undefined; + + if (!currentUser || !targetUserId || !serverId || !nextVoiceState?.roomId) { + return EMPTY; + } + + if (targetUserId !== currentUser.id && targetUserId !== currentUser.oderId) { + return EMPTY; + } + + const room = resolveRoom(serverId, currentRoom, savedRooms); + const movedChannel = room?.channels?.find((channel) => channel.id === nextVoiceState.roomId && channel.type === 'voice'); + + if (!room || !movedChannel) { + return EMPTY; + } + + const updatedVoiceState: Partial = { + isConnected: true, + isMuted: currentUser.voiceState?.isMuted ?? false, + isDeafened: currentUser.voiceState?.isDeafened ?? false, + isSpeaking: currentUser.voiceState?.isSpeaking ?? false, + isMutedByAdmin: currentUser.voiceState?.isMutedByAdmin, + volume: currentUser.voiceState?.volume, + roomId: movedChannel.id, + serverId: room.id + }; + const wasViewingVoiceServer = this.voiceSessionService.isViewingVoiceServer(); + + this.webrtc.startVoiceHeartbeat(movedChannel.id, room.id); + this.voiceSessionService.startSession({ + serverId: room.id, + serverName: room.name, + roomId: movedChannel.id, + roomName: `🔊 ${movedChannel.name}`, + serverIcon: room.icon, + serverDescription: room.description, + serverRoute: `/room/${room.id}` + }); + + this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer); + this.webrtc.broadcastMessage({ + type: 'voice-state', + oderId: currentUser.oderId || currentUser.id, + displayName: currentUser.displayName || 'User', + voiceState: updatedVoiceState + }); + + return of(UsersActions.updateVoiceState({ + userId: currentUser.id, + voiceState: updatedVoiceState + })); + } + + private isSameVoiceRoom( + voiceState: Partial | undefined, + currentUserVoiceState: Partial | undefined + ): boolean { + return !!voiceState?.isConnected + && !!currentUserVoiceState?.isConnected + && !!voiceState.roomId + && !!voiceState.serverId + && voiceState.roomId === currentUserVoiceState.roomId + && voiceState.serverId === currentUserVoiceState.serverId; + } + + /** + * Returns `true` and cleans up the entry if the given user left + * recently enough to be considered a reconnect. + */ + private consumeRecentLeave(userId: string): boolean { + const now = Date.now(); + + for (const [id, ts] of this.recentlyLeftVoiceTimestamps) { + if (now - ts > RECONNECT_SOUND_GRACE_MS) { + this.recentlyLeftVoiceTimestamps.delete(id); + } + } + + const leaveTs = this.recentlyLeftVoiceTimestamps.get(userId); + + if (leaveTs !== undefined && now - leaveTs <= RECONNECT_SOUND_GRACE_MS) { + this.recentlyLeftVoiceTimestamps.delete(userId); + return true; + } + + return false; + } + + // ── Server-state sync handlers ───────────────────────────────── + + private handleServerStateRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + const fromPeerId = event.fromPeerId; + + if (!room || !fromPeerId) + return EMPTY; + + return from(this.db.getBansForRoom(room.id)).pipe( + tap((bans) => { + this.webrtc.sendToPeer(fromPeerId, { + type: 'server-state-full', + roomId: room.id, + room: sanitizeRoomSnapshot(room), + bans + }); + }), + mergeMap(() => EMPTY) + ); + } + + private handleServerStateFull( + event: ChatEvent, + currentRoom: Room | null, + savedRooms: Room[], + currentUser: { id: string; oderId: string } | null + ) { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + const incomingRoom = event.room as Partial | undefined; + + if (!room || !incomingRoom) + return EMPTY; + + const roomChanges = sanitizeRoomSnapshot(incomingRoom); + const bans = normalizeIncomingBans(room.id, event.bans); + + return this.syncBansToLocalRoom(room.id, bans).pipe( + mergeMap(() => { + const actions: (ReturnType + | ReturnType + | ReturnType)[] = [ + RoomsActions.updateRoom({ + roomId: room.id, + changes: roomChanges + }) + ]; + const isCurrentUserBanned = hasRoomBanForUser( + bans, + currentUser, + getPersistedCurrentUserId() + ); + + if (currentRoom?.id === room.id) { + actions.push(UsersActions.loadBansSuccess({ bans })); + } + + if (isCurrentUserBanned) { + actions.push(RoomsActions.forgetRoom({ roomId: room.id })); + } + + return actions; + }), + catchError(() => EMPTY) + ); + } + + private handleRoomSettingsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + const settings = event.settings as Partial | undefined; + + if (!room || !settings) + return EMPTY; + + return of( + RoomsActions.updateRoom({ + roomId: room.id, + changes: { + name: settings.name ?? room.name, + description: settings.description ?? room.description, + topic: settings.topic ?? room.topic, + isPrivate: settings.isPrivate ?? room.isPrivate, + password: settings.password === '' ? undefined : room.password, + hasPassword: + typeof settings.hasPassword === 'boolean' + ? settings.hasPassword + : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password), + maxUsers: settings.maxUsers ?? room.maxUsers + } + }) + ); + } + + private handleRoomPermissionsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + const permissions = event.permissions as Partial | undefined; + const incomingRoom = event.room as Partial | undefined; + + if (!room || (!permissions && !incomingRoom)) + return EMPTY; + + return of( + RoomsActions.updateRoom({ + roomId: room.id, + changes: { + permissions: permissions + ? { ...(room.permissions || {}), + ...permissions } as RoomPermissions + : room.permissions, + roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles, + roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments, + channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions, + slowModeInterval: typeof incomingRoom?.slowModeInterval === 'number' ? incomingRoom.slowModeInterval : room.slowModeInterval + } + }) + ); + } + + private handleChannelsUpdate( + event: ChatEvent, + currentRoom: Room | null, + savedRooms: Room[], + activeChannelId: string + ): Action[] { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + const channels = Array.isArray(event.channels) ? event.channels : null; + + if (!room || !channels) { + return []; + } + + const actions: Action[] = [ + RoomsActions.updateRoom({ + roomId: room.id, + changes: { channels } + }) + ]; + + if (!channels.some((channel) => channel.id === activeChannelId)) { + const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id + ?? 'general'; + + actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId })); + } + + return actions; + } + + // ── Icon sync handlers ───────────────────────────────────────── + + private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + + if (!room) + return EMPTY; + + const remoteUpdated = event.iconUpdatedAt || 0; + const localUpdated = room.iconUpdatedAt || 0; + + if (remoteUpdated > localUpdated && event.fromPeerId) { + this.webrtc.sendToPeer(event.fromPeerId, { + type: 'server-icon-request', + roomId: room.id + }); + } + + return EMPTY; + } + + private handleIconRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + + if (!room) + return EMPTY; + + if (event.fromPeerId) { + this.webrtc.sendToPeer(event.fromPeerId, { + type: 'server-icon-full', + roomId: room.id, + icon: room.icon, + iconUpdatedAt: room.iconUpdatedAt || 0 + }); + } + + return EMPTY; + } + + private handleIconData(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { + const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; + const room = resolveRoom(roomId, currentRoom, savedRooms); + const senderId = event.fromPeerId; + + if (!room || typeof event.icon !== 'string' || !senderId) + return EMPTY; + + return this.store.select(selectAllUsers).pipe( + map((users) => users.find((user) => user.id === senderId)), + mergeMap((sender) => { + if (!sender) + return EMPTY; + + const isOwner = room.hostId === sender.id; + const canByRole = resolveRoomPermission(room, sender, 'manageIcon'); + + if (!isOwner && !canByRole) + return EMPTY; + + const updates: Partial = { + icon: event.icon, + iconUpdatedAt: event.iconUpdatedAt || Date.now() + }; + + this.db.updateRoom(room.id, updates); + return of(RoomsActions.updateRoom({ roomId: room.id, + changes: updates })); + }) + ); + } + + // ── Internal helpers ─────────────────────────────────────────── + + private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) { + return from(this.db.getBansForRoom(roomId)).pipe( + switchMap((localBans) => { + const nextIds = new Set(bans.map((ban) => ban.oderId)); + const removals = localBans + .filter((ban) => !nextIds.has(ban.oderId)) + .map((ban) => this.db.removeBan(ban.oderId)); + const saves = bans.map((ban) => this.db.saveBan({ ...ban, + roomId })); + + return from(Promise.all([...removals, ...saves])); + }) + ); + } +} diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index 33282cc..f5bb89e 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/member-ordering */ -/* eslint-disable id-length */ -/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */ + import { Injectable, inject } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { @@ -8,12 +7,11 @@ import { createEffect, ofType } from '@ngrx/effects'; -import { Action, Store } from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { of, from, EMPTY, - firstValueFrom, merge } from 'rxjs'; import { @@ -22,7 +20,6 @@ import { catchError, withLatestFrom, tap, - debounceTime, switchMap, filter } from 'rxjs/operators'; @@ -30,7 +27,7 @@ import { v4 as uuidv4 } from 'uuid'; import { RoomsActions } from './rooms.actions'; import { UsersActions } from '../users/users.actions'; import { MessagesActions } from '../messages/messages.actions'; -import { selectCurrentUser, selectAllUsers } from '../users/users.selectors'; +import { selectCurrentUser } from '../users/users.selectors'; import { selectActiveChannelId, selectCurrentRoom, @@ -43,141 +40,21 @@ import { loadLastViewedChatFromStorage, saveLastViewedChatToStorage } from '../../infrastructure/persistence'; -import { - areRoomSignalSourcesEqual, - CLIENT_UPDATE_REQUIRED_MESSAGE, - type RoomSignalSource, - type ServerInfo, - type ServerSourceSelector, - ServerDirectoryFacade -} from '../../domains/server-directory'; -import { - normalizeRoomAccessControl, - resolveLegacyRole, - resolveRoomPermission, - withLegacyRoomPermissions -} from '../../domains/access-control'; -import { - ChatEvent, - Room, - RoomSettings, - RoomPermissions, - BanEntry, - User, - VoiceState -} from '../../shared-kernel'; -import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; -import { RECONNECT_SOUND_GRACE_MS, ROOM_URL_PATTERN } from '../../core/constants'; -import { VoiceSessionFacade } from '../../domains/voice-session'; +import { Room } from '../../shared-kernel'; import { - findRoomMember, removeRoomMember, - transferRoomOwnership + transferRoomOwnership, + findRoomMember } from './room-members.helpers'; import { defaultChannels } from './room-channels.defaults'; - -/** Build a minimal User object from signaling payload. */ -function buildSignalingUser( - data: { oderId: string; displayName?: string }, - extras: Record = {} -) { - const displayName = data.displayName?.trim() || 'User'; - - return { - oderId: data.oderId, - id: data.oderId, - username: displayName.toLowerCase().replace(/\s+/g, '_'), - displayName, - status: 'online' as const, - isOnline: true, - role: 'member' as const, - joinedAt: Date.now(), - ...extras - }; -} - -/** Best-known persisted member metadata for a signaling user in the viewed room. */ -function buildKnownUserExtras(room: Room | null, identifier: string): Record { - const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined; - - if (!knownMember) - return {}; - - return { - username: knownMember.username, - avatarUrl: knownMember.avatarUrl, - role: knownMember.role, - joinedAt: knownMember.joinedAt - }; -} - -/** Returns true when the message's server ID does not match the viewed server. */ -function isWrongServer( - msgServerId: string | undefined, - viewedServerId: string | undefined -): boolean { - return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId); -} - -function resolveUserDisplayName(user: Pick | null | undefined): string { - const displayName = user?.displayName?.trim(); - - if (displayName) { - return displayName; - } - - return user?.username?.trim() || 'User'; -} - -function hasPersistedChannels(channels: Room['channels'] | undefined): channels is NonNullable { - return Array.isArray(channels) && channels.length > 0; -} - -/** Keep cached channels until directory metadata provides a concrete replacement. */ -function resolveRoomChannels( - cachedChannels: Room['channels'] | undefined, - incomingChannels: Room['channels'] | undefined -): Room['channels'] | undefined { - if (hasPersistedChannels(incomingChannels)) { - return incomingChannels; - } - - if (hasPersistedChannels(cachedChannels)) { - return cachedChannels; - } - - return undefined; -} - -function resolveTextChannelId( - channels: Room['channels'] | undefined, - preferredChannelId?: string | null -): string | null { - const textChannels = (channels ?? []).filter((channel) => channel.type === 'text'); - - if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) { - return preferredChannelId; - } - - return textChannels[0]?.id ?? null; -} - -interface RoomPresenceSignalingMessage { - type: string; - reason?: string; - serverId?: string; - serverIds?: string[]; - users?: { oderId: string; displayName: string }[]; - oderId?: string; - displayName?: string; -} - -interface RoomSignalConnectionPlan { - fallbackSources: RoomSignalSource[]; - primarySource: RoomSignalSource | null; - room: Room; -} +import { RoomSignalingConnection } from './room-signaling-connection'; +import { + resolveRoomChannels, + resolveTextChannelId, + extractRoomIdFromUrl +} from './rooms.helpers'; type BlockedRoomAccessAction = | ReturnType @@ -191,24 +68,12 @@ export class RoomsEffects { private db = inject(DatabaseService); private webrtc = inject(RealtimeSessionFacade); private serverDirectory = inject(ServerDirectoryFacade); - private audioService = inject(NotificationAudioService); - private voiceSessionService = inject(VoiceSessionFacade); - /** - * Tracks user IDs we already know are in voice. Lives outside the - * NgRx store so it survives room switches and presence re-syncs, - * preventing false join/leave sounds during state refreshes. - */ - private knownVoiceUsers = new Set(); - /** - * When a user leaves (e.g. socket drops), record the timestamp so - * that a rapid re-join (reconnect) does not trigger a false - * join/leave sound within {@link RECONNECT_SOUND_GRACE_MS}. - */ - private recentlyLeftVoiceTimestamps = new Map(); - private readonly roomSignalFallbackSources = new Map(); - private roomNavigationRequestVersion = 0; - private latestNavigatedRoomId: string | null = null; + private readonly signalingConnection = new RoomSignalingConnection( + this.webrtc, + this.serverDirectory, + this.store + ); /** Loads all saved rooms from the local database. */ loadRooms$ = createEffect(() => @@ -251,7 +116,7 @@ export class RoomsEffects { return EMPTY; } - const roomId = this.extractRoomIdFromUrl(this.router.url); + const roomId = extractRoomIdFromUrl(this.router.url); if (!roomId || currentRoom?.id === roomId) { return EMPTY; @@ -302,7 +167,7 @@ export class RoomsEffects { currentRoom, savedRooms ]) => { - this.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms); + this.signalingConnection.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms, this.router.url); }) ), { dispatch: false } @@ -384,7 +249,7 @@ export class RoomsEffects { this.actions$.pipe( ofType(RoomsActions.joinRoom), withLatestFrom(this.store.select(selectCurrentUser)), - switchMap(([{ roomId, password, serverInfo }, currentUser]) => { + switchMap(([{ roomId, password: _password, serverInfo }, currentUser]) => { if (!currentUser) { return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' })); } @@ -544,9 +409,9 @@ export class RoomsEffects { user, savedRooms ]) => { - const navigationRequestVersion = this.beginRoomNavigation(room.id); + const navigationRequestVersion = this.signalingConnection.beginRoomNavigation(room.id); - void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, { + void this.signalingConnection.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, { showCompatibilityError: true, navigationRequestVersion }); @@ -658,8 +523,8 @@ export class RoomsEffects { this.actions$.pipe( ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), switchMap(({ room }) => { - const source = this.resolveRoomSignalSource(room); - const selector = this.resolveRoomSignalSelector(source, room.name); + const source = this.signalingConnection.resolveRoomSignalSource(room); + const selector = this.signalingConnection.resolveRoomSignalSelector(source, room.name); const roomRequest$ = selector ? this.serverDirectory.getServer(room.id, selector).pipe( switchMap((serverData) => serverData @@ -724,9 +589,9 @@ export class RoomsEffects { const activateViewedRoom = () => { const oderId = user.oderId || this.webrtc.peerId(); - const navigationRequestVersion = this.beginRoomNavigation(room.id); + const navigationRequestVersion = this.signalingConnection.beginRoomNavigation(room.id); - void this.connectToRoomSignaling(room, user, oderId, savedRooms, { + void this.signalingConnection.connectToRoomSignaling(room, user, oderId, savedRooms, { showCompatibilityError: true, navigationRequestVersion }); @@ -782,7 +647,7 @@ export class RoomsEffects { ), switchMap(([{ roomId }]) => { this.db.deleteRoom(roomId); - this.roomSignalFallbackSources.delete(roomId); + this.signalingConnection.deleteRoomFallbackSource(roomId); this.webrtc.broadcastMessage({ type: 'room-deleted', roomId }); @@ -875,7 +740,7 @@ export class RoomsEffects { // Delete from local DB this.db.deleteRoom(roomId); - this.roomSignalFallbackSources.delete(roomId); + this.signalingConnection.deleteRoomFallbackSource(roomId); // Leave this specific server (doesn't affect other servers) this.webrtc.leaveRoom(roomId); @@ -903,318 +768,6 @@ export class RoomsEffects { { dispatch: false } ); - /** Updates room settings (host/admin-only) and broadcasts changes to all peers. */ - updateRoomSettings$ = createEffect(() => - this.actions$.pipe( - ofType(RoomsActions.updateRoomSettings), - withLatestFrom( - this.store.select(selectCurrentUser), - this.store.select(selectCurrentRoom), - this.store.select(selectSavedRooms) - ), - mergeMap(([ - { roomId, settings }, - currentUser, - currentRoom, - savedRooms - ]) => { - if (!currentUser) - return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not logged in' })); - - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - - if (!room) - return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' })); - - const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom); - const isOwner = currentUserRole === 'host'; - const canManageRoom = isOwner || resolveRoomPermission(room, currentUser, 'manageServer'); - - if (!canManageRoom) { - return of( - RoomsActions.updateRoomSettingsFailure({ - error: 'Permission denied' - }) - ); - } - - const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(settings, 'password'); - const normalizedPassword = typeof settings.password === 'string' ? settings.password.trim() : undefined; - const nextHasPassword = typeof settings.hasPassword === 'boolean' - ? settings.hasPassword - : (hasPasswordUpdate - ? !!normalizedPassword - : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)); - const updatedSettings: RoomSettings = { - name: settings.name ?? room.name, - description: settings.description ?? room.description, - topic: settings.topic ?? room.topic, - isPrivate: settings.isPrivate ?? room.isPrivate, - password: hasPasswordUpdate ? (normalizedPassword || '') : room.password, - hasPassword: nextHasPassword, - maxUsers: settings.maxUsers ?? room.maxUsers - }; - const localRoomUpdates: Partial = { - ...updatedSettings, - password: hasPasswordUpdate ? (normalizedPassword || undefined) : room.password, - hasPassword: nextHasPassword - }; - const sharedSettings: RoomSettings = { - name: updatedSettings.name, - description: updatedSettings.description, - topic: updatedSettings.topic, - isPrivate: updatedSettings.isPrivate, - hasPassword: nextHasPassword, - maxUsers: updatedSettings.maxUsers, - password: hasPasswordUpdate ? (normalizedPassword || '') : undefined - }; - - this.db.updateRoom(room.id, localRoomUpdates); - - this.webrtc.broadcastMessage({ - type: 'room-settings-update', - roomId: room.id, - settings: sharedSettings - }); - - if (canManageRoom) { - this.serverDirectory.updateServer(room.id, { - currentOwnerId: currentUser.id, - actingRole: currentUserRole ?? undefined, - name: updatedSettings.name, - description: updatedSettings.description, - isPrivate: updatedSettings.isPrivate, - maxUsers: updatedSettings.maxUsers, - password: hasPasswordUpdate ? (normalizedPassword || null) : undefined - }, { - sourceId: room.sourceId, - sourceUrl: room.sourceUrl - }).subscribe({ - error: () => {} - }); - } - - return of(RoomsActions.updateRoomSettingsSuccess({ roomId: room.id, - settings: updatedSettings })); - }), - catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message }))) - ) - ); - - /** Persists room field changes to the local database. */ - updateRoom$ = createEffect( - () => - this.actions$.pipe( - ofType(RoomsActions.updateRoom), - tap(({ roomId, changes }) => { - this.db.updateRoom(roomId, changes); - }) - ), - { dispatch: false } - ); - - syncChannelChanges$ = createEffect( - () => - this.actions$.pipe( - ofType(RoomsActions.addChannel, RoomsActions.removeChannel, RoomsActions.renameChannel), - withLatestFrom( - this.store.select(selectCurrentUser), - this.store.select(selectCurrentRoom) - ), - tap(([ - , currentUser, - currentRoom - ]) => { - if (!currentUser || !currentRoom) { - return; - } - - const role = this.getUserRoleForRoom(currentRoom, currentUser, currentRoom); - - if (!this.canManageChannelsInRoom(currentRoom, currentUser, currentRoom, role)) { - return; - } - - const channels = currentRoom.channels ?? defaultChannels(); - - this.db.updateRoom(currentRoom.id, { channels }); - - this.webrtc.broadcastMessage({ - type: 'channels-update', - roomId: currentRoom.id, - channels - }); - - this.serverDirectory.updateServer(currentRoom.id, { - currentOwnerId: currentUser.id, - actingRole: role ?? undefined, - channels - }, { - sourceId: currentRoom.sourceId, - sourceUrl: currentRoom.sourceUrl - }).subscribe({ - error: () => {} - }); - }) - ), - { dispatch: false } - ); - - /** Updates room permission grants (host-only) and broadcasts to peers. */ - updateRoomPermissions$ = createEffect(() => - this.actions$.pipe( - ofType(RoomsActions.updateRoomPermissions), - withLatestFrom( - this.store.select(selectCurrentUser), - this.store.select(selectCurrentRoom), - this.store.select(selectSavedRooms) - ), - mergeMap(([ - { roomId, permissions }, - currentUser, - currentRoom, - savedRooms - ]) => { - if (!currentUser) - return EMPTY; - - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - - if (!room) - return EMPTY; - - const nextRoom = withLegacyRoomPermissions(room, permissions); - - return of(RoomsActions.updateRoomAccessControl({ - roomId: room.id, - changes: { - roles: nextRoom.roles, - roleAssignments: nextRoom.roleAssignments, - channelPermissions: nextRoom.channelPermissions, - slowModeInterval: nextRoom.slowModeInterval - } - })); - }) - ) - ); - - updateRoomAccessControl$ = createEffect(() => - this.actions$.pipe( - ofType(RoomsActions.updateRoomAccessControl), - withLatestFrom( - this.store.select(selectCurrentUser), - this.store.select(selectCurrentRoom), - this.store.select(selectSavedRooms) - ), - mergeMap(([ - { roomId, changes }, - currentUser, - currentRoom, - savedRooms - ]) => { - if (!currentUser) - return EMPTY; - - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - - if (!room) - return EMPTY; - - const requiresRoleManagement = !!changes.roles || !!changes.roleAssignments || !!changes.channelPermissions; - const requiresServerManagement = typeof changes.slowModeInterval === 'number'; - const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId; - const canManageRoles = isOwner || resolveRoomPermission(room, currentUser, 'manageRoles'); - const canManageServer = isOwner || resolveRoomPermission(room, currentUser, 'manageServer'); - - if ((requiresRoleManagement && !canManageRoles) || (requiresServerManagement && !canManageServer)) { - return EMPTY; - } - - const nextRoom = normalizeRoomAccessControl({ - ...room, - ...changes - }); - const nextChanges: Partial = { - roles: nextRoom.roles, - roleAssignments: nextRoom.roleAssignments, - channelPermissions: nextRoom.channelPermissions, - slowModeInterval: nextRoom.slowModeInterval, - permissions: nextRoom.permissions, - members: nextRoom.members - }; - - this.webrtc.broadcastMessage({ - type: 'room-permissions-update', - roomId: room.id, - permissions: nextRoom.permissions, - room: { - roles: nextRoom.roles, - roleAssignments: nextRoom.roleAssignments, - channelPermissions: nextRoom.channelPermissions, - slowModeInterval: nextRoom.slowModeInterval - } - }); - - this.serverDirectory.updateServer(room.id, { - currentOwnerId: currentUser.id, - roles: nextRoom.roles, - roleAssignments: nextRoom.roleAssignments, - channelPermissions: nextRoom.channelPermissions, - slowModeInterval: nextRoom.slowModeInterval - }, { - sourceId: room.sourceId, - sourceUrl: room.sourceUrl - }).subscribe({ - error: () => {} - }); - - return of(RoomsActions.updateRoom({ roomId: room.id, - changes: nextChanges })); - }) - ) - ); - - /** Updates the server icon (permission-enforced) and broadcasts to peers. */ - updateServerIcon$ = createEffect(() => - this.actions$.pipe( - ofType(RoomsActions.updateServerIcon), - withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), - mergeMap(([ - { roomId, icon }, - currentUser, - currentRoom - ]) => { - if (!currentUser || !currentRoom || currentRoom.id !== roomId) { - return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' })); - } - - const isOwner = currentRoom.hostId === currentUser.id; - const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon'); - - if (!isOwner && !canByRole) { - return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' })); - } - - const iconUpdatedAt = Date.now(); - const changes: Partial = { icon, - iconUpdatedAt }; - - this.db.updateRoom(roomId, changes); - // Broadcast to peers - this.webrtc.broadcastMessage({ - type: 'server-icon-update', - roomId, - icon, - iconUpdatedAt - }); - - return of(RoomsActions.updateServerIconSuccess({ roomId, - icon, - iconUpdatedAt })); - }) - ) - ); - /** Persists newly created room to the local database. */ persistRoomCreation$ = createEffect( () => @@ -1262,1235 +815,19 @@ export class RoomsEffects { ) ); - /** Handles WebRTC signaling events for user presence (join, leave, server_users). */ - signalingMessages$ = createEffect(() => - this.webrtc.onSignalingMessage.pipe( - withLatestFrom( - this.store.select(selectCurrentUser), - this.store.select(selectCurrentRoom), - this.store.select(selectSavedRooms) - ), - mergeMap(([ - message, - currentUser, - currentRoom, - savedRooms - ]) => { - const signalingMessage: RoomPresenceSignalingMessage = message; - const myId = currentUser?.oderId || currentUser?.id; - const viewedServerId = currentRoom?.id; - const room = this.resolveRoom(signalingMessage.serverId, currentRoom, savedRooms); - const shouldClearReconnectFlag = !isWrongServer(signalingMessage.serverId, viewedServerId); - - switch (signalingMessage.type) { - case 'server_users': { - if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) - return EMPTY; - - const syncedUsers = signalingMessage.users - .filter((u) => u.oderId !== myId) - .map((u) => - buildSignalingUser(u, { - ...buildKnownUserExtras(room, u.oderId), - presenceServerIds: [signalingMessage.serverId] - }) - ); - const actions: Action[] = [ - UsersActions.syncServerPresence({ - roomId: signalingMessage.serverId, - users: syncedUsers - }) - ]; - - if (shouldClearReconnectFlag) { - actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); - } - - return actions; - } - - case 'user_joined': { - if (!signalingMessage.serverId || signalingMessage.oderId === myId) - return EMPTY; - - if (!signalingMessage.oderId) - return EMPTY; - - const joinedUser = { - oderId: signalingMessage.oderId, - displayName: signalingMessage.displayName - }; - const actions: Action[] = [ - UsersActions.userJoined({ - user: buildSignalingUser(joinedUser, { - ...buildKnownUserExtras(room, joinedUser.oderId), - presenceServerIds: [signalingMessage.serverId] - }) - }) - ]; - - if (shouldClearReconnectFlag) { - actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); - } - - return actions; - } - - case 'user_left': { - if (!signalingMessage.oderId) - return EMPTY; - - const remainingServerIds = Array.isArray(signalingMessage.serverIds) - ? signalingMessage.serverIds - : undefined; - - if (!remainingServerIds || remainingServerIds.length === 0) { - if (this.knownVoiceUsers.has(signalingMessage.oderId)) { - this.recentlyLeftVoiceTimestamps.set(signalingMessage.oderId, Date.now()); - } - - this.knownVoiceUsers.delete(signalingMessage.oderId); - } - - const actions: Action[] = [ - UsersActions.userLeft({ - userId: signalingMessage.oderId, - serverId: signalingMessage.serverId, - serverIds: remainingServerIds - }) - ]; - - if (shouldClearReconnectFlag) { - actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); - } - - return actions; - } - - case 'access_denied': { - if (isWrongServer(signalingMessage.serverId, viewedServerId)) - return EMPTY; - - if (signalingMessage.reason !== 'SERVER_NOT_FOUND') - return EMPTY; - - // When multiple signal URLs are configured, the room may already - // be successfully joined on a different signal server. Only show - // the reconnect notice when the room is not reachable at all. - if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) - return EMPTY; - - return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })]; - } - - default: - return EMPTY; - } - }) - ) - ); - - /** Request a full room-state snapshot whenever a peer data channel opens. */ - peerConnectedServerStateSync$ = createEffect( - () => - this.webrtc.onPeerConnected.pipe( - withLatestFrom(this.store.select(selectCurrentRoom)), - tap(([peerId, room]) => { - if (!room) - return; - - this.webrtc.sendToPeer(peerId, { - type: 'server-state-request', - roomId: room.id - }); - }) - ), - { dispatch: false } - ); - - /** Re-request the latest room-state snapshot whenever the user enters or views a server. */ - roomEntryServerStateSync$ = createEffect( + /** Persists room field changes to the local database. */ + updateRoom$ = createEffect( () => this.actions$.pipe( - ofType( - RoomsActions.createRoomSuccess, - RoomsActions.joinRoomSuccess, - RoomsActions.viewServerSuccess - ), - tap(({ room }) => { - for (const peerId of this.webrtc.getConnectedPeers()) { - try { - this.webrtc.sendToPeer(peerId, { - type: 'server-state-request', - roomId: room.id - }); - } catch { - /* peer may have disconnected */ - } - } + ofType(RoomsActions.updateRoom), + tap(({ roomId, changes }) => { + this.db.updateRoom(roomId, changes); }) ), { dispatch: false } ); - /** Processes incoming P2P room-state, room-sync, and icon-sync events. */ - incomingRoomEvents$ = createEffect(() => - this.webrtc.onMessageReceived.pipe( - withLatestFrom( - this.store.select(selectCurrentRoom), - this.store.select(selectSavedRooms), - this.store.select(selectAllUsers), - this.store.select(selectCurrentUser), - this.store.select(selectActiveChannelId) - ), - mergeMap(([ - event, - currentRoom, - savedRooms, - allUsers, - currentUser, - activeChannelId - ]) => { - switch (event.type) { - case 'voice-state': - return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice'); - case 'voice-channel-move': - return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null); - case 'screen-state': - return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen'); - case 'camera-state': - return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera'); - case 'server-state-request': - return this.handleServerStateRequest(event, currentRoom, savedRooms); - case 'server-state-full': - return this.handleServerStateFull(event, currentRoom, savedRooms, currentUser ?? null); - case 'room-settings-update': - return this.handleRoomSettingsUpdate(event, currentRoom, savedRooms); - case 'room-permissions-update': - return this.handleRoomPermissionsUpdate(event, currentRoom, savedRooms); - case 'channels-update': - return this.handleChannelsUpdate(event, currentRoom, savedRooms, activeChannelId); - case 'server-icon-summary': - return this.handleIconSummary(event, currentRoom, savedRooms); - case 'server-icon-request': - return this.handleIconRequest(event, currentRoom, savedRooms); - case 'server-icon-full': - case 'server-icon-update': - return this.handleIconData(event, currentRoom, savedRooms); - default: - return EMPTY; - } - }) - ) - ); - - private handleVoiceOrScreenState( - event: ChatEvent, - allUsers: User[], - currentUser: User | null, - kind: 'voice' | 'screen' | 'camera' - ) { - const userId: string | undefined = event.fromPeerId ?? event.oderId; - - if (!userId) - return EMPTY; - - const existingUser = allUsers.find((u) => u.id === userId || u.oderId === userId); - const userExists = !!existingUser; - - if (kind === 'voice') { - const vs = event.voiceState as Partial | undefined; - - if (!vs) - return EMPTY; - - const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId) - ? UsersActions.userJoined({ - user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || existingUser?.displayName || 'User' }, - { presenceServerIds: [vs.serverId] } - ) - }) - : null; - // Detect voice-connection transitions to play join/leave sounds. - // Use the local knownVoiceUsers set (not the store) so presence - // re-syncs and room switches do not create false transitions. - const weAreInVoice = this.webrtc.isVoiceConnected(); - const nowConnected = vs.isConnected ?? false; - const wasKnown = this.knownVoiceUsers.has(userId); - const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState); - // Merge with existing state so partial updates (e.g. mute toggle - // that omits roomId/serverId) don't look like a room change. - const mergedVoiceState = { ...existingUser?.voiceState, ...vs }; - const isInCurrentVoiceRoom = this.isSameVoiceRoom(mergedVoiceState, currentUser?.voiceState); - - if (weAreInVoice) { - const isReconnect = this.consumeRecentLeave(userId); - - if (!isReconnect) { - if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) { - this.audioService.play(AppSound.Joining); - } else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) { - this.audioService.play(AppSound.Leave); - } - } - } - - // Keep the tracking set in sync - if (nowConnected) { - this.knownVoiceUsers.add(userId); - } else { - this.knownVoiceUsers.delete(userId); - } - - if (!userExists) { - return of( - UsersActions.userJoined({ - user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || 'User' }, - { - presenceServerIds: vs.serverId ? [vs.serverId] : undefined, - voiceState: { - isConnected: vs.isConnected ?? false, - isMuted: vs.isMuted ?? false, - isDeafened: vs.isDeafened ?? false, - isSpeaking: vs.isSpeaking ?? false, - isMutedByAdmin: vs.isMutedByAdmin, - volume: vs.volume, - roomId: vs.roomId, - serverId: vs.serverId - } - } - ) - }) - ); - } - - const actions: Action[] = []; - - if (presenceRefreshAction) { - actions.push(presenceRefreshAction); - } - - actions.push(UsersActions.updateVoiceState({ userId, - voiceState: vs })); - - return actions; - } - - if (kind === 'screen') { - const isSharing = event.isScreenSharing as boolean | undefined; - - if (isSharing === undefined) - return EMPTY; - - if (!userExists) { - return of( - UsersActions.userJoined({ - user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || 'User' }, - { screenShareState: { isSharing } } - ) - }) - ); - } - - return of( - UsersActions.updateScreenShareState({ - userId, - screenShareState: { isSharing } - }) - ); - } - - const isCameraEnabled = event.isCameraEnabled as boolean | undefined; - - if (isCameraEnabled === undefined) - return EMPTY; - - if (!userExists) { - return of( - UsersActions.userJoined({ - user: buildSignalingUser( - { oderId: userId, - displayName: event.displayName || 'User' }, - { cameraState: { isEnabled: isCameraEnabled } } - ) - }) - ); - } - - return of( - UsersActions.updateCameraState({ - userId, - cameraState: { isEnabled: isCameraEnabled } - }) - ); - } - - private handleVoiceChannelMove( - event: ChatEvent, - currentRoom: Room | null, - savedRooms: Room[], - currentUser: User | null - ) { - const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null; - const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId; - const nextVoiceState = event.voiceState as Partial | undefined; - - if (!currentUser || !targetUserId || !serverId || !nextVoiceState?.roomId) { - return EMPTY; - } - - if (targetUserId !== currentUser.id && targetUserId !== currentUser.oderId) { - return EMPTY; - } - - const room = this.resolveRoom(serverId, currentRoom, savedRooms); - const movedChannel = room?.channels?.find((channel) => channel.id === nextVoiceState.roomId && channel.type === 'voice'); - - if (!room || !movedChannel) { - return EMPTY; - } - - const updatedVoiceState: Partial = { - isConnected: true, - isMuted: currentUser.voiceState?.isMuted ?? false, - isDeafened: currentUser.voiceState?.isDeafened ?? false, - isSpeaking: currentUser.voiceState?.isSpeaking ?? false, - isMutedByAdmin: currentUser.voiceState?.isMutedByAdmin, - volume: currentUser.voiceState?.volume, - roomId: movedChannel.id, - serverId: room.id - }; - const wasViewingVoiceServer = this.voiceSessionService.isViewingVoiceServer(); - - this.webrtc.startVoiceHeartbeat(movedChannel.id, room.id); - this.voiceSessionService.startSession({ - serverId: room.id, - serverName: room.name, - roomId: movedChannel.id, - roomName: `🔊 ${movedChannel.name}`, - serverIcon: room.icon, - serverDescription: room.description, - serverRoute: `/room/${room.id}` - }); - - this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer); - this.webrtc.broadcastMessage({ - type: 'voice-state', - oderId: currentUser.oderId || currentUser.id, - displayName: currentUser.displayName || 'User', - voiceState: updatedVoiceState - }); - - return of(UsersActions.updateVoiceState({ - userId: currentUser.id, - voiceState: updatedVoiceState - })); - } - - private isSameVoiceRoom( - voiceState: Partial | undefined, - currentUserVoiceState: Partial | undefined - ): boolean { - return !!voiceState?.isConnected - && !!currentUserVoiceState?.isConnected - && !!voiceState.roomId - && !!voiceState.serverId - && voiceState.roomId === currentUserVoiceState.roomId - && voiceState.serverId === currentUserVoiceState.serverId; - } - - /** - * Returns `true` and cleans up the entry if the given user left - * recently enough to be considered a reconnect. Also prunes any - * stale entries older than the grace window. - */ - private consumeRecentLeave(userId: string): boolean { - const now = Date.now(); - - // Prune stale entries while iterating. - for (const [id, ts] of this.recentlyLeftVoiceTimestamps) { - if (now - ts > RECONNECT_SOUND_GRACE_MS) { - this.recentlyLeftVoiceTimestamps.delete(id); - } - } - - const leaveTs = this.recentlyLeftVoiceTimestamps.get(userId); - - if (leaveTs !== undefined && now - leaveTs <= RECONNECT_SOUND_GRACE_MS) { - this.recentlyLeftVoiceTimestamps.delete(userId); - return true; - } - - return false; - } - - private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null { - if (!roomId) - return currentRoom; - - if (currentRoom?.id === roomId) - return currentRoom; - - return savedRooms.find((room) => room.id === roomId) ?? null; - } - - private sanitizeRoomSnapshot(room: Partial): Partial { - return { - name: typeof room.name === 'string' ? room.name : undefined, - description: typeof room.description === 'string' ? room.description : undefined, - topic: typeof room.topic === 'string' ? room.topic : undefined, - hostId: typeof room.hostId === 'string' ? room.hostId : undefined, - hasPassword: - typeof room.hasPassword === 'boolean' - ? room.hasPassword - : (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined), - isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined, - maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined, - icon: typeof room.icon === 'string' ? room.icon : undefined, - iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined, - slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined, - permissions: room.permissions ? { ...room.permissions } : undefined, - channels: Array.isArray(room.channels) ? room.channels : undefined, - members: Array.isArray(room.members) ? room.members : undefined, - roles: Array.isArray(room.roles) ? room.roles : undefined, - roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined, - channelPermissions: Array.isArray(room.channelPermissions) ? room.channelPermissions : undefined, - sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined, - sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined, - sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined - }; - } - - private normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] { - if (!Array.isArray(bans)) - return []; - - const now = Date.now(); - - return bans - .filter((ban): ban is Partial => !!ban && typeof ban === 'object') - .map((ban) => ({ - oderId: typeof ban.oderId === 'string' ? ban.oderId : uuidv4(), - userId: typeof ban.userId === 'string' ? ban.userId : '', - roomId, - bannedBy: typeof ban.bannedBy === 'string' ? ban.bannedBy : '', - displayName: typeof ban.displayName === 'string' ? ban.displayName : undefined, - reason: typeof ban.reason === 'string' ? ban.reason : undefined, - expiresAt: typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined, - timestamp: typeof ban.timestamp === 'number' ? ban.timestamp : now - })) - .filter((ban) => !!ban.userId && !!ban.bannedBy && (!ban.expiresAt || ban.expiresAt > now)); - } - - private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) { - return from(this.db.getBansForRoom(roomId)).pipe( - switchMap((localBans) => { - const nextIds = new Set(bans.map((ban) => ban.oderId)); - const removals = localBans - .filter((ban) => !nextIds.has(ban.oderId)) - .map((ban) => this.db.removeBan(ban.oderId)); - const saves = bans.map((ban) => this.db.saveBan({ ...ban, - roomId })); - - return from(Promise.all([...removals, ...saves])); - }) - ); - } - - private handleServerStateRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - const fromPeerId = event.fromPeerId; - - if (!room || !fromPeerId) - return EMPTY; - - return from(this.db.getBansForRoom(room.id)).pipe( - tap((bans) => { - this.webrtc.sendToPeer(fromPeerId, { - type: 'server-state-full', - roomId: room.id, - room: this.sanitizeRoomSnapshot(room), - bans - }); - }), - mergeMap(() => EMPTY) - ); - } - - private handleServerStateFull( - event: ChatEvent, - currentRoom: Room | null, - savedRooms: Room[], - currentUser: { id: string; oderId: string } | null - ) { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - const incomingRoom = event.room as Partial | undefined; - - if (!room || !incomingRoom) - return EMPTY; - - const roomChanges = this.sanitizeRoomSnapshot(incomingRoom); - const bans = this.normalizeIncomingBans(room.id, event.bans); - - return this.syncBansToLocalRoom(room.id, bans).pipe( - mergeMap(() => { - const actions: (ReturnType - | ReturnType - | ReturnType)[] = [ - RoomsActions.updateRoom({ - roomId: room.id, - changes: roomChanges - }) - ]; - const isCurrentUserBanned = hasRoomBanForUser( - bans, - currentUser, - this.getPersistedCurrentUserId() - ); - - if (currentRoom?.id === room.id) { - actions.push(UsersActions.loadBansSuccess({ bans })); - } - - if (isCurrentUserBanned) { - actions.push(RoomsActions.forgetRoom({ roomId: room.id })); - } - - return actions; - }), - catchError(() => EMPTY) - ); - } - - private handleRoomSettingsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - const settings = event.settings as Partial | undefined; - - if (!room || !settings) - return EMPTY; - - return of( - RoomsActions.updateRoom({ - roomId: room.id, - changes: { - name: settings.name ?? room.name, - description: settings.description ?? room.description, - topic: settings.topic ?? room.topic, - isPrivate: settings.isPrivate ?? room.isPrivate, - password: settings.password === '' ? undefined : room.password, - hasPassword: - typeof settings.hasPassword === 'boolean' - ? settings.hasPassword - : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password), - maxUsers: settings.maxUsers ?? room.maxUsers - } - }) - ); - } - - private handleRoomPermissionsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - const permissions = event.permissions as Partial | undefined; - const incomingRoom = event.room as Partial | undefined; - - if (!room || (!permissions && !incomingRoom)) - return EMPTY; - - return of( - RoomsActions.updateRoom({ - roomId: room.id, - changes: { - permissions: permissions - ? { ...(room.permissions || {}), - ...permissions } as RoomPermissions - : room.permissions, - roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles, - roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments, - channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions, - slowModeInterval: typeof incomingRoom?.slowModeInterval === 'number' ? incomingRoom.slowModeInterval : room.slowModeInterval - } - }) - ); - } - - private handleChannelsUpdate( - event: ChatEvent, - currentRoom: Room | null, - savedRooms: Room[], - activeChannelId: string - ): Action[] { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - const channels = Array.isArray(event.channels) ? event.channels : null; - - if (!room || !channels) { - return []; - } - - const actions: Action[] = [ - RoomsActions.updateRoom({ - roomId: room.id, - changes: { channels } - }) - ]; - - if (!channels.some((channel) => channel.id === activeChannelId)) { - const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id - ?? 'general'; - - actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId })); - } - - return actions; - } - - private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - - if (!room) - return EMPTY; - - const remoteUpdated = event.iconUpdatedAt || 0; - const localUpdated = room.iconUpdatedAt || 0; - - if (remoteUpdated > localUpdated && event.fromPeerId) { - this.webrtc.sendToPeer(event.fromPeerId, { - type: 'server-icon-request', - roomId: room.id - }); - } - - return EMPTY; - } - - private handleIconRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - - if (!room) - return EMPTY; - - if (event.fromPeerId) { - this.webrtc.sendToPeer(event.fromPeerId, { - type: 'server-icon-full', - roomId: room.id, - icon: room.icon, - iconUpdatedAt: room.iconUpdatedAt || 0 - }); - } - - return EMPTY; - } - - private handleIconData(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { - const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; - const room = this.resolveRoom(roomId, currentRoom, savedRooms); - const senderId = event.fromPeerId; - - if (!room || typeof event.icon !== 'string' || !senderId) - return EMPTY; - - return this.store.select(selectAllUsers).pipe( - map((users) => users.find((u) => u.id === senderId)), - mergeMap((sender) => { - if (!sender) - return EMPTY; - - const isOwner = room.hostId === sender.id; - const canByRole = resolveRoomPermission(room, sender, 'manageIcon'); - - if (!isOwner && !canByRole) - return EMPTY; - - const updates: Partial = { - icon: event.icon, - iconUpdatedAt: event.iconUpdatedAt || Date.now() - }; - - this.db.updateRoom(room.id, updates); - return of(RoomsActions.updateRoom({ roomId: room.id, - changes: updates })); - }) - ); - } - - /** Broadcasts the local server icon summary to peers when a new peer connects. */ - peerConnectedIconSync$ = createEffect( - () => - this.webrtc.onPeerConnected.pipe( - withLatestFrom(this.store.select(selectCurrentRoom)), - tap(([peerId, room]) => { - if (!room) - return; - - const iconUpdatedAt = room.iconUpdatedAt || 0; - - this.webrtc.broadcastMessage({ - type: 'server-icon-summary', - roomId: room.id, - iconUpdatedAt - }); - }) - ), - { dispatch: false } - ); - - private async connectToRoomSignaling( - room: Room, - user: User | null, - resolvedOderId?: string, - savedRooms: Room[] = [], - options: { showCompatibilityError?: boolean; navigationRequestVersion?: number } = {} - ): Promise { - const shouldShowCompatibilityError = options.showCompatibilityError ?? false; - const navigationRequestVersion = options.navigationRequestVersion; - const isViewedRoom = () => room.id === this.latestNavigatedRoomId; - - await this.serverDirectory.awaitInitialServerHealthCheck(); - - if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { - return; - } - - const connectionPlan = await this.resolveRoomSignalConnectionPlan(room); - - if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { - return; - } - - const sessionFallbackSource = this.roomSignalFallbackSources.get(room.id); - const connectionCandidates: { - isExistingFallback?: boolean; - isFallback?: boolean; - isPrimary?: boolean; - source: RoomSignalSource; - }[] = []; - const pushConnectionCandidate = ( - source: RoomSignalSource | null | undefined, - flags: { isExistingFallback?: boolean; isFallback?: boolean; isPrimary?: boolean } = {} - ) => { - if (!source || !this.resolveRoomSignalSelector(source, room.name)) { - return; - } - - if (connectionCandidates.some((candidate) => areRoomSignalSourcesEqual(candidate.source, source))) { - return; - } - - connectionCandidates.push({ - ...flags, - source - }); - }; - - if (sessionFallbackSource && this.webrtc.hasJoinedServer(room.id)) { - pushConnectionCandidate(sessionFallbackSource, { isExistingFallback: true, isFallback: true }); - } - - pushConnectionCandidate(connectionPlan.primarySource, { isPrimary: true }); - - for (const fallbackSource of connectionPlan.fallbackSources) { - pushConnectionCandidate(fallbackSource, { isFallback: true }); - } - - let attemptedFallback = false; - - for (const candidate of connectionCandidates) { - const selector = this.resolveRoomSignalSelector(candidate.source, connectionPlan.room.name); - - if (!selector) { - continue; - } - - const isCompatible = await this.serverDirectory.ensureEndpointVersionCompatibility(selector); - - if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { - return; - } - - if (!isCompatible) { - if (candidate.isPrimary) { - if (shouldShowCompatibilityError) { - this.store.dispatch( - RoomsActions.setSignalServerCompatibilityError({ message: CLIENT_UPDATE_REQUIRED_MESSAGE }) - ); - } - - if (isViewedRoom()) { - this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); - } - - return; - } - - continue; - } - - if (candidate.isFallback && !candidate.isExistingFallback && !attemptedFallback) { - attemptedFallback = true; - - if (isViewedRoom()) { - this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); - } - } - - const connected = await this.connectRoomToSignalSource( - connectionPlan.room, - candidate.source, - user, - resolvedOderId, - savedRooms, - navigationRequestVersion - ); - - if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { - return; - } - - if (!connected) { - continue; - } - - if (candidate.isFallback) { - this.roomSignalFallbackSources.set(room.id, candidate.source); - } else { - this.roomSignalFallbackSources.delete(room.id); - } - - if (shouldShowCompatibilityError) { - this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null })); - } - - if (isViewedRoom()) { - this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false })); - } - - return; - } - - if (shouldShowCompatibilityError) { - this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null })); - } - - if (isViewedRoom()) { - this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true })); - } - } - - private syncSavedRoomConnections(user: User | null, currentRoom: Room | null, savedRooms: Room[]): void { - if (!user || savedRooms.length === 0) { - return; - } - - const watchedRoomId = this.extractRoomIdFromUrl(this.router.url); - const roomsToSync = currentRoom ? this.includeRoom(savedRooms, currentRoom) : savedRooms; - const roomsBySignalingUrl = new Map(); - - for (const room of roomsToSync) { - const wsUrl = this.resolveRoomSignalingUrl(room); - - if (!wsUrl) { - continue; - } - - const groupedRooms = roomsBySignalingUrl.get(wsUrl) ?? []; - - if (!groupedRooms.some((groupedRoom) => groupedRoom.id === room.id)) { - groupedRooms.push(room); - } - - roomsBySignalingUrl.set(wsUrl, groupedRooms); - } - - for (const groupedRooms of roomsBySignalingUrl.values()) { - const preferredRoom = groupedRooms.find((room) => room.id === watchedRoomId) - ?? (currentRoom && groupedRooms.some((room) => room.id === currentRoom.id) - ? currentRoom - : null) - ?? groupedRooms[0] - ?? null; - - if (!preferredRoom) { - continue; - } - - const shouldShowCompatibilityError = preferredRoom.id === watchedRoomId - || (!!currentRoom && preferredRoom.id === currentRoom.id); - - void this.connectToRoomSignaling(preferredRoom, user, user.oderId || this.webrtc.peerId(), roomsToSync, { - showCompatibilityError: shouldShowCompatibilityError - }); - } - } - - private resolveCompatibilitySelector(room: Room): ServerSourceSelector | undefined | null { - if (room.sourceId) { - const endpointById = this.serverDirectory.servers().find((entry) => entry.id === room.sourceId); - - if (endpointById) { - return { sourceId: room.sourceId }; - } - - if (room.sourceUrl && this.serverDirectory.findServerByUrl(room.sourceUrl)) { - return { sourceUrl: room.sourceUrl }; - } - - return null; - } - - if (room.sourceUrl) { - return this.serverDirectory.findServerByUrl(room.sourceUrl) - ? { sourceUrl: room.sourceUrl } - : null; - } - - return undefined; - } - - private includeRoom(rooms: Room[], room: Room): Room[] { - return rooms.some((candidate) => candidate.id === room.id) - ? rooms - : [...rooms, room]; - } - - private getRoomsForSignalingUrl(rooms: Room[], wsUrl: string): Room[] { - const seenRoomIds = new Set(); - const matchingRooms: Room[] = []; - - for (const room of rooms) { - if (seenRoomIds.has(room.id)) { - continue; - } - - if (this.resolveRoomSignalingUrl(room) !== wsUrl) { - continue; - } - - seenRoomIds.add(room.id); - matchingRooms.push(room); - } - - return matchingRooms; - } - - private extractRoomIdFromUrl(url: string): string | null { - const roomMatch = url.match(ROOM_URL_PATTERN); - - 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 { - return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser); - } - - private canManageChannelsInRoom( - room: Room, - currentUser: User, - currentRoom: Room | null, - currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom) - ): boolean { - return currentUserRole === 'host' || resolveRoomPermission(room, currentUser, 'manageChannels'); - } - - private getPersistedCurrentUserId(): string | null { - return localStorage.getItem('metoyou_currentUserId'); - } - - private async resolveRoomSignalConnectionPlan(room: Room): Promise { - let resolvedRoom = this.repairRoomSignalSource(room, this.resolveRoomSignalSource(room)); - let primarySource = this.resolveRoomSignalSource(resolvedRoom); - - if (!this.webrtc.hasJoinedServer(room.id)) { - const selector = this.resolveRoomSignalSelector(primarySource, resolvedRoom.name); - const authoritativeServer = ( - selector - ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) - : null - ) ?? await firstValueFrom(this.serverDirectory.findServerAcrossActiveEndpoints(room.id, primarySource)); - - if (authoritativeServer) { - const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource({ - sourceId: authoritativeServer.sourceId ?? primarySource.sourceId, - sourceName: authoritativeServer.sourceName ?? primarySource.sourceName, - sourceUrl: authoritativeServer.sourceUrl ?? primarySource.sourceUrl, - fallbackName: authoritativeServer.sourceName ?? primarySource.sourceName ?? resolvedRoom.name - }, { - ensureEndpoint: !!(authoritativeServer.sourceUrl ?? primarySource.sourceUrl) - }); - - resolvedRoom = this.repairRoomSignalSource(resolvedRoom, authoritativeSource); - primarySource = authoritativeSource; - } - } - - const fallbackSources = this.serverDirectory.getFallbackRoomEndpoints(primarySource) - .map((endpoint) => this.serverDirectory.normaliseRoomSignalSource({ - sourceId: endpoint.id, - sourceName: endpoint.name, - sourceUrl: endpoint.url, - fallbackName: endpoint.name - })) - .filter((source, index, sources) => - sources.findIndex((candidate) => areRoomSignalSourcesEqual(candidate, source)) === index - ); - - return { - fallbackSources, - primarySource: this.resolveRoomSignalSelector(primarySource, resolvedRoom.name) ? primarySource : null, - room: resolvedRoom - }; - } - - private async connectRoomToSignalSource( - room: Room, - source: RoomSignalSource, - user: User | null, - resolvedOderId: string | undefined, - savedRooms: Room[], - navigationRequestVersion?: number - ): Promise { - const selector = this.resolveRoomSignalSelector(source, room.name); - - if (!selector) { - return false; - } - - const wsUrl = this.serverDirectory.getWebSocketUrl(selector); - const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId(); - const displayName = resolveUserDisplayName(user); - 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); - - for (const backgroundRoom of backgroundRooms) { - this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl); - } - - if (this.webrtc.hasJoinedServer(room.id)) { - this.webrtc.switchServer(room.id, oderId, wsUrl); - } else { - this.webrtc.joinRoom(room.id, oderId, wsUrl); - } - }; - - if (this.webrtc.isSignalingConnectedTo(wsUrl)) { - joinCurrentEndpointRooms(); - return true; - } - - try { - const connected = await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl)); - - if (!connected || !this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { - return false; - } - - joinCurrentEndpointRooms(); - return true; - } catch { - return false; - } - } - - private resolveRoomSignalSource( - room: Pick - ): RoomSignalSource { - return this.serverDirectory.normaliseRoomSignalSource({ - sourceId: room.sourceId, - sourceName: room.sourceName, - sourceUrl: room.sourceUrl, - fallbackName: room.sourceName ?? room.name - }, { - ensureEndpoint: !!room.sourceUrl - }); - } - - private repairRoomSignalSource(room: Room, source: RoomSignalSource | null): Room { - if (!source || areRoomSignalSourcesEqual(room, source)) { - return room; - } - - const changes: Partial = { - sourceId: source.sourceId, - sourceName: source.sourceName, - sourceUrl: source.sourceUrl - }; - - this.store.dispatch(RoomsActions.updateRoom({ - roomId: room.id, - changes - })); - - return { - ...room, - ...changes - }; - } - - private resolveRoomSignalSelector( - source: RoomSignalSource | null | undefined, - fallbackName: string - ): ServerSourceSelector | undefined { - if (!source) { - return undefined; - } - - return this.serverDirectory.buildRoomSignalSelector({ - ...source, - fallbackName: source.sourceName ?? fallbackName - }, { - ensureEndpoint: !!source.sourceUrl - }); - } - - private getPreferredRoomSignalSource(room: Room): RoomSignalSource { - const fallbackSource = this.roomSignalFallbackSources.get(room.id); - - if (fallbackSource && this.webrtc.hasJoinedServer(room.id)) { - return fallbackSource; - } - - return this.resolveRoomSignalSource(room); - } - - private resolveRoomSignalingUrl(room: Room): string { - const selector = this.resolveRoomSignalSelector(this.getPreferredRoomSignalSource(room), room.name); - - return selector ? this.serverDirectory.getWebSocketUrl(selector) : ''; - } + // ── Private helpers ──────────────────────────────────────────── private async getBlockedRoomAccessActions( roomId: string, @@ -2511,4 +848,8 @@ export class RoomsEffects { return blockedActions; } + + private getPersistedCurrentUserId(): string | null { + return localStorage.getItem('metoyou_currentUserId'); + } } diff --git a/toju-app/src/app/store/rooms/rooms.helpers.ts b/toju-app/src/app/store/rooms/rooms.helpers.ts new file mode 100644 index 0000000..b700086 --- /dev/null +++ b/toju-app/src/app/store/rooms/rooms.helpers.ts @@ -0,0 +1,186 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + Room, + BanEntry, + User +} from '../../shared-kernel'; +import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control'; +import { findRoomMember } from './room-members.helpers'; +import { ROOM_URL_PATTERN } from '../../core/constants'; + +/** Build a minimal User object from signaling payload. */ +export function buildSignalingUser( + data: { oderId: string; displayName?: string }, + extras: Record = {} +) { + const displayName = data.displayName?.trim() || 'User'; + + return { + oderId: data.oderId, + id: data.oderId, + username: displayName.toLowerCase().replace(/\s+/g, '_'), + displayName, + status: 'online' as const, + isOnline: true, + role: 'member' as const, + joinedAt: Date.now(), + ...extras + }; +} + +/** Best-known persisted member metadata for a signaling user in the viewed room. */ +export function buildKnownUserExtras(room: Room | null, identifier: string): Record { + const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined; + + if (!knownMember) + return {}; + + return { + username: knownMember.username, + avatarUrl: knownMember.avatarUrl, + role: knownMember.role, + joinedAt: knownMember.joinedAt + }; +} + +/** Returns true when the message's server ID does not match the viewed server. */ +export function isWrongServer( + msgServerId: string | undefined, + viewedServerId: string | undefined +): boolean { + return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId); +} + +export function resolveUserDisplayName(user: Pick | null | undefined): string { + const displayName = user?.displayName?.trim(); + + if (displayName) { + return displayName; + } + + return user?.username?.trim() || 'User'; +} + +export function hasPersistedChannels(channels: Room['channels'] | undefined): channels is NonNullable { + return Array.isArray(channels) && channels.length > 0; +} + +/** Keep cached channels until directory metadata provides a concrete replacement. */ +export function resolveRoomChannels( + cachedChannels: Room['channels'] | undefined, + incomingChannels: Room['channels'] | undefined +): Room['channels'] | undefined { + if (hasPersistedChannels(incomingChannels)) { + return incomingChannels; + } + + if (hasPersistedChannels(cachedChannels)) { + return cachedChannels; + } + + return undefined; +} + +export function resolveTextChannelId( + channels: Room['channels'] | undefined, + preferredChannelId?: string | null +): string | null { + const textChannels = (channels ?? []).filter((channel) => channel.type === 'text'); + + if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) { + return preferredChannelId; + } + + return textChannels[0]?.id ?? null; +} + +export function resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null { + if (!roomId) + return currentRoom; + + if (currentRoom?.id === roomId) + return currentRoom; + + return savedRooms.find((room) => room.id === roomId) ?? null; +} + +export function sanitizeRoomSnapshot(room: Partial): Partial { + return { + name: typeof room.name === 'string' ? room.name : undefined, + description: typeof room.description === 'string' ? room.description : undefined, + topic: typeof room.topic === 'string' ? room.topic : undefined, + hostId: typeof room.hostId === 'string' ? room.hostId : undefined, + hasPassword: + typeof room.hasPassword === 'boolean' + ? room.hasPassword + : (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined), + isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined, + maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined, + icon: typeof room.icon === 'string' ? room.icon : undefined, + iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined, + slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined, + permissions: room.permissions ? { ...room.permissions } : undefined, + channels: Array.isArray(room.channels) ? room.channels : undefined, + members: Array.isArray(room.members) ? room.members : undefined, + roles: Array.isArray(room.roles) ? room.roles : undefined, + roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined, + channelPermissions: Array.isArray(room.channelPermissions) ? room.channelPermissions : undefined, + sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined, + sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined, + sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined + }; +} + +export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] { + if (!Array.isArray(bans)) + return []; + + const now = Date.now(); + + return bans + .filter((ban): ban is Partial => !!ban && typeof ban === 'object') + .map((ban) => ({ + oderId: typeof ban.oderId === 'string' ? ban.oderId : uuidv4(), + userId: typeof ban.userId === 'string' ? ban.userId : '', + roomId, + bannedBy: typeof ban.bannedBy === 'string' ? ban.bannedBy : '', + displayName: typeof ban.displayName === 'string' ? ban.displayName : undefined, + reason: typeof ban.reason === 'string' ? ban.reason : undefined, + expiresAt: typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined, + timestamp: typeof ban.timestamp === 'number' ? ban.timestamp : now + })) + .filter((ban) => !!ban.userId && !!ban.bannedBy && (!ban.expiresAt || ban.expiresAt > now)); +} + +export function getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null { + return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser); +} + +export function canManageChannelsInRoom( + room: Room, + currentUser: User, + currentRoom: Room | null, + currentUserRole = getUserRoleForRoom(room, currentUser, currentRoom) +): boolean { + return currentUserRole === 'host' || resolveRoomPermission(room, currentUser, 'manageChannels'); +} + +export function getPersistedCurrentUserId(): string | null { + return localStorage.getItem('metoyou_currentUserId'); +} + +export function extractRoomIdFromUrl(url: string): string | null { + const roomMatch = url.match(ROOM_URL_PATTERN); + + return roomMatch ? roomMatch[1] : null; +} + +export interface RoomPresenceSignalingMessage { + type: string; + reason?: string; + serverId?: string; + serverIds?: string[]; + users?: { oderId: string; displayName: string }[]; + oderId?: string; + displayName?: string; +}