/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, computed, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus, lucideVolumeX } from '@ng-icons/lucide'; import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors'; import { UsersActions } from '../../../store/users/users.actions'; import { RoomsActions } from '../../../store/rooms/rooms.actions'; import { MessagesActions } from '../../../store/messages/messages.actions'; import { WebRTCService } from '../../../core/services/webrtc.service'; import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; import { VoiceActivityService } from '../../../core/services/voice-activity.service'; import { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service'; import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent, UserVolumeMenuComponent } from '../../../shared'; import { Channel, ChatEvent, RoomMember, Room, User } from '../../../core/models/index'; import { v4 as uuidv4 } from 'uuid'; type TabView = 'channels' | 'users'; @Component({ selector: 'app-rooms-side-panel', standalone: true, imports: [ CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserVolumeMenuComponent, UserAvatarComponent, ConfirmDialogComponent ], viewProviders: [ provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus, lucideVolumeX }) ], templateUrl: './rooms-side-panel.component.html' }) export class RoomsSidePanelComponent { private store = inject(Store); private webrtc = inject(WebRTCService); private voiceSessionService = inject(VoiceSessionService); private voiceWorkspace = inject(VoiceWorkspaceService); private voicePlayback = inject(VoicePlaybackService); voiceActivity = inject(VoiceActivityService); activeTab = signal('channels'); showFloatingControls = this.voiceSessionService.showFloatingControls; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; onlineUsers = this.store.selectSignal(selectOnlineUsers); currentUser = this.store.selectSignal(selectCurrentUser); currentRoom = this.store.selectSignal(selectCurrentRoom); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); activeChannelId = this.store.selectSignal(selectActiveChannelId); textChannels = this.store.selectSignal(selectTextChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels); roomMembers = computed(() => this.currentRoom()?.members ?? []); roomMemberIdentifiers = computed(() => { const identifiers = new Set(); for (const member of this.roomMembers()) { this.addIdentifiers(identifiers, member); } return identifiers; }); onlineRoomUsers = computed(() => { const memberIdentifiers = this.roomMemberIdentifiers(); return this.onlineUsers().filter((user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user)); }); offlineRoomMembers = computed(() => { const onlineIdentifiers = new Set(); for (const user of this.onlineRoomUsers()) { this.addIdentifiers(onlineIdentifiers, user); } this.addIdentifiers(onlineIdentifiers, this.currentUser()); return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member)); }); knownUserCount = computed(() => { const memberIds = new Set( this.roomMembers() .map((member) => this.roomMemberKey(member)) .filter(Boolean) ); const current = this.currentUser(); if (current) { memberIds.add(current.oderId || current.id); } return memberIds.size; }); showChannelMenu = signal(false); channelMenuX = signal(0); channelMenuY = signal(0); contextChannel = signal(null); renamingChannelId = signal(null); showCreateChannelDialog = signal(false); createChannelType = signal<'text' | 'voice'>('text'); newChannelName = ''; showUserMenu = signal(false); userMenuX = signal(0); userMenuY = signal(0); contextMenuUser = signal(null); showVolumeMenu = signal(false); volumeMenuX = signal(0); volumeMenuY = signal(0); volumeMenuPeerId = signal(''); volumeMenuDisplayName = signal(''); private roomMemberKey(member: RoomMember): string { return member.oderId || member.id; } private addIdentifiers(identifiers: Set, entity: { id?: string; oderId?: string } | null | undefined): void { if (!entity) return; if (entity.id) { identifiers.add(entity.id); } if (entity.oderId) { identifiers.add(entity.oderId); } } private matchesIdentifiers(identifiers: Set, entity: { id?: string; oderId?: string }): boolean { return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId))); } private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean { const current = this.currentUser(); return !!current && ( (typeof entity.id === 'string' && entity.id === current.id) || (typeof entity.oderId === 'string' && entity.oderId === current.oderId) ); } canManageChannels(): boolean { const room = this.currentRoom(); const user = this.currentUser(); if (!room || !user) return false; if (room.hostId === user.id) return true; const perms = room.permissions || {}; if (user.role === 'admin' && perms.adminsManageRooms) return true; if (user.role === 'moderator' && perms.moderatorsManageRooms) return true; return false; } selectTextChannel(channelId: string) { if (this.renamingChannelId()) return; this.voiceWorkspace.showChat(); this.store.dispatch(RoomsActions.selectChannel({ channelId })); } openChannelContextMenu(evt: MouseEvent, channel: Channel) { evt.preventDefault(); this.contextChannel.set(channel); this.channelMenuX.set(evt.clientX); this.channelMenuY.set(evt.clientY); this.showChannelMenu.set(true); } closeChannelMenu() { this.showChannelMenu.set(false); } startRename() { const ch = this.contextChannel(); this.closeChannelMenu(); if (ch) { this.renamingChannelId.set(ch.id); } } confirmRename(event: Event) { const input = event.target as HTMLInputElement; const name = input.value.trim(); const channelId = this.renamingChannelId(); if (channelId && name) { this.store.dispatch(RoomsActions.renameChannel({ channelId, name })); } this.renamingChannelId.set(null); } cancelRename() { this.renamingChannelId.set(null); } deleteChannel() { const ch = this.contextChannel(); this.closeChannelMenu(); if (ch) { this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id })); } } resyncMessages() { this.closeChannelMenu(); const room = this.currentRoom(); if (!room) { return; } this.store.dispatch(MessagesActions.startSync()); const peers = this.webrtc.getConnectedPeers(); const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id }; peers.forEach((pid) => { try { this.webrtc.sendToPeer(pid, inventoryRequest); } catch { return; } }); } createChannel(type: 'text' | 'voice') { this.createChannelType.set(type); this.newChannelName = ''; this.showCreateChannelDialog.set(true); } confirmCreateChannel() { const name = this.newChannelName.trim(); if (!name) return; const type = this.createChannelType(); const existing = type === 'text' ? this.textChannels() : this.voiceChannels(); const channel: Channel = { id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8), name, type, position: existing.length }; this.store.dispatch(RoomsActions.addChannel({ channel })); this.showCreateChannelDialog.set(false); } cancelCreateChannel() { this.showCreateChannelDialog.set(false); } openUserContextMenu(evt: MouseEvent, user: User) { evt.preventDefault(); if (!this.isAdmin()) return; this.contextMenuUser.set(user); this.userMenuX.set(evt.clientX); this.userMenuY.set(evt.clientY); this.showUserMenu.set(true); } closeUserMenu() { this.showUserMenu.set(false); } openVoiceUserVolumeMenu(evt: MouseEvent, user: User) { evt.preventDefault(); const me = this.currentUser(); if (user.id === me?.id || user.oderId === me?.oderId) return; this.volumeMenuPeerId.set(user.oderId || user.id); this.volumeMenuDisplayName.set(user.displayName); this.volumeMenuX.set(evt.clientX); this.volumeMenuY.set(evt.clientY); this.showVolumeMenu.set(true); } changeUserRole(role: 'admin' | 'moderator' | 'member') { const user = this.contextMenuUser(); const roomId = this.currentRoom()?.id; this.closeUserMenu(); if (user) { this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.webrtc.broadcastMessage({ type: 'role-change', roomId, targetUserId: user.id, role }); } } kickUserAction() { const user = this.contextMenuUser(); this.closeUserMenu(); if (user) { this.store.dispatch(UsersActions.kickUser({ userId: user.id })); } } joinVoice(roomId: string) { const room = this.currentRoom(); const current = this.currentUser(); if ( room && current?.voiceState?.isConnected && current.voiceState.roomId === roomId && current.voiceState.serverId === room.id ) { this.voiceWorkspace.open(null, { connectRemoteShares: true }); return; } if (room && room.permissions && room.permissions.allowVoice === false) { return; } if (!room) return; if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { if (!this.webrtc.isVoiceConnected()) { if (current.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: current.id, voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } }) ); } } else { return; } } const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId; const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); enableVoicePromise .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null)) .catch(() => undefined); } private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void { this.updateVoiceStateStore(roomId, room, current); this.trackCurrentUserMic(); this.startVoiceHeartbeat(roomId, room); this.broadcastVoiceConnected(roomId, room, current); this.startVoiceSession(roomId, room); } private trackCurrentUserMic(): void { const userId = this.currentUser()?.oderId || this.currentUser()?.id; const micStream = this.webrtc.getRawMicStream(); if (userId && micStream) { this.voiceActivity.trackLocalMic(userId, micStream); } } private untrackCurrentUserMic(): void { const userId = this.currentUser()?.oderId || this.currentUser()?.id; if (userId) { this.voiceActivity.untrackLocalMic(userId); } } private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void { if (!current?.id) return; this.store.dispatch( UsersActions.updateVoiceState({ userId: current.id, voiceState: { isConnected: true, isMuted: current.voiceState?.isMuted ?? false, isDeafened: current.voiceState?.isDeafened ?? false, roomId, serverId: room.id } }) ); } private startVoiceHeartbeat(roomId: string, room: Room): void { this.webrtc.startVoiceHeartbeat(roomId, room.id); } private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void { this.webrtc.broadcastMessage({ type: 'voice-state', oderId: current?.oderId || current?.id, displayName: current?.displayName || 'User', voiceState: { isConnected: true, isMuted: current?.voiceState?.isMuted ?? false, isDeafened: current?.voiceState?.isDeafened ?? false, roomId, serverId: room.id } }); } private startVoiceSession(roomId: string, room: Room): void { const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId); const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId; this.voiceSessionService.startSession({ serverId: room.id, serverName: room.name, roomId, roomName: voiceRoomName, serverIcon: room.icon, serverDescription: room.description, serverRoute: `/room/${room.id}` }); } leaveVoice(roomId: string) { const current = this.currentUser(); if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return; this.webrtc.stopVoiceHeartbeat(); this.untrackCurrentUserMic(); this.webrtc.disableVoice(); if (current?.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: current.id, voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } }) ); } this.webrtc.broadcastMessage({ type: 'voice-state', oderId: current?.oderId || current?.id, displayName: current?.displayName || 'User', voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } }); this.voiceSessionService.endSession(); } voiceOccupancy(roomId: string): number { return this.voiceUsersInRoom(roomId).length; } viewShare(userId: string) { this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true }); } viewStream(userId: string) { this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true }); } isUserLocallyMuted(user: User): boolean { const peerId = user.oderId || user.id; return this.voicePlayback.isUserMuted(peerId); } isUserSharing(userId: string): boolean { const me = this.currentUser(); if (me?.id === userId) { return this.webrtc.isScreenSharing(); } const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId); if (user?.screenShareState?.isSharing === false) { return false; } const peerKeys = [ user?.oderId, user?.id, userId ].filter( (candidate): candidate is string => !!candidate ); const stream = peerKeys .map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey)) .find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null; return !!stream && stream.getVideoTracks().length > 0; } voiceUsersInRoom(roomId: string) { const room = this.currentRoom(); const me = this.currentUser(); const remoteUsers = this.onlineUsers().filter( (user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id ); if ( me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id ) { const meId = me.id; const meOderId = me.oderId; const alreadyIncluded = remoteUsers.some( (user) => user.id === meId || user.oderId === meOderId ); if (!alreadyIncluded) { return [me, ...remoteUsers]; } } return remoteUsers; } isCurrentRoom(roomId: string): boolean { const me = this.currentUser(); const room = this.currentRoom(); return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id); } voiceEnabled(): boolean { const room = this.currentRoom(); return room?.permissions?.allowVoice !== false; } getPeerLatency(user: User): number | null { const latencies = this.webrtc.peerLatencies(); return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null; } getPingColorClass(user: User): string { const ms = this.getPeerLatency(user); if (ms === null) return 'bg-gray-500'; if (ms < 100) return 'bg-green-500'; if (ms < 200) return 'bg-yellow-500'; if (ms < 350) return 'bg-orange-500'; return 'bg-red-500'; } }