/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, computed, input, OnDestroy, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideAlertTriangle, lucideMonitor, lucideVideo, lucideHash, lucideUsers, lucidePlus, lucideVolumeX, lucideGamepad2 } from '@ng-icons/lucide'; import { selectOnlineUsers, selectCurrentUser } 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 { RealtimeSessionFacade } from '../../../core/realtime'; import { ScreenShareFacade } from '../../../domains/screen-share'; import { NotificationsFacade } from '../../../domains/notifications'; import { ThemeNodeDirective } from '../../../domains/theme'; import { VoiceActivityService, VoiceConnectionFacade, VoiceConnectivityHealthService } from '../../../domains/voice-connection'; import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session'; import { DirectMessageService } from '../../../domains/direct-message'; import { VoicePlaybackService } from '../../../domains/voice-connection'; import { formatGameActivityElapsed } from '../../../domains/game-activity'; import { ExternalLinkService } from '../../../core/platform/external-link.service'; import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component'; import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules'; import { canManageMember, resolveRoomPermission, setRoleAssignmentsForMember, SYSTEM_ROLE_IDS } from '../../../domains/access-control'; import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent, UserVolumeMenuComponent, ProfileCardService } from '../../../shared'; import { Channel, ChatEvent, GameActivity, RoomMember, Room, User } from '../../../shared-kernel'; import { v4 as uuidv4 } from 'uuid'; type PanelMode = 'channels' | 'users'; @Component({ selector: 'app-rooms-side-panel', standalone: true, imports: [ CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserVolumeMenuComponent, UserAvatarComponent, ConfirmDialogComponent, ThemeNodeDirective ], viewProviders: [ provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideAlertTriangle, lucideMonitor, lucideVideo, lucideHash, lucideUsers, lucidePlus, lucideVolumeX, lucideGamepad2 }) ], templateUrl: './rooms-side-panel.component.html' }) export class RoomsSidePanelComponent implements OnDestroy { private store = inject(Store); private router = inject(Router); private realtime = inject(RealtimeSessionFacade); private voiceConnection = inject(VoiceConnectionFacade); private screenShare = inject(ScreenShareFacade); private notifications = inject(NotificationsFacade); private voiceSessionService = inject(VoiceSessionFacade); private voiceWorkspace = inject(VoiceWorkspaceService); private voicePlayback = inject(VoicePlaybackService); private profileCard = inject(ProfileCardService); private directMessages = inject(DirectMessageService); private readonly externalLinks = inject(ExternalLinkService); private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceConnectivity = inject(VoiceConnectivityHealthService); private profileCardOpenTimer: ReturnType | null = null; private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000); readonly panelMode = input('channels'); readonly showVoiceControls = input(true); showFloatingControls = this.voiceSessionService.showFloatingControls; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; onlineUsers = this.store.selectSignal(selectOnlineUsers); currentUser = this.store.selectSignal(selectCurrentUser); currentRoom = this.store.selectSignal(selectCurrentRoom); activeChannelId = this.store.selectSignal(selectActiveChannelId); textChannels = this.store.selectSignal(selectTextChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels); localUserHasDesync = this.voiceConnectivity.localUserHasDesync; 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(); const roomId = this.currentRoom()?.id; return this.onlineUsers().filter( (user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId) ); }); 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); channelNameError = 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(''); draggedVoiceUserId = signal(null); dragTargetVoiceChannelId = signal(null); activityNow = signal(Date.now()); ngOnDestroy(): void { clearInterval(this.activityTimer); this.cancelQueuedProfileCardOpen(); } gameActivityElapsed(user: User | null | undefined): string { const activity = user?.gameActivity; return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : ''; } openGameStore(event: Event, activity: GameActivity): void { event.stopPropagation(); if (activity.store?.url) { this.externalLinks.open(activity.store.url); } } openProfileCard(event: Event, user: User, editable: boolean): void { event.stopPropagation(); const el = event.currentTarget as HTMLElement; this.profileCard.open(el, user, { placement: 'left', editable }); } openUserCard(event: Event, user: User): void { event.stopPropagation(); this.queueProfileCardOpen(event.currentTarget as HTMLElement, user, false); } openProfileCardForMember(event: Event, member: RoomMember): void { const user = this.roomMemberToUser(member); this.openProfileCard(event, user, false); } openMemberCard(event: Event, member: RoomMember): void { event.stopPropagation(); this.queueProfileCardOpen(event.currentTarget as HTMLElement, this.roomMemberToUser(member), false); } async openDirectMessage(event: Event, user: User): Promise { event.stopPropagation(); this.cancelQueuedProfileCardOpen(); if (this.isCurrentUserIdentity(user)) { return; } const conversation = await this.directMessages.createConversation(user); await this.router.navigate(['/dm', conversation.id]); } async openDirectMessageForMember(event: Event, member: RoomMember): Promise { await this.openDirectMessage(event, this.roomMemberToUser(member)); } private roomMemberToUser(member: RoomMember): User { return { id: member.id, oderId: member.oderId || member.id, username: member.username, displayName: member.displayName, description: member.description, profileUpdatedAt: member.profileUpdatedAt, avatarUrl: member.avatarUrl, avatarHash: member.avatarHash, avatarMime: member.avatarMime, avatarUpdatedAt: member.avatarUpdatedAt, status: 'disconnected', role: member.role, joinedAt: member.joinedAt }; } 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 isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean { if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) { return true; } return entity.presenceServerIds.includes(roomId); } 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)) ); } private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void { this.cancelQueuedProfileCardOpen(); this.profileCardOpenTimer = setTimeout(() => { this.profileCardOpenTimer = null; this.profileCard.open(anchor, user, { placement: 'left', editable }); }, 180); } private cancelQueuedProfileCardOpen(): void { if (!this.profileCardOpenTimer) { return; } clearTimeout(this.profileCardOpenTimer); this.profileCardOpenTimer = null; } hasConnectivityIssue(user: User): boolean { return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id); } canManageChannels(): boolean { const room = this.currentRoom(); const user = this.currentUser(); if (!room || !user) return false; return resolveRoomPermission(room, user, 'manageChannels'); } 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(); this.channelNameError.set(null); if (ch) { this.renamingChannelId.set(ch.id); } } confirmRename(event: Event) { const input = event.target as HTMLInputElement; const name = normalizeChannelName(input.value); const channelId = this.renamingChannelId(); 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 })); } this.renamingChannelId.set(null); } cancelRename() { this.channelNameError.set(null); this.renamingChannelId.set(null); } deleteChannel() { const ch = this.contextChannel(); this.closeChannelMenu(); if (ch) { this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id })); } } toggleChannelNotifications(): void { const channel = this.contextChannel(); const roomId = this.currentRoom()?.id; this.closeChannelMenu(); if (!channel || channel.type !== 'text' || !roomId) { return; } this.notifications.setChannelMuted(roomId, channel.id, !this.notifications.isChannelMuted(roomId, channel.id)); } isContextChannelMuted(): boolean { const channel = this.contextChannel(); const roomId = this.currentRoom()?.id; return !!channel && channel.type === 'text' && !!roomId && this.notifications.isChannelMuted(roomId, channel.id); } channelUnreadCount(channelId: string): number { const roomId = this.currentRoom()?.id; return roomId ? this.notifications.channelUnreadCount(roomId, channelId) : 0; } formatUnreadCount(count: number): string { return count > 99 ? '99+' : String(count); } resyncMessages() { this.closeChannelMenu(); const room = this.currentRoom(); if (!room) { return; } this.store.dispatch(MessagesActions.startSync()); const peers = this.realtime.getConnectedPeers(); const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id }; peers.forEach((pid) => { try { this.realtime.sendToPeer(pid, inventoryRequest); } catch { return; } }); } createChannel(type: 'text' | 'voice') { this.createChannelType.set(type); this.newChannelName = ''; this.channelNameError.set(null); this.showCreateChannelDialog.set(true); } confirmCreateChannel() { const name = normalizeChannelName(this.newChannelName); const validationError = this.getChannelNameError(name); if (validationError) { this.channelNameError.set(validationError); 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.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 ?? []; const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType(); if (!channelType) { return null; } if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) { return 'Channel names must be unique within text or voice channels.'; } return null; } openUserContextMenu(evt: MouseEvent, user: User) { evt.preventDefault(); if (!this.canManageContextUser(user)) 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 room = this.currentRoom(); this.closeUserMenu(); if (!user || !room) return; const roleIds = role === 'admin' ? [SYSTEM_ROLE_IDS.admin] : role === 'moderator' ? [SYSTEM_ROLE_IDS.moderator] : []; const roleAssignments = setRoleAssignmentsForMember(room.roleAssignments, user, roleIds); this.store.dispatch( RoomsActions.updateRoomAccessControl({ roomId: room.id, changes: { roleAssignments } }) ); } kickUserAction() { const user = this.contextMenuUser(); this.closeUserMenu(); if (user) { this.store.dispatch(UsersActions.kickUser({ userId: user.id })); } } private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean { if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) { return false; } this.voiceWorkspace.open(null, { connectRemoteShares: true }); return true; } private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean { return !current || resolveRoomPermission(room, current, 'joinVoice', roomId); } private prepareCrossServerVoiceJoin(room: Room, current: User | null): boolean { if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) { return true; } if (this.voiceConnection.isVoiceConnected()) { return false; } if (current.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: current.id, voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } }) ); } return true; } private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise { const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId; return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined); } joinVoice(roomId: string) { const room = this.currentRoom(); const current = this.currentUser(); if (this.openExistingVoiceWorkspace(room, current ?? null, roomId)) { this.voiceConnection.clearConnectionError(); return; } if (!room) { this.voiceConnection.reportConnectionError('No active room selected for voice join.'); return; } if (!this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) { this.voiceConnection.reportConnectionError('You do not have permission to join this voice channel.'); return; } if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) { this.voiceConnection.reportConnectionError('Disconnect from the current voice server before joining a different server.'); return; } this.enableVoiceForJoin(room, current ?? null, roomId) .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null)) .catch((error) => this.handleVoiceJoinFailure(error)); } private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void { this.voiceConnection.clearConnectionError(); this.updateVoiceStateStore(roomId, room, current); this.trackCurrentUserMic(); this.startVoiceHeartbeat(roomId, room); this.broadcastVoiceConnected(roomId, room, current); this.startVoiceSession(roomId, room); } private handleVoiceJoinFailure(error: unknown): void { const message = error instanceof Error ? error.message : 'Failed to join voice channel.'; this.voiceConnection.reportConnectionError(message); } private trackCurrentUserMic(): void { const userId = this.currentUser()?.oderId || this.currentUser()?.id; const micStream = this.voiceConnection.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.voiceConnection.startVoiceHeartbeat(roomId, room.id); } private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void { this.voiceConnection.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.voiceConnection.stopVoiceHeartbeat(); this.untrackCurrentUserMic(); this.voiceConnection.disableVoice(); if (current?.id) { this.store.dispatch( UsersActions.updateVoiceState({ userId: current.id, voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } }) ); this.store.dispatch( UsersActions.updateCameraState({ userId: current.id, cameraState: { isEnabled: false } }) ); } this.voiceConnection.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(`screen:${userId}`, { connectRemoteShares: true }); } viewStream(userId: string) { const focusTarget = this.isUserSharing(userId) ? `screen:${userId}` : `camera:${userId}`; this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true }); } canMoveVoiceUsers(): boolean { return this.canManageChannels(); } canDragVoiceUser(user: User): boolean { return this.canMoveVoiceUsers() && !this.isCurrentUserIdentity(user) && !!user.voiceState?.isConnected; } onVoiceUserDragStart(event: DragEvent, user: User): void { if (!this.canDragVoiceUser(user)) { event.preventDefault(); return; } const dragId = user.id || user.oderId; if (!dragId) { event.preventDefault(); return; } this.draggedVoiceUserId.set(dragId); event.dataTransfer?.setData('text/plain', dragId); if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; } } onVoiceUserDragEnd(): void { this.draggedVoiceUserId.set(null); this.dragTargetVoiceChannelId.set(null); } onVoiceChannelDragOver(event: DragEvent, channelId: string): void { if (!this.draggedVoiceUserId()) { return; } event.preventDefault(); this.dragTargetVoiceChannelId.set(channelId); if (event.dataTransfer) { event.dataTransfer.dropEffect = 'move'; } } onVoiceChannelDragLeave(channelId: string): void { if (this.dragTargetVoiceChannelId() === channelId) { this.dragTargetVoiceChannelId.set(null); } } onVoiceChannelDrop(event: DragEvent, channelId: string): void { event.preventDefault(); const draggedUserId = this.draggedVoiceUserId() || event.dataTransfer?.getData('text/plain') || null; this.draggedVoiceUserId.set(null); this.dragTargetVoiceChannelId.set(null); if (!draggedUserId) { return; } this.moveVoiceUserToChannel(draggedUserId, channelId); } private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void { const room = this.currentRoom(); const actor = this.currentUser(); if (!room || !actor || !this.canMoveVoiceUsers()) { return; } const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId); if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) { return; } const movedVoiceState: Partial = { isConnected: true, isMuted: targetUser.voiceState.isMuted, isDeafened: targetUser.voiceState.isDeafened, isSpeaking: targetUser.voiceState.isSpeaking, isMutedByAdmin: targetUser.voiceState.isMutedByAdmin, volume: targetUser.voiceState.volume, roomId: channelId, serverId: room.id }; this.store.dispatch( UsersActions.updateVoiceState({ userId: targetUser.id, voiceState: movedVoiceState }) ); this.realtime.broadcastMessage({ type: 'voice-channel-move', roomId: room.id, targetUserId: targetUser.oderId || targetUser.id, voiceState: movedVoiceState, displayName: targetUser.displayName }); } isUserLocallyMuted(user: User): boolean { const peerId = user.oderId || user.id; return this.voicePlayback.isUserMuted(peerId); } isUserOnCamera(userId: string): boolean { const user = this.findKnownUser(userId); if (!this.isUserInCurrentVoiceRoom(userId, user)) { return false; } const current = this.currentUser(); if (current && (current.id === userId || current.oderId === userId)) { return this.voiceConnection.isCameraEnabled(); } if (user?.cameraState?.isEnabled === true) { return true; } if (user?.cameraState?.isEnabled === false) { return false; } return this.getPeerKeysForUser(user, userId).some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey))); } isUserSharing(userId: string): boolean { const user = this.findKnownUser(userId); if (!this.isUserInCurrentVoiceRoom(userId, user)) { return false; } const current = this.currentUser(); if (current && (current.id === userId || current.oderId === userId)) { return this.screenShare.isScreenSharing(); } if (user?.screenShareState?.isSharing === true) { return true; } if (user?.screenShareState?.isSharing === false) { return false; } const stream = this.getPeerKeysForUser(user, userId) .map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey)) .find((candidate) => this.hasActiveVideoStream(candidate)) || null; return this.hasActiveVideoStream(stream); } isUserStreaming(userId: string): boolean { return this.isUserSharing(userId) || this.isUserOnCamera(userId); } getVoiceUserRingClass(user: User): string { if (user.voiceState?.isDeafened) { return 'ring-2 ring-red-500'; } if (user.voiceState?.isMuted) { return 'ring-2 ring-yellow-500'; } if (this.isVoiceUserSpeaking(user)) { return 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]'; } return 'ring-2 ring-green-500/40'; } getUserLiveIconName(userId: string): string { return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo'; } 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(); const user = this.currentUser(); return !!room && !!user && resolveRoomPermission(room, user, 'joinVoice'); } canManageContextUser(user: User | null): boolean { const room = this.currentRoom(); const currentUser = this.currentUser(); if (!room || !currentUser || !user) { return false; } return this.canChangeUserRole(user) || this.canKickUser(user); } canChangeUserRole(user: User | null): boolean { const room = this.currentRoom(); const currentUser = this.currentUser(); if (!room || !currentUser || !user) { return false; } return canManageMember(room, currentUser, user, 'manageRoles'); } canKickUser(user: User | null): boolean { const room = this.currentRoom(); const currentUser = this.currentUser(); if (!room || !currentUser || !user) { return false; } return canManageMember(room, currentUser, user, 'kickMembers'); } getPeerLatency(user: User): number | null { const latencies = this.voiceConnection.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'; } private isVoiceUserSpeaking(user: User): boolean { const userKey = user.oderId || user.id; return !!userKey && this.voiceActivity.speakingMap().get(userKey) === true; } private findKnownUser(userId: string): User | null { const current = this.currentUser(); if (current && (current.id === userId || current.oderId === userId)) { return current; } return this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) ?? null; } private isUserInCurrentVoiceRoom(userId: string, user: User | null): boolean { const currentVoiceState = this.currentUser()?.voiceState; const current = this.currentUser(); if (!currentVoiceState?.isConnected || !currentVoiceState.roomId || !currentVoiceState.serverId) { return false; } if (current && (current.id === userId || current.oderId === userId)) { return true; } return ( !!user?.voiceState?.isConnected && user.voiceState.roomId === currentVoiceState.roomId && user.voiceState.serverId === currentVoiceState.serverId ); } private getPeerKeysForUser(user: User | null, userId: string): string[] { return [ user?.oderId, user?.id, userId ].filter((candidate): candidate is string => !!candidate); } private hasActiveVideoStream(stream: MediaStream | null): boolean { return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live'); } }