diff --git a/server/src/cqrs/commands/handlers/upsertServer.ts b/server/src/cqrs/commands/handlers/upsertServer.ts index 7a8dfc9..f45d23d 100644 --- a/server/src/cqrs/commands/handlers/upsertServer.ts +++ b/server/src/cqrs/commands/handlers/upsertServer.ts @@ -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 }); diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts index 48e014b..12aaebf 100644 --- a/server/src/cqrs/mappers.ts +++ b/server/src/cqrs/mappers.ts @@ -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(); + const seenNames = new Set(); + + return parsed + .filter((channel): channel is Record => !!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 }; diff --git a/server/src/cqrs/types.ts b/server/src/cqrs/types.ts index 16be3f0..3087060 100644 --- a/server/src/cqrs/types.ts +++ b/server/src/cqrs/types.ts @@ -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; } diff --git a/server/src/entities/ServerEntity.ts b/server/src/entities/ServerEntity.ts index 8ea36cf..f978ab0 100644 --- a/server/src/entities/ServerEntity.ts +++ b/server/src/entities/ServerEntity.ts @@ -36,6 +36,9 @@ export class ServerEntity { @Column('text', { default: '[]' }) tags!: string; + @Column('text', { default: '[]' }) + channels!: string; + @Column('integer') createdAt!: number; diff --git a/server/src/migrations/1000000000000-InitialSchema.ts b/server/src/migrations/1000000000000-InitialSchema.ts index ef1d9c1..5b1eb25 100644 --- a/server/src/migrations/1000000000000-InitialSchema.ts +++ b/server/src/migrations/1000000000000-InitialSchema.ts @@ -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 ) diff --git a/server/src/migrations/1000000000002-ServerChannels.ts b/server/src/migrations/1000000000002-ServerChannels.ts new file mode 100644 index 0000000..fba903c --- /dev/null +++ b/server/src/migrations/1000000000002-ServerChannels.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ServerChannels1000000000002 implements MigrationInterface { + name = 'ServerChannels1000000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`); + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index e44856e..f2d0c53 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -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 ]; diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 8a6fbf0..6e1e944 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -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(); + const seenNames = new Set(); + 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() diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index aedb682..69b6cb7 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -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); diff --git a/toju-app/src/app/domains/chat/README.md b/toju-app/src/app/domains/chat/README.md index e8d7c3e..a7e941a 100644 --- a/toju-app/src/app/domains/chat/README.md +++ b/toju-app/src/app/domains/chat/README.md @@ -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". diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index f00397e..cc9c4f0 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -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 */ } diff --git a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts index be659aa..cc212f1 100644 --- a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts +++ b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts @@ -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(); + private readonly typingMap = new Map(); 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([]); typingOthersCount = signal(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)); diff --git a/toju-app/src/app/domains/server-directory/README.md b/toju-app/src/app/domains/server-directory/README.md index d750e57..6318689 100644 --- a/toju-app/src/app/domains/server-directory/README.md +++ b/toju-app/src/app/domains/server-directory/README.md @@ -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: diff --git a/toju-app/src/app/domains/server-directory/domain/server-directory.models.ts b/toju-app/src/app/domains/server-directory/domain/server-directory.models.ts index 312d6c5..4198a4d 100644 --- a/toju-app/src/app/domains/server-directory/domain/server-directory.models.ts +++ b/toju-app/src/app/domains/server-directory/domain/server-directory.models.ts @@ -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; diff --git a/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts index 75abfc4..68c5151 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts @@ -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 => !!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; } diff --git a/toju-app/src/app/features/room/chat-room/chat-room.component.html b/toju-app/src/app/features/room/chat-room/chat-room.component.html index f7f6b7c..b0370ba 100644 --- a/toju-app/src/app/features/room/chat-room/chat-room.component.html +++ b/toju-app/src/app/features/room/chat-room/chat-room.component.html @@ -23,12 +23,24 @@
-
- -
+ @if (!isVoiceWorkspaceExpanded()) { + @if (hasTextChannels()) { +
+ +
+ } @else { +
+
+ +

No text channels

+

There are no existing text channels currently.

+
+
+ } + }
diff --git a/toju-app/src/app/features/room/chat-room/chat-room.component.ts b/toju-app/src/app/features/room/chat-room/chat-room.component.ts index 3d737d2..403ab83 100644 --- a/toju-app/src/app/features/room/chat-room/chat-room.component.ts +++ b/toju-app/src/app/features/room/chat-room/chat-room.component.ts @@ -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; }); diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 4795511..f97658f 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -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()) { +

{{ channelNameError() }}

+ } } diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index ec8ccfb..8e3eb9b 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -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(null); renamingChannelId = signal(null); + channelNameError = signal(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(); diff --git a/toju-app/src/app/infrastructure/persistence/README.md b/toju-app/src/app/infrastructure/persistence/README.md index 9b46199..84f5500 100644 --- a/toju-app/src/app/infrastructure/persistence/README.md +++ b/toju-app/src/app/infrastructure/persistence/README.md @@ -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) diff --git a/toju-app/src/app/store/rooms/room-channels.defaults.ts b/toju-app/src/app/store/rooms/room-channels.defaults.ts new file mode 100644 index 0000000..0665011 --- /dev/null +++ b/toju-app/src/app/store/rooms/room-channels.defaults.ts @@ -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 } + ]; +} diff --git a/toju-app/src/app/store/rooms/room-channels.rules.ts b/toju-app/src/app/store/rooms/room-channels.rules.ts new file mode 100644 index 0000000..9bfda25 --- /dev/null +++ b/toju-app/src/app/store/rooms/room-channels.rules.ts @@ -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(); + const seenNames = new Set(); + 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; +} diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index 22def1c..313c76f 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -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'); } diff --git a/toju-app/src/app/store/rooms/rooms.reducer.ts b/toju-app/src/app/store/rooms/rooms.reducer.ts index e628094..b28aefa 100644 --- a/toju-app/src/app/store/rooms/rooms.reducer.ts +++ b/toju-app/src/app/store/rooms/rooms.reducer.ts @@ -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(); @@ -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 };