All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
1160 lines
33 KiB
TypeScript
1160 lines
33 KiB
TypeScript
/* 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<typeof setTimeout> | null = null;
|
|
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
|
|
|
|
readonly panelMode = input<PanelMode>('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<string>();
|
|
|
|
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<string>();
|
|
|
|
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<Channel | null>(null);
|
|
|
|
renamingChannelId = signal<string | null>(null);
|
|
channelNameError = signal<string | null>(null);
|
|
|
|
showCreateChannelDialog = signal(false);
|
|
createChannelType = signal<'text' | 'voice'>('text');
|
|
newChannelName = '';
|
|
|
|
showUserMenu = signal(false);
|
|
userMenuX = signal(0);
|
|
userMenuY = signal(0);
|
|
contextMenuUser = signal<User | null>(null);
|
|
|
|
showVolumeMenu = signal(false);
|
|
volumeMenuX = signal(0);
|
|
volumeMenuY = signal(0);
|
|
volumeMenuPeerId = signal('');
|
|
volumeMenuDisplayName = signal('');
|
|
draggedVoiceUserId = signal<string | null>(null);
|
|
dragTargetVoiceChannelId = signal<string | null>(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<void> {
|
|
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<void> {
|
|
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<string>, 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<string>, 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<void> {
|
|
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<User['voiceState']> = {
|
|
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');
|
|
}
|
|
}
|