Files
Toju/toju-app/src/app/app.ts
Myx ae0ee8fac7
All checks were successful
Queue Release Build / prepare (push) Successful in 11s
Deploy Web Apps / deploy (push) Successful in 14m0s
Queue Release Build / build-linux (push) Successful in 35m41s
Queue Release Build / build-windows (push) Successful in 28m53s
Queue Release Build / finalize (push) Successful in 2m6s
Fix lint, make design more consistent, add license texts,
2026-04-02 04:08:53 +02:00

420 lines
13 KiB
TypeScript

/* 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<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | 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<void> {
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<void> {
await this.desktopUpdates.refreshServerContext();
}
async restartToApplyUpdate(): Promise<void> {
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<void> {
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<void> {
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;
}
}
}