Files
Toju/toju-app/src/app/store/rooms/rooms.reducer.ts
Myx 1656b8a17f
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 6m54s
Queue Release Build / build-windows (push) Successful in 16m6s
Queue Release Build / build-linux (push) Successful in 30m58s
Queue Release Build / finalize (push) Successful in 44s
fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-24 22:19:57 +02:00

484 lines
14 KiB
TypeScript

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<string, Room>();
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)
};
})
);