Add seperation of voice channels, creation of new ones, and move around users
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user