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

@@ -16,6 +16,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
tags: JSON.stringify(server.tags),
channels: JSON.stringify(server.channels ?? []),
createdAt: server.createdAt,
lastSeen: server.lastSeen
});

View File

@@ -3,10 +3,63 @@ import { ServerEntity } from '../entities/ServerEntity';
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
import {
AuthUserPayload,
ServerChannelPayload,
ServerPayload,
JoinRequestPayload
} from './types';
function parseStringArray(raw: string | null | undefined): string[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === 'string')
: [];
} catch {
return [];
}
}
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = name.toLocaleLowerCase();
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies ServerChannelPayload;
})
.filter((channel): channel is ServerChannelPayload => !!channel);
} catch {
return [];
}
}
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return {
id: row.id,
@@ -29,7 +82,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,
tags: JSON.parse(row.tags || '[]'),
tags: parseStringArray(row.tags),
channels: parseServerChannels(row.channels),
createdAt: row.createdAt,
lastSeen: row.lastSeen
};

View File

@@ -28,6 +28,15 @@ export interface AuthUserPayload {
createdAt: number;
}
export type ServerChannelType = 'text' | 'voice';
export interface ServerChannelPayload {
id: string;
name: string;
type: ServerChannelType;
position: number;
}
export interface ServerPayload {
id: string;
name: string;
@@ -40,6 +49,7 @@ export interface ServerPayload {
maxUsers: number;
currentUsers: number;
tags: string[];
channels: ServerChannelPayload[];
createdAt: number;
lastSeen: number;
}

View File

@@ -36,6 +36,9 @@ export class ServerEntity {
@Column('text', { default: '[]' })
tags!: string;
@Column('text', { default: '[]' })
channels!: string;
@Column('integer')
createdAt!: number;

View File

@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT NOT NULL DEFAULT '[]',
"channels" TEXT NOT NULL DEFAULT '[]',
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL
)

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerChannels1000000000002 implements MigrationInterface {
name = 'ServerChannels1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
}
}

View File

@@ -1,7 +1,9 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
export const serverMigrations = [
InitialSchema1000000000000,
ServerAccessControl1000000000001
ServerAccessControl1000000000001,
ServerChannels1000000000002
];

View File

@@ -1,6 +1,9 @@
import { Response, Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { ServerPayload } from '../cqrs/types';
import {
ServerChannelPayload,
ServerPayload
} from '../cqrs/types';
import {
getAllPublicServers,
getServerById,
@@ -38,6 +41,43 @@ function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
return !!role && allowedRoles.includes(role);
}
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const seenNames = new Set<string>();
const channels: ServerChannelPayload[] = [];
for (const [index, channel] of value.entries()) {
if (!channel || typeof channel !== 'object') {
continue;
}
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = name.toLocaleLowerCase();
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
continue;
}
seen.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
const owner = await getUserById(server.ownerId);
const { passwordHash, ...publicServer } = server;
@@ -124,7 +164,8 @@ router.post('/', async (req, res) => {
isPrivate,
maxUsers,
password,
tags
tags,
channels
} = req.body;
if (!name || !ownerId || !ownerPublicKey)
@@ -143,6 +184,7 @@ router.post('/', async (req, res) => {
maxUsers: maxUsers ?? 0,
currentUsers: 0,
tags: tags ?? [],
channels: normalizeServerChannels(channels),
createdAt: Date.now(),
lastSeen: Date.now()
};
@@ -161,6 +203,7 @@ router.put('/:id', async (req, res) => {
password,
hasPassword: _ignoredHasPassword,
passwordHash: _ignoredPasswordHash,
channels,
...updates
} = req.body;
const existing = await getServerById(id);
@@ -178,10 +221,12 @@ router.put('/:id', async (req, res) => {
}
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
const server: ServerPayload = {
...existing,
...updates,
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
hasPassword: !!nextPasswordHash,
passwordHash: nextPasswordHash,
lastSeen: Date.now()

View File

@@ -134,11 +134,15 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
? message['channelId'].trim()
: 'general';
if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, {
type: 'user_typing',
serverId: typingSid,
channelId,
oderId: user.oderId,
displayName: user.displayName
}, user.oderId);

View File

@@ -90,6 +90,12 @@ sequenceDiagram
User->>DC: broadcastMessage(delete-message)
```
## Text channel scoping
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server.
If a room has no text channels, the room shell in `features/room/chat-room/` renders an empty state instead of mounting the chat view. The chat domain only mounts once a valid text channel exists.
## Message sync
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
@@ -140,4 +146,4 @@ graph LR
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -108,8 +108,18 @@ export class ChatMessagesComponent {
}
handleTypingStarted(): void {
const roomId = this.currentRoom()?.id;
if (!roomId) {
return;
}
try {
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
this.webrtc.sendRawMessage({
type: 'typing',
serverId: roomId,
channelId: this.activeChannelId() ?? 'general'
});
} catch {
/* ignore */
}

View File

@@ -9,7 +9,10 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import {
selectActiveChannelId,
selectCurrentRoom
} from '../../../../store/rooms/rooms.selectors';
import {
merge,
interval,
@@ -27,6 +30,7 @@ interface TypingSignalingMessage {
displayName: string;
oderId: string;
serverId: string;
channelId?: string;
}
@Component({
@@ -39,10 +43,12 @@ interface TypingSignalingMessage {
}
})
export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
private readonly typingMap = new Map<string, { name: string; channelId: string; expiresAt: number }>();
private readonly store = inject(Store);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private lastRoomId: string | null = null;
private lastConversationKey: string | null = null;
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
@@ -60,9 +66,13 @@ export class TypingIndicatorComponent {
filter((msg) => msg.serverId === this.currentRoom()?.id),
tap((msg) => {
const now = Date.now();
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim()
? msg.channelId.trim()
: 'general';
this.typingMap.set(msg.oderId, {
this.typingMap.set(`${channelId}:${msg.oderId}`, {
name: msg.displayName,
channelId,
expiresAt: now + TYPING_TTL
});
})
@@ -89,20 +99,27 @@ export class TypingIndicatorComponent {
effect(() => {
const roomId = this.currentRoom()?.id ?? null;
const activeChannelId = this.activeChannelId() ?? 'general';
const conversationKey = roomId ? `${roomId}:${activeChannelId}` : null;
if (roomId === this.lastRoomId)
if (roomId !== this.lastRoomId) {
this.lastRoomId = roomId;
this.typingMap.clear();
}
if (conversationKey === this.lastConversationKey)
return;
this.lastRoomId = roomId;
this.typingMap.clear();
this.lastConversationKey = conversationKey;
this.recomputeDisplay();
});
}
private recomputeDisplay(): void {
const now = Date.now();
const activeChannelId = this.activeChannelId() ?? 'general';
const names = Array.from(this.typingMap.values())
.filter((e) => e.expiresAt > now)
.filter((entry) => entry.expiresAt > now && entry.channelId === activeChannelId)
.map((e) => e.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN));

View File

@@ -132,6 +132,12 @@ The facade's `searchServers(query)` method supports two modes controlled by a `s
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
## Server-owned room metadata
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
The renderer may cache room data locally, but channel creation, rename, and removal must round-trip through the server-directory API instead of being treated as client-only state. Server-side normalisation also deduplicates channel names before persistence.
## Default endpoint management
Default servers are configured in the environment file. The state service builds `DefaultEndpointTemplate` objects from the configuration and uses them during reconciliation:

View File

@@ -1,3 +1,5 @@
import type { Channel } from '../../../shared-kernel';
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
export interface ServerInfo {
@@ -14,6 +16,7 @@ export interface ServerInfo {
hasPassword?: boolean;
isPrivate: boolean;
tags?: string[];
channels?: Channel[];
createdAt: number;
sourceId?: string;
sourceName?: string;

View File

@@ -8,7 +8,10 @@ import {
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { User } from '../../../shared-kernel';
import {
type Channel,
User
} from '../../../shared-kernel';
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
import type {
BanServerMemberRequest,
@@ -382,6 +385,7 @@ export class ServerDirectoryApiService {
hasPassword: this.getBooleanValue(candidate['hasPassword']),
isPrivate: this.getBooleanValue(candidate['isPrivate']),
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
channels: this.getChannelsValue(candidate['channels']),
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
sourceName: sourceName ?? source?.name,
@@ -399,6 +403,37 @@ export class ServerDirectoryApiService {
return typeof value === 'number' ? value : fallback;
}
private getChannelsValue(value: unknown): Channel[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
return value
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = this.getStringValue(channel['id']);
const name = this.getStringValue(channel['name']);
const type = this.getChannelTypeValue(channel['type']);
const position = this.getNumberValue(channel['position'], index);
if (!id || !name || !type) {
return null;
}
return {
id,
name,
type,
position
} satisfies Channel;
})
.filter((channel): channel is Channel => !!channel);
}
private getChannelTypeValue(value: unknown): Channel['type'] | undefined {
return value === 'text' || value === 'voice' ? value : undefined;
}
private getStringValue(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

View File

@@ -23,12 +23,24 @@
<div class="flex-1 flex overflow-hidden">
<!-- Chat Area -->
<main class="relative flex-1 min-w-0">
<div
class="h-full overflow-hidden"
[class.hidden]="isVoiceWorkspaceExpanded()"
>
<app-chat-messages />
</div>
@if (!isVoiceWorkspaceExpanded()) {
@if (hasTextChannels()) {
<div class="h-full overflow-hidden">
<app-chat-messages />
</div>
} @else {
<div class="flex h-full items-center justify-center px-6">
<div class="max-w-md text-center text-muted-foreground">
<ng-icon
name="lucideHash"
class="mx-auto mb-4 h-16 w-16 opacity-30"
/>
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
<p class="text-sm">There are no existing text channels currently.</p>
</div>
</div>
}
}
<app-screen-share-workspace />
</main>

View File

@@ -72,10 +72,17 @@ export class ChatRoomComponent {
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
hasTextChannels = computed(() => this.textChannels().length > 0);
activeTextChannelName = computed(() => {
const textChannels = this.textChannels();
if (textChannels.length === 0) {
return 'No text channels';
}
const id = this.activeChannelId();
const activeChannel = this.textChannels().find((channel) => channel.id === id);
const activeChannel = textChannels.find((channel) => channel.id === id) ?? textChannels[0];
return activeChannel ? activeChannel.name : id;
});

View File

@@ -77,9 +77,12 @@
#renameInput
type="text"
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ?? '') : ''"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
(input)="clearChannelNameError()"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
@@ -132,9 +135,12 @@
#renameInput
type="text"
[value]="ch.name"
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
[title]="renamingChannelId() === ch.id ? (channelNameError() ?? '') : ''"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
(input)="clearChannelNameError()"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
@@ -483,7 +489,12 @@
[(ngModel)]="newChannelName"
placeholder="Channel name"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
[class.border-destructive]="!!channelNameError()"
(ngModelChange)="clearChannelNameError()"
(keydown.enter)="confirmCreateChannel()"
/>
@if (channelNameError()) {
<p class="mt-2 text-sm text-destructive">{{ channelNameError() }}</p>
}
</app-confirm-dialog>
}

View File

@@ -40,6 +40,10 @@ import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/vo
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import {
isChannelNameTaken,
normalizeChannelName
} from '../../../store/rooms/room-channels.rules';
import {
ContextMenuComponent,
UserAvatarComponent,
@@ -152,6 +156,7 @@ export class RoomsSidePanelComponent {
contextChannel = signal<Channel | null>(null);
renamingChannelId = signal<string | null>(null);
channelNameError = signal<string | null>(null);
showCreateChannelDialog = signal(false);
createChannelType = signal<'text' | 'voice'>('text');
@@ -243,6 +248,7 @@ export class RoomsSidePanelComponent {
const ch = this.contextChannel();
this.closeChannelMenu();
this.channelNameError.set(null);
if (ch) {
this.renamingChannelId.set(ch.id);
@@ -251,10 +257,29 @@ export class RoomsSidePanelComponent {
confirmRename(event: Event) {
const input = event.target as HTMLInputElement;
const name = input.value.trim();
const name = normalizeChannelName(input.value);
const channelId = this.renamingChannelId();
if (channelId && name) {
if (!channelId) {
return;
}
const validationError = this.getChannelNameError(name, channelId);
if (validationError) {
this.channelNameError.set(validationError);
requestAnimationFrame(() => {
input.focus();
input.select();
});
return;
}
this.channelNameError.set(null);
const currentName = this.currentRoom()?.channels?.find((channel) => channel.id === channelId)?.name;
if (currentName !== name) {
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
}
@@ -262,6 +287,7 @@ export class RoomsSidePanelComponent {
}
cancelRename() {
this.channelNameError.set(null);
this.renamingChannelId.set(null);
}
@@ -300,14 +326,19 @@ export class RoomsSidePanelComponent {
createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type);
this.newChannelName = '';
this.channelNameError.set(null);
this.showCreateChannelDialog.set(true);
}
confirmCreateChannel() {
const name = this.newChannelName.trim();
const name = normalizeChannelName(this.newChannelName);
if (!name)
const validationError = this.getChannelNameError(name);
if (validationError) {
this.channelNameError.set(validationError);
return;
}
const type = this.createChannelType();
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
@@ -319,13 +350,35 @@ export class RoomsSidePanelComponent {
};
this.store.dispatch(RoomsActions.addChannel({ channel }));
this.channelNameError.set(null);
this.showCreateChannelDialog.set(false);
}
cancelCreateChannel() {
this.channelNameError.set(null);
this.showCreateChannelDialog.set(false);
}
clearChannelNameError(): void {
if (this.channelNameError()) {
this.channelNameError.set(null);
}
}
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
if (!name) {
return 'Channel name is required.';
}
const channels = this.currentRoom()?.channels ?? [];
if (isChannelNameTaken(channels, name, excludeChannelId)) {
return 'Channel names must be unique in a server.';
}
return null;
}
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();

View File

@@ -46,6 +46,8 @@ Both backends store the same entity types:
The IndexedDB schema is at version 2.
The persisted `rooms` store is a local cache of room metadata. Channel topology is still server-owned metadata: after room create, join, view, or channel-management changes, the renderer should hydrate the authoritative channel list from server-directory responses so every member converges on the same room structure.
## How the two backends differ
### Browser (IndexedDB)

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 };