376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
/* 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<Room> = {
|
|
...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<Room> = {
|
|
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),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
mergeMap(([
|
|
{ roomId, icon },
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
if (!currentUser) {
|
|
return of(RoomsActions.updateServerIconFailure({ error: 'Not logged in' }));
|
|
}
|
|
|
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!room) {
|
|
return of(RoomsActions.updateServerIconFailure({ error: 'Room not found' }));
|
|
}
|
|
|
|
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
|
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
|
|
|
|
if (!isOwner && !canByRole) {
|
|
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
|
}
|
|
|
|
const iconUpdatedAt = Date.now();
|
|
const changes: Partial<Room> = { icon,
|
|
iconUpdatedAt };
|
|
|
|
this.db.updateRoom(room.id, changes);
|
|
this.webrtc.broadcastMessage({
|
|
type: 'server-icon-update',
|
|
roomId: room.id,
|
|
icon,
|
|
iconUpdatedAt
|
|
});
|
|
|
|
this.webrtc.sendRawMessage({
|
|
type: 'server_icon_available',
|
|
serverId: room.id,
|
|
iconUpdatedAt
|
|
});
|
|
|
|
this.serverDirectory.updateServer(room.id, {
|
|
currentOwnerId: currentUser.id,
|
|
actingRole: isOwner ? 'host' : undefined,
|
|
icon,
|
|
iconUpdatedAt
|
|
}, {
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
}).subscribe({
|
|
error: () => {}
|
|
});
|
|
|
|
return of(RoomsActions.updateServerIconSuccess({ roomId: room.id,
|
|
icon,
|
|
iconUpdatedAt }));
|
|
})
|
|
)
|
|
);
|
|
}
|