chore: enforce lint across codebase and ban "maybe" in identifiers
Remove member-ordering and complexity eslint-disable comments by reordering class members and applying targeted fixes. Add metoyou/no-maybe-in-naming, type-safe WebRTC e2e harness helpers, and resolve remaining lint errors so npm run lint exits cleanly. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
@@ -100,24 +99,22 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
|
||||
}
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private friendsService = inject(FriendService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private searchSubject = new Subject<string>();
|
||||
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
searchQuery = signal('');
|
||||
|
||||
serverResults = this.store.selectSignal(selectSearchResults);
|
||||
|
||||
isSearching = this.store.selectSignal(selectIsSearching);
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
popularServers = signal<ServerInfo[]>([]);
|
||||
|
||||
recentSearches = signal<string[]>(this.loadRecentSearches());
|
||||
private users = this.store.selectSignal(selectAllUsers);
|
||||
|
||||
/** True while the user is actively typing a query. */
|
||||
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
|
||||
@@ -125,37 +122,6 @@ export class DashboardComponent implements OnInit {
|
||||
/** Server matches limited for the quick-search list. */
|
||||
topServerResults = computed(() => this.serverResults().slice(0, QUICK_RESULT_LIMIT));
|
||||
|
||||
/** Every distinct person known to the account (known users plus saved-room members), excluding self. */
|
||||
private discoveredPeople = computed<User[]>(() => {
|
||||
const currentKey = this.currentUserKey();
|
||||
const byKey = new Map<string, User>();
|
||||
|
||||
for (const user of this.users()) {
|
||||
byKey.set(user.oderId || user.id, user);
|
||||
}
|
||||
|
||||
for (const room of this.savedRooms()) {
|
||||
for (const member of room.members ?? []) {
|
||||
const key = member.oderId || member.id;
|
||||
|
||||
if (byKey.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byKey.set(key, {
|
||||
id: member.id,
|
||||
oderId: key,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
avatarUrl: member.avatarUrl,
|
||||
status: 'disconnected'
|
||||
} as User);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey);
|
||||
});
|
||||
|
||||
/** People matches derived from known users and saved-room members. */
|
||||
topPeopleResults = computed<User[]>(() => {
|
||||
const query = this.searchQuery().trim()
|
||||
@@ -200,6 +166,63 @@ export class DashboardComponent implements OnInit {
|
||||
/** True for a brand-new account with no servers and no known people. */
|
||||
isNewUser = computed(() => this.savedRooms().length === 0 && this.users().length === 0);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private friendsService = inject(FriendService);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private searchSubject = new Subject<string>();
|
||||
|
||||
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
private users = this.store.selectSignal(selectAllUsers);
|
||||
|
||||
/** Every distinct person known to the account (known users plus saved-room members), excluding self. */
|
||||
private discoveredPeople = computed<User[]>(() => {
|
||||
const currentKey = this.currentUserKey();
|
||||
const byKey = new Map<string, User>();
|
||||
|
||||
for (const user of this.users()) {
|
||||
byKey.set(user.oderId || user.id, user);
|
||||
}
|
||||
|
||||
for (const room of this.savedRooms()) {
|
||||
for (const member of room.members ?? []) {
|
||||
const key = member.oderId || member.id;
|
||||
|
||||
if (byKey.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byKey.set(key, {
|
||||
id: member.id,
|
||||
oderId: key,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
avatarUrl: member.avatarUrl,
|
||||
status: 'disconnected'
|
||||
} as User);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey);
|
||||
});
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKeydown(event: KeyboardEvent): void {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
this.searchInputRef()?.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
@@ -217,14 +240,6 @@ export class DashboardComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKeydown(event: KeyboardEvent): void {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
this.searchInputRef()?.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onSearchChange(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
this.searchSubject.next(query);
|
||||
@@ -357,4 +372,5 @@ export class DashboardComponent implements OnInit {
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.some((value) => value.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
Component,
|
||||
@@ -82,34 +81,29 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant-
|
||||
templateUrl: './private-call.component.html'
|
||||
})
|
||||
export class PrivateCallComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly calls = inject(DirectCallService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
private chatResizing = false;
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile());
|
||||
|
||||
readonly speakerphoneEnabled = signal(true);
|
||||
|
||||
readonly callIdInput = input<string | null>(null);
|
||||
|
||||
readonly overlayMode = input(false);
|
||||
|
||||
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('callId')
|
||||
});
|
||||
|
||||
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
|
||||
|
||||
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
||||
|
||||
readonly participantUsers = computed(() => {
|
||||
const session = this.session();
|
||||
|
||||
@@ -121,25 +115,40 @@ export class PrivateCallComponent {
|
||||
.map((participantId) => this.userForSessionParticipant(session, participantId))
|
||||
.filter((user): user is User => !!user);
|
||||
});
|
||||
|
||||
readonly isConnected = computed(() => {
|
||||
const session = this.session();
|
||||
const currentUserId = this.currentUserKey();
|
||||
|
||||
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined;
|
||||
});
|
||||
|
||||
readonly isMuted = this.voice.isMuted;
|
||||
|
||||
readonly isDeafened = this.voice.isDeafened;
|
||||
|
||||
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
||||
|
||||
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
||||
|
||||
readonly remoteStreamRevision = signal(0);
|
||||
|
||||
readonly includeSystemAudio = signal(false);
|
||||
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
|
||||
readonly askScreenShareQuality = signal(true);
|
||||
|
||||
readonly showScreenShareQualityDialog = signal(false);
|
||||
|
||||
readonly inviteUserId = signal('');
|
||||
|
||||
readonly focusedStreamId = signal<string | null>(null);
|
||||
|
||||
readonly showAllStreamsMode = signal(false);
|
||||
|
||||
readonly chatWidthPx = signal(384);
|
||||
|
||||
readonly inviteCandidates = computed(() => {
|
||||
const participantIds = new Set(this.session()?.participantIds ?? []);
|
||||
const currentUserId = this.currentUserKey();
|
||||
@@ -150,6 +159,7 @@ export class PrivateCallComponent {
|
||||
return userId !== currentUserId && !participantIds.has(userId);
|
||||
});
|
||||
});
|
||||
|
||||
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
|
||||
this.remoteStreamRevision();
|
||||
|
||||
@@ -193,8 +203,11 @@ export class PrivateCallComponent {
|
||||
|
||||
return shares;
|
||||
});
|
||||
|
||||
readonly featuredShare = computed(() => this.activeShares()[0] ?? null);
|
||||
|
||||
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
||||
|
||||
readonly focusedShareId = computed(() => {
|
||||
const requested = this.focusedStreamId();
|
||||
const activeShares = this.activeShares();
|
||||
@@ -213,7 +226,9 @@ export class PrivateCallComponent {
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null);
|
||||
|
||||
readonly thumbnailShares = computed(() => {
|
||||
const focusedShareId = this.focusedShareId();
|
||||
|
||||
@@ -223,6 +238,37 @@ export class PrivateCallComponent {
|
||||
|
||||
return this.activeShares().filter((share) => share.id !== focusedShareId);
|
||||
});
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
private readonly router = inject(Router);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly store = inject(Store);
|
||||
|
||||
private readonly calls = inject(DirectCallService);
|
||||
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
|
||||
private readonly mobileMedia = inject(MobileMediaService);
|
||||
|
||||
private chatResizing = false;
|
||||
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const callId = this.callId();
|
||||
@@ -300,6 +346,8 @@ export class PrivateCallComponent {
|
||||
this.chatResizing = false;
|
||||
}
|
||||
|
||||
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
|
||||
|
||||
async join(): Promise<void> {
|
||||
const session = this.session();
|
||||
|
||||
@@ -508,8 +556,6 @@ export class PrivateCallComponent {
|
||||
return user.oderId || user.id;
|
||||
}
|
||||
|
||||
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
|
||||
|
||||
private currentUserKey(): string {
|
||||
const user = this.currentUser();
|
||||
|
||||
@@ -659,4 +705,5 @@ export class PrivateCallComponent {
|
||||
private bumpRemoteStreamRevision(): void {
|
||||
this.remoteStreamRevision.update((value) => value + 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
Component,
|
||||
@@ -97,28 +96,25 @@ interface SwiperElement extends HTMLElement {
|
||||
* remains the source of truth and stays in sync with the active slide.
|
||||
*/
|
||||
export class ChatRoomComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
private readonly zone = inject(NgZone);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private lastSeenChannelId: string | null = null;
|
||||
private lastSeenRoomId: string | null = null;
|
||||
private swiperListenerAttached: SwiperElement | null = null;
|
||||
showMenu = signal(false);
|
||||
|
||||
showAdminPanel = signal(false);
|
||||
|
||||
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
||||
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
/**
|
||||
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
|
||||
* with the selected channel name instead of the room name.
|
||||
@@ -132,19 +128,46 @@ export class ChatRoomComponent {
|
||||
|
||||
return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null;
|
||||
});
|
||||
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
hasTextChannels = computed(() => this.textChannels().length > 0);
|
||||
|
||||
activeCall = computed(() => {
|
||||
const currentSession = this.directCalls.currentSession();
|
||||
const visibleSessions = this.directCalls.visibleActiveSessions();
|
||||
|
||||
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
|
||||
});
|
||||
|
||||
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
||||
|
||||
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
||||
|
||||
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
||||
|
||||
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
|
||||
|
||||
private readonly store = inject(Store);
|
||||
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
|
||||
private readonly theme = inject(ThemeService);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
|
||||
private readonly zone = inject(NgZone);
|
||||
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private lastSeenChannelId: string | null = null;
|
||||
|
||||
private lastSeenRoomId: string | null = null;
|
||||
|
||||
private swiperListenerAttached: SwiperElement | null = null;
|
||||
|
||||
constructor() {
|
||||
// When entering a server, always land on the channels list ("first page") on mobile, even
|
||||
// if a default channel is pre-selected. Once inside the server, *changing* channels
|
||||
@@ -237,5 +260,6 @@ export class ChatRoomComponent {
|
||||
this.settingsModal.open('server', room.id);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||
<div
|
||||
appThemeNode="roomPanelHeader"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -141,52 +140,53 @@ const SKELETON_REVEAL_DELAY_MS = 180;
|
||||
templateUrl: './rooms-side-panel.component.html'
|
||||
})
|
||||
export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private store = inject(Store);
|
||||
private signalServerAuth = inject(SignalServerAuthService);
|
||||
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 directCalls = inject(DirectCallService);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
private directMessages = inject(DirectMessageService);
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
private readonly pluginActionMenu = inject(PluginActionMenuService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
|
||||
readonly showVoiceControls = input(true);
|
||||
|
||||
readonly textChannelSelected = output<string>();
|
||||
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
isConnecting = this.store.selectSignal(selectIsConnecting);
|
||||
|
||||
messagesLoading = this.store.selectSignal(selectMessagesLoading);
|
||||
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
|
||||
pluginChannelSections = this.pluginUi.channelSectionRecords;
|
||||
|
||||
pluginMenuActions = this.pluginUi.toolbarActionRecords;
|
||||
|
||||
pluginSidePanels = this.pluginUi.sidePanelRecords;
|
||||
|
||||
panelHydrating = computed(() => this.panelMode() === 'channels' && (this.isConnecting() || this.messagesLoading()));
|
||||
|
||||
delayedPanelHydrating = signal(false);
|
||||
|
||||
showTextChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.textChannels().length === 0);
|
||||
|
||||
showVoiceChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.voiceEnabled() && this.voiceChannels().length === 0);
|
||||
|
||||
showPluginSkeleton = computed(() => this.delayedPanelHydrating() && !this.hasPluginPanelContent());
|
||||
|
||||
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
||||
|
||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||
|
||||
roomMemberIdentifiers = computed(() => {
|
||||
const identifiers = new Set<string>();
|
||||
|
||||
@@ -196,6 +196,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
|
||||
return identifiers;
|
||||
});
|
||||
|
||||
onlineRoomUsers = computed(() => {
|
||||
const memberIdentifiers = this.roomMemberIdentifiers();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
@@ -204,6 +205,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
(user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId)
|
||||
);
|
||||
});
|
||||
|
||||
offlineRoomMembers = computed(() => {
|
||||
const onlineIdentifiers = new Set<string>();
|
||||
|
||||
@@ -215,6 +217,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
|
||||
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
|
||||
});
|
||||
|
||||
knownUserCount = computed(() => {
|
||||
const memberIds = new Set(
|
||||
this.roomMembers()
|
||||
@@ -230,36 +233,92 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return memberIds.size;
|
||||
});
|
||||
|
||||
private hasPluginPanelContent(): boolean {
|
||||
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
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 directCalls = inject(DirectCallService);
|
||||
|
||||
private profileCard = inject(ProfileCardService);
|
||||
|
||||
private directMessages = inject(DirectMessageService);
|
||||
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
|
||||
private readonly pluginActionMenu = inject(PluginActionMenuService);
|
||||
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.panelHydrating()) {
|
||||
@@ -292,15 +351,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.pluginActionMenu.close();
|
||||
}
|
||||
|
||||
private clearSkeletonRevealTimer(): void {
|
||||
if (!this.skeletonRevealTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.skeletonRevealTimer);
|
||||
this.skeletonRevealTimer = null;
|
||||
}
|
||||
|
||||
gameActivityElapsed(user: User | null | undefined): string {
|
||||
const activity = user?.gameActivity;
|
||||
|
||||
@@ -361,102 +411,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
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();
|
||||
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
);
|
||||
|
||||
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
|
||||
}
|
||||
|
||||
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addIdentifiers(identifiers, current);
|
||||
|
||||
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
)) {
|
||||
identifiers.add(selfId);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -643,25 +597,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
|
||||
if (!name) {
|
||||
return 'room.channel.nameRequired';
|
||||
}
|
||||
|
||||
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 'room.channel.nameUnique';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
|
||||
@@ -722,21 +657,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.voiceWorkspace.open(null, { connectRemoteShares: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
isPassiveInVoiceRoom(roomId: string): boolean {
|
||||
const current = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
@@ -778,24 +698,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return this.appI18n.instant('room.panel.joinVoiceChannel');
|
||||
}
|
||||
|
||||
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
|
||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||
}
|
||||
|
||||
private prepareVoiceJoin(room: Room, current: User | null): void {
|
||||
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -833,94 +735,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
startJoin();
|
||||
}
|
||||
|
||||
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 : 'room.voiceJoin.failed';
|
||||
|
||||
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,
|
||||
clientInstanceId: this.realtime.getClientInstanceId()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
const clientInstanceId = this.realtime.getClientInstanceId();
|
||||
|
||||
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,
|
||||
clientInstanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -930,53 +744,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private disconnectCurrentVoiceTarget(current: User | null): void {
|
||||
const previousVoiceState = current?.voiceState;
|
||||
|
||||
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,
|
||||
clientInstanceId: 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: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
voiceOccupancy(roomId: string): number {
|
||||
return this.voiceUsersInRoom(roomId).length;
|
||||
}
|
||||
@@ -1059,47 +826,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
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;
|
||||
|
||||
@@ -1299,6 +1025,343 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return this.appI18n.instant('room.panel.messageUser', { name: displayName });
|
||||
}
|
||||
|
||||
private hasPluginPanelContent(): boolean {
|
||||
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
|
||||
}
|
||||
|
||||
private clearSkeletonRevealTimer(): void {
|
||||
if (!this.skeletonRevealTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.skeletonRevealTimer);
|
||||
this.skeletonRevealTimer = null;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
);
|
||||
|
||||
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
|
||||
}
|
||||
|
||||
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addIdentifiers(identifiers, current);
|
||||
|
||||
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
)) {
|
||||
identifiers.add(selfId);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
|
||||
if (!name) {
|
||||
return 'room.channel.nameRequired';
|
||||
}
|
||||
|
||||
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 'room.channel.nameUnique';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
||||
) {
|
||||
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 prepareVoiceJoin(room: Room, current: User | null): void {
|
||||
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 : 'room.voiceJoin.failed';
|
||||
|
||||
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,
|
||||
clientInstanceId: this.realtime.getClientInstanceId()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
const clientInstanceId = this.realtime.getClientInstanceId();
|
||||
|
||||
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,
|
||||
clientInstanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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}`
|
||||
});
|
||||
}
|
||||
|
||||
private disconnectCurrentVoiceTarget(current: User | null): void {
|
||||
const previousVoiceState = current?.voiceState;
|
||||
|
||||
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,
|
||||
clientInstanceId: 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: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
private isVoiceUserSpeaking(user: User): boolean {
|
||||
const userKey = user.oderId || user.id;
|
||||
|
||||
@@ -1345,4 +1408,5 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private hasActiveVideoStream(stream: MediaStream | null): boolean {
|
||||
return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div
|
||||
#tileRoot
|
||||
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
@@ -56,29 +55,47 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
}
|
||||
})
|
||||
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
||||
|
||||
readonly focused = input(false);
|
||||
|
||||
readonly featured = input(false);
|
||||
|
||||
readonly compact = input(false);
|
||||
|
||||
readonly mini = input(false);
|
||||
|
||||
readonly immersive = input(false);
|
||||
|
||||
readonly focusRequested = output<string>();
|
||||
|
||||
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
|
||||
|
||||
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
||||
|
||||
readonly isFullscreen = signal(false);
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
readonly showFullscreenHeader = signal(true);
|
||||
|
||||
readonly volume = signal(100);
|
||||
|
||||
readonly muted = signal(false);
|
||||
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||
|
||||
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
void this.mobileLifecycle.initialize();
|
||||
this.mobileLifecycle.onAppStateChange((isActive) => {
|
||||
@@ -172,24 +189,6 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
this.unlockOrientation();
|
||||
}
|
||||
|
||||
private async handleAppStateChange(isActive: boolean): Promise<void> {
|
||||
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
|
||||
if (isActive) {
|
||||
await this.mobilePictureInPicture.exit();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const video = this.videoRef()?.nativeElement;
|
||||
|
||||
if (!video || !this.item().stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mobilePictureInPicture.enter(video);
|
||||
}
|
||||
|
||||
canToggleFullscreen(): boolean {
|
||||
return !this.mini() && !this.compact();
|
||||
}
|
||||
@@ -374,6 +373,24 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
return !item.isLocal && item.hasAudio;
|
||||
}
|
||||
|
||||
private async handleAppStateChange(isActive: boolean): Promise<void> {
|
||||
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
|
||||
if (isActive) {
|
||||
await this.mobilePictureInPicture.exit();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const video = this.videoRef()?.nativeElement;
|
||||
|
||||
if (!video || !this.item().stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mobilePictureInPicture.enter(video);
|
||||
}
|
||||
|
||||
private async enterFullscreen(): Promise<void> {
|
||||
const tile = this.tileRef()?.nativeElement;
|
||||
|
||||
@@ -425,6 +442,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
clearTimeout(this.fullscreenHeaderHideTimeoutId);
|
||||
this.fullscreenHeaderHideTimeoutId = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface WebKitFullscreenVideoElement extends HTMLVideoElement {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div class="absolute inset-0">
|
||||
@if (showExpanded()) {
|
||||
<section
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, complexity */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
@@ -88,55 +87,42 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
||||
}
|
||||
})
|
||||
export class VoiceWorkspaceComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private readonly remoteStreamRevision = signal(0);
|
||||
|
||||
private readonly miniWindowWidth = 320;
|
||||
private readonly miniWindowHeight = 228;
|
||||
private miniWindowDragging = false;
|
||||
private miniDragOffsetX = 0;
|
||||
private miniDragOffsetY = 0;
|
||||
private wasExpanded = false;
|
||||
private wasAutoHideChrome = false;
|
||||
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly observedRemoteStreams = new Map<string, {
|
||||
stream: MediaStream;
|
||||
cleanup: () => void;
|
||||
}>();
|
||||
|
||||
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
|
||||
|
||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
readonly voiceSessionInfo = this.voiceSession.voiceSession;
|
||||
|
||||
readonly showExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
|
||||
|
||||
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
|
||||
|
||||
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
|
||||
|
||||
readonly showWorkspaceHeader = signal(true);
|
||||
|
||||
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
||||
|
||||
readonly isMuted = computed(() => this.webrtc.isMuted());
|
||||
|
||||
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
||||
|
||||
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
||||
|
||||
readonly includeSystemAudio = signal(false);
|
||||
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
|
||||
readonly askScreenShareQuality = signal(true);
|
||||
|
||||
readonly showScreenShareQualityDialog = signal(false);
|
||||
|
||||
readonly connectedVoiceUsers = computed(() => {
|
||||
@@ -290,18 +276,23 @@ export class VoiceWorkspaceComponent {
|
||||
});
|
||||
|
||||
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
|
||||
|
||||
readonly shouldAutoHideChrome = computed(
|
||||
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
|
||||
);
|
||||
|
||||
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
||||
|
||||
readonly widescreenShare = computed(
|
||||
() => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null
|
||||
);
|
||||
|
||||
readonly focusedAudioShare = computed(() => {
|
||||
const share = this.widescreenShare();
|
||||
|
||||
return share && !share.isLocal && share.hasAudio ? share : null;
|
||||
});
|
||||
|
||||
readonly focusedShareTitle = computed(() => {
|
||||
const share = this.widescreenShare();
|
||||
|
||||
@@ -317,6 +308,7 @@ export class VoiceWorkspaceComponent {
|
||||
? this.appI18n.instant('voice.workspace.yourCamera')
|
||||
: this.appI18n.instant('voice.workspace.yourScreen');
|
||||
});
|
||||
|
||||
readonly thumbnailShares = computed(() => {
|
||||
const widescreenShareId = this.widescreenShareId();
|
||||
|
||||
@@ -326,9 +318,11 @@ export class VoiceWorkspaceComponent {
|
||||
|
||||
return this.activeShares().filter((share) => share.id !== widescreenShareId);
|
||||
});
|
||||
|
||||
readonly miniPreviewShare = computed(
|
||||
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
|
||||
);
|
||||
|
||||
readonly miniPreviewTitle = computed(() => {
|
||||
const previewShare = this.miniPreviewShare();
|
||||
|
||||
@@ -344,7 +338,9 @@ export class VoiceWorkspaceComponent {
|
||||
? this.appI18n.instant('voice.workspace.yourCamera')
|
||||
: this.appI18n.instant('voice.workspace.yourScreen');
|
||||
});
|
||||
|
||||
readonly liveShareCount = computed(() => this.activeShares().length);
|
||||
|
||||
readonly connectedVoiceChannelName = computed(() => {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
@@ -361,21 +357,55 @@ export class VoiceWorkspaceComponent {
|
||||
|
||||
return sessionRoomName || this.appI18n.instant('voice.workspace.voiceLounge');
|
||||
});
|
||||
|
||||
readonly serverName = computed(
|
||||
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || this.appI18n.instant('voice.workspace.voiceServer')
|
||||
);
|
||||
|
||||
liveStreamCountLabel(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
miniWindowStreamHint(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
|
||||
private readonly store = inject(Store);
|
||||
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private readonly remoteStreamRevision = signal(0);
|
||||
|
||||
private readonly miniWindowWidth = 320;
|
||||
|
||||
private readonly miniWindowHeight = 228;
|
||||
|
||||
private miniWindowDragging = false;
|
||||
|
||||
private miniDragOffsetX = 0;
|
||||
|
||||
private miniDragOffsetY = 0;
|
||||
|
||||
private wasExpanded = false;
|
||||
|
||||
private wasAutoHideChrome = false;
|
||||
|
||||
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly observedRemoteStreams = new Map<string, {
|
||||
stream: MediaStream;
|
||||
cleanup: () => void;
|
||||
}>();
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
@@ -509,14 +539,6 @@ export class VoiceWorkspaceComponent {
|
||||
});
|
||||
}
|
||||
|
||||
onWorkspacePointerMove(): void {
|
||||
if (!this.shouldAutoHideChrome()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealWorkspaceChrome();
|
||||
}
|
||||
|
||||
@HostListener('window:mousemove', ['$event'])
|
||||
onWindowMouseMove(event: MouseEvent): void {
|
||||
if (!this.miniWindowDragging) {
|
||||
@@ -548,6 +570,26 @@ export class VoiceWorkspaceComponent {
|
||||
this.ensureMiniWindowPosition();
|
||||
}
|
||||
|
||||
liveStreamCountLabel(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
|
||||
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
|
||||
miniWindowStreamHint(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
|
||||
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
|
||||
onWorkspacePointerMove(): void {
|
||||
if (!this.shouldAutoHideChrome()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealWorkspaceChrome();
|
||||
}
|
||||
|
||||
trackUser(index: number, user: User): string {
|
||||
return this.getUserPeerKey(user) || `${index}`;
|
||||
}
|
||||
@@ -1057,4 +1099,5 @@ export class VoiceWorkspaceComponent {
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -70,36 +69,32 @@ const ACTIVATION_DEBOUNCE_MS = 150;
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
export class ServersRailComponent {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private notifications = inject(NotificationsFacade);
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private banLookupRequestVersion = 0;
|
||||
private bannedLookupUserKey: string | null = null;
|
||||
private activationRequestVersion = 0;
|
||||
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
private joinRequestVersion = 0;
|
||||
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
private visibleSavedRoomCache: Room[] = [];
|
||||
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
showMenu = signal(false);
|
||||
|
||||
menuX = signal(72);
|
||||
|
||||
menuY = signal(100);
|
||||
|
||||
contextRoom = signal<Room | null>(null);
|
||||
|
||||
optimisticSelectedRoomId = signal<string | null>(null);
|
||||
|
||||
showLeaveConfirm = signal(false);
|
||||
|
||||
showCreateDialog = signal(false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||
|
||||
isOnServers = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -107,6 +102,7 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/servers') }
|
||||
);
|
||||
|
||||
isOnDirectMessage = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -114,6 +110,7 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.isDirectMessageUrl(this.router.url) }
|
||||
);
|
||||
|
||||
isOnDashboard = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -121,6 +118,7 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/dashboard') }
|
||||
);
|
||||
|
||||
isOnCall = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -128,6 +126,7 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/call/') }
|
||||
);
|
||||
|
||||
currentCallId = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -135,6 +134,7 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.callIdFromUrl(this.router.url) }
|
||||
);
|
||||
|
||||
selectedCallIndex = computed(() => {
|
||||
const routeCallId = this.currentCallId();
|
||||
const visibleCalls = this.directCalls.visibleActiveSessions();
|
||||
@@ -155,13 +155,21 @@ export class ServersRailComponent {
|
||||
|
||||
return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
|
||||
});
|
||||
|
||||
bannedServerName = signal('');
|
||||
|
||||
showBannedDialog = signal(false);
|
||||
|
||||
showPasswordDialog = signal(false);
|
||||
|
||||
passwordPromptRoom = signal<Room | null>(null);
|
||||
|
||||
joinPassword = signal('');
|
||||
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
|
||||
visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
|
||||
|
||||
voicePresenceByRoom = computed(() => {
|
||||
const presence: Record<string, number> = {};
|
||||
const seenByRoom = new Map<string, Set<string>>();
|
||||
@@ -203,6 +211,38 @@ export class ServersRailComponent {
|
||||
return presence;
|
||||
});
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
private notifications = inject(NotificationsFacade);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
private bannedLookupUserKey: string | null = null;
|
||||
|
||||
private activationRequestVersion = 0;
|
||||
|
||||
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
private joinRequestVersion = 0;
|
||||
|
||||
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
private visibleSavedRoomCache: Room[] = [];
|
||||
|
||||
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const rooms = this.savedRooms();
|
||||
@@ -241,6 +281,8 @@ export class ServersRailComponent {
|
||||
});
|
||||
}
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name)
|
||||
return '?';
|
||||
@@ -250,8 +292,6 @@ export class ServersRailComponent {
|
||||
return ch || '?';
|
||||
}
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
goToDashboard(): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
@@ -342,13 +382,6 @@ export class ServersRailComponent {
|
||||
return !!this.bannedRoomLookup()[room.id];
|
||||
}
|
||||
|
||||
private callIdFromUrl(url: string): string | null {
|
||||
const path = url.split(/[?#]/, 1)[0];
|
||||
const match = path.match(/^\/call\/([^/]+)/);
|
||||
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
@@ -454,6 +487,13 @@ export class ServersRailComponent {
|
||||
return this.currentRoom()?.id === room.id;
|
||||
}
|
||||
|
||||
private callIdFromUrl(url: string): string | null {
|
||||
const path = url.split(/[?#]/, 1)[0];
|
||||
const match = path.match(/^\/call\/([^/]+)/);
|
||||
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
|
||||
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
|
||||
const stabilizedRooms = nextRooms.map((room) => {
|
||||
@@ -806,4 +846,5 @@ export class ServersRailComponent {
|
||||
|
||||
return nextRoom;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
@@ -35,17 +34,21 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
templateUrl: './bans-settings.component.html'
|
||||
})
|
||||
export class BansSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private actions$ = inject(Actions);
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
bannedUsers = signal<BanEntry[]>([]);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private actions$ = inject(Actions);
|
||||
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const roomId = this.server()?.id;
|
||||
@@ -82,10 +85,6 @@ export class BansSettingsComponent {
|
||||
oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
private async loadBansForServer(roomId: string): Promise<void> {
|
||||
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
@@ -96,4 +95,9 @@ export class BansSettingsComponent {
|
||||
minute: '2-digit' })
|
||||
);
|
||||
}
|
||||
|
||||
private async loadBansForServer(roomId: string): Promise<void> {
|
||||
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -41,16 +40,23 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
||||
templateUrl: './data-settings.component.html'
|
||||
})
|
||||
export class DataSettingsComponent {
|
||||
private readonly electron = inject(ElectronBridgeService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly isElectron = this.electron.isAvailable;
|
||||
|
||||
readonly dataPath = signal<string | null>(null);
|
||||
|
||||
readonly busyAction = signal<DataAction | null>(null);
|
||||
|
||||
readonly statusMessage = signal<string | null>(null);
|
||||
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
readonly restartRequired = signal(false);
|
||||
|
||||
private readonly electron = inject(ElectronBridgeService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
void this.loadDataPath();
|
||||
}
|
||||
@@ -145,4 +151,5 @@ export class DataSettingsComponent {
|
||||
this.busyAction.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -49,35 +48,46 @@ const APP_METRICS_POLL_INTERVAL_MS = 2_000;
|
||||
templateUrl: './debugging-settings.component.html'
|
||||
})
|
||||
export class DebuggingSettingsComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly debugging = inject(DebuggingService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
|
||||
readonly ramLabel = signal<string | null>(null);
|
||||
|
||||
readonly enabled = this.debugging.enabled;
|
||||
|
||||
readonly isConsoleOpen = this.debugging.isConsoleOpen;
|
||||
|
||||
readonly entryCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0);
|
||||
});
|
||||
|
||||
readonly errorCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => {
|
||||
return sum + (entry.level === 'error' ? entry.count : 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly warningCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => {
|
||||
return sum + (entry.level === 'warn' ? entry.count : 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly lastUpdatedLabel = computed(() => {
|
||||
const lastEntry = this.debugging.entries().at(-1);
|
||||
|
||||
return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet');
|
||||
});
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly platform = inject(PlatformService);
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
if (this.isElectron)
|
||||
this.startRamPolling();
|
||||
@@ -118,4 +128,5 @@ export class DebuggingSettingsComponent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -34,20 +33,30 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
||||
templateUrl: './general-settings.component.html'
|
||||
})
|
||||
export class GeneralSettingsComponent {
|
||||
private platform = inject(PlatformService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
|
||||
reopenLastViewedChat = signal(true);
|
||||
|
||||
autoStart = signal(false);
|
||||
|
||||
closeToTray = signal(true);
|
||||
|
||||
savingAutoStart = signal(false);
|
||||
|
||||
savingCloseToTray = signal(false);
|
||||
|
||||
ignoredGameProcesses = signal<string[]>([]);
|
||||
|
||||
ignoredProcessDraft = signal('');
|
||||
|
||||
savingIgnoredGameProcesses = signal(false);
|
||||
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
constructor() {
|
||||
this.loadGeneralSettings();
|
||||
|
||||
@@ -119,31 +128,6 @@ export class GeneralSettingsComponent {
|
||||
input.checked = this.experimentalMedia.vlcJsPlaybackEnabled();
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await api.getDesktopSettings();
|
||||
|
||||
this.applyDesktopSettings(snapshot);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private loadGeneralSettings(): void {
|
||||
const settings = loadGeneralSettingsFromStorage();
|
||||
|
||||
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
|
||||
}
|
||||
|
||||
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
|
||||
this.autoStart.set(snapshot.autoStart);
|
||||
this.closeToTray.set(snapshot.closeToTray);
|
||||
}
|
||||
|
||||
onIgnoredProcessDraftChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
@@ -169,6 +153,31 @@ export class GeneralSettingsComponent {
|
||||
await this.saveIgnoredGameProcesses(next);
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await api.getDesktopSettings();
|
||||
|
||||
this.applyDesktopSettings(snapshot);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private loadGeneralSettings(): void {
|
||||
const settings = loadGeneralSettingsFromStorage();
|
||||
|
||||
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
|
||||
}
|
||||
|
||||
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
|
||||
this.autoStart.set(snapshot.autoStart);
|
||||
this.closeToTray.set(snapshot.closeToTray);
|
||||
}
|
||||
|
||||
private async loadIgnoredGameProcesses(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
@@ -200,4 +209,5 @@ export class GeneralSettingsComponent {
|
||||
this.savingIgnoredGameProcesses.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -52,17 +51,23 @@ import {
|
||||
templateUrl: './ice-server-settings.component.html'
|
||||
})
|
||||
export class IceServerSettingsComponent {
|
||||
private iceSettings = inject(IceServerSettingsService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
entries = this.iceSettings.entries;
|
||||
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newType: 'stun' | 'turn' = 'stun';
|
||||
|
||||
newUrl = '';
|
||||
|
||||
newUsername = '';
|
||||
|
||||
newCredential = '';
|
||||
|
||||
private iceSettings = inject(IceServerSettingsService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
addEntry(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
@@ -134,4 +139,5 @@ export class IceServerSettingsComponent {
|
||||
trackEntry(_index: number, entry: IceServerEntry): string {
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
@@ -29,8 +28,6 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
templateUrl: './local-api-settings.component.html'
|
||||
})
|
||||
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
private readonly bridge = inject(ElectronBridgeService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly isElectron = this.bridge.isAvailable;
|
||||
|
||||
@@ -55,10 +52,15 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
readonly allowedServersText = signal('');
|
||||
|
||||
readonly hasPendingAllowedServersChanges = signal(false);
|
||||
|
||||
readonly portText = signal('17878');
|
||||
|
||||
readonly hasPendingPortChange = signal(false);
|
||||
|
||||
readonly busy = signal(false);
|
||||
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
@@ -81,6 +83,10 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
private readonly bridge = inject(ElectronBridgeService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private statusPollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -278,4 +284,5 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
@@ -56,23 +55,27 @@ interface ServerMemberView extends RoomMember {
|
||||
templateUrl: './members-settings.component.html'
|
||||
})
|
||||
export class MembersSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
accessRole = input<string | null>(null);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
usersEntities = this.store.selectSignal(selectUsersEntities);
|
||||
|
||||
normalizedServer = computed(() => {
|
||||
const room = this.server();
|
||||
|
||||
return room ? normalizeRoomAccessControl(room) : null;
|
||||
});
|
||||
|
||||
assignableRoles = computed<RoomRole[]>(() => findAssignableRoles(this.normalizedServer()?.roles ?? []));
|
||||
|
||||
members = computed<ServerMemberView[]>(() => {
|
||||
@@ -104,6 +107,10 @@ export class MembersSettingsComponent {
|
||||
});
|
||||
});
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
canChangeRoles(member: ServerMemberView): boolean {
|
||||
const room = this.normalizedServer();
|
||||
const currentUser = this.currentUser();
|
||||
@@ -163,4 +170,5 @@ export class MembersSettingsComponent {
|
||||
roomId: room.id,
|
||||
displayName: member.displayName }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -53,25 +52,40 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
||||
templateUrl: './network-settings.component.html'
|
||||
})
|
||||
export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
|
||||
|
||||
readonly provisionNotice = this.provisionNoticeService.notice;
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
|
||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||
|
||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||
|
||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||
|
||||
isTesting = signal(false);
|
||||
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newServerName = '';
|
||||
|
||||
newServerUrl = '';
|
||||
|
||||
autoReconnect = true;
|
||||
|
||||
searchAllServers = true;
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
|
||||
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
|
||||
|
||||
constructor() {
|
||||
this.loadConnectionSettings();
|
||||
}
|
||||
@@ -166,4 +180,5 @@ export class NetworkSettingsComponent {
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
@@ -92,12 +91,13 @@ function upsertRoleChannelOverride(
|
||||
templateUrl: './permissions-settings.component.html'
|
||||
})
|
||||
export class PermissionsSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
server = input<Room | null>(null);
|
||||
|
||||
isAdmin = input(false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
permissionDefinitions = computed(() =>
|
||||
ROOM_PERMISSION_DEFINITIONS.map((definition) => ({
|
||||
key: definition.key,
|
||||
@@ -105,24 +105,30 @@ export class PermissionsSettingsComponent {
|
||||
description: this.appI18n.instant(`permissions.${definition.key}.description`)
|
||||
}))
|
||||
);
|
||||
|
||||
permissionStates: PermissionState[] = [
|
||||
'inherit',
|
||||
'allow',
|
||||
'deny'
|
||||
];
|
||||
|
||||
normalizedServer = computed(() => {
|
||||
const room = this.server();
|
||||
|
||||
return room ? normalizeRoomAccessControl(room) : null;
|
||||
});
|
||||
|
||||
roles = computed<RoomRole[]>(() => sortRolesForDisplay(this.normalizedServer()?.roles ?? []));
|
||||
|
||||
channels = computed(() => this.normalizedServer()?.channels ?? []);
|
||||
|
||||
canManageRoles = computed(() => {
|
||||
const room = this.normalizedServer();
|
||||
const user = this.currentUser();
|
||||
|
||||
return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageRoles'));
|
||||
});
|
||||
|
||||
canManageServer = computed(() => {
|
||||
const room = this.normalizedServer();
|
||||
const user = this.currentUser();
|
||||
@@ -131,10 +137,17 @@ export class PermissionsSettingsComponent {
|
||||
});
|
||||
|
||||
selectedRoleKey: string | null = null;
|
||||
|
||||
selectedChannelKey = '';
|
||||
|
||||
roleName = '';
|
||||
|
||||
roleColor = '#94a3b8';
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const room = this.normalizedServer();
|
||||
@@ -406,4 +419,5 @@ export class PermissionsSettingsComponent {
|
||||
trackRole(_: number, role: RoomRole): string {
|
||||
return role.id;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
@@ -54,37 +53,54 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
||||
templateUrl: './server-settings.component.html'
|
||||
})
|
||||
export class ServerSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private modal = inject(SettingsModalService);
|
||||
private serverIconImages = inject(ServerIconImageService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
/** Whether the current user can manage this server's icon. */
|
||||
canManageIcon = input(false);
|
||||
|
||||
/** Whether the current user can delete this server. */
|
||||
canDeleteServer = input(false);
|
||||
|
||||
roomName = '';
|
||||
|
||||
roomDescription = '';
|
||||
|
||||
isPrivate = signal(false);
|
||||
|
||||
hasPassword = signal(false);
|
||||
|
||||
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
||||
|
||||
passwordError = signal<string | null>(null);
|
||||
|
||||
roomPassword = '';
|
||||
|
||||
maxUsers = 0;
|
||||
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
iconError = signal<string | null>(null);
|
||||
|
||||
saveSuccess = signal<string | null>(null);
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Reload form fields whenever the server input changes. */
|
||||
readonly serverData = this.server;
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private modal = inject(SettingsModalService);
|
||||
|
||||
private serverIconImages = inject(ServerIconImageService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const room = this.server();
|
||||
@@ -243,4 +259,5 @@ export class ServerSettingsComponent {
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -104,14 +103,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
||||
})
|
||||
export class SettingsModalComponent {
|
||||
readonly modal = inject(SettingsModalService);
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private theme = inject(ThemeService);
|
||||
private themeLibrary = inject(ThemeLibraryService);
|
||||
private viewport = inject(ViewportService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
@@ -124,21 +117,30 @@ export class SettingsModalComponent {
|
||||
*/
|
||||
readonly mobilePage = signal<'menu' | 'detail'>('menu');
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
isOpen = this.modal.isOpen;
|
||||
|
||||
activePage = this.modal.activePage;
|
||||
|
||||
themeStudioFullscreen = this.modal.themeStudioFullscreen;
|
||||
|
||||
themeStudioMinimized = this.modal.themeStudioMinimized;
|
||||
|
||||
isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen());
|
||||
|
||||
activeThemeName = this.theme.activeThemeName;
|
||||
|
||||
savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||
|
||||
savedThemes = this.themeLibrary.entries;
|
||||
|
||||
savedThemesBusy = this.themeLibrary.isBusy;
|
||||
|
||||
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||
|
||||
readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
||||
@@ -153,6 +155,7 @@ export class SettingsModalComponent {
|
||||
{ id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' },
|
||||
{ id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' }
|
||||
]);
|
||||
|
||||
readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
||||
{ id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' },
|
||||
{ id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' },
|
||||
@@ -192,6 +195,7 @@ export class SettingsModalComponent {
|
||||
});
|
||||
|
||||
selectedServerId = signal<string | null>(null);
|
||||
|
||||
selectedServer = computed<Room | null>(() => {
|
||||
const id = this.selectedServerId();
|
||||
const currentRoom = this.currentRoom();
|
||||
@@ -294,8 +298,25 @@ export class SettingsModalComponent {
|
||||
});
|
||||
|
||||
animating = signal(false);
|
||||
|
||||
showThirdPartyLicenses = signal(false);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private theme = inject(ThemeService);
|
||||
|
||||
private themeLibrary = inject(ThemeLibraryService);
|
||||
|
||||
private viewport = inject(ViewportService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.isOpen()) {
|
||||
@@ -482,4 +503,5 @@ export class SettingsModalComponent {
|
||||
|
||||
this.themeLibrary.select(matchingTheme?.fileName ?? null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,23 +31,34 @@ type DesktopUpdateStatus =
|
||||
templateUrl: './updates-settings.component.html'
|
||||
})
|
||||
export class UpdatesSettingsComponent {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly desktopUpdates = inject(DesktopAppUpdateService);
|
||||
|
||||
readonly mobileUpdates = inject(MobileAppUpdateService);
|
||||
|
||||
readonly isElectron = this.desktopUpdates.isElectron;
|
||||
|
||||
readonly isCapacitor = this.mobileUpdates.isCapacitor;
|
||||
|
||||
readonly state = this.desktopUpdates.state;
|
||||
|
||||
readonly mobileState = this.mobileUpdates.state;
|
||||
|
||||
readonly mobileStatusLabel = computed(() =>
|
||||
this.getMobileStatusLabel(this.mobileState().status)
|
||||
);
|
||||
|
||||
readonly hasPendingManifestUrlChanges = signal(false);
|
||||
|
||||
readonly manifestUrlsText = signal('');
|
||||
|
||||
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
|
||||
|
||||
readonly isUsingConnectedServerDefaults = computed(() => {
|
||||
return this.state().configuredManifestUrls.length === 0;
|
||||
});
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.hasPendingManifestUrlChanges()) {
|
||||
@@ -178,4 +189,5 @@ export class UpdatesSettingsComponent {
|
||||
|
||||
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -53,13 +52,10 @@ interface AudioDevice {
|
||||
templateUrl: './voice-settings.component.html'
|
||||
})
|
||||
export class VoiceSettingsComponent {
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private platform = inject(PlatformService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly audioService = inject(NotificationAudioService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
|
||||
readonly screenShareQualityOptions = computed(() =>
|
||||
SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({
|
||||
id: option.id,
|
||||
@@ -69,26 +65,51 @@ export class VoiceSettingsComponent {
|
||||
);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
|
||||
selectedInputDevice = signal<string>('');
|
||||
|
||||
selectedOutputDevice = signal<string>('');
|
||||
|
||||
inputVolume = signal(100);
|
||||
|
||||
outputVolume = signal(100);
|
||||
|
||||
audioBitrate = signal(96);
|
||||
|
||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||
|
||||
includeSystemAudio = signal(false);
|
||||
|
||||
noiseReduction = signal(true);
|
||||
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
|
||||
askScreenShareQuality = signal(true);
|
||||
|
||||
hardwareAcceleration = signal(true);
|
||||
|
||||
hardwareAccelerationRestartRequired = signal(false);
|
||||
|
||||
readonly selectedScreenShareQualityDescription = computed(
|
||||
() => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? ''
|
||||
);
|
||||
|
||||
readonly notificationVolumePercent = computed(() =>
|
||||
String(Math.round(this.audioService.notificationVolume() * 100))
|
||||
);
|
||||
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
this.loadVoiceSettings();
|
||||
this.loadAudioDevices();
|
||||
@@ -291,4 +312,5 @@ export class VoiceSettingsComponent {
|
||||
this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
|
||||
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -63,26 +62,40 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../shared/dir
|
||||
* Settings page for managing signaling servers and connection preferences.
|
||||
*/
|
||||
export class SettingsComponent implements OnInit {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private router = inject(Router);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
audioService = inject(NotificationAudioService);
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
|
||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||
|
||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||
|
||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||
|
||||
isTesting = signal(false);
|
||||
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newServerName = '';
|
||||
|
||||
newServerUrl = '';
|
||||
|
||||
autoReconnect = true;
|
||||
|
||||
searchAllServers = true;
|
||||
|
||||
noiseReduction = true;
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
/** Load persisted connection settings on component init. */
|
||||
ngOnInit(): void {
|
||||
this.loadConnectionSettings();
|
||||
@@ -233,4 +246,5 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -59,13 +59,19 @@ const NON_TEXT_INPUT_TYPES = new Set([
|
||||
})
|
||||
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
params = signal<ContextMenuParams | null>(null);
|
||||
|
||||
customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null);
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private cleanup: (() => void) | null = null;
|
||||
|
||||
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
||||
|
||||
@HostListener('document:contextmenu', ['$event'])
|
||||
@@ -709,4 +715,5 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
return typeof navigator !== 'undefined'
|
||||
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div
|
||||
appThemeNode="titleBar"
|
||||
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -82,39 +81,37 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||
* Electron-style title bar with window controls, navigation, and server menu.
|
||||
*/
|
||||
export class TitleBarComponent {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private store = inject(Store);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private router = inject(Router);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private platform = inject(PlatformService);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private pluginRegistry = inject(PluginRegistryService);
|
||||
private pluginRequirements = inject(PluginRequirementStateService);
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
|
||||
isElectron = computed(() => this.platform.isElectron);
|
||||
|
||||
showMenuState = computed(() => false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest'));
|
||||
|
||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer'));
|
||||
|
||||
isConnected = computed(() => this.webrtc.isConnected());
|
||||
|
||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||
|
||||
isAuthed = computed(() => !!this.currentUser());
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||
|
||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||
|
||||
isInDirectMessage = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -122,6 +119,7 @@ export class TitleBarComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/dm/') }
|
||||
);
|
||||
|
||||
isInRoomView = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -129,8 +127,11 @@ export class TitleBarComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/room/') }
|
||||
);
|
||||
|
||||
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
|
||||
|
||||
roomName = computed(() => this.currentRoom()?.name || '');
|
||||
|
||||
activeTextChannelName = computed(() => {
|
||||
const textChannels = this.textChannels();
|
||||
|
||||
@@ -143,12 +144,14 @@ export class TitleBarComponent {
|
||||
|
||||
return activeChannel ? activeChannel.name : id;
|
||||
});
|
||||
|
||||
connectedVoiceChannelName = computed(() => {
|
||||
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
|
||||
|
||||
return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge');
|
||||
});
|
||||
|
||||
roomContextMeta = computed(() => {
|
||||
if (!this.currentRoom()) {
|
||||
return '';
|
||||
@@ -162,9 +165,11 @@ export class TitleBarComponent {
|
||||
|
||||
return parts.join(' | ');
|
||||
});
|
||||
|
||||
showRoomCompatibilityNotice = computed(() =>
|
||||
this.inRoom() && !!this.signalServerCompatibilityError()
|
||||
);
|
||||
|
||||
showRoomReconnectNotice = computed(() =>
|
||||
this.inRoom()
|
||||
&& !this.signalServerCompatibilityError()
|
||||
@@ -174,21 +179,57 @@ export class TitleBarComponent {
|
||||
|| this.isReconnecting()
|
||||
)
|
||||
);
|
||||
|
||||
serverPluginCount = computed(() => this.pluginRegistry.entries()
|
||||
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
|
||||
.length);
|
||||
|
||||
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
|
||||
|
||||
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
|
||||
|
||||
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
|
||||
|
||||
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
|
||||
private _showMenu = signal(false);
|
||||
|
||||
showMenu = computed(() => this._showMenu());
|
||||
|
||||
showLeaveConfirm = signal(false);
|
||||
|
||||
inviteStatus = signal<string | null>(null);
|
||||
|
||||
creatingInvite = signal(false);
|
||||
|
||||
pluginRequirementBusy = signal(false);
|
||||
|
||||
pluginRequirementError = signal<string | null>(null);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
|
||||
private pluginRegistry = inject(PluginRegistryService);
|
||||
|
||||
private pluginRequirements = inject(PluginRequirementStateService);
|
||||
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
|
||||
private _showMenu = signal(false);
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
const api = this.getWindowControlsApi();
|
||||
@@ -256,15 +297,6 @@ export class TitleBarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
if (this.currentRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the server dropdown menu. */
|
||||
toggleMenu() {
|
||||
this.inviteStatus.set(null);
|
||||
@@ -354,6 +386,34 @@ export class TitleBarComponent {
|
||||
this._showMenu.set(false);
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
clearStoredCurrentUserId();
|
||||
this.store.dispatch(MessagesActions.clearMessages());
|
||||
this.store.dispatch(RoomsActions.resetRoomsState());
|
||||
this.store.dispatch(UsersActions.resetUsersState());
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
if (this.currentRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
|
||||
const room = this.currentRoom();
|
||||
|
||||
@@ -373,21 +433,6 @@ export class TitleBarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
clearStoredCurrentUserId();
|
||||
this.store.dispatch(MessagesActions.clearMessages());
|
||||
this.store.dispatch(RoomsActions.resetRoomsState());
|
||||
this.store.dispatch(UsersActions.resetUsersState());
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
@@ -427,4 +472,5 @@ export class TitleBarComponent {
|
||||
sourceUrl: room.sourceUrl
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user