/* eslint-disable @angular-eslint/component-class-suffix */ import { Component, OnInit, OnDestroy, computed, effect, inject, HostListener, signal, Type } from '@angular/core'; import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideX } from '@ng-icons/lucide'; import { DatabaseService, loadGeneralSettingsFromStorage, loadLastViewedChatFromStorage } from './infrastructure/persistence'; import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; import { ServerDirectoryFacade } from './domains/server-directory'; import { NotificationsFacade } from './domains/notifications'; import { TimeSyncService } from './core/services/time-sync.service'; import { VoiceSessionFacade } from './domains/voice-session'; import { ExternalLinkService } from './core/platform'; import { SettingsModalService } from './core/services/settings-modal.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ServersRailComponent } from './features/servers/servers-rail.component'; import { TitleBarComponent } from './features/shell/title-bar.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; import { UsersActions } from './store/users/users.actions'; import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants'; import { ThemeNodeDirective, ThemePickerOverlayComponent, ThemeService } from './domains/theme'; @Component({ selector: 'app-root', imports: [ CommonModule, NgIcon, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent, SettingsModalComponent, DebugConsoleComponent, ScreenShareSourcePickerComponent, ThemeNodeDirective, ThemePickerOverlayComponent ], viewProviders: [ provideIcons({ lucideX }) ], templateUrl: './app.html', styleUrl: './app.scss' }) export class App implements OnInit, OnDestroy { private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16; private static readonly TITLE_BAR_HEIGHT = 40; store = inject(Store); currentRoom = this.store.selectSignal(selectCurrentRoom); desktopUpdates = inject(DesktopAppUpdateService); desktopUpdateState = this.desktopUpdates.state; readonly databaseService = inject(DatabaseService); readonly router = inject(Router); readonly servers = inject(ServerDirectoryFacade); readonly notifications = inject(NotificationsFacade); readonly settingsModal = inject(SettingsModalService); readonly timeSync = inject(TimeSyncService); readonly theme = inject(ThemeService); readonly voiceSession = inject(VoiceSessionFacade); readonly externalLinks = inject(ExternalLinkService); readonly electronBridge = inject(ElectronBridgeService); readonly dismissedDesktopUpdateNoticeKey = signal(null); readonly themeStudioFullscreenComponent = signal | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); readonly isDraggingThemeStudioControls = signal(false); readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell')); readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail')); readonly appWorkspaceLayoutStyles = computed(() => this.theme.getLayoutItemStyles('appWorkspace')); readonly isThemeStudioFullscreen = computed(() => { return this.settingsModal.isOpen() && this.settingsModal.activePage() === 'theme' && this.settingsModal.themeStudioFullscreen(); }); readonly isThemeStudioMinimized = computed(() => { return this.settingsModal.activePage() === 'theme' && this.settingsModal.themeStudioMinimized(); }); readonly desktopUpdateNoticeKey = computed(() => { const updateState = this.desktopUpdateState(); return updateState.targetVersion?.trim() || updateState.latestVersion?.trim() || `restart:${updateState.currentVersion}`; }); readonly showDesktopUpdateNotice = computed(() => { return this.desktopUpdateState().restartRequired && this.dismissedDesktopUpdateNoticeKey() !== this.desktopUpdateNoticeKey(); }); readonly appWorkspaceShellStyles = computed(() => { const workspaceStyles = this.appWorkspaceLayoutStyles(); if (!this.isThemeStudioFullscreen()) { return workspaceStyles; } return { ...workspaceStyles, gridColumn: '1 / -1', gridRow: '1 / -1' }; }); readonly themeStudioControlsPositionStyles = computed(() => { const position = this.themeStudioControlsPosition(); if (!position) { return { right: `${App.THEME_STUDIO_CONTROLS_MARGIN}px`, bottom: `${App.THEME_STUDIO_CONTROLS_MARGIN}px` }; } return { left: `${position.x}px`, top: `${position.y}px` }; }); private deepLinkCleanup: (() => void) | null = null; private themeStudioControlsDragOffset: { x: number; y: number } | null = null; private themeStudioControlsBounds: { width: number; height: number } | null = null; constructor() { effect(() => { if (!this.isThemeStudioFullscreen() || this.themeStudioFullscreenComponent()) { return; } void import('./domains/theme/feature/settings/theme-settings.component') .then((module) => { this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent); }); }); effect(() => { if (this.isThemeStudioFullscreen()) { return; } this.isDraggingThemeStudioControls.set(false); this.themeStudioControlsDragOffset = null; this.themeStudioControlsBounds = null; }); } @HostListener('document:click', ['$event']) onGlobalLinkClick(evt: MouseEvent): void { this.externalLinks.handleClick(evt); } @HostListener('document:keydown', ['$event']) onGlobalKeyDown(evt: KeyboardEvent): void { this.theme.handleGlobalShortcut(evt); } @HostListener('document:pointermove', ['$event']) onDocumentPointerMove(event: PointerEvent): void { if (!this.isDraggingThemeStudioControls() || !this.themeStudioControlsDragOffset || !this.themeStudioControlsBounds) { return; } this.themeStudioControlsPosition.set(this.clampThemeStudioControlsPosition( event.clientX - this.themeStudioControlsDragOffset.x, event.clientY - this.themeStudioControlsDragOffset.y, this.themeStudioControlsBounds.width, this.themeStudioControlsBounds.height )); } @HostListener('document:pointerup') @HostListener('document:pointercancel') onDocumentPointerEnd(): void { if (!this.isDraggingThemeStudioControls()) { return; } this.isDraggingThemeStudioControls.set(false); this.themeStudioControlsDragOffset = null; this.themeStudioControlsBounds = null; } async ngOnInit(): Promise { this.theme.initialize(); void this.desktopUpdates.initialize(); await this.databaseService.initialize(); try { const apiBase = this.servers.getApiBaseUrl(); await this.timeSync.syncWithEndpoint(apiBase); } catch {} await this.notifications.initialize(); await this.setupDesktopDeepLinks(); this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(RoomsActions.loadRooms()); const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID); if (!currentUserId) { if (!this.isPublicRoute(this.router.url)) { this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } }).catch(() => {}); } } else { const current = this.router.url; const generalSettings = loadGeneralSettingsFromStorage(); const lastViewedChat = loadLastViewedChatFromStorage(currentUserId); if ( generalSettings.reopenLastViewedChat && lastViewedChat && (current === '/' || current === '/search') ) { this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {}); } } this.router.events.subscribe((evt) => { if (evt instanceof NavigationEnd) { const url = evt.urlAfterRedirects || evt.url; const roomMatch = url.match(ROOM_URL_PATTERN); const currentRoomId = roomMatch ? roomMatch[1] : null; this.voiceSession.checkCurrentRoute(currentRoomId); } }); } ngOnDestroy(): void { this.deepLinkCleanup?.(); this.deepLinkCleanup = null; } openNetworkSettings(): void { this.settingsModal.open('network'); } openUpdatesSettings(): void { this.settingsModal.open('updates'); } dismissDesktopUpdateNotice(): void { if (!this.desktopUpdateState().restartRequired) { return; } this.dismissedDesktopUpdateNoticeKey.set(this.desktopUpdateNoticeKey()); } startThemeStudioControlsDrag(event: PointerEvent, controlsElement: HTMLElement): void { if (event.button !== 0) { return; } const rect = controlsElement.getBoundingClientRect(); this.themeStudioControlsBounds = { width: rect.width, height: rect.height }; this.themeStudioControlsDragOffset = { x: event.clientX - rect.left, y: event.clientY - rect.top }; this.themeStudioControlsPosition.set({ x: rect.left, y: rect.top }); this.isDraggingThemeStudioControls.set(true); event.preventDefault(); } reopenThemeStudio(): void { this.settingsModal.restoreMinimizedThemeStudio(); } minimizeThemeStudio(): void { this.settingsModal.minimizeThemeStudio(); } dismissMinimizedThemeStudio(): void { this.settingsModal.dismissMinimizedThemeStudio(); } closeThemeStudio(): void { this.settingsModal.close(); } async refreshDesktopUpdateContext(): Promise { await this.desktopUpdates.refreshServerContext(); } async restartToApplyUpdate(): Promise { await this.desktopUpdates.restartToApplyUpdate(); } private clampThemeStudioControlsPosition(left: number, top: number, width: number, height: number): { x: number; y: number } { const minX = App.THEME_STUDIO_CONTROLS_MARGIN; const minY = App.TITLE_BAR_HEIGHT + App.THEME_STUDIO_CONTROLS_MARGIN; const maxX = Math.max(minX, window.innerWidth - width - App.THEME_STUDIO_CONTROLS_MARGIN); const maxY = Math.max(minY, window.innerHeight - height - App.THEME_STUDIO_CONTROLS_MARGIN); return { x: Math.min(Math.max(minX, left), maxX), y: Math.min(Math.max(minY, top), maxY) }; } private async setupDesktopDeepLinks(): Promise { const electronApi = this.electronBridge.getApi(); if (!electronApi) { return; } this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => { void this.handleDesktopDeepLink(url); }) || null; const pendingDeepLink = await electronApi.consumePendingDeepLink?.(); if (pendingDeepLink) { await this.handleDesktopDeepLink(pendingDeepLink); } } private async handleDesktopDeepLink(url: string): Promise { const invite = this.parseDesktopInviteUrl(url); if (!invite) { return; } await this.router.navigate(['/invite', invite.inviteId], { queryParams: { server: invite.sourceUrl } }); } private isPublicRoute(url: string): boolean { return url === '/login' || url === '/register' || url.startsWith('/invite/'); } private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null { try { const parsedUrl = new URL(url); if (parsedUrl.protocol !== 'toju:') { return null; } const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)] .map((segment) => decodeURIComponent(segment)); if (pathSegments[0] !== 'invite' || !pathSegments[1]) { return null; } const sourceUrl = parsedUrl.searchParams.get('server')?.trim(); if (!sourceUrl) { return null; } return { inviteId: pathSegments[1], sourceUrl }; } catch { return null; } } }