import { createReducer, on } from '@ngrx/store'; import { Room, RoomSettings } from '../../shared-kernel'; import { normalizeRoomAccessControl } from '../../domains/access-control'; import { type ServerInfo } from '../../domains/server-directory'; import { RoomsActions } from './rooms.actions'; import { defaultChannels } from './room-channels.defaults'; import { isChannelNameTaken, normalizeChannelName, normalizeRoomChannels } from './room-channels.rules'; import { pruneRoomMembers } from './room-members.helpers'; /** Deduplicate rooms by id, keeping the last occurrence */ function deduplicateRooms(rooms: Room[]): Room[] { const seen = new Map(); for (const room of rooms) { seen.set(room.id, room); } return Array.from(seen.values()); } /** Normalize room defaults and prune any stale persisted member entries. */ function enrichRoom(room: Room): Room { return normalizeRoomAccessControl({ ...room, hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password, channels: normalizeRoomChannels(room.channels) || defaultChannels(), members: pruneRoomMembers(room.members || []) }); } function resolveActiveTextChannelId(channels: Room['channels'], currentActiveChannelId: string): string { const textChannels = (channels ?? []).filter((channel) => channel.type === 'text'); return textChannels.some((channel) => channel.id === currentActiveChannelId) ? currentActiveChannelId : (textChannels[0]?.id ?? 'general'); } function getDefaultTextChannelId(room: Room): string { return resolveActiveTextChannelId(enrichRoom(room).channels, 'general'); } /** Upsert a room into a saved-rooms list (add or replace by id) */ function upsertRoom(savedRooms: Room[], room: Room): Room[] { const normalizedRoom = enrichRoom(room); const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id); if (idx >= 0) { const updated = [...savedRooms]; updated[idx] = normalizedRoom; return updated; } return [...savedRooms, normalizedRoom]; } /** State shape for the rooms feature slice. */ export interface RoomsState { /** The room the user is currently viewing. */ currentRoom: Room | null; /** All rooms persisted locally (joined or created). */ savedRooms: Room[]; /** Editable settings for the current room. */ roomSettings: RoomSettings | null; /** Results returned from the server directory search. */ searchResults: ServerInfo[]; /** Whether a server directory search is in progress. */ isSearching: boolean; /** Whether a connection to a room is being established. */ isConnecting: boolean; /** Whether the user is connected to a room. */ isConnected: boolean; /** Whether the current room is using locally cached data while reconnecting. */ isSignalServerReconnecting: boolean; /** Banner message when the viewed room's signaling endpoint is incompatible. */ signalServerCompatibilityError: string | null; /** Whether rooms are being loaded from local storage. */ loading: boolean; /** Most recent error message, if any. */ error: string | null; /** ID of the currently selected text channel. */ activeChannelId: string; } export const initialState: RoomsState = { currentRoom: null, savedRooms: [], roomSettings: null, searchResults: [], isSearching: false, isConnecting: false, isConnected: false, isSignalServerReconnecting: false, signalServerCompatibilityError: null, loading: false, error: null, activeChannelId: 'general' }; export const roomsReducer = createReducer( initialState, on(RoomsActions.resetRoomsState, () => ({ ...initialState })), // Load rooms on(RoomsActions.loadRooms, (state) => ({ ...state, loading: true, error: null })), on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({ ...state, savedRooms: deduplicateRooms(rooms.map(enrichRoom)), loading: false })), on(RoomsActions.loadRoomsFailure, (state, { error }) => ({ ...state, loading: false, error })), // Search servers on(RoomsActions.searchServers, (state) => ({ ...state, isSearching: true, error: null })), on(RoomsActions.searchServersSuccess, (state, { servers }) => ({ ...state, searchResults: servers, isSearching: false })), on(RoomsActions.searchServersFailure, (state, { error }) => ({ ...state, isSearching: false, error })), // Create room on(RoomsActions.createRoom, (state) => ({ ...state, isConnecting: true, signalServerCompatibilityError: null, error: null })), on(RoomsActions.createRoomSuccess, (state, { room }) => { const enriched = enrichRoom(room); return { ...state, currentRoom: enriched, savedRooms: upsertRoom(state.savedRooms, enriched), isConnecting: false, isSignalServerReconnecting: false, signalServerCompatibilityError: null, isConnected: true, activeChannelId: getDefaultTextChannelId(enriched) }; }), on(RoomsActions.createRoomFailure, (state, { error }) => ({ ...state, isConnecting: false, error })), // Join room on(RoomsActions.joinRoom, (state) => ({ ...state, isConnecting: true, signalServerCompatibilityError: null, error: null })), on(RoomsActions.joinRoomSuccess, (state, { room }) => { const enriched = enrichRoom(room); return { ...state, currentRoom: enriched, savedRooms: upsertRoom(state.savedRooms, enriched), isConnecting: false, isSignalServerReconnecting: false, signalServerCompatibilityError: null, isConnected: true, activeChannelId: getDefaultTextChannelId(enriched) }; }), on(RoomsActions.joinRoomFailure, (state, { error }) => ({ ...state, isConnecting: false, error })), // Leave room on(RoomsActions.leaveRoom, (state) => ({ ...state, isConnecting: true })), on(RoomsActions.leaveRoomSuccess, (state) => ({ ...state, currentRoom: null, roomSettings: null, isSignalServerReconnecting: false, signalServerCompatibilityError: null, isConnecting: false, isConnected: false })), // View server - just switch the viewed room, stay connected on(RoomsActions.viewServer, (state) => ({ ...state, isConnecting: true, signalServerCompatibilityError: null, error: null })), on(RoomsActions.viewServerSuccess, (state, { room }) => { const enriched = enrichRoom(room); return { ...state, currentRoom: enriched, savedRooms: upsertRoom(state.savedRooms, enriched), isConnecting: false, signalServerCompatibilityError: null, isConnected: true, activeChannelId: getDefaultTextChannelId(enriched) }; }), // Update room settings on(RoomsActions.updateRoomSettings, (state) => ({ ...state, error: null })), on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => { const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null); if (!baseRoom) { return { ...state, roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings }; } const updatedRoom = enrichRoom({ ...baseRoom, name: settings.name, description: settings.description, topic: settings.topic, isPrivate: settings.isPrivate, password: settings.password === '' ? undefined : (settings.password ?? baseRoom.password), hasPassword: typeof settings.hasPassword === 'boolean' ? settings.hasPassword : (typeof settings.password === 'string' ? settings.password.trim().length > 0 : baseRoom.hasPassword), maxUsers: settings.maxUsers }); return { ...state, roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings, currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom) }; }), on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({ ...state, error })), // Delete room on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({ ...state, isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting, signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError, savedRooms: state.savedRooms.filter((room) => room.id !== roomId), currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom })), // Forget room (local only) on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({ ...state, isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting, signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError, savedRooms: state.savedRooms.filter((room) => room.id !== roomId), currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom })), // Set current room on(RoomsActions.setCurrentRoom, (state, { room }) => ({ ...state, currentRoom: enrichRoom(room), savedRooms: upsertRoom(state.savedRooms, room), isSignalServerReconnecting: false, signalServerCompatibilityError: null, isConnected: true, activeChannelId: getDefaultTextChannelId(room) })), // Clear current room on(RoomsActions.clearCurrentRoom, (state) => ({ ...state, currentRoom: null, roomSettings: null, isSignalServerReconnecting: false, signalServerCompatibilityError: null, isConnected: false })), // Update room on(RoomsActions.updateRoom, (state, { roomId, changes }) => { const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null); if (!baseRoom) return state; const updatedRoom = enrichRoom({ ...baseRoom, ...changes }); return { ...state, currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom), activeChannelId: state.currentRoom?.id === roomId ? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) : state.activeChannelId }; }), // Update server icon success on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => { if (state.currentRoom?.id !== roomId) return state; const updatedRoom = enrichRoom({ ...state.currentRoom, icon, iconUpdatedAt }); return { ...state, currentRoom: updatedRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom) }; }), // Receive room update on(RoomsActions.receiveRoomUpdate, (state, { room }) => { if (!state.currentRoom) return state; const updatedRoom = enrichRoom({ ...state.currentRoom, ...room }); return { ...state, currentRoom: updatedRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom), activeChannelId: resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) }; }), // Clear search results on(RoomsActions.clearSearchResults, (state) => ({ ...state, searchResults: [] })), // Set connecting on(RoomsActions.setConnecting, (state, { isConnecting }) => ({ ...state, isConnecting })), on(RoomsActions.setSignalServerReconnecting, (state, { isReconnecting }) => ({ ...state, isSignalServerReconnecting: isReconnecting })), on(RoomsActions.setSignalServerCompatibilityError, (state, { message }) => ({ ...state, signalServerCompatibilityError: message })), // Channel management on(RoomsActions.selectChannel, (state, { channelId }) => ({ ...state, activeChannelId: channelId })), on(RoomsActions.addChannel, (state, { channel }) => { if (!state.currentRoom) return state; const existing = state.currentRoom.channels || defaultChannels(); const normalizedName = normalizeChannelName(channel.name); if ( !normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName, channel.type) ) { return state; } const updatedChannels = [ ...existing, { ...channel, name: normalizedName } ]; const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; return { ...state, currentRoom: updatedRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom), activeChannelId: resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) }; }), on(RoomsActions.removeChannel, (state, { channelId }) => { if (!state.currentRoom) return state; const existing = state.currentRoom.channels || defaultChannels(); const updatedChannels = existing.filter(channel => channel.id !== channelId); const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; return { ...state, currentRoom: updatedRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom), activeChannelId: resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) }; }), on(RoomsActions.renameChannel, (state, { channelId, name }) => { if (!state.currentRoom) return state; const existing = state.currentRoom.channels || defaultChannels(); const normalizedName = normalizeChannelName(name); const existingChannel = existing.find((channel) => channel.id === channelId); if (!normalizedName || !existingChannel || isChannelNameTaken(existing, normalizedName, existingChannel.type, channelId)) { return state; } const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name: normalizedName } : channel); const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; return { ...state, currentRoom: updatedRoom, savedRooms: upsertRoom(state.savedRooms, updatedRoom) }; }) );