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
isolated users, db backup, weird disconnect issues for long voice sessions,
484 lines
14 KiB
TypeScript
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)
|
|
};
|
|
})
|
|
);
|