feat: Allow admin to create new text channels
This commit is contained in:
22
toju-app/src/app/store/rooms/room-channels.defaults.ts
Normal file
22
toju-app/src/app/store/rooms/room-channels.defaults.ts
Normal 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 }
|
||||
];
|
||||
}
|
||||
55
toju-app/src/app/store/rooms/room-channels.rules.ts
Normal file
55
toju-app/src/app/store/rooms/room-channels.rules.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user