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
420 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|