Add seperation of voice channels, creation of new ones, and move around users

This commit is contained in:
2026-03-30 02:11:39 +02:00
parent 83694570e3
commit 727059fb52
19 changed files with 614 additions and 50 deletions

View File

@@ -1,4 +1,7 @@
import { Channel } from '../../shared-kernel';
import {
Channel,
ChannelType
} from '../../shared-kernel';
export function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
@@ -8,9 +11,14 @@ function channelNameKey(name: string): string {
return normalizeChannelName(name).toLocaleLowerCase();
}
function typedChannelNameKey(type: ChannelType, name: string): string {
return `${type}:${channelNameKey(name)}`;
}
export function isChannelNameTaken(
channels: Channel[],
name: string,
channelType: ChannelType,
excludeChannelId?: string
): boolean {
const targetKey = channelNameKey(name);
@@ -19,7 +27,11 @@ export function isChannelNameTaken(
return false;
}
return channels.some((channel) => channel.id !== excludeChannelId && channelNameKey(channel.name) === targetKey);
return channels.some(
(channel) => channel.id !== excludeChannelId
&& channel.type === channelType
&& channelNameKey(channel.name) === targetKey
);
}
export function normalizeRoomChannels(channels: Channel[] | undefined): Channel[] | undefined {
@@ -35,7 +47,7 @@ export function normalizeRoomChannels(channels: Channel[] | undefined): Channel[
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);
const nameKey = type ? typedChannelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
continue;

View File

@@ -56,6 +56,7 @@ import {
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import { ROOM_URL_PATTERN } from '../../core/constants';
import { VoiceSessionFacade } from '../../domains/voice-session';
import {
findRoomMember,
removeRoomMember,
@@ -138,6 +139,7 @@ export class RoomsEffects {
private webrtc = inject(RealtimeSessionFacade);
private serverDirectory = inject(ServerDirectoryFacade);
private audioService = inject(NotificationAudioService);
private voiceSessionService = inject(VoiceSessionFacade);
/**
* Tracks user IDs we already know are in voice. Lives outside the
@@ -404,6 +406,42 @@ export class RoomsEffects {
{ dispatch: false }
);
refreshServerOwnedRoomMetadata$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
switchMap(({ room }) =>
this.serverDirectory.getServer(room.id, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).pipe(
map((serverData) => {
if (!serverData) {
return null;
}
return RoomsActions.updateRoom({
roomId: room.id,
changes: {
name: serverData.name,
description: serverData.description,
hostId: serverData.ownerId || room.hostId,
hasPassword: !!serverData.hasPassword,
isPrivate: serverData.isPrivate,
maxUsers: serverData.maxUsers,
channels: Array.isArray(serverData.channels) ? serverData.channels : room.channels,
sourceId: serverData.sourceId ?? room.sourceId,
sourceName: serverData.sourceName ?? room.sourceName,
sourceUrl: serverData.sourceUrl ?? room.sourceUrl
}
});
}),
filter((action): action is ReturnType<typeof RoomsActions.updateRoom> => !!action),
catchError(() => EMPTY)
)
)
)
);
/** Switches the UI view to an already-joined server without leaving others. */
viewServer$ = createEffect(() =>
this.actions$.pipe(
@@ -1024,9 +1062,11 @@ export class RoomsEffects {
]) => {
switch (event.type) {
case 'voice-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'voice') : EMPTY;
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice') : EMPTY;
case 'voice-channel-move':
return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null);
case 'screen-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'screen') : EMPTY;
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen') : EMPTY;
case 'server-state-request':
return this.handleServerStateRequest(event, currentRoom, savedRooms);
case 'server-state-full':
@@ -1051,13 +1091,14 @@ export class RoomsEffects {
)
);
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], kind: 'voice' | 'screen') {
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen') {
const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId)
return EMPTY;
const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId);
const existingUser = allUsers.find((u) => u.id === userId || u.oderId === userId);
const userExists = !!existingUser;
if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined;
@@ -1070,13 +1111,14 @@ export class RoomsEffects {
// clearUsers() from server-switching doesn't create false transitions.
const weAreInVoice = this.webrtc.isVoiceConnected();
const nowConnected = vs.isConnected ?? false;
const wasKnown = this.knownVoiceUsers.has(userId);
const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState);
const isInCurrentVoiceRoom = this.isSameVoiceRoom(vs, currentUser?.voiceState);
if (weAreInVoice) {
const wasKnown = this.knownVoiceUsers.has(userId);
if (!wasKnown && nowConnected) {
if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) {
this.audioService.play(AppSound.Joining);
} else if (wasKnown && !nowConnected) {
} else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) {
this.audioService.play(AppSound.Leave);
}
}
@@ -1141,6 +1183,79 @@ export class RoomsEffects {
);
}
private handleVoiceChannelMove(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
) {
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null;
const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId;
const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined;
if (!currentUser || !targetUserId || !serverId || !nextVoiceState?.roomId) {
return EMPTY;
}
if (targetUserId !== currentUser.id && targetUserId !== currentUser.oderId) {
return EMPTY;
}
const room = this.resolveRoom(serverId, currentRoom, savedRooms);
const movedChannel = room?.channels?.find((channel) => channel.id === nextVoiceState.roomId && channel.type === 'voice');
if (!room || !movedChannel) {
return EMPTY;
}
const updatedVoiceState: Partial<VoiceState> = {
isConnected: true,
isMuted: currentUser.voiceState?.isMuted ?? false,
isDeafened: currentUser.voiceState?.isDeafened ?? false,
isSpeaking: currentUser.voiceState?.isSpeaking ?? false,
isMutedByAdmin: currentUser.voiceState?.isMutedByAdmin,
volume: currentUser.voiceState?.volume,
roomId: movedChannel.id,
serverId: room.id
};
const wasViewingVoiceServer = this.voiceSessionService.isViewingVoiceServer();
this.webrtc.startVoiceHeartbeat(movedChannel.id, room.id);
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId: movedChannel.id,
roomName: `🔊 ${movedChannel.name}`,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer);
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: currentUser.oderId || currentUser.id,
displayName: currentUser.displayName || 'User',
voiceState: updatedVoiceState
});
return of(UsersActions.updateVoiceState({
userId: currentUser.id,
voiceState: updatedVoiceState
}));
}
private isSameVoiceRoom(
voiceState: Partial<VoiceState> | undefined,
currentUserVoiceState: Partial<VoiceState> | undefined
): boolean {
return !!voiceState?.isConnected
&& !!currentUserVoiceState?.isConnected
&& !!voiceState.roomId
&& !!voiceState.serverId
&& voiceState.roomId === currentUserVoiceState.roomId
&& voiceState.serverId === currentUserVoiceState.serverId;
}
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId)
return currentRoom;

View File

@@ -340,7 +340,10 @@ export const roomsReducer = createReducer(
return {
...state,
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.currentRoom?.id === roomId
? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId)
: state.activeChannelId
};
}),
@@ -411,7 +414,11 @@ export const roomsReducer = createReducer(
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(channel.name);
if (!normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName)) {
if (
!normalizedName
|| existing.some((entry) => entry.id === channel.id)
|| isChannelNameTaken(existing, normalizedName, channel.type)
) {
return state;
}
@@ -451,8 +458,9 @@ export const roomsReducer = createReducer(
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(name);
const existingChannel = existing.find((channel) => channel.id === channelId);
if (!normalizedName || isChannelNameTaken(existing, normalizedName, channelId)) {
if (!normalizedName || !existingChannel || isChannelNameTaken(existing, normalizedName, existingChannel.type, channelId)) {
return state;
}