From 727059fb520b89db501003ce437eff2864b97173 Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 30 Mar 2026 02:11:39 +0200 Subject: [PATCH] Add seperation of voice channels, creation of new ones, and move around users --- server/src/cqrs/mappers.ts | 6 +- ...1000000000003-RepairLegacyVoiceChannels.ts | 119 ++++++++++++++++ server/src/migrations/index.ts | 4 +- server/src/routes/servers.ts | 6 +- toju-app/src/app/domains/chat/README.md | 2 +- .../app/domains/server-directory/README.md | 2 +- .../application/voice-connection.facade.ts | 4 + .../application/voice-playback.service.ts | 71 +++++++++- .../src/app/domains/voice-session/README.md | 4 + .../rooms-side-panel.component.html | 15 +- .../rooms-side-panel.component.ts | 120 +++++++++++++++- .../app/infrastructure/persistence/README.md | 2 +- .../src/app/infrastructure/realtime/README.md | 6 +- .../realtime/media/media.manager.ts | 126 +++++++++++++---- .../realtime/realtime-session.service.ts | 6 + toju-app/src/app/shared-kernel/chat-events.ts | 8 ++ .../app/store/rooms/room-channels.rules.ts | 18 ++- toju-app/src/app/store/rooms/rooms.effects.ts | 131 ++++++++++++++++-- toju-app/src/app/store/rooms/rooms.reducer.ts | 14 +- 19 files changed, 614 insertions(+), 50 deletions(-) create mode 100644 server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts index 12aaebf..78bae3a 100644 --- a/server/src/cqrs/mappers.ts +++ b/server/src/cqrs/mappers.ts @@ -8,6 +8,10 @@ import { JoinRequestPayload } from './types'; +function channelNameKey(type: ServerChannelPayload['type'], name: string): string { + return `${type}:${name.toLocaleLowerCase()}`; +} + function parseStringArray(raw: string | null | undefined): string[] { try { const parsed = JSON.parse(raw || '[]'); @@ -38,7 +42,7 @@ function parseServerChannels(raw: string | null | undefined): ServerChannelPaylo 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(); + const nameKey = type ? channelNameKey(type, name) : ''; if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) { return null; diff --git a/server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts b/server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts new file mode 100644 index 0000000..fc849e5 --- /dev/null +++ b/server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts @@ -0,0 +1,119 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +interface LegacyServerRow { + id: string; + channels: string | null; +} + +interface LegacyServerChannel { + id: string; + name: string; + type: 'text' | 'voice'; + position: number; +} + +function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] { + 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 = type ? `${type}:${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 LegacyServerChannel; + }) + .filter((channel): channel is LegacyServerChannel => !!channel); + } catch { + return []; + } +} + +function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean { + const hasTextGeneral = channels.some( + (channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general') + ); + const hasVoiceAfk = channels.some( + (channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk') + ); + const hasVoiceGeneral = channels.some( + (channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general') + ); + + return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral; +} + +function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] { + if (!shouldRestoreLegacyVoiceGeneral(channels)) { + return channels; + } + + const textChannels = channels.filter((channel) => channel.type === 'text'); + const voiceChannels = channels.filter((channel) => channel.type === 'voice'); + const repairedVoiceChannels = [ + { + id: 'vc-general', + name: 'General', + type: 'voice' as const, + position: 0 + }, + ...voiceChannels + ].map((channel, index) => ({ + ...channel, + position: index + })); + + return [ + ...textChannels, + ...repairedVoiceChannels + ]; +} + +export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface { + name = 'RepairLegacyVoiceChannels1000000000003'; + + public async up(queryRunner: QueryRunner): Promise { + const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[]; + + for (const row of rows) { + const channels = normalizeLegacyChannels(row.channels); + const repaired = repairLegacyVoiceChannels(channels); + + if (JSON.stringify(repaired) === JSON.stringify(channels)) { + continue; + } + + await queryRunner.query( + `UPDATE "servers" SET "channels" = ? WHERE "id" = ?`, + [JSON.stringify(repaired), row.id] + ); + } + } + + public async down(_queryRunner: QueryRunner): Promise { + // Forward-only data repair migration. + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index f2d0c53..4614762 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -1,9 +1,11 @@ import { InitialSchema1000000000000 } from './1000000000000-InitialSchema'; import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl'; import { ServerChannels1000000000002 } from './1000000000002-ServerChannels'; +import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels'; export const serverMigrations = [ InitialSchema1000000000000, ServerAccessControl1000000000001, - ServerChannels1000000000002 + ServerChannels1000000000002, + RepairLegacyVoiceChannels1000000000003 ]; diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 6e1e944..ced8cf6 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -37,6 +37,10 @@ function normalizeRole(role: unknown): string | null { return typeof role === 'string' ? role.trim().toLowerCase() : null; } +function channelNameKey(type: ServerChannelPayload['type'], name: string): string { + return `${type}:${name.toLocaleLowerCase()}`; +} + function isAllowedRole(role: string | null, allowedRoles: string[]): boolean { return !!role && allowedRoles.includes(role); } @@ -59,7 +63,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] { 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(); + const nameKey = type ? channelNameKey(type, name) : ''; if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) { continue; diff --git a/toju-app/src/app/domains/chat/README.md b/toju-app/src/app/domains/chat/README.md index a7e941a..1d1d413 100644 --- a/toju-app/src/app/domains/chat/README.md +++ b/toju-app/src/app/domains/chat/README.md @@ -92,7 +92,7 @@ sequenceDiagram ## 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. +`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. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing. 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. diff --git a/toju-app/src/app/domains/server-directory/README.md b/toju-app/src/app/domains/server-directory/README.md index 6318689..d023ec0 100644 --- a/toju-app/src/app/domains/server-directory/README.md +++ b/toju-app/src/app/domains/server-directory/README.md @@ -136,7 +136,7 @@ The API service normalises every `ServerInfo` response, filling in `sourceId`, ` `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. +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 deduplicates channel names within each channel type, so a text `general` channel and a voice `General` channel can coexist while duplicate voice-to-voice or text-to-text names are still rejected. ## Default endpoint management diff --git a/toju-app/src/app/domains/voice-connection/application/voice-connection.facade.ts b/toju-app/src/app/domains/voice-connection/application/voice-connection.facade.ts index 396f87c..4e6de13 100644 --- a/toju-app/src/app/domains/voice-connection/application/voice-connection.facade.ts +++ b/toju-app/src/app/domains/voice-connection/application/voice-connection.facade.ts @@ -91,4 +91,8 @@ export class VoiceConnectionFacade { stopVoiceHeartbeat(): void { this.realtime.stopVoiceHeartbeat(); } + + syncOutgoingVoiceRouting(allowedPeerIds: string[]): void { + this.realtime.syncOutgoingVoiceRouting(allowedPeerIds); + } } diff --git a/toju-app/src/app/domains/voice-connection/application/voice-playback.service.ts b/toju-app/src/app/domains/voice-connection/application/voice-playback.service.ts index d40fba3..ebbdc9d 100644 --- a/toju-app/src/app/domains/voice-connection/application/voice-playback.service.ts +++ b/toju-app/src/app/domains/voice-connection/application/voice-playback.service.ts @@ -3,8 +3,14 @@ import { effect, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants'; import { ScreenShareFacade } from '../../../domains/screen-share'; +import { User } from '../../../shared-kernel'; +import { + selectAllUsers, + selectCurrentUser +} from '../../../store/users/users.selectors'; import { VoiceConnectionFacade } from './voice-connection.facade'; export interface PlaybackOptions { @@ -34,8 +40,11 @@ interface PeerAudioPipeline { @Injectable({ providedIn: 'root' }) export class VoicePlaybackService { + private readonly store = inject(Store); private readonly voiceConnection = inject(VoiceConnectionFacade); private readonly screenShare = inject(ScreenShareFacade); + private readonly allUsers = this.store.selectSignal(selectAllUsers); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); private peerPipelines = new Map(); private pendingRemoteStreams = new Map(); @@ -64,6 +73,11 @@ export class VoicePlaybackService { void this.applyEffectiveOutputDeviceToAllPipelines(); }); + effect(() => { + this.syncOutgoingVoiceRouting(); + this.recalcAllGains(); + }); + this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => { const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId); @@ -305,7 +319,7 @@ export class VoicePlaybackService { if (!pipeline) return; - if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) { + if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.isPeerInCurrentVoiceRoom(peerId)) { pipeline.gainNode.gain.value = 0; return; } @@ -320,6 +334,61 @@ export class VoicePlaybackService { this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId)); } + private isPeerInCurrentVoiceRoom(peerId: string): boolean { + const localVoiceState = this.currentUser()?.voiceState; + + if (!localVoiceState?.isConnected || !localVoiceState.roomId || !localVoiceState.serverId) { + return false; + } + + const remoteVoiceState = this.findUserForPeer(peerId)?.voiceState; + + return !!remoteVoiceState?.isConnected + && remoteVoiceState.roomId === localVoiceState.roomId + && remoteVoiceState.serverId === localVoiceState.serverId; + } + + private findUserForPeer(peerId: string): User | undefined { + return this.allUsers().find((user) => user.id === peerId || user.oderId === peerId || user.peerId === peerId); + } + + private syncOutgoingVoiceRouting(): void { + const localVoiceState = this.currentUser()?.voiceState; + + if (!localVoiceState?.isConnected || !localVoiceState.roomId || !localVoiceState.serverId) { + this.voiceConnection.syncOutgoingVoiceRouting([]); + return; + } + + const allowedPeerIds = new Set(); + + for (const user of this.allUsers()) { + const voiceState = user.voiceState; + + if ( + !voiceState?.isConnected + || voiceState.roomId !== localVoiceState.roomId + || voiceState.serverId !== localVoiceState.serverId + ) { + continue; + } + + if (user.id) { + allowedPeerIds.add(user.id); + } + + if (user.oderId) { + allowedPeerIds.add(user.oderId); + } + + if (user.peerId) { + allowedPeerIds.add(user.peerId); + } + } + + this.voiceConnection.syncOutgoingVoiceRouting(Array.from(allowedPeerIds)); + } + private persistVolumes(): void { try { const data: Record = {}; diff --git a/toju-app/src/app/domains/voice-session/README.md b/toju-app/src/app/domains/voice-session/README.md index 9a51bc9..fd57d30 100644 --- a/toju-app/src/app/domains/voice-session/README.md +++ b/toju-app/src/app/domains/voice-session/README.md @@ -73,6 +73,10 @@ stateDiagram-v2 When a voice session is active and the user navigates away from the voice-connected server, `showFloatingControls` becomes `true` and the floating overlay appears. Clicking the overlay dispatches `RoomsActions.viewServer` to navigate back. +Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session. + +Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move. + ## Workspace modes `VoiceWorkspaceService` controls the voice workspace panel state. The workspace is only visible when the user is viewing the voice-connected server. 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 f97658f..cbb8789 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 @@ -116,7 +116,15 @@ }
@for (ch of voiceChannels(); track ch.id) { -
+