/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { of, from, EMPTY, merge, timer } from 'rxjs'; import { map, mergeMap, catchError, withLatestFrom, tap, switchMap, filter } from 'rxjs/operators'; 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 } from '../users/users.selectors'; import { selectActiveChannelId, selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; import { RealtimeSessionFacade } from '../../core/realtime'; import { clearLastViewedChatFromStorage, DatabaseService, loadLastViewedChatFromStorage, saveLastViewedChatToStorage } from '../../infrastructure/persistence'; import { setStoredCurrentUserId } from '../../core/storage/current-user-storage'; import { AppI18nService } from '../../core/i18n'; import { ServerDirectoryFacade } from '../../domains/server-directory'; import { hasRoomBanForUser } from '../../domains/access-control'; import { Room } from '../../shared-kernel'; import { removeRoomMember, transferRoomOwnership, findRoomMember } from './room-members.helpers'; import { defaultChannels } from './room-channels.defaults'; import { RoomSignalingConnection } from './room-signaling-connection'; import { resolveRoomChannels, resolveTextChannelId, extractRoomIdFromUrl } from './rooms.helpers'; type BlockedRoomAccessAction = | ReturnType | ReturnType; const VIEW_SERVER_LOAD_DELAY_MS = 0; @Injectable() export class RoomsEffects { private actions$ = inject(Actions); private store = inject(Store); private router = inject(Router); private db = inject(DatabaseService); private webrtc = inject(RealtimeSessionFacade); private serverDirectory = inject(ServerDirectoryFacade); private readonly i18n = inject(AppI18nService); private readonly signalingConnection = new RoomSignalingConnection( this.webrtc, this.serverDirectory, this.store ); /** Loads all saved rooms from the local database. */ loadRooms$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.loadRooms), switchMap(() => from(this.db.getAllRooms()).pipe( map((rooms) => RoomsActions.loadRoomsSuccess({ rooms })), catchError((error) => of(RoomsActions.loadRoomsFailure({ error: error.message }))) ) ) ) ); /** Opens the routed room after login/refresh once saved rooms are available. */ syncViewedRoomToRoute$ = createEffect(() => merge( this.actions$.pipe( ofType( RoomsActions.loadRoomsSuccess, UsersActions.loadCurrentUserSuccess, UsersActions.setCurrentUser ) ), this.router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd) ) ).pipe( withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), mergeMap(([ , currentUser, currentRoom, savedRooms ]) => { if (!currentUser) { return EMPTY; } const roomId = extractRoomIdFromUrl(this.router.url); if (!roomId || currentRoom?.id === roomId) { return EMPTY; } const room = savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null; if (!room) { return EMPTY; } return of(RoomsActions.viewServer({ room })); }) ) ); /** Searches the server directory with debounced input. */ searchServers$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.searchServers), switchMap(({ query }) => this.serverDirectory.searchServers(query).pipe( map((servers) => RoomsActions.searchServersSuccess({ servers })), catchError((error) => of(RoomsActions.searchServersFailure({ error: error.message }))) ) ) ) ); /** Re-joins saved rooms after the signaling socket reconnects so presence is restored. */ resyncRoomsOnSignalingReconnect$ = createEffect( () => this.webrtc.signalingReconnected$.pipe( withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), tap(([ , user, currentRoom, savedRooms ]) => { this.signalingConnection.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms, this.router.url); }) ), { dispatch: false } ); /** Reconnects saved rooms so joined servers stay online while the app is running. */ keepSavedRoomsConnected$ = createEffect( () => this.actions$.pipe( ofType( RoomsActions.loadRoomsSuccess, RoomsActions.forgetRoomSuccess, RoomsActions.deleteRoomSuccess, UsersActions.loadCurrentUserSuccess, UsersActions.setCurrentUser ), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), tap(([ , user, currentRoom, savedRooms ]) => { this.signalingConnection.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms, this.router.url); }) ), { dispatch: false } ); /** Creates a new room, saves it locally, and registers it with the server directory. */ createRoom$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.createRoom), withLatestFrom(this.store.select(selectCurrentUser)), switchMap(([{ name, description, topic, isPrivate, password, sourceId, sourceUrl }, currentUser]) => { if (!currentUser) { return of(RoomsActions.createRoomFailure({ error: this.i18n.instant('servers.errors.notLoggedIn') })); } const allEndpoints = this.serverDirectory.servers(); const activeEndpoints = this.serverDirectory.activeServers(); const selectedEndpoint = allEndpoints.find((endpoint) => (sourceId && endpoint.id === sourceId) || (!!sourceUrl && endpoint.url === sourceUrl) ); const endpoint = selectedEndpoint ?? activeEndpoints[0] ?? allEndpoints[0] ?? null; const normalizedPassword = typeof password === 'string' ? password.trim() : ''; setStoredCurrentUserId(currentUser.id); const room: Room = { id: uuidv4(), name, description, topic, hostId: currentUser.id, password: normalizedPassword || undefined, hasPassword: normalizedPassword.length > 0, isPrivate: isPrivate ?? false, createdAt: Date.now(), userCount: 1, maxUsers: 50, channels: defaultChannels(), sourceId: endpoint?.id, sourceName: endpoint?.name, sourceUrl: endpoint?.url }; return from(this.db.saveRoom(room)).pipe( map(() => { // Register with central server (using the same room ID for discoverability) this.serverDirectory .registerServer({ id: room.id, // Use the same ID as the local room name: room.name, description: room.description, ownerId: currentUser.id, ownerPublicKey: currentUser.oderId, hostName: currentUser.displayName, password: normalizedPassword || null, hasPassword: normalizedPassword.length > 0, isPrivate: room.isPrivate, userCount: room.userCount, maxUsers: room.maxUsers || 50, icon: room.icon, iconUpdatedAt: room.iconUpdatedAt, tags: [], channels: room.channels ?? defaultChannels() }, endpoint ? { sourceId: endpoint.id, sourceUrl: endpoint.url } : undefined ) .subscribe(); return RoomsActions.createRoomSuccess({ room }); }) ); }), catchError((error) => of(RoomsActions.createRoomFailure({ error: error.message }))) ) ); /** Joins an existing room by ID, resolving room data from local DB or server directory. */ joinRoom$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.joinRoom), withLatestFrom(this.store.select(selectCurrentUser)), switchMap(([{ roomId, password: _password, serverInfo }, currentUser]) => { if (!currentUser) { return of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.notLoggedIn') })); } return from(this.getBlockedRoomAccessActions(roomId, currentUser)).pipe( switchMap((blockedActions) => { if (blockedActions.length > 0) { return from(blockedActions); } // First check local DB return from(this.db.getRoom(roomId)).pipe( switchMap((room) => { const sourceSelector = serverInfo ? this.serverDirectory.buildRoomSignalSelector({ sourceId: serverInfo.sourceId, sourceName: serverInfo.sourceName, sourceUrl: serverInfo.sourceUrl, signalingUrl: serverInfo.signalingUrl, fallbackName: serverInfo.sourceName ?? serverInfo.name }, { ensureEndpoint: !!(serverInfo.sourceUrl ?? serverInfo.signalingUrl) }) : undefined; if (room) { setStoredCurrentUserId(currentUser.id); const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({ sourceId: serverInfo?.sourceId ?? room.sourceId, sourceName: serverInfo?.sourceName ?? room.sourceName, sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl, signalingUrl: serverInfo?.signalingUrl, fallbackName: serverInfo?.sourceName ?? room.sourceName ?? serverInfo?.name ?? room.name }, { ensureEndpoint: !!(serverInfo?.sourceUrl ?? room.sourceUrl ?? serverInfo?.signalingUrl) }); const resolvedRoom: Room = { ...room, isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate, icon: serverInfo?.icon ?? room.icon, iconUpdatedAt: serverInfo?.iconUpdatedAt ?? room.iconUpdatedAt, channels: resolveRoomChannels(room.channels, serverInfo?.channels), slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval, roles: serverInfo?.roles ?? room.roles, roleAssignments: serverInfo?.roleAssignments ?? room.roleAssignments, channelPermissions: serverInfo?.channelPermissions ?? room.channelPermissions, ...resolvedSource, hasPassword: typeof serverInfo?.hasPassword === 'boolean' ? serverInfo.hasPassword : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password) }; return from(this.db.updateRoom(room.id, { sourceId: resolvedRoom.sourceId, sourceName: resolvedRoom.sourceName, sourceUrl: resolvedRoom.sourceUrl, channels: resolvedRoom.channels, slowModeInterval: resolvedRoom.slowModeInterval, roles: resolvedRoom.roles, roleAssignments: resolvedRoom.roleAssignments, channelPermissions: resolvedRoom.channelPermissions, icon: resolvedRoom.icon, iconUpdatedAt: resolvedRoom.iconUpdatedAt, hasPassword: resolvedRoom.hasPassword, isPrivate: resolvedRoom.isPrivate })).pipe(map(() => RoomsActions.joinRoomSuccess({ room: resolvedRoom }))); } // If not in local DB but we have server info from search, create a room entry if (serverInfo) { setStoredCurrentUserId(currentUser.id); const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({ sourceId: serverInfo.sourceId, sourceName: serverInfo.sourceName, sourceUrl: serverInfo.sourceUrl, signalingUrl: serverInfo.signalingUrl, fallbackName: serverInfo.sourceName ?? serverInfo.name }, { ensureEndpoint: !!(serverInfo.sourceUrl ?? serverInfo.signalingUrl) }); const newRoom: Room = { id: roomId, name: serverInfo.name, description: serverInfo.description, hostId: '', // Unknown, will be determined via signaling hasPassword: !!serverInfo.hasPassword, isPrivate: !!serverInfo.isPrivate, createdAt: Date.now(), userCount: 1, maxUsers: 50, icon: serverInfo.icon, iconUpdatedAt: serverInfo.iconUpdatedAt, channels: resolveRoomChannels(undefined, serverInfo.channels), slowModeInterval: serverInfo.slowModeInterval, roles: serverInfo.roles, roleAssignments: serverInfo.roleAssignments, channelPermissions: serverInfo.channelPermissions, ...resolvedSource }; return from(this.db.saveRoom(newRoom)).pipe( map(() => RoomsActions.joinRoomSuccess({ room: newRoom })) ); } // Try to get room info from server return this.serverDirectory.getServer(roomId, sourceSelector).pipe( switchMap((serverData) => { if (serverData) { setStoredCurrentUserId(currentUser.id); const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({ sourceId: serverData.sourceId, sourceName: serverData.sourceName, sourceUrl: serverData.sourceUrl, fallbackName: serverData.sourceName ?? serverData.name }, { ensureEndpoint: !!serverData.sourceUrl }); const newRoom: Room = { id: serverData.id, name: serverData.name, description: serverData.description, hostId: serverData.ownerId || '', hasPassword: !!serverData.hasPassword, isPrivate: serverData.isPrivate, createdAt: serverData.createdAt || Date.now(), userCount: serverData.userCount, maxUsers: serverData.maxUsers, icon: serverData.icon, iconUpdatedAt: serverData.iconUpdatedAt, channels: resolveRoomChannels(undefined, serverData.channels), slowModeInterval: serverData.slowModeInterval, roles: serverData.roles, roleAssignments: serverData.roleAssignments, channelPermissions: serverData.channelPermissions, ...resolvedSource }; return from(this.db.saveRoom(newRoom)).pipe( map(() => RoomsActions.joinRoomSuccess({ room: newRoom })) ); } return of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.roomNotFound') })); }), catchError(() => of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.roomNotFound') }))) ); }), catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message }))) ); }), catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message }))) ); }) ) ); /** Navigates to the room view and establishes or reuses a signaling connection. */ navigateToRoom$ = createEffect( () => this.actions$.pipe( ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)), tap(([ { room }, user, savedRooms ]) => { const navigationRequestVersion = this.signalingConnection.beginRoomNavigation(room.id); void this.signalingConnection.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, { showCompatibilityError: true, navigationRequestVersion }); this.router.navigate(['/room', room.id]); }) ), { dispatch: false } ); /** Remembers the viewed room whenever a room becomes active. */ persistLastViewedChatOnRoomActivation$ = createEffect( () => this.actions$.pipe( ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), withLatestFrom(this.store.select(selectCurrentUser)), tap(([{ room }, currentUser]) => { if (!currentUser) { return; } const persisted = loadLastViewedChatFromStorage(currentUser.id); const channelId = persisted?.roomId === room.id ? resolveTextChannelId(room.channels, persisted.channelId) : resolveTextChannelId(room.channels); saveLastViewedChatToStorage({ userId: currentUser.id, roomId: room.id, channelId }); }) ), { dispatch: false } ); /** Remembers the currently selected text channel for the active room. */ persistLastViewedChatOnChannelSelection$ = createEffect( () => this.actions$.pipe( ofType(RoomsActions.selectChannel), withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)), tap(([ { channelId }, currentRoom, currentUser ]) => { if (!currentRoom || !currentUser) { return; } const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId); if (!resolvedChannelId || resolvedChannelId !== channelId) { return; } saveLastViewedChatToStorage({ userId: currentUser.id, roomId: currentRoom.id, channelId }); }) ), { dispatch: false } ); /** Restores the last viewed text channel once the active room's channels are known. */ restoreLastViewedTextChannel$ = createEffect(() => this.actions$.pipe( ofType( RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess, RoomsActions.updateRoom ), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectActiveChannelId) ), mergeMap(([ , currentUser, currentRoom, activeChannelId ]) => { if (!currentUser || !currentRoom) { return EMPTY; } const persisted = loadLastViewedChatFromStorage(currentUser.id); if (!persisted || persisted.roomId !== currentRoom.id) { return EMPTY; } const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId); if (!channelId || channelId === activeChannelId) { return EMPTY; } return of(RoomsActions.selectChannel({ channelId })); }) ) ); refreshServerOwnedRoomMetadata$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), switchMap(({ room }) => { 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 ? of(serverData) : this.serverDirectory.findServerAcrossActiveEndpoints(room.id, source)) ) : this.serverDirectory.findServerAcrossActiveEndpoints(room.id, source); return roomRequest$.pipe( map((serverData) => { if (!serverData) { return null; } const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({ sourceId: serverData.sourceId ?? room.sourceId, sourceName: serverData.sourceName ?? room.sourceName, sourceUrl: serverData.sourceUrl ?? room.sourceUrl, fallbackName: serverData.sourceName ?? room.sourceName ?? room.name }, { ensureEndpoint: !!(serverData.sourceUrl ?? room.sourceUrl) }); return RoomsActions.updateRoom({ roomId: room.id, changes: { name: serverData.name, description: serverData.description, hostId: serverData.ownerId || room.hostId, hasPassword: !!serverData.hasPassword, isPrivate: serverData.isPrivate, maxUsers: serverData.maxUsers, icon: serverData.icon ?? room.icon, iconUpdatedAt: serverData.iconUpdatedAt ?? room.iconUpdatedAt, channels: resolveRoomChannels(room.channels, serverData.channels), slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval, roles: serverData.roles ?? room.roles, roleAssignments: serverData.roleAssignments ?? room.roleAssignments, channelPermissions: serverData.channelPermissions ?? room.channelPermissions, ...resolvedSource } }); }), filter((action): action is ReturnType => !!action), catchError(() => EMPTY) ); }) ) ); /** Switches the UI view to an already-joined server without leaving others. */ viewServer$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.viewServer), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)), switchMap(([ { room, skipBanCheck }, user, savedRooms ]) => { if (!user) { return of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.notLoggedIn') })); } const activateViewedRoom = () => { const oderId = user.oderId || this.webrtc.peerId(); const navigationRequestVersion = this.signalingConnection.beginRoomNavigation(room.id); void this.signalingConnection.connectToRoomSignaling(room, user, oderId, savedRooms, { showCompatibilityError: true, navigationRequestVersion }); window.setTimeout(() => { if (this.signalingConnection.isCurrentRoomNavigation(room.id, navigationRequestVersion)) { void this.router.navigate(['/room', room.id]); } }, 0); return of(RoomsActions.viewServerSuccess({ room })); }; if (skipBanCheck) { return activateViewedRoom(); } return from(this.getBlockedRoomAccessActions(room.id, user)).pipe( switchMap((blockedActions) => { if (blockedActions.length > 0) { return from(blockedActions); } return activateViewedRoom(); }), catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message }))) ); }) ) ); /** Reloads messages and bans when the viewed server changes. */ onViewServerSuccess$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.viewServerSuccess), switchMap(({ room }) => VIEW_SERVER_LOAD_DELAY_MS > 0 ? timer(VIEW_SERVER_LOAD_DELAY_MS).pipe( mergeMap(() => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()]) ) : of(MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans())) ) ); /** Handles leave-room dispatches (navigation only, peers stay connected). */ leaveRoom$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.leaveRoom), map(() => RoomsActions.leaveRoomSuccess()) ) ); /** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */ deleteRoom$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.deleteRoom), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)), filter( ([ , currentUser, currentRoom ]) => !!currentUser && currentRoom?.hostId === currentUser.id ), switchMap(([{ roomId }]) => { this.db.deleteRoom(roomId); this.signalingConnection.deleteRoomFallbackSource(roomId); this.webrtc.broadcastMessage({ type: 'room-deleted', roomId }); this.webrtc.disconnectAll(); return of(RoomsActions.deleteRoomSuccess({ roomId })); }) ) ); /** Leaves a server, optionally transfers ownership, and removes it locally. */ forgetRoom$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.forgetRoom), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), switchMap(([ { roomId, nextOwnerKey }, currentUser, currentRoom, savedRooms ]) => { const room = currentRoom?.id === roomId ? currentRoom : (savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null); const isRoomOwner = !!currentUser && !!room && (room.hostId === currentUser.id || room.hostId === currentUser.oderId); if (currentUser && room && isRoomOwner) { const nextOwner = nextOwnerKey ? (findRoomMember(room.members ?? [], nextOwnerKey) ?? null) : null; const updatedMembers = removeRoomMember( transferRoomOwnership( room.members ?? [], nextOwner, { id: room.hostId, oderId: currentUser.oderId } ), currentUser.id, currentUser.oderId ); const nextHostId = nextOwner?.id || nextOwner?.oderId || ''; const nextHostOderId = nextOwner?.oderId || ''; this.webrtc.broadcastMessage({ type: 'host-change', roomId, hostId: nextHostId, hostOderId: nextHostOderId, previousHostId: room.hostId, previousHostOderId: currentUser.oderId, members: updatedMembers }); this.serverDirectory.updateServer(roomId, { actingRole: 'host', ownerId: nextHostId, ownerPublicKey: nextHostOderId }, { sourceId: room.sourceId, sourceUrl: room.sourceUrl }).subscribe({ error: () => {} }); } if (currentUser) { this.webrtc.broadcastMessage({ type: 'member-leave', roomId, targetUserId: currentUser.id, oderId: currentUser.oderId, displayName: currentUser.displayName }); } if (currentUser && room) { this.serverDirectory.notifyLeave(roomId, currentUser.id, { sourceId: room.sourceId, sourceUrl: room.sourceUrl }).subscribe({ error: () => {} }); } // Delete from local DB this.db.deleteRoom(roomId); this.signalingConnection.deleteRoomFallbackSource(roomId); // Leave this specific server (doesn't affect other servers) this.webrtc.leaveRoom(roomId); return currentRoom?.id === roomId ? [RoomsActions.leaveRoomSuccess(), RoomsActions.forgetRoomSuccess({ roomId })] : of(RoomsActions.forgetRoomSuccess({ roomId })); }) ) ); /** Clears stale resume state when the remembered room is removed locally. */ clearLastViewedChatOnRoomRemoval$ = createEffect( () => this.actions$.pipe( ofType(RoomsActions.deleteRoomSuccess, RoomsActions.forgetRoomSuccess), tap(({ roomId }) => { const persisted = loadLastViewedChatFromStorage(); if (persisted?.roomId === roomId) { clearLastViewedChatFromStorage(); } }) ), { dispatch: false } ); /** Persists newly created room to the local database. */ persistRoomCreation$ = createEffect( () => this.actions$.pipe( ofType(RoomsActions.createRoomSuccess), tap(({ room }) => { this.db.saveRoom(room); }) ), { dispatch: false } ); /** Set the creator's role to 'host' after creating a room. */ setHostRoleOnCreate$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.createRoomSuccess), map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } })) ) ); /** Set the user's role to 'host' when rejoining a room they own. */ setHostRoleOnJoin$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.joinRoomSuccess), withLatestFrom(this.store.select(selectCurrentUser)), filter(([{ room }, user]) => !!user && !!room.hostId && room.hostId === user.id), map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } })) ) ); /** Loads messages and bans when joining a room. */ onJoinRoomSuccess$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.joinRoomSuccess), // Don't load users from database - they come from signaling server. mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()]) ) ); /** Clears viewed messages when leaving a room. */ onLeaveRoom$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.leaveRoomSuccess), mergeMap(() => [MessagesActions.clearMessages()]) ) ); /** 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 } ); // ── Private helpers ──────────────────────────────────────────── private async getBlockedRoomAccessActions( roomId: string, currentUser: { id: string; oderId: string } | null ): Promise { const bans = await this.db.getBansForRoom(roomId); if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) { return []; } const blockedActions: BlockedRoomAccessAction[] = [RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.banned') })]; const storedRoom = await this.db.getRoom(roomId); if (storedRoom) { blockedActions.unshift(RoomsActions.forgetRoom({ roomId })); } return blockedActions; } private getPersistedCurrentUserId(): string | null { return localStorage.getItem('metoyou_currentUserId'); } }