feat: Allow admin to create new text channels

This commit is contained in:
2026-03-30 01:25:56 +02:00
parent 109402cdd6
commit 83694570e3
24 changed files with 563 additions and 64 deletions

View File

@@ -0,0 +1,22 @@
import { Channel } from '../../shared-kernel';
export function defaultChannels(): Channel[] {
return [
{ id: 'general',
name: 'general',
type: 'text',
position: 0 },
{ id: 'random',
name: 'random',
type: 'text',
position: 1 },
{ id: 'vc-general',
name: 'General',
type: 'voice',
position: 0 },
{ id: 'vc-afk',
name: 'AFK',
type: 'voice',
position: 1 }
];
}

View File

@@ -0,0 +1,55 @@
import { Channel } from '../../shared-kernel';
export function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(name: string): string {
return normalizeChannelName(name).toLocaleLowerCase();
}
export function isChannelNameTaken(
channels: Channel[],
name: string,
excludeChannelId?: string
): boolean {
const targetKey = channelNameKey(name);
if (!targetKey) {
return false;
}
return channels.some((channel) => channel.id !== excludeChannelId && channelNameKey(channel.name) === targetKey);
}
export function normalizeRoomChannels(channels: Channel[] | undefined): Channel[] | undefined {
if (!Array.isArray(channels)) {
return channels;
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
const normalized: Channel[] = [];
for (const [index, channel] of channels.entries()) {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = normalizeChannelName(channel.name);
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const nameKey = channelNameKey(name);
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
continue;
}
seenIds.add(id);
seenNames.add(nameKey);
normalized.push({
id,
name,
type,
position: typeof channel.position === 'number' ? channel.position : index
});
}
return normalized;
}

View File

@@ -8,7 +8,10 @@ import {
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
Action,
Store
} from '@ngrx/store';
import {
of,
from,
@@ -29,7 +32,11 @@ import { RoomsActions } from './rooms.actions';
import { UsersActions } from '../users/users.actions';
import { MessagesActions } from '../messages/messages.actions';
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import {
selectActiveChannelId,
selectCurrentRoom,
selectSavedRooms
} from './rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import {
@@ -54,6 +61,7 @@ import {
removeRoomMember,
transferRoomOwnership
} from './room-members.helpers';
import { defaultChannels } from './room-channels.defaults';
/** Build a minimal User object from signaling payload. */
function buildSignalingUser(
@@ -224,6 +232,7 @@ export class RoomsEffects {
createdAt: Date.now(),
userCount: 1,
maxUsers: 50,
channels: defaultChannels(),
sourceId: endpoint?.id,
sourceName: endpoint?.name,
sourceUrl: endpoint?.url
@@ -246,7 +255,8 @@ export class RoomsEffects {
isPrivate: room.isPrivate,
userCount: 1,
maxUsers: room.maxUsers || 50,
tags: []
tags: [],
channels: room.channels ?? defaultChannels()
}, endpoint ? {
sourceId: endpoint.id,
sourceUrl: endpoint.url
@@ -290,6 +300,7 @@ export class RoomsEffects {
const resolvedRoom: Room = {
...room,
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
channels: Array.isArray(serverInfo?.channels) ? serverInfo.channels : room.channels,
sourceId: serverInfo?.sourceId ?? room.sourceId,
sourceName: serverInfo?.sourceName ?? room.sourceName,
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
@@ -303,6 +314,7 @@ export class RoomsEffects {
sourceId: resolvedRoom.sourceId,
sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl,
channels: resolvedRoom.channels,
hasPassword: resolvedRoom.hasPassword,
isPrivate: resolvedRoom.isPrivate
});
@@ -322,6 +334,7 @@ export class RoomsEffects {
createdAt: Date.now(),
userCount: 1,
maxUsers: 50,
channels: Array.isArray(serverInfo.channels) ? serverInfo.channels : undefined,
sourceId: serverInfo.sourceId,
sourceName: serverInfo.sourceName,
sourceUrl: serverInfo.sourceUrl
@@ -346,6 +359,7 @@ export class RoomsEffects {
createdAt: serverData.createdAt || Date.now(),
userCount: serverData.userCount,
maxUsers: serverData.maxUsers,
channels: Array.isArray(serverData.channels) ? serverData.channels : undefined,
sourceId: serverData.sourceId,
sourceName: serverData.sourceName,
sourceUrl: serverData.sourceUrl
@@ -679,6 +693,50 @@ export class RoomsEffects {
{ dispatch: false }
);
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 = this.getUserRoleForRoom(currentRoom, currentUser, currentRoom);
if (!this.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(
@@ -953,14 +1011,16 @@ export class RoomsEffects {
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms),
this.store.select(selectAllUsers),
this.store.select(selectCurrentUser)
this.store.select(selectCurrentUser),
this.store.select(selectActiveChannelId)
),
mergeMap(([
event,
currentRoom,
savedRooms,
allUsers,
currentUser
currentUser,
activeChannelId
]) => {
switch (event.type) {
case 'voice-state':
@@ -975,6 +1035,8 @@ export class RoomsEffects {
return this.handleRoomSettingsUpdate(event, currentRoom, savedRooms);
case 'room-permissions-update':
return this.handleRoomPermissionsUpdate(event, currentRoom, savedRooms);
case 'channels-update':
return this.handleChannelsUpdate(event, currentRoom, savedRooms, activeChannelId);
case 'server-icon-summary':
return this.handleIconSummary(event, currentRoom, savedRooms);
case 'server-icon-request':
@@ -1261,6 +1323,37 @@ export class RoomsEffects {
);
}
private handleChannelsUpdate(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
activeChannelId: string
): Action[] {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const channels = Array.isArray(event.channels) ? event.channels : null;
if (!room || !channels) {
return [];
}
const actions: Action[] = [
RoomsActions.updateRoom({
roomId: room.id,
changes: { channels }
})
];
if (!channels.some((channel) => channel.id === activeChannelId)) {
const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id
?? 'general';
actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId }));
}
return actions;
}
private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
@@ -1540,6 +1633,29 @@ export class RoomsEffects {
|| null;
}
private canManageChannelsInRoom(
room: Room,
currentUser: User,
currentRoom: Room | null,
currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom)
): boolean {
if (currentUserRole === 'host') {
return true;
}
const permissions = room.permissions || {};
if (currentUserRole === 'admin' && permissions.adminsManageRooms) {
return true;
}
if (currentUserRole === 'moderator' && permissions.moderatorsManageRooms) {
return true;
}
return false;
}
private getPersistedCurrentUserId(): string | null {
return localStorage.getItem('metoyou_currentUserId');
}

View File

@@ -1,35 +1,18 @@
import { createReducer, on } from '@ngrx/store';
import {
Room,
RoomSettings,
Channel
RoomSettings
} from '../../shared-kernel';
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';
/** Default channels for a new server */
export function defaultChannels(): Channel[] {
return [
{ id: 'general',
name: 'general',
type: 'text',
position: 0 },
{ id: 'random',
name: 'random',
type: 'text',
position: 1 },
{ id: 'vc-general',
name: 'General',
type: 'voice',
position: 0 },
{ id: 'vc-afk',
name: 'AFK',
type: 'voice',
position: 1 }
];
}
/** Deduplicate rooms by id, keeping the last occurrence */
function deduplicateRooms(rooms: Room[]): Room[] {
const seen = new Map<string, Room>();
@@ -46,11 +29,23 @@ function enrichRoom(room: Room): Room {
return {
...room,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
channels: room.channels || defaultChannels(),
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);
@@ -169,7 +164,7 @@ export const roomsReducer = createReducer(
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
activeChannelId: getDefaultTextChannelId(enriched)
};
}),
@@ -198,7 +193,7 @@ export const roomsReducer = createReducer(
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
activeChannelId: getDefaultTextChannelId(enriched)
};
}),
@@ -242,7 +237,7 @@ export const roomsReducer = createReducer(
isConnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: 'general'
activeChannelId: getDefaultTextChannelId(enriched)
};
}),
@@ -317,7 +312,8 @@ export const roomsReducer = createReducer(
savedRooms: upsertRoom(state.savedRooms, room),
isSignalServerReconnecting: false,
signalServerCompatibilityError: null,
isConnected: true
isConnected: true,
activeChannelId: getDefaultTextChannelId(room)
})),
// Clear current room
@@ -375,7 +371,8 @@ export const roomsReducer = createReducer(
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId)
};
}),
@@ -412,14 +409,22 @@ export const roomsReducer = createReducer(
return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = [...existing, channel];
const normalizedName = normalizeChannelName(channel.name);
if (!normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName)) {
return state;
}
const updatedChannels = [...existing, { ...channel,
name: normalizedName }];
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId)
};
}),
@@ -436,7 +441,7 @@ export const roomsReducer = createReducer(
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.activeChannelId === channelId ? 'general' : state.activeChannelId
activeChannelId: resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId)
};
}),
@@ -445,8 +450,14 @@ export const roomsReducer = createReducer(
return state;
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(name);
if (!normalizedName || isChannelNameTaken(existing, normalizedName, channelId)) {
return state;
}
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
name } : channel);
name: normalizedName } : channel);
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };