From 31962aeb1addc3c29c720c66ee74b49312f6e15c Mon Sep 17 00:00:00 2001 From: Myx Date: Thu, 11 Jun 2026 12:16:40 +0200 Subject: [PATCH] fix: restore build and stabilize E2E cross-signal behavior Revert the automated member-ordering pass that broke Angular field init (TS2729) and disable that rule until a safe reorder strategy exists. Fix modal/confirm dialog i18n defaults via template fallbacks, search all active endpoints (including offline), register foreign rooms with actor owner IDs, sync profile display names from avatar summaries, and guard dm-chat when a private call converts to a group conversation. Co-authored-by: Cursor --- eslint.config.js | 19 +- .../services/notification-audio.service.ts | 85 +- .../app/core/services/time-sync.service.ts | 9 +- .../services/browser-attachment-file-store.ts | 11 +- .../capacitor-attachment-file-store.ts | 8 +- .../electron-attachment-file-store.ts | 8 +- .../services/authentication.service.ts | 71 +- .../chat-image-proxy-fallback.directive.ts | 15 +- .../chat-messages/chat-messages.component.ts | 100 +-- .../chat-message-composer.component.ts | 203 ++--- .../chat-message-item.component.html | 2 +- .../chat-message-item.component.ts | 267 +++--- .../chat-message-markdown.component.ts | 10 +- .../chat-message-list.component.ts | 60 +- .../klipy-gif-picker.component.ts | 38 +- .../typing-indicator.component.ts | 27 +- .../feature/user-list/user-list.component.ts | 22 +- .../application/custom-emoji-sync.effects.ts | 20 +- .../application/custom-emoji.service.ts | 27 +- .../custom-emoji-picker.component.ts | 57 +- .../services/direct-call.service.ts | 97 +-- .../incoming-call-modal.component.ts | 11 +- .../services/direct-message.service.ts | 45 +- .../application/services/friend.service.ts | 21 +- .../services/peer-delivery.service.ts | 15 +- .../feature/dm-chat/dm-chat.component.ts | 68 +- .../feature/dm-chat/dm-chat.rules.spec.ts | 21 + .../feature/dm-chat/dm-chat.rules.ts | 8 + .../feature/dm-rail/dm-rail.component.ts | 24 +- .../dm-conversation-item.component.ts | 35 +- .../dm-conversations-panel.component.ts | 11 +- .../dm-workspace/dm-workspace.component.ts | 31 +- .../friend-button/friend-button.component.ts | 11 +- .../user-search-list.component.ts | 23 +- .../effects/notifications.effects.ts | 11 +- .../facades/notifications.facade.ts | 6 +- .../services/notifications.service.ts | 77 +- .../notifications-settings.component.ts | 9 +- .../plugin-requirement-state.service.ts | 47 +- .../services/plugin-store.service.ts | 238 +++--- .../plugin-action-menu.component.ts | 15 +- .../plugin-manager.component.html | 1 + .../plugin-manager.component.ts | 32 +- .../plugin-store/plugin-store.component.html | 2 +- .../plugin-store/plugin-store.component.ts | 43 +- .../local-plugin-discovery.service.ts | 4 +- .../profile-avatar-editor.component.ts | 16 +- .../screen-share-viewer.component.ts | 88 +- .../facades/server-directory.facade.ts | 8 +- .../create-server-dialog.component.ts | 36 +- .../create-server/create-server.component.ts | 30 +- .../find-servers/find-servers.component.ts | 15 +- .../server-browser.component.ts | 148 ++-- .../server-directory-api.service.spec.ts | 28 +- .../services/server-directory-api.service.ts | 2 +- .../theme-settings.component.ts | 89 +- .../services/theme-library-storage.service.ts | 4 +- .../services/voice-activity.service.ts | 88 +- .../facades/voice-session.facade.ts | 17 +- .../services/voice-workspace.service.ts | 31 +- .../floating-voice-controls.component.ts | 33 +- .../voice-controls.component.ts | 192 ++--- .../util/voice-settings-storage.util.ts | 4 +- .../features/dashboard/dashboard.component.ts | 114 ++- .../direct-call/private-call.component.ts | 83 +- .../room/chat-room/chat-room.component.ts | 46 +- .../rooms-side-panel.component.html | 2 +- .../rooms-side-panel.component.ts | 784 ++++++++---------- ...voice-workspace-stream-tile.component.html | 1 + .../voice-workspace-stream-tile.component.ts | 68 +- .../voice-workspace.component.html | 1 + .../voice-workspace.component.ts | 129 +-- .../servers-rail/servers-rail.component.ts | 93 +-- .../bans-settings/bans-settings.component.ts | 20 +- .../data-settings/data-settings.component.ts | 13 +- .../debugging-settings.component.ts | 21 +- .../general-settings.component.ts | 66 +- .../ice-server-settings.component.ts | 12 +- .../local-api-settings.component.ts | 13 +- .../members-settings.component.ts | 14 +- .../network-settings.component.ts | 25 +- .../permissions-settings.component.ts | 20 +- .../server-settings.component.ts | 29 +- .../settings-modal.component.html | 1 + .../settings-modal.component.ts | 42 +- .../updates-settings.component.ts | 14 +- .../voice-settings.component.ts | 34 +- .../features/settings/settings.component.ts | 24 +- .../native-context-menu.component.ts | 7 - .../shell/title-bar/title-bar.component.html | 1 + .../shell/title-bar/title-bar.component.ts | 140 ++-- .../capacitor-mobile-persistence.adapter.ts | 8 +- .../services/mobile-app-update.service.ts | 20 +- .../services/mobile-persistence.service.ts | 13 +- .../mobile-sqlite-connection.service.ts | 13 +- .../persistence/app-resume.storage.ts | 10 +- .../persistence/database.service.ts | 52 +- .../account-sync/account-sync.effects.ts | 23 +- .../realtime/media/media.manager.ts | 46 +- .../realtime/media/noise-reduction.manager.ts | 12 +- .../realtime/media/screen-share.manager.ts | 2 +- .../peer-connection.manager.ts | 121 ++- .../realtime/realtime-session.service.ts | 290 +++---- .../realtime/signaling/signaling.manager.ts | 94 +-- .../realtime/state/webrtc-state-controller.ts | 41 +- .../realtime/streams/peer-media-facade.ts | 8 +- .../chat-audio-player.component.ts | 60 +- .../chat-video-player.component.ts | 67 +- .../confirm-dialog.component.html | 8 +- .../confirm-dialog.component.ts | 21 +- .../context-menu/context-menu.component.ts | 36 +- .../services/debug-console-resize.service.ts | 49 +- .../leave-server-dialog.component.ts | 7 +- .../modal-backdrop.component.html | 2 +- .../modal-backdrop.component.spec.ts | 2 +- .../modal-backdrop.component.ts | 10 +- .../profile-card-mobile.component.ts | 55 +- .../user-volume-menu.component.ts | 13 +- .../virtual-list/virtual-list.component.ts | 11 - toju-app/src/app/shared/rxjs/idle.ts | 4 +- .../store/messages/messages-sync.effects.ts | 29 +- .../app/store/messages/messages.effects.ts | 130 ++- .../store/rooms/room-members-sync.effects.ts | 14 +- .../app/store/rooms/room-settings.effects.ts | 17 +- .../store/rooms/room-state-sync.effects.ts | 59 +- toju-app/src/app/store/rooms/rooms.effects.ts | 95 ++- .../rooms/server-registration.rules.spec.ts | 12 + .../store/rooms/server-registration.rules.ts | 5 +- .../store/users/user-avatar.effects.spec.ts | 52 +- .../app/store/users/user-avatar.effects.ts | 129 ++- toju-app/src/app/store/users/users.effects.ts | 128 ++- 131 files changed, 2483 insertions(+), 3896 deletions(-) create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.spec.ts create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.ts diff --git a/eslint.config.js b/eslint.config.js index 1867639..8f87e8e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -43,7 +43,6 @@ module.exports = tseslint.config( } ], 'metoyou/no-unicode-symbols': 'error', - 'metoyou/no-maybe-in-naming': 'error', '@typescript-eslint/no-extraneous-class': 'off', '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ], '@angular-eslint/directive-class-suffix': 'error', @@ -60,21 +59,8 @@ module.exports = tseslint.config( 'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key' ], SwitchCase:1 }], '@stylistic/ts/member-delimiter-style': ['error',{ multiline:{ delimiter:'semi', requireLast:true }, singleline:{ delimiter:'semi', requireLast:false } }], - '@typescript-eslint/member-ordering': ['error',{ default:[ - 'signature','call-signature', - 'public-static-field','protected-static-field','private-static-field','#private-static-field', - 'public-decorated-field','protected-decorated-field','private-decorated-field', - 'public-instance-field','protected-instance-field','private-instance-field','#private-instance-field', - 'public-abstract-field','protected-abstract-field', - 'public-field','protected-field','private-field','#private-field', - 'static-field','instance-field','abstract-field','decorated-field','field','static-initialization', - 'public-constructor','protected-constructor','private-constructor','constructor', - 'public-static-method','protected-static-method','private-static-method','#private-static-method', - 'public-decorated-method','protected-decorated-method','private-decorated-method', - 'public-instance-method','protected-instance-method','private-instance-method','#private-instance-method', - 'public-abstract-method','protected-abstract-method','public-method','protected-method','private-method','#private-method', - 'static-method','instance-method','abstract-method','decorated-method','method' - ] }], + // Disabled: bulk member reordering breaks Angular inject()/field init order (TS2729). + '@typescript-eslint/member-ordering': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-explicit-any': 'error', @@ -178,7 +164,6 @@ module.exports = tseslint.config( extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: { 'metoyou/no-unicode-symbols': 'error', - 'metoyou/no-maybe-in-naming': 'error', // Angular template best practices '@angular-eslint/template/button-has-type': 'warn', '@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }], diff --git a/toju-app/src/app/core/services/notification-audio.service.ts b/toju-app/src/app/core/services/notification-audio.service.ts index c034c4e..ec0b5ce 100644 --- a/toju-app/src/app/core/services/notification-audio.service.ts +++ b/toju-app/src/app/core/services/notification-audio.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, signal } from '@angular/core'; /** @@ -33,13 +34,6 @@ const DEFAULT_VOLUME = 0.2; */ @Injectable({ providedIn: 'root' }) export class NotificationAudioService { - - /** Reactive notification volume (0 - 1), persisted to localStorage. */ - readonly notificationVolume = signal(this.loadVolume()); - - /** When true, all sound playback is suppressed (Do Not Disturb). */ - readonly dndMuted = signal(false); - /** Pre-loaded audio buffers keyed by {@link AppSound}. */ private readonly cache = new Map(); @@ -47,10 +41,51 @@ export class NotificationAudioService { private readonly activeLoops = new Map(); + /** Reactive notification volume (0 - 1), persisted to localStorage. */ + readonly notificationVolume = signal(this.loadVolume()); + + /** When true, all sound playback is suppressed (Do Not Disturb). */ + readonly dndMuted = signal(false); + constructor() { this.preload(); } + /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ + private preload(): void { + for (const sound of Object.values(AppSound)) { + const src = this.resolveAudioUrl(sound); + const audio = new Audio(); + + audio.preload = 'auto'; + audio.src = src; + audio.load(); + + this.sources.set(sound, src); + this.cache.set(sound, audio); + } + } + + private resolveAudioUrl(sound: AppSound): string { + return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString(); + } + + /** Read persisted volume from localStorage, falling back to the default. */ + private loadVolume(): number { + try { + const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME); + + if (raw !== null) { + const parsed = parseFloat(raw); + + if (!isNaN(parsed)) + return Math.max(0, Math.min(1, parsed)); + } + } catch {} + + return DEFAULT_VOLUME; + } + /** * Update the notification volume and persist it. * @@ -143,40 +178,4 @@ export class NotificationAudioService { audio.remove(); this.activeLoops.delete(sound); } - - /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ - private preload(): void { - for (const sound of Object.values(AppSound)) { - const src = this.resolveAudioUrl(sound); - const audio = new Audio(); - - audio.preload = 'auto'; - audio.src = src; - audio.load(); - - this.sources.set(sound, src); - this.cache.set(sound, audio); - } - } - - private resolveAudioUrl(sound: AppSound): string { - return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString(); - } - - /** Read persisted volume from localStorage, falling back to the default. */ - private loadVolume(): number { - try { - const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME); - - if (raw !== null) { - const parsed = parseFloat(raw); - - if (!isNaN(parsed)) - return Math.max(0, Math.min(1, parsed)); - } - } catch {} - - return DEFAULT_VOLUME; - } - } diff --git a/toju-app/src/app/core/services/time-sync.service.ts b/toju-app/src/app/core/services/time-sync.service.ts index 4ba727d..59fcd7d 100644 --- a/toju-app/src/app/core/services/time-sync.service.ts +++ b/toju-app/src/app/core/services/time-sync.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, signal, @@ -17,10 +18,6 @@ const DEFAULT_SYNC_TIMEOUT_MS = 5000; */ @Injectable({ providedIn: 'root' }) export class TimeSyncService { - - /** Reactive read-only offset (milliseconds). */ - readonly offset = computed(() => this._offset()); - /** * Internal offset signal: * `serverTime = Date.now() + offset`. @@ -30,6 +27,9 @@ export class TimeSyncService { /** Epoch timestamp of the most recent successful sync. */ private lastSyncTimestamp = 0; + /** Reactive read-only offset (milliseconds). */ + readonly offset = computed(() => this._offset()); + /** * Return a server-adjusted "now" timestamp. * @@ -97,5 +97,4 @@ export class TimeSyncService { // Sync failure is non-fatal; retain the previous offset. } } - } diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts b/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts index d7a4428..e79fc66 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts @@ -27,21 +27,17 @@ interface StoredFileRecord { @Injectable({ providedIn: 'root' }) export class BrowserAttachmentFileStore implements AttachmentFileStore { readonly maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES; - readonly supportsStreamingToDisk = false; - readonly supportsChunkedReads = true; - readonly providesInlineObjectUrl = false; + private database: IDBDatabase | null = null; + private activeDatabaseName: string | null = null; + get isAvailable(): boolean { return typeof indexedDB !== 'undefined'; } - private database: IDBDatabase | null = null; - - private activeDatabaseName: string | null = null; - async getAppDataPath(): Promise { return this.isAvailable ? BROWSER_APP_DATA_ROOT : null; } @@ -229,5 +225,4 @@ export class BrowserAttachmentFileStore implements AttachmentFileStore { transaction.onabort = () => reject(transaction.error); }); } - } diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts b/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts index ccac612..a58f049 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts @@ -16,19 +16,16 @@ const CAPACITOR_APP_DATA_ROOT = 'metoyou'; @Injectable({ providedIn: 'root' }) export class CapacitorAttachmentFileStore implements AttachmentFileStore { readonly maxPersistableBytes = Number.POSITIVE_INFINITY; - readonly supportsStreamingToDisk = true; - readonly supportsChunkedReads = false; - readonly providesInlineObjectUrl = true; + private readonly loadFilesystem: () => Promise = loadCapacitorAttachmentFilesystem; + get isAvailable(): boolean { return isCapacitorNativeRuntime(); } - private readonly loadFilesystem: () => Promise = loadCapacitorAttachmentFilesystem; - async getAppDataPath(): Promise { return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null; } @@ -203,5 +200,4 @@ export class CapacitorAttachmentFileStore implements AttachmentFileStore { return null; } } - } diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts b/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts index ff58ac2..4efbe83 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts @@ -6,21 +6,18 @@ import type { AttachmentFileStore } from './attachment-file-store'; @Injectable({ providedIn: 'root' }) export class ElectronAttachmentFileStore implements AttachmentFileStore { readonly maxPersistableBytes = Number.POSITIVE_INFINITY; - readonly supportsStreamingToDisk = true; - readonly supportsChunkedReads = true; - readonly providesInlineObjectUrl = false; + private readonly electronBridge = inject(ElectronBridgeService); + get isAvailable(): boolean { const electronApi = this.electronBridge.getApi(); return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath; } - private readonly electronBridge = inject(ElectronBridgeService); - async getAppDataPath(): Promise { const electronApi = this.electronBridge.getApi(); @@ -172,5 +169,4 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore { return null; } } - } diff --git a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts index 6411e4f..4d4bf51 100644 --- a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, tap } from 'rxjs'; @@ -17,17 +18,47 @@ import { MessageSigningService } from './message-signing.service'; @Injectable({ providedIn: 'root' }) export class AuthenticationService { private readonly http = inject(HttpClient); - private readonly serverDirectory = inject(ServerDirectoryFacade); - private readonly authTokenStore = inject(AuthTokenStoreService); - private readonly messageSigning = inject(MessageSigningService); + /** + * Resolve the API base URL for the given server. + * + * @param serverId - Optional server ID to look up. When omitted the + * currently active endpoint is used. + * @returns Fully-qualified API base URL (e.g. `http://host:3001/api`). + */ + private resolveServerUrl(serverId?: string): string { + return this.endpointFor(serverId).replace(/\/api$/, ''); + } + + private persistSessionToken(serverId: string | undefined, response: LoginResponse): void { + const serverUrl = this.resolveServerUrl(serverId); + + this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt); + } + resolveServerUrlFor(serverId?: string): string { return this.resolveServerUrl(serverId); } + private endpointFor(serverId?: string): string { + let endpoint: ServerEndpoint | undefined; + + if (serverId) { + endpoint = this.serverDirectory.servers().find( + (server) => server.id === serverId + ); + } + + const activeEndpoint = endpoint ?? this.serverDirectory.activeServer(); + + return activeEndpoint + ? `${activeEndpoint.url}/api` + : this.serverDirectory.getApiBaseUrl(); + } + /** * Register a new user account on the target server. * @@ -84,38 +115,4 @@ export class AuthenticationService { }) ); } - - /** - * Resolve the API base URL for the given server. - * - * @param serverId - Optional server ID to look up. When omitted the - * currently active endpoint is used. - * @returns Fully-qualified API base URL (e.g. `http://host:3001/api`). - */ - private resolveServerUrl(serverId?: string): string { - return this.endpointFor(serverId).replace(/\/api$/, ''); - } - - private persistSessionToken(serverId: string | undefined, response: LoginResponse): void { - const serverUrl = this.resolveServerUrl(serverId); - - this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt); - } - - private endpointFor(serverId?: string): string { - let endpoint: ServerEndpoint | undefined; - - if (serverId) { - endpoint = this.serverDirectory.servers().find( - (server) => server.id === serverId - ); - } - - const activeEndpoint = endpoint ?? this.serverDirectory.activeServer(); - - return activeEndpoint - ? `${activeEndpoint.url}/api` - : this.serverDirectory.getApiBaseUrl(); - } - } diff --git a/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts index 9b9df53..3230113 100644 --- a/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts +++ b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts @@ -15,20 +15,11 @@ import type { RoomSignalSourceInput } from '../../server-directory'; standalone: true }) export class ChatImageProxyFallbackDirective { - - @HostBinding('src') - get src(): string { - return this.renderedSource(); - } - readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' }); - readonly signalSource = input(null); private readonly klipy = inject(KlipyService); - private readonly renderedSource = signal(''); - private hasAppliedProxyFallback = false; constructor() { @@ -38,6 +29,11 @@ export class ChatImageProxyFallbackDirective { }); } + @HostBinding('src') + get src(): string { + return this.renderedSource(); + } + @HostListener('error') handleError(): void { if (this.hasAppliedProxyFallback) { @@ -53,5 +49,4 @@ export class ChatImageProxyFallbackDirective { this.hasAppliedProxyFallback = true; this.renderedSource.set(proxyUrl); } - } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index 0f37fdb..b405a4d 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, @@ -66,64 +67,42 @@ import { }) export class ChatMessagesComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; - @ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent; + private readonly electronBridge = inject(ElectronBridgeService); + private readonly store = inject(Store); + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly attachmentsSvc = inject(AttachmentFacade); + private readonly klipy = inject(KlipyService); + private readonly viewport = inject(ViewportService); + readonly isMobile = this.viewport.isMobile; readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages); - readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages); - + private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly loading = this.store.selectSignal(selectMessagesLoading); - readonly syncing = this.store.selectSignal(selectMessagesSyncing); - readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder); - readonly currentUser = this.store.selectSignal(selectCurrentUser); - readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`); - readonly conversationExhausted = toSignal( toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))), { initialValue: false } ); - readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); - readonly composerBottomPadding = signal(140); - readonly klipyGifPickerAnchorRight = signal(16); - readonly replyTo = signal(null); - readonly showKlipyGifPicker = signal(false); - readonly lightboxState = signal(null); - readonly galleryAttachments = signal(null); - readonly imageContextMenu = signal(null); - private readonly electronBridge = inject(ElectronBridgeService); - - private readonly store = inject(Store); - - private readonly webrtc = inject(RealtimeSessionFacade); - - private readonly attachmentsSvc = inject(AttachmentFacade); - - private readonly klipy = inject(KlipyService); - - private readonly viewport = inject(ViewportService); - - private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); - constructor() { effect(() => { void this.klipy.refreshAvailability(this.currentRoom()); @@ -283,6 +262,36 @@ export class ChatMessagesComponent { this.composer?.handleKlipyGifSelected(gif); } + private syncKlipyGifPickerAnchor(): void { + const triggerRect = this.composer?.getKlipyTriggerRect(); + + if (!triggerRect) { + this.klipyGifPickerAnchorRight.set(16); + return; + } + + const viewportWidth = window.innerWidth; + const popupWidth = this.getKlipyGifPickerWidth(viewportWidth); + const preferredRight = viewportWidth - triggerRect.right; + const minRight = 16; + const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); + + this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)); + } + + private getKlipyGifPickerWidth(viewportWidth: number): number { + if (viewportWidth >= 1280) + return 52 * 16; + + if (viewportWidth >= 768) + return 42 * 16; + + if (viewportWidth >= 640) + return 34 * 16; + + return Math.max(0, viewportWidth - 32); + } + openLightbox(event: ChatMessageImageLightboxEvent): void { const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl); const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id); @@ -402,36 +411,6 @@ export class ChatMessagesComponent { } } - private syncKlipyGifPickerAnchor(): void { - const triggerRect = this.composer?.getKlipyTriggerRect(); - - if (!triggerRect) { - this.klipyGifPickerAnchorRight.set(16); - return; - } - - const viewportWidth = window.innerWidth; - const popupWidth = this.getKlipyGifPickerWidth(viewportWidth); - const preferredRight = viewportWidth - triggerRect.right; - const minRight = 16; - const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); - - this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)); - } - - private getKlipyGifPickerWidth(viewportWidth: number): number { - if (viewportWidth >= 1280) - return 52 * 16; - - if (viewportWidth >= 768) - return 42 * 16; - - if (viewportWidth >= 640) - return 34 * 16; - - return Math.max(0, viewportWidth - 32); - } - private isOwnMessage(message: Message): boolean { return message.senderId === this.currentUser()?.id; } @@ -528,5 +507,4 @@ export class ChatMessagesComponent { this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined); } - } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts index a82d10b..28dea52 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { @@ -107,43 +108,38 @@ const DEFAULT_TEXTAREA_HEIGHT = 62; }) export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { @ViewChild('messageInputRef') messageInputRef?: ElementRef; - @ViewChild('composerRoot') composerRoot?: ElementRef; - @ViewChild('klipyTrigger') klipyTrigger?: ElementRef; readonly replyTo = input(null); - readonly showKlipyGifPicker = input(false); - readonly currentUserId = input(null); - readonly klipyEnabled = input(false); - readonly klipySignalSource = input(null); - readonly textareaTestId = input(null); - readonly commandSurface = input('server'); readonly messageSubmitted = output(); - readonly typingStarted = output(); - readonly replyCleared = output(); - readonly heightChanged = output(); - readonly klipyGifPickerToggleRequested = output(); + private readonly klipy = inject(KlipyService); + private readonly markdown = inject(ChatMarkdownService); + private readonly electronBridge = inject(ElectronBridgeService); + private readonly pluginApi = inject(PluginClientApiService); + private readonly pluginUi = inject(PluginUiRegistryService); + private readonly customEmoji = inject(CustomEmojiService); + private readonly mobilePlatform = inject(MobilePlatformService); + private readonly mobileMedia = inject(MobileMediaService); + private readonly viewport = inject(ViewportService); + private readonly appI18n = inject(AppI18nService); + readonly pendingKlipyGif = signal(null); - readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton; - readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile())); - readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled())); - readonly composerTextareaPaddingClass = computed(() => resolveComposerTextareaPaddingClass({ isMobileViewport: this.viewport.isMobile(), @@ -151,78 +147,38 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { klipyEnabled: this.klipyEnabled() }) ); - readonly showComposerMediaMenu = signal(false); - readonly showEmojiPicker = signal(false); - readonly emojiButton = signal('🙂'); - readonly pluginComposerActions = this.pluginUi.composerActionRecords; - readonly slashQuery = signal(null); - readonly slashActiveIndex = signal(0); - + private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries( + (text) => this.sendBuiltInSlashText(text), + (key) => this.appI18n.instant(key) + ); readonly availableSlashCommands = computed(() => selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface()) ); - readonly slashCommandResults = computed(() => { const query = this.slashQuery(); return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query); }); - readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0); - readonly toolbarVisible = signal(false); - readonly dragActive = signal(false); - readonly inputHovered = signal(false); - readonly ctrlHeld = signal(false); - readonly textareaExpanded = signal(false); messageContent = ''; - pendingFiles: File[] = []; - inlineCodeToken = '`'; - private readonly klipy = inject(KlipyService); - - private readonly markdown = inject(ChatMarkdownService); - - private readonly electronBridge = inject(ElectronBridgeService); - - private readonly pluginApi = inject(PluginClientApiService); - - private readonly pluginUi = inject(PluginUiRegistryService); - - private readonly customEmoji = inject(CustomEmojiService); - - private readonly mobilePlatform = inject(MobilePlatformService); - - private readonly mobileMedia = inject(MobileMediaService); - - private readonly viewport = inject(ViewportService); - - private readonly appI18n = inject(AppI18nService); - - private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries( - (text) => this.sendBuiltInSlashText(text), - (key) => this.appI18n.instant(key) - ); - private toolbarHovering = false; - private dragDepth = 0; - private lastTypingSentAt = 0; - private resizeObserver: ResizeObserver | null = null; ngAfterViewInit(): void { @@ -238,7 +194,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { sendMessage(): void { const raw = this.messageContent.trim(); - if (this.runSlashCommandWhenPresent(raw)) + if (this.maybeRunSlashCommand(raw)) return; if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif()) @@ -503,6 +459,68 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { this.resetComposerAfterCommand(); } + private maybeRunSlashCommand(raw: string): boolean { + const parsed = parseSlashCommandInput(raw); + + if (!parsed) + return false; + + const entry = findSlashCommand(this.availableSlashCommands(), parsed.name); + + if (!entry) + return false; + + this.executeSlashCommand(entry, parsed.rawArgs); + this.resetComposerAfterCommand(); + + return true; + } + + private sendBuiltInSlashText(text: string): void { + this.messageSubmitted.emit({ + content: text, + pendingFiles: [] + }); + + this.replyCleared.emit(); + } + + private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void { + const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []); + const context = this.pluginApi.createSlashCommandContext({ + args, + command: entry.contribution.name, + rawArgs + }); + + void Promise.resolve().then(() => entry.contribution.run(context)); + } + + private resetComposerAfterCommand(): void { + this.messageContent = ''; + this.closeSlashCommandMenu(); + + requestAnimationFrame(() => { + this.autoResizeTextarea(); + this.messageInputRef?.nativeElement.focus(); + }); + } + + private moveSlashActive(delta: number): void { + const total = this.slashCommandResults().length; + + if (total === 0) + return; + + this.slashActiveIndex.update((current) => (current + delta + total) % total); + } + + private activeSlashCommand(): SlashCommandEntry | null { + const results = this.slashCommandResults(); + + return results[this.slashActiveIndex()] ?? results[0] ?? null; + } + getKlipyTriggerRect(): DOMRect | null { return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null; } @@ -668,68 +686,6 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { } } - private runSlashCommandWhenPresent(raw: string): boolean { - const parsed = parseSlashCommandInput(raw); - - if (!parsed) - return false; - - const entry = findSlashCommand(this.availableSlashCommands(), parsed.name); - - if (!entry) - return false; - - this.executeSlashCommand(entry, parsed.rawArgs); - this.resetComposerAfterCommand(); - - return true; - } - - private sendBuiltInSlashText(text: string): void { - this.messageSubmitted.emit({ - content: text, - pendingFiles: [] - }); - - this.replyCleared.emit(); - } - - private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void { - const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []); - const context = this.pluginApi.createSlashCommandContext({ - args, - command: entry.contribution.name, - rawArgs - }); - - void Promise.resolve().then(() => entry.contribution.run(context)); - } - - private resetComposerAfterCommand(): void { - this.messageContent = ''; - this.closeSlashCommandMenu(); - - requestAnimationFrame(() => { - this.autoResizeTextarea(); - this.messageInputRef?.nativeElement.focus(); - }); - } - - private moveSlashActive(delta: number): void { - const total = this.slashCommandResults().length; - - if (total === 0) - return; - - this.slashActiveIndex.update((current) => (current + delta + total) % total); - } - - private activeSlashCommand(): SlashCommandEntry | null { - const results = this.slashCommandResults(); - - return results[this.slashActiveIndex()] ?? results[0] ?? null; - } - private getSelection(): { start: number; end: number } { const element = this.messageInputRef?.nativeElement; @@ -1004,5 +960,4 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { this.heightChanged.emit(root.offsetHeight); } } - } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index 8448803..2a4809e 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -1,4 +1,4 @@ - + @let msg = message(); @let attachmentsList = attachmentViewModels(); @if (isSystemMessage()) { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 2dd7a66..8d9881f 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering, */ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { @@ -168,61 +169,56 @@ interface MissingPluginEmbedFallback { }) export class ChatMessageItemComponent implements OnDestroy { @ViewChild('editTextareaRef') editTextareaRef?: ElementRef; - @ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef; + private readonly attachmentsSvc = inject(AttachmentFacade); + private readonly klipy = inject(KlipyService); + private readonly pluginRequirements = inject(PluginRequirementStateService); + private readonly pluginUi = inject(PluginUiRegistryService); + private readonly customEmoji = inject(CustomEmojiService); + private readonly electronBridge = inject(ElectronBridgeService); + private readonly platform = inject(PlatformService); + private readonly experimentalMedia = inject(ExperimentalMediaSettingsService); + private readonly profileCard = inject(ProfileCardService); + private readonly router = inject(Router); + private readonly viewport = inject(ViewportService); + private readonly overlay = inject(Overlay); + private readonly viewContainerRef = inject(ViewContainerRef); + private readonly appI18n = inject(AppI18nService); + private mobileSheetOverlayRef: OverlayRef | null = null; + private longPressTimer: number | null = null; readonly isMobile = this.viewport.isMobile; - readonly mobileSheetOpen = signal(false); + private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); + private readonly experimentalPlayerAttachmentId = signal(null); + private readonly mediaSupportCache = new Map(); readonly message = input.required(); - readonly repliedMessage = input(); - readonly currentUserId = input(null); - readonly isAdmin = input(false); - readonly userLookup = input>(new Map()); readonly replyRequested = output(); - readonly deleteRequested = output(); - readonly editSaved = output(); - readonly reactionAdded = output(); - readonly reactionToggled = output(); - readonly referenceRequested = output(); - readonly downloadRequested = output(); - readonly imageOpened = output(); - readonly imageGalleryOpened = output(); - readonly imageContextMenuRequested = output(); - readonly embedRemoved = output(); readonly emojiShortcuts = this.customEmoji.shortcutEntries; - readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted'); - readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content)); - readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken())); - readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed()); - readonly isSystemMessage = computed(() => this.message().kind === 'system'); - readonly isEditing = signal(false); - readonly showEmojiPicker = signal(false); - readonly senderUser = computed(() => { const msg = this.message(); const found = this.userLookup().get(msg.senderId); @@ -242,60 +238,26 @@ export class ChatMessageItemComponent implements OnDestroy { editContent = ''; + openSenderProfileCard(event: MouseEvent): void { + event.stopPropagation(); + const el = event.currentTarget as HTMLElement; + const user = this.senderUser(); + const editable = user.id === this.currentUserId(); + + this.profileCard.open(el, user, { editable }); + } + readonly attachmentViewModels = computed(() => { void this.attachmentVersion(); return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment)); }); - readonly imageAttachments = computed(() => dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment))) ); - readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment))); - readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage)); - readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length)); - - private readonly attachmentsSvc = inject(AttachmentFacade); - - private readonly klipy = inject(KlipyService); - - private readonly pluginRequirements = inject(PluginRequirementStateService); - - private readonly pluginUi = inject(PluginUiRegistryService); - - private readonly customEmoji = inject(CustomEmojiService); - - private readonly electronBridge = inject(ElectronBridgeService); - - private readonly platform = inject(PlatformService); - - private readonly experimentalMedia = inject(ExperimentalMediaSettingsService); - - private readonly profileCard = inject(ProfileCardService); - - private readonly router = inject(Router); - - private readonly viewport = inject(ViewportService); - - private readonly overlay = inject(Overlay); - - private readonly viewContainerRef = inject(ViewContainerRef); - - private readonly appI18n = inject(AppI18nService); - - private mobileSheetOverlayRef: OverlayRef | null = null; - - private longPressTimer: number | null = null; - - private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); - - private readonly experimentalPlayerAttachmentId = signal(null); - - private readonly mediaSupportCache = new Map(); - private readonly hydrateMessageImages = effect(() => { const messageId = this.message().id; const images = this.imageAttachments(); @@ -320,7 +282,6 @@ export class ChatMessageItemComponent implements OnDestroy { void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId); } }); - private readonly syncAttachmentVersion = effect(() => { const version = this.attachmentsSvc.updated(); @@ -331,15 +292,6 @@ export class ChatMessageItemComponent implements OnDestroy { }); }); - openSenderProfileCard(event: MouseEvent): void { - event.stopPropagation(); - const el = event.currentTarget as HTMLElement; - const user = this.senderUser(); - const editable = user.id === this.currentUserId(); - - this.profileCard.open(el, user, { editable }); - } - openMissingPluginStore(fallback: MissingPluginEmbedFallback): void { const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url; @@ -351,6 +303,43 @@ export class ChatMessageItemComponent implements OnDestroy { }); } + private findPluginEmbeds(token: PluginEmbedToken | null) { + if (!token) { + return []; + } + + const payload = parseEmbedPayload(token.payloadText); + + return this.pluginUi + .embedRecords() + .filter((record) => record.contribution.embedType === token.embedType) + .map((record) => ({ + ...record, + render: () => record.contribution.render(payload) + })); + } + + private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null { + const token = this.pluginEmbedToken(); + + if (!token || this.pluginEmbeds().length > 0) { + return null; + } + + const missingRequirement = + this.pluginRequirements + .missingRequiredRequirements() + .find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ?? + this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ?? + this.pluginRequirements.missingRequiredRequirements()[0]; + const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType); + + return { + pluginName, + searchTerm: pluginName + }; + } + startEdit(): void { this.editContent = this.message().content; this.isEditing.set(true); @@ -464,10 +453,53 @@ export class ChatMessageItemComponent implements OnDestroy { this.clearLongPressTimer(); } + private clearLongPressTimer(): void { + if (this.longPressTimer !== null) { + window.clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + } + + private isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) { + return false; + } + + return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null; + } + closeMobileActions(): void { this.detachMobileSheet(); } + private openMobileSheet(): void { + if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) { + this.mobileSheetOpen.set(true); + return; + } + + const overlayRef = this.overlay.create({ + positionStrategy: this.overlay.position().global(), + scrollStrategy: this.overlay.scrollStrategies.block(), + hasBackdrop: false, + panelClass: 'metoyou-chat-actions-sheet-pane' + }); + const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef); + + overlayRef.attach(portal); + this.mobileSheetOverlayRef = overlayRef; + this.mobileSheetOpen.set(true); + } + + private detachMobileSheet(): void { + this.mobileSheetOpen.set(false); + + if (this.mobileSheetOverlayRef) { + this.mobileSheetOverlayRef.dispose(); + this.mobileSheetOverlayRef = null; + } + } + ngOnDestroy(): void { this.clearLongPressTimer(); this.detachMobileSheet(); @@ -804,86 +836,6 @@ export class ChatMessageItemComponent implements OnDestroy { this.experimentalPlayerAttachmentId.set(null); } - private findPluginEmbeds(token: PluginEmbedToken | null) { - if (!token) { - return []; - } - - const payload = parseEmbedPayload(token.payloadText); - - return this.pluginUi - .embedRecords() - .filter((record) => record.contribution.embedType === token.embedType) - .map((record) => ({ - ...record, - render: () => record.contribution.render(payload) - })); - } - - private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null { - const token = this.pluginEmbedToken(); - - if (!token || this.pluginEmbeds().length > 0) { - return null; - } - - const missingRequirement = - this.pluginRequirements - .missingRequiredRequirements() - .find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ?? - this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ?? - this.pluginRequirements.missingRequiredRequirements()[0]; - const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType); - - return { - pluginName, - searchTerm: pluginName - }; - } - - private clearLongPressTimer(): void { - if (this.longPressTimer !== null) { - window.clearTimeout(this.longPressTimer); - this.longPressTimer = null; - } - } - - private isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) { - return false; - } - - return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null; - } - - private openMobileSheet(): void { - if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) { - this.mobileSheetOpen.set(true); - return; - } - - const overlayRef = this.overlay.create({ - positionStrategy: this.overlay.position().global(), - scrollStrategy: this.overlay.scrollStrategies.block(), - hasBackdrop: false, - panelClass: 'metoyou-chat-actions-sheet-pane' - }); - const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef); - - overlayRef.attach(portal); - this.mobileSheetOverlayRef = overlayRef; - this.mobileSheetOpen.set(true); - } - - private detachMobileSheet(): void { - this.mobileSheetOpen.set(false); - - if (this.mobileSheetOverlayRef) { - this.mobileSheetOverlayRef.dispose(); - this.mobileSheetOverlayRef = null; - } - } - private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel { const isRawVideo = this.isVideoAttachment(attachment); const isRawAudio = this.isAudioAttachment(attachment); @@ -955,7 +907,6 @@ export class ChatMessageItemComponent implements OnDestroy { return canPlay; } - } function parsePluginEmbedToken(content: string): PluginEmbedToken | null { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts index 8c449cd..0a4eb99 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { Component, @@ -72,21 +73,15 @@ const REMARK_PROCESSOR = unified().use(remarkParse) templateUrl: './chat-message-markdown.component.html' }) export class ChatMessageMarkdownComponent { + private readonly customEmoji = inject(CustomEmojiService); readonly content = input.required(); - readonly displayContent = computed(() => replaceCustomEmojiMessageTokens(this.content(), (id) => this.customEmoji.findEmoji(id))); - readonly largeCustomEmoji = computed(() => isCustomEmojiOnlyMessage(this.content())); - readonly largeUnicodeEmoji = computed(() => isSingleUnicodeEmojiOnlyMessage(this.content())); - readonly remarkProcessor = REMARK_PROCESSOR; - readonly splitTextIntoEmojiSegments = splitTextIntoEmojiSegments; - private readonly customEmoji = inject(CustomEmojiService); - shouldRenderLargeCustomEmoji(url?: string): boolean { return this.isCustomEmojiDataUrl(url) && this.largeCustomEmoji(); } @@ -146,5 +141,4 @@ export class ChatMessageMarkdownComponent { return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized; } - } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts index 547d72c..dfda817 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { AfterViewChecked, @@ -63,50 +64,37 @@ declare global { } }) export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { - private static readonly INITIAL_SETTLE_MS = 1500; - @ViewChild('messagesContainer') messagesContainer?: ElementRef; - @ViewChild('messagesContent') messagesContent?: ElementRef; + private readonly store = inject(Store); + private readonly allUsers = this.store.selectSignal(selectAllUsers); + private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + readonly allMessages = input.required(); - readonly channelMessages = input.required(); - readonly loading = input(false); - readonly syncing = input(false); - readonly currentUserId = input(null); - readonly isAdmin = input(false); - readonly bottomPadding = input(120); - readonly conversationKey = input.required(); - readonly userLookupOverrides = input([]); readonly replyRequested = output(); - readonly deleteRequested = output(); - readonly editSaved = output(); - readonly reactionAdded = output(); - readonly reactionToggled = output(); - readonly downloadRequested = output(); - readonly imageOpened = output(); - readonly imageGalleryOpened = output(); - readonly imageContextMenuRequested = output(); - readonly embedRemoved = output(); - /** * Emitted when the user scrolls up past the in-store window and the * component needs the parent to fetch an older page from the DB. @@ -115,14 +103,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { /** True while a DB-backed older-page fetch dispatched by the parent is in flight. */ readonly loadingOlder = input(false); - /** True once the parent has paginated all the way back to the start of DB history. */ readonly conversationExhausted = input(false); + private readonly PAGE_SIZE = 50; + readonly displayLimit = signal(this.PAGE_SIZE); - readonly loadingMore = signal(false); - readonly showNewMessagesBar = signal(false); readonly messages = computed(() => { @@ -136,7 +123,6 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { }); readonly initialLoading = computed(() => this.loading() && this.messages().length === 0); - readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0); readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit()); @@ -181,18 +167,6 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { return lookup; }); - private readonly store = inject(Store); - - private readonly allUsers = this.store.selectSignal(selectAllUsers); - - private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', { - day: 'numeric', - month: 'long', - year: 'numeric' - }); - - private readonly PAGE_SIZE = 50; - /** * O(1) index of messages by id, built once per `allMessages()` change. * Used by `findRepliedMessage` so each rendered row doing a reply lookup @@ -209,21 +183,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { }); private contentResizeObserver: ResizeObserver | null = null; - private observedContent: HTMLElement | null = null; - private localSendScrollPending = false; - private localSendScrollTimer: ReturnType | null = null; - private isAutoScrolling = false; - private lastMessageCount = 0; - private initialScrollPending = true; - private prismHighlightScheduled = false; - /** * True while the list should keep auto-pinning to the newest message. Set * when the conversation opens and whenever the user is scrolled near the @@ -233,14 +199,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { * latest message. */ private stickToBottom = true; - /** * Timestamp (ms) until which a freshly opened conversation is still * settling. Inside this window new messages jump instantly instead of * animating, so a channel switch always lands at the bottom. */ private settleUntil = 0; - + private static readonly INITIAL_SETTLE_MS = 1500; /** * Set when an older-page DB fetch is in flight. While true, the * `onMessagesChanged` effect treats incoming message-count growth as a @@ -745,5 +710,4 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { return String(hash); } - } diff --git a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts index 673d1ad..f4edaf6 100644 --- a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts +++ b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { AfterViewInit, Component, @@ -54,43 +55,26 @@ const KLIPY_CARD_FALLBACK_SIZE = 160; templateUrl: './klipy-gif-picker.component.html' }) export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy { - - @ViewChild('searchInput') searchInput?: ElementRef; - readonly signalSource = input(null); readonly gifSelected = output(); - readonly closed = output(); - readonly isMobile = this.viewport.isMobile; - - searchQuery = ''; - - results = signal([]); - - loading = signal(false); - - errorMessage = signal(''); - - hasNext = signal(false); + @ViewChild('searchInput') searchInput?: ElementRef; private readonly klipy = inject(KlipyService); - private readonly viewport = inject(ViewportService); - private readonly appI18n = inject(AppI18nService); - + readonly isMobile = this.viewport.isMobile; private currentPage = 1; - private searchTimer: ReturnType | null = null; - private requestId = 0; - @HostListener('document:keydown.escape') - onEscape(): void { - this.close(); - } + searchQuery = ''; + results = signal([]); + loading = signal(false); + errorMessage = signal(''); + hasNext = signal(false); ngOnInit(): void { void this.loadResults(true); @@ -107,6 +91,11 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy this.clearSearchTimer(); } + @HostListener('document:keydown.escape') + onEscape(): void { + this.close(); + } + onSearchQueryChanged(query: string): void { this.searchQuery = query; this.clearSearchTimer(); @@ -217,5 +206,4 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy height: Math.min(KLIPY_CARD_MAX_HEIGHT, Math.max(KLIPY_CARD_MIN_HEIGHT, scaledHeight)) }; } - } diff --git a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts index d62922b..7d301b6 100644 --- a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts +++ b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering, */ import { Component, computed, @@ -43,10 +44,17 @@ interface TypingSignalingMessage { } }) export class TypingIndicatorComponent { + private readonly typingMap = new Map(); + private readonly store = inject(Store); + private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); + private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + private lastRoomId: string | null = null; + private lastConversationKey: string | null = null; typingDisplay = signal([]); - typingOthersCount = signal(0); + private readonly appI18n = inject(AppI18nService); readonly typingLabel = computed(() => { const names = this.typingDisplay(); @@ -72,22 +80,6 @@ export class TypingIndicatorComponent { return this.appI18n.instant('chat.typing.many', { names: namesText }); }); - private readonly typingMap = new Map(); - - private readonly store = inject(Store); - - private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); - - private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); - - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - - private lastRoomId: string | null = null; - - private lastConversationKey: string | null = null; - - private readonly appI18n = inject(AppI18nService); - constructor() { const webrtc = inject(RealtimeSessionFacade); const destroyRef = inject(DestroyRef); @@ -175,5 +167,4 @@ export class TypingIndicatorComponent { this.typingDisplay.set(names.slice(0, MAX_SHOWN)); this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN)); } - } diff --git a/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts b/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts index 1edbcd9..d6f5d2c 100644 --- a/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, @@ -66,32 +67,20 @@ import { DirectMessageService } from '../../../direct-message'; * Displays the list of online users with voice state indicators and admin actions. */ export class UserListComponent { + private store = inject(Store); + private router = inject(Router); + private directMessages = inject(DirectMessageService); onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal; - voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected)); - currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal; - isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); showUserMenu = signal(null); - showBanDialog = signal(false); - userToBan = signal(null); - banReason = ''; - - banDuration = '86400000'; - - private store = inject(Store); - - private router = inject(Router); - - private directMessages = inject(DirectMessageService); - - // Default 1 day + banDuration = '86400000'; // Default 1 day statusLabel(status: User['status']): string { switch (status) { @@ -178,5 +167,4 @@ export class UserListComponent { this.closeBanDialog(); } - } diff --git a/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts b/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts index ec3a9e9..928e94c 100644 --- a/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts +++ b/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { createEffect } from '@ngrx/effects'; import { Store } from '@ngrx/store'; @@ -24,6 +25,13 @@ import { CustomEmojiService } from './custom-emoji.service'; @Injectable() export class CustomEmojiSyncEffects { + private readonly customEmoji = inject(CustomEmojiService); + private readonly store = inject(Store); + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly incomingEvents$ = merge( + this.webrtc.onMessageReceived, + this.webrtc.onSignalingMessage as Observable + ); currentUserLoad$ = createEffect( () => this.store.select(selectCurrentUser).pipe( @@ -116,16 +124,4 @@ export class CustomEmojiSyncEffects { ), { dispatch: false } ); - - private readonly customEmoji = inject(CustomEmojiService); - - private readonly store = inject(Store); - - private readonly webrtc = inject(RealtimeSessionFacade); - - private readonly incomingEvents$ = merge( - this.webrtc.onMessageReceived, - this.webrtc.onSignalingMessage as Observable - ); - } diff --git a/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts b/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts index 89eef02..4cdebde 100644 --- a/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts +++ b/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -42,34 +43,25 @@ interface PendingCustomEmojiTransfer { @Injectable({ providedIn: 'root' }) export class CustomEmojiService { + private readonly db = inject(DatabaseService); + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly emojisState = signal([]); + private readonly usageState = signal>(new Map()); + private readonly savedIdsState = signal>(new Set()); + private readonly pendingTransfers = new Map(); + private activeUserId: string | null = null; + private loaded = false; readonly emojis = computed(() => { const savedIds = this.savedIdsState(); return this.emojisState().filter((emoji) => savedIds.has(emoji.id)); }); - readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({ customEmojis: this.emojis(), usage: this.usageState() })); - private readonly db = inject(DatabaseService); - - private readonly webrtc = inject(RealtimeSessionFacade); - - private readonly emojisState = signal([]); - - private readonly usageState = signal>(new Map()); - - private readonly savedIdsState = signal>(new Set()); - - private readonly pendingTransfers = new Map(); - - private activeUserId: string | null = null; - - private loaded = false; - async loadForUser(userId: string | null | undefined): Promise { this.activeUserId = userId ?? null; @@ -582,5 +574,4 @@ export class CustomEmojiService { return baseName || 'emoji'; } - } diff --git a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts index a14d760..5168031 100644 --- a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts +++ b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { Component, @@ -45,74 +46,41 @@ import { templateUrl: './custom-emoji-picker.component.html' }) export class CustomEmojiPickerComponent { + private readonly customEmoji = inject(CustomEmojiService); + private readonly appI18n = inject(AppI18nService); + private readonly host = inject>(ElementRef); readonly currentUserId = input(null); - readonly compact = input(true); - /** Render the picker panel in normal document flow for bottom-sheet embedding. */ readonly inline = input(false); readonly emojiSelected = output(); - readonly dismissed = output(); readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE; - readonly modalOpen = signal(false); - readonly uploadError = signal(null); - readonly uploading = signal(false); - readonly shortcuts = this.customEmoji.shortcutEntries; - readonly customEmojis = this.customEmoji.emojis; - readonly searchQuery = signal(''); - readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries( UNICODE_EMOJI_PICKER_ENTRIES, this.searchQuery() )); - readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker( this.customEmojis(), this.searchQuery() )); - readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0); - readonly showEmptySearchState = computed(() => this.hasActiveSearch() && this.filteredUnicodeEntries().length === 0 && this.filteredCustomEmojis().length === 0); - - private readonly customEmoji = inject(CustomEmojiService); - - private readonly appI18n = inject(AppI18nService); - - private readonly host = inject>(ElementRef); - private readonly loadForUser = effect(() => { void this.customEmoji.loadForUser(this.currentUserId()); }); - @HostListener('document:click', ['$event']) - onDocumentClick(event: MouseEvent): void { - const target = event.target; - - if (target == null || this.host.nativeElement.contains(target as Node)) { - return; - } - - this.dismiss(); - } - - @HostListener('document:keydown.escape') - onEscape(): void { - this.dismiss(); - } - setSearchQuery(query: string): void { this.searchQuery.set(query); } @@ -146,6 +114,22 @@ export class CustomEmojiPickerComponent { this.modalOpen.set(false); } + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target; + + if (target == null || this.host.nativeElement.contains(target as Node)) { + return; + } + + this.dismiss(); + } + + @HostListener('document:keydown.escape') + onEscape(): void { + this.dismiss(); + } + openModal(): void { this.modalOpen.set(true); } @@ -184,5 +168,4 @@ export class CustomEmojiPickerComponent { this.uploading.set(false); } } - } diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index a9df4f4..ba05d78 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -36,13 +37,31 @@ import { toDirectMessageParticipant } from '../../../direct-message'; @Injectable({ providedIn: 'root' }) export class DirectCallService { + private readonly router = inject(Router); + private readonly store = inject(Store); + private readonly delivery = inject(PeerDeliveryService); + private readonly directMessages = inject(DirectMessageService); + private readonly audio = inject(NotificationAudioService); + private readonly voice = inject(VoiceConnectionFacade); + private readonly voiceSession = inject(VoiceSessionFacade); + private readonly realtime = inject(RealtimeSessionFacade); + private readonly voiceActivity = inject(VoiceActivityService); + private readonly playback = inject(VoicePlaybackService); + private readonly viewport = inject(ViewportService); + private readonly mobileNotifications = inject(MobileNotificationsService); + private readonly mobileCallSession = inject(MobileCallSessionService); + private readonly mobileMedia = inject(MobileMediaService); + private readonly i18n = inject(AppI18nService); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + private readonly users = this.store.selectSignal(selectAllUsers); + private readonly sessionsSignal = signal([]); + private readonly mobileOverlayCallId = signal(null); + private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = []; + private readonly declinedCallIds = new Set(); readonly sessions = computed(() => this.sessionsSignal()); - readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended')); - readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session))); - readonly incomingCall = computed(() => { if (this.isDoNotDisturb()) { return null; @@ -61,11 +80,8 @@ export class DirectCallService { && !session.participants[meId]?.joined && this.hasConnectedParticipant(session)) ?? null; }); - readonly currentSession = signal(null); - readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0); - readonly mobileOverlaySession = computed(() => { const callId = this.mobileOverlayCallId(); @@ -76,48 +92,6 @@ export class DirectCallService { return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null; }); - private readonly router = inject(Router); - - private readonly store = inject(Store); - - private readonly delivery = inject(PeerDeliveryService); - - private readonly directMessages = inject(DirectMessageService); - - private readonly audio = inject(NotificationAudioService); - - private readonly voice = inject(VoiceConnectionFacade); - - private readonly voiceSession = inject(VoiceSessionFacade); - - private readonly realtime = inject(RealtimeSessionFacade); - - private readonly voiceActivity = inject(VoiceActivityService); - - private readonly playback = inject(VoicePlaybackService); - - private readonly viewport = inject(ViewportService); - - private readonly mobileNotifications = inject(MobileNotificationsService); - - private readonly mobileCallSession = inject(MobileCallSessionService); - - private readonly mobileMedia = inject(MobileMediaService); - - private readonly i18n = inject(AppI18nService); - - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - - private readonly users = this.store.selectSignal(selectAllUsers); - - private readonly sessionsSignal = signal([]); - - private readonly mobileOverlayCallId = signal(null); - - private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = []; - - private readonly declinedCallIds = new Set(); - constructor() { this.mobileCallSession.initialize(); this.mobileCallSession.onCallControlAction((intent, callId) => { @@ -418,6 +392,19 @@ export class DirectCallService { } } + private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void { + const action = endForEveryone ? 'end' : 'leave'; + const nextSession = this.markCurrentUserLeft(session, endForEveryone); + + this.audio.stop(AppSound.Call); + this.broadcastCallEvent(action, nextSession); + this.stopLocalMedia(nextSession); + this.upsertSession(nextSession); + + this.currentSession.set(null); + void this.mobileCallSession.endActiveCall(session.callId); + } + async inviteUser(callId: string, user: User): Promise { const session = this.sessionById(callId); @@ -458,19 +445,6 @@ export class DirectCallService { return participant ? participantToUser(participant) : null; } - private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void { - const action = endForEveryone ? 'end' : 'leave'; - const nextSession = this.markCurrentUserLeft(session, endForEveryone); - - this.audio.stop(AppSound.Call); - this.broadcastCallEvent(action, nextSession); - this.stopLocalMedia(nextSession); - this.upsertSession(nextSession); - - this.currentSession.set(null); - void this.mobileCallSession.endActiveCall(session.callId); - } - private async drainPendingIncomingCallPayloads(): Promise { if (this.pendingIncomingCallPayloads.length === 0) { return; @@ -1067,5 +1041,4 @@ export class DirectCallService { return user; } - } diff --git a/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts index 586de88..7bf1cb7 100644 --- a/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts +++ b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts @@ -36,13 +36,10 @@ import { DirectCallSession, participantToUser } from '../../domain/models/direct }) export class IncomingCallModalComponent { readonly calls = inject(DirectCallService); - + private readonly i18n = inject(AppI18nService); readonly currentUser = inject(Store).selectSignal(selectCurrentUser); - readonly session = this.calls.incomingCall; - readonly answering = signal(false); - readonly caller = computed(() => { const session = this.session(); @@ -56,13 +53,10 @@ export class IncomingCallModalComponent { return (callerId ? this.calls.userForParticipant(callerId) : null) ?? (participant ? participantToUser(participant) : null); }); - readonly callerName = computed(() => this.caller()?.displayName || this.i18n.instant('call.incoming.someone')); - readonly callerCallingLabel = computed(() => this.i18n.instant('call.incoming.callerCalling', { name: this.callerName() }) ); - readonly callKindLabel = computed(() => { const participantCount = this.session()?.participantIds.length ?? 0; @@ -71,8 +65,6 @@ export class IncomingCallModalComponent { : this.i18n.instant('call.incoming.directCall'); }); - private readonly i18n = inject(AppI18nService); - @HostListener('document:keydown.escape') onEscape(): void { this.decline(); @@ -123,5 +115,4 @@ export class IncomingCallModalComponent { private userKey(user: User): string { return user.oderId || user.id; } - } diff --git a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts index 65b5d57..6a3ee4d 100644 --- a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -61,13 +62,24 @@ interface DirectMessageTypingEntry { @Injectable({ providedIn: 'root' }) export class DirectMessageService { + private readonly repository = inject(DirectMessageRepository); + private readonly offlineQueue = inject(OfflineMessageQueueService); + private readonly delivery = inject(PeerDeliveryService); + private readonly attachments = inject(AttachmentFacade); + private readonly customEmoji = inject(CustomEmojiService); + private readonly store = inject(Store); + private readonly router = inject(Router); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + private readonly conversationsSignal = signal([]); + private readonly selectedConversationIdSignal = signal(null); + private readonly typingEntriesSignal = signal([]); + private readonly lastSyncRequestAt = new Map(); + private loadedOwnerId: string | null = null; readonly conversations = computed(() => [...this.conversationsSignal()].sort( (firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt )); - readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly(); - readonly selectedConversation = computed(() => { const selectedId = this.selectedConversationIdSignal(); @@ -75,40 +87,12 @@ export class DirectMessageService { ? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null : null; }); - readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce( (total, conversation) => total + conversation.unreadCount, 0 )); - readonly typingEntries = this.typingEntriesSignal.asReadonly(); - private readonly repository = inject(DirectMessageRepository); - - private readonly offlineQueue = inject(OfflineMessageQueueService); - - private readonly delivery = inject(PeerDeliveryService); - - private readonly attachments = inject(AttachmentFacade); - - private readonly customEmoji = inject(CustomEmojiService); - - private readonly store = inject(Store); - - private readonly router = inject(Router); - - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - - private readonly conversationsSignal = signal([]); - - private readonly selectedConversationIdSignal = signal(null); - - private readonly typingEntriesSignal = signal([]); - - private readonly lastSyncRequestAt = new Map(); - - private loadedOwnerId: string | null = null; - constructor() { effect(() => { const ownerId = this.getCurrentUserId(); @@ -1007,5 +991,4 @@ export class DirectMessageService { return ownerId; } - } diff --git a/toju-app/src/app/domains/direct-message/application/services/friend.service.ts b/toju-app/src/app/domains/direct-message/application/services/friend.service.ts index 9764136..a5b8917 100644 --- a/toju-app/src/app/domains/direct-message/application/services/friend.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/friend.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -13,23 +14,16 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors'; @Injectable({ providedIn: 'root' }) export class FriendService { + private readonly repository = inject(FriendRepository); + private readonly store = inject(Store); + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + private readonly friendsSignal = signal([]); + private loadedOwnerId: string | null = null; readonly friends = this.friendsSignal.asReadonly(); - readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId))); - private readonly repository = inject(FriendRepository); - - private readonly store = inject(Store); - - private readonly webrtc = inject(RealtimeSessionFacade); - - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - - private readonly friendsSignal = signal([]); - - private loadedOwnerId: string | null = null; - constructor() { effect(() => { const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null; @@ -122,5 +116,4 @@ export class FriendService { await this.loadForOwner(ownerId); return ownerId; } - } diff --git a/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts b/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts index c1c8a44..24c4b5b 100644 --- a/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { @@ -12,6 +13,10 @@ import type { ChatEvent, User } from '../../../../shared-kernel'; @Injectable({ providedIn: 'root' }) export class PeerDeliveryService { + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly store = inject(Store); + private readonly users = this.store.selectSignal(selectAllUsers); + private readonly networkRestoredSubject = new Subject(); readonly directMessageEvents$: Observable = merge( this.webrtc.onMessageReceived, @@ -33,17 +38,8 @@ export class PeerDeliveryService { ); readonly peerConnected$ = this.webrtc.onPeerConnected; - readonly networkRestored$ = this.networkRestoredSubject.asObservable(); - private readonly webrtc = inject(RealtimeSessionFacade); - - private readonly store = inject(Store); - - private readonly users = this.store.selectSignal(selectAllUsers); - - private readonly networkRestoredSubject = new Subject(); - constructor() { this.installNetworkTestHooks(); } @@ -178,5 +174,4 @@ export class PeerDeliveryService { this.networkRestoredSubject.next(); }; } - } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts index 1c39612..ca6b9e6 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -25,6 +26,7 @@ import { DirectCallService } from '../../../direct-call'; import { Attachment, AttachmentFacade } from '../../../attachment'; import { ThemeNodeDirective } from '../../../theme'; import { DirectMessageService } from '../../application/services/direct-message.service'; +import { isConversationBound } from './dm-chat.rules'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide'; @@ -83,68 +85,55 @@ interface DmStatusLabel { export class DmChatComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; + private readonly route = inject(ActivatedRoute); + private readonly store = inject(Store); + private readonly electronBridge = inject(ElectronBridgeService); + private readonly attachments = inject(AttachmentFacade); + private readonly klipy = inject(KlipyService); + private readonly linkMetadata = inject(LinkMetadataService); + private readonly profileCard = inject(ProfileCardService); + private readonly viewport = inject(ViewportService); + private readonly metadataRequestKeys = new Set(); + private openedConversationId: string | null = null; + private readonly i18n = inject(AppI18nService); readonly isMobile = this.viewport.isMobile; - readonly directCalls = inject(DirectCallService); - readonly directMessages = inject(DirectMessageService); - readonly currentUser = this.store.selectSignal(selectCurrentUser); - readonly allUsers = this.store.selectSignal(selectAllUsers); - readonly showGifPicker = signal(false); - readonly conversationId = input(null); - readonly showCallButton = input(true); - readonly composerBottomPadding = signal(140); - readonly gifPickerAnchorRight = signal(16); - readonly linkMetadataByMessageId = signal>({}); - readonly replyTo = signal(null); - readonly lightboxState = signal(null); - readonly galleryAttachments = signal(null); - readonly imageContextMenu = signal(null); - readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); - readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId()); - readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); - readonly conversation = this.directMessages.selectedConversation; - readonly klipyEnabled = computed(() => this.klipy.isEnabled(null)); - readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none'); - readonly typingUsers = computed(() => { void this.directMessages.typingEntries(); return this.directMessages.typingUsers(this.conversation()?.id); }); - readonly peerUser = computed(() => { const conversation = this.conversation(); return conversation ? this.peerUserFor(conversation) : null; }); - readonly isGroupConversation = computed(() => { const conversation = this.conversation(); return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2); }); - readonly participantUsers = computed(() => { const conversation = this.conversation(); const knownUsers = this.allUsers(); @@ -176,7 +165,6 @@ export class DmChatComponent { ); }); }); - readonly messageStatuses = computed(() => { const conversation = this.conversation(); const currentUserId = this.currentUserId(); @@ -192,12 +180,12 @@ export class DmChatComponent { status: message.status })); }); - readonly chatMessages = computed(() => { const conversation = this.conversation(); const metadataByMessageId = this.linkMetadataByMessageId(); + const boundConversationId = this.effectiveConversationId(); - if (!conversation) { + if (!conversation || !isConversationBound(conversation.id, boundConversationId)) { return []; } @@ -227,7 +215,6 @@ export class DmChatComponent { }; }); }); - readonly peerName = computed(() => { const conversation = this.conversation(); const currentUserId = this.currentUserId(); @@ -240,7 +227,6 @@ export class DmChatComponent { return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle'); }); - readonly peerCallIcon = computed(() => { const conversation = this.conversation(); @@ -252,7 +238,6 @@ export class DmChatComponent { return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone'; }); - readonly canCallConversation = computed(() => { const conversation = this.conversation(); @@ -267,28 +252,6 @@ export class DmChatComponent { return !!this.peerUser(); }); - private readonly route = inject(ActivatedRoute); - - private readonly store = inject(Store); - - private readonly electronBridge = inject(ElectronBridgeService); - - private readonly attachments = inject(AttachmentFacade); - - private readonly klipy = inject(KlipyService); - - private readonly linkMetadata = inject(LinkMetadataService); - - private readonly profileCard = inject(ProfileCardService); - - private readonly viewport = inject(ViewportService); - - private readonly metadataRequestKeys = new Set(); - - private openedConversationId: string | null = null; - - private readonly i18n = inject(AppI18nService); - constructor() { effect(() => { const conversationId = this.effectiveConversationId(); @@ -702,5 +665,4 @@ export class DmChatComponent { return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; } - } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.spec.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.spec.ts new file mode 100644 index 0000000..c2119b7 --- /dev/null +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.spec.ts @@ -0,0 +1,21 @@ +import { + describe, + it, + expect +} from 'vitest'; +import { isConversationBound } from './dm-chat.rules'; + +describe('isConversationBound', () => { + it('returns false when either id is missing', () => { + expect(isConversationBound(null, 'conv-1')).toBe(false); + expect(isConversationBound('conv-1', null)).toBe(false); + }); + + it('returns false when the selected conversation lags behind navigation', () => { + expect(isConversationBound('old-conv', 'new-conv')).toBe(false); + }); + + it('returns true when the store conversation matches the bound route/input id', () => { + expect(isConversationBound('conv-1', 'conv-1')).toBe(true); + }); +}); diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.ts new file mode 100644 index 0000000..4af587f --- /dev/null +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.rules.ts @@ -0,0 +1,8 @@ +export function isConversationBound( + conversationId: string | null | undefined, + selectedConversationId: string | null | undefined +): boolean { + return !!conversationId + && !!selectedConversationId + && conversationId === selectedConversationId; +} diff --git a/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts index e32b2fb..54ac4f8 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -59,16 +60,15 @@ const EXIT_ANIMATION_MS = 160; styleUrl: './dm-rail.component.scss' }) export class DmRailComponent implements OnDestroy { + private readonly router = inject(Router); + private readonly store = inject(Store); + private readonly exitTimers = new Map>(); + private readonly i18n = inject(AppI18nService); readonly directMessages = inject(DirectMessageService); - readonly friends = inject(FriendService); - readonly users = this.store.selectSignal(selectAllUsers); - readonly currentUser = this.store.selectSignal(selectCurrentUser); - readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); - readonly activeConversationId = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), @@ -76,15 +76,11 @@ export class DmRailComponent implements OnDestroy { ), { initialValue: this.getConversationIdFromUrl(this.router.url) } ); - readonly friendUsers = computed(() => this.users().filter((user) => this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId() )); - readonly railItems = signal([]); - readonly contextMenu = signal(null); - readonly unreadRailItems = computed(() => { const currentUserId = this.currentUserId(); const items = new Map(); @@ -147,7 +143,6 @@ export class DmRailComponent implements OnDestroy { return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0); }); - readonly isOnDirectMessages = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), @@ -156,14 +151,6 @@ export class DmRailComponent implements OnDestroy { { initialValue: this.router.url.startsWith('/dm') } ); - private readonly router = inject(Router); - - private readonly store = inject(Store); - - private readonly exitTimers = new Map>(); - - private readonly i18n = inject(AppI18nService); - constructor() { effect(() => { const unreadItems = this.unreadRailItems(); @@ -347,5 +334,4 @@ export class DmRailComponent implements OnDestroy { return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; } - } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts index 43d9bf1..50d3b89 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -43,42 +44,26 @@ import { DirectMessageService } from '../../application/services/direct-message. templateUrl: './dm-conversation-item.component.html' }) export class DmConversationItemComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly store = inject(Store); + private readonly attachments = inject(AttachmentFacade); + private readonly directMessages = inject(DirectMessageService); + private readonly directCalls = inject(DirectCallService); + private readonly i18n = inject(AppI18nService); readonly conversation = input.required(); - readonly conversationOpened = output(); - readonly users = this.store.selectSignal(selectAllUsers); - readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); - readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id); - readonly peerName = computed(() => this.resolvePeerName(this.conversation())); - readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation())); - readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation())); - readonly canCall = computed(() => this.canCallConversation(this.conversation())); - readonly callIcon = computed(() => this.conversationCallIcon(this.conversation())); - private readonly route = inject(ActivatedRoute); - - private readonly router = inject(Router); - - private readonly store = inject(Store); - - private readonly attachments = inject(AttachmentFacade); - - private readonly directMessages = inject(DirectMessageService); - - private readonly directCalls = inject(DirectCallService); - - private readonly i18n = inject(AppI18nService); - constructor() { effect(() => { const conversation = this.conversation(); @@ -133,8 +118,9 @@ export class DmConversationItemComponent { const peerId = this.peerId(conversation); const knownUser = this.peerUser(conversation); - if (!peerId) + if (!peerId) { return this.i18n.instant('dm.chat.defaultTitle'); + } return knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName @@ -249,5 +235,4 @@ export class DmConversationItemComponent { ? this.i18n.instant('dm.previews.oneAttachment') : this.i18n.instant('dm.previews.manyAttachments'); } - } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts index e0ee7c1..ed1866e 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -32,16 +33,12 @@ import { DmConversationItemComponent } from './dm-conversation-item.component'; templateUrl: './dm-conversations-panel.component.html' }) export class DmConversationsPanelComponent { - readonly directMessages = inject(DirectMessageService); - - readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel')); - - readonly conversationSelected = output(); - private readonly theme = inject(ThemeService); + readonly directMessages = inject(DirectMessageService); + readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel')); + readonly conversationSelected = output(); trackConversationId(index: number, conversation: DirectMessageConversation): string { return conversation.id; } - } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts index 8bb7003..a1c0ab9 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -54,18 +55,21 @@ interface SwiperElement extends HTMLElement { templateUrl: './dm-workspace.component.html' }) export class DmWorkspaceComponent implements OnDestroy { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly theme = inject(ThemeService); + private readonly viewport = inject(ViewportService); + private readonly zone = inject(NgZone); + private readonly directCalls = inject(DirectCallService); + private lastSeenConversationId: string | null = null; + private swiperListenerAttached: SwiperElement | null = null; readonly directMessages = inject(DirectMessageService); - readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); - readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout')); - readonly isMobile = this.viewport.isMobile; - readonly swiperRef = viewChild>('swiperEl'); - readonly activeCall = computed(() => { const currentSession = this.directCalls.currentSession(); const visibleSessions = this.directCalls.visibleActiveSessions(); @@ -76,22 +80,6 @@ export class DmWorkspaceComponent implements OnDestroy { /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */ readonly mobilePage = signal('conversations'); - private readonly route = inject(ActivatedRoute); - - private readonly router = inject(Router); - - private readonly theme = inject(ThemeService); - - private readonly viewport = inject(ViewportService); - - private readonly zone = inject(NgZone); - - private readonly directCalls = inject(DirectCallService); - - private lastSeenConversationId: string | null = null; - - private swiperListenerAttached: SwiperElement | null = null; - constructor() { effect(() => { const conversationId = this.routeConversationId(); @@ -183,6 +171,5 @@ export class DmWorkspaceComponent implements OnDestroy { ngOnDestroy(): void { this.directMessages.closeConversationView(this.routeConversationId()); } - } diff --git a/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts b/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts index 3d514fd..9ad6191 100644 --- a/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -23,26 +24,20 @@ import type { User } from '../../../../shared-kernel'; templateUrl: './friend-button.component.html' }) export class FriendButtonComponent { + private readonly friends = inject(FriendService); + private readonly i18n = inject(AppI18nService); readonly user = input.required(); - readonly userId = computed(() => this.user().oderId || this.user().id); - readonly isFriend = computed(() => this.friends.isFriend(this.userId())); - readonly ariaLabel = computed(() => this.isFriend() ? this.i18n.instant('dm.friend.remove') : this.i18n.instant('dm.friend.add') ); - private readonly friends = inject(FriendService); - - private readonly i18n = inject(AppI18nService); - toggle(event: Event): void { event.stopPropagation(); void this.friends.toggleFriend(this.userId()); } - } diff --git a/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts b/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts index 592dc76..722a0e5 100644 --- a/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -38,18 +39,16 @@ import type { User } from '../../../../shared-kernel'; templateUrl: './user-search-list.component.html' }) export class UserSearchListComponent { + private readonly store = inject(Store); + private readonly router = inject(Router); + private readonly directMessages = inject(DirectMessageService); readonly directCalls = inject(DirectCallService); - readonly friends = inject(FriendService); - + private readonly i18n = inject(AppI18nService); readonly searchQuery = input(''); - readonly users = this.store.selectSignal(selectAllUsers); - readonly savedRooms = this.store.selectSignal(selectSavedRooms); - readonly currentUser = this.store.selectSignal(selectCurrentUser); - readonly discoveredUsers = computed(() => { const usersById = new Map(); @@ -82,7 +81,6 @@ export class UserSearchListComponent { return Array.from(usersById.values()); }); - readonly matchingUsers = computed(() => { const query = this.normalizedSearchQuery(); const currentUserId = this.currentUserKey(); @@ -92,23 +90,13 @@ export class UserSearchListComponent { .filter((user) => this.matchesQuery(user, query)) .slice(0, 24); }); - readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user)))); - readonly results = computed(() => { const friendIds = this.friends.friendIds(); return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user))); }); - private readonly store = inject(Store); - - private readonly router = inject(Router); - - private readonly directMessages = inject(DirectMessageService); - - private readonly i18n = inject(AppI18nService); - async messageUser(user: User): Promise { const conversation = await this.directMessages.createConversation(user); @@ -163,5 +151,4 @@ export class UserSearchListComponent { .filter((value): value is string => !!value) .some((value) => value.toLowerCase().includes(query)); } - } diff --git a/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts index 12c59f0..b946a09 100644 --- a/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts +++ b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { Actions, @@ -37,6 +38,9 @@ export function groupMessagesByRoom(messages: Message[]): Map @Injectable() export class NotificationsEffects { + private readonly actions$ = inject(Actions); + private readonly store = inject(Store); + private readonly notifications = inject(NotificationsFacade); syncRoomCatalog$ = createEffect( () => @@ -132,11 +136,4 @@ export class NotificationsEffects { ), { dispatch: false } ); - - private readonly actions$ = inject(Actions); - - private readonly store = inject(Store); - - private readonly notifications = inject(NotificationsFacade); - } diff --git a/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts b/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts index ef3fb3d..7dac8ff 100644 --- a/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts +++ b/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts @@ -1,15 +1,14 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { NotificationsService } from '../services/notifications.service'; @Injectable({ providedIn: 'root' }) export class NotificationsFacade { + private readonly service = inject(NotificationsService); readonly settings = this.service.settings; - readonly unread = this.service.unread; - private readonly service = inject(NotificationsService); - initialize( ...args: Parameters ): ReturnType { @@ -99,5 +98,4 @@ export class NotificationsFacade { ): ReturnType { return this.service.setChannelMuted(...args); } - } diff --git a/toju-app/src/app/domains/notifications/application/services/notifications.service.ts b/toju-app/src/app/domains/notifications/application/services/notifications.service.ts index 1f7fd46..c71ec69 100644 --- a/toju-app/src/app/domains/notifications/application/services/notifications.service.ts +++ b/toju-app/src/app/domains/notifications/application/services/notifications.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -45,55 +46,34 @@ const MAX_NOTIFIED_MESSAGE_IDS = 500; @Injectable({ providedIn: 'root' }) export class NotificationsService { - - readonly settings = computed(() => this._settings()); - - readonly unread = computed(() => this._unread()); - private readonly store = inject(Store); - private readonly db = inject(DatabaseService); - private readonly audio = inject(NotificationAudioService); - private readonly appI18n = inject(AppI18nService); - private readonly timeSync = inject(TimeSyncService); - private readonly desktopNotifications = inject(DesktopNotificationService); - private readonly storage = inject(NotificationSettingsStorageService); private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); - private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); - private readonly savedRooms = this.store.selectSignal(selectSavedRooms); - private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly _settings = signal(createDefaultNotificationSettings()); - private readonly _unread = signal(createEmptyUnreadState()); - private readonly _windowFocused = signal(typeof document === 'undefined' ? true : document.hasFocus()); - private readonly _documentVisible = signal(typeof document === 'undefined' ? true : document.visibilityState === 'visible'); - private readonly _windowMinimized = signal(false); - private readonly platformKind = detectPlatform(); - private readonly notifiedMessageIds = new Set(); - private readonly notifiedMessageOrder: string[] = []; - private attentionActive = false; - private windowStateCleanup: (() => void) | null = null; - private initialised = false; + readonly settings = computed(() => this._settings()); + readonly unread = computed(() => this._unread()); + async initialize(): Promise { if (this.initialised) { return; @@ -331,30 +311,6 @@ export class NotificationsService { }); } - private readonly handleWindowFocus = (): void => { - this._windowFocused.set(true); - this._windowMinimized.set(false); - this.markCurrentChannelReadIfActive(); - }; - - private readonly handleWindowBlur = (): void => { - this._windowFocused.set(false); - this.syncWindowAttention(); - }; - - private readonly handleVisibilityChange = (): void => { - const isVisible = document.visibilityState === 'visible'; - - this._documentVisible.set(isVisible); - - if (isVisible && this._windowFocused()) { - this.markCurrentChannelReadIfActive(); - return; - } - - this.syncWindowAttention(); - }; - private registerWindowListeners(): void { if (typeof window === 'undefined' || typeof document === 'undefined') { return; @@ -379,6 +335,30 @@ export class NotificationsService { }); } + private readonly handleWindowFocus = (): void => { + this._windowFocused.set(true); + this._windowMinimized.set(false); + this.markCurrentChannelReadIfActive(); + }; + + private readonly handleWindowBlur = (): void => { + this._windowFocused.set(false); + this.syncWindowAttention(); + }; + + private readonly handleVisibilityChange = (): void => { + const isVisible = document.visibilityState === 'visible'; + + this._documentVisible.set(isVisible); + + if (isVisible && this._windowFocused()) { + this.markCurrentChannelReadIfActive(); + return; + } + + this.syncWindowAttention(); + }; + private buildContext(): NotificationDeliveryContext { return { activeChannelId: this.activeChannelId(), @@ -645,7 +625,6 @@ export class NotificationsService { } } } - } function detectPlatform(): DesktopPlatform { diff --git a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts index 427bd2c..b77a583 100644 --- a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts +++ b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -36,20 +37,15 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n'; templateUrl: './notifications-settings.component.html' }) export class NotificationsSettingsComponent { + private readonly store = inject(Store); readonly notifications = inject(NotificationsFacade); readonly rooms = this.store.selectSignal(selectSavedRooms); - readonly settings = this.notifications.settings; - readonly enabled = computed(() => this.settings().enabled); - readonly showPreview = computed(() => this.settings().showPreview); - readonly respectBusyStatus = computed(() => this.settings().respectBusyStatus); - private readonly store = inject(Store); - trackRoom = (_index: number, room: Room) => room.id; textChannels(room: Room) { @@ -116,5 +112,4 @@ export class NotificationsSettingsComponent { formatUnreadCount(count: number): string { return count > 99 ? '99+' : String(count); } - } diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts index 1328f69..ab1dd44 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { DestroyRef, Injectable, @@ -42,15 +43,27 @@ export interface PluginRequirementComparison { @Injectable({ providedIn: 'root' }) export class PluginRequirementStateService { + private readonly destroyRef = inject(DestroyRef); + private readonly pluginRequirements = inject(PluginRequirementService); + private readonly pluginStore = inject(PluginStoreService); + private readonly realtime = inject(RealtimeSessionFacade); + private readonly registry = inject(PluginRegistryService); + private readonly serverDirectory = inject(ServerDirectoryFacade); + private readonly store = inject(Store); + + private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); + private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId); + private readonly snapshotsSignal = signal>({}); + private readonly refreshErrorsSignal = signal>({}); + private readonly sessionDismissedOptionalSignal = signal>({}); + private readonly hiddenOptionalSignal = signal(loadRequirementDismissals()); readonly currentSnapshot = computed(() => { const roomId = this.currentRoomId(); return roomId ? this.snapshotsSignal()[roomId] ?? null : null; }); - readonly refreshErrors = this.refreshErrorsSignal.asReadonly(); - readonly missingInstallableRequirements = computed(() => { if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) { return []; @@ -66,14 +79,11 @@ export class PluginRequirementStateService { return requirements; }); - readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements() .filter((requirement) => requirement.status === 'required')); - readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements() .filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended') .filter((requirement) => !this.isOptionalRequirementDismissed(requirement))); - readonly comparisons = computed(() => { const snapshot = this.currentSnapshot(); const installedEntries = this.registry.entries(); @@ -105,32 +115,6 @@ export class PluginRequirementStateService { return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId)); }); - private readonly destroyRef = inject(DestroyRef); - - private readonly pluginRequirements = inject(PluginRequirementService); - - private readonly pluginStore = inject(PluginStoreService); - - private readonly realtime = inject(RealtimeSessionFacade); - - private readonly registry = inject(PluginRegistryService); - - private readonly serverDirectory = inject(ServerDirectoryFacade); - - private readonly store = inject(Store); - - private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); - - private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId); - - private readonly snapshotsSignal = signal>({}); - - private readonly refreshErrorsSignal = signal>({}); - - private readonly sessionDismissedOptionalSignal = signal>({}); - - private readonly hiddenOptionalSignal = signal(loadRequirementDismissals()); - constructor() { this.realtime.onSignalingMessage .pipe(takeUntilDestroyed(this.destroyRef)) @@ -284,7 +268,6 @@ export class PluginRequirementStateService { return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt; } - } function loadRequirementDismissals(): RequirementDismissalState { diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts index 72c91aa..6e6b211 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { DestroyRef, Injectable, @@ -69,27 +70,52 @@ interface ServerInstalledPluginsLoadState { @Injectable({ providedIn: 'root' }) export class PluginStoreService { + private readonly electronBridge = inject(ElectronBridgeService); + private readonly capabilities = inject(PluginCapabilityService); + private readonly desktopState = inject(PluginDesktopStateService); + private readonly destroyRef = inject(DestroyRef); + private readonly host = inject(PluginHostService); + private readonly jsonStorage = jsonStorage; + private readonly pluginRequirements = inject(PluginRequirementService); + private readonly realtime = inject(RealtimeSessionFacade, { optional: true }); + private readonly registry = inject(PluginRegistryService); + private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true }); + private readonly store = inject(Store, { optional: true }); + private readonly appI18n = inject(AppI18nService); + private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null; + private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null; + private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null; + private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null; + private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null; + private readonly sourceUrlsSignal = signal([]); + private readonly sourcesSignal = signal([]); + private readonly clientInstalledPluginsSignal = signal([]); + private readonly serverInstalledPluginsSignal = signal([]); + private readonly serverInstalledPluginsLoadStateSignal = signal({ + actorUserId: null, + loaded: false, + loading: false, + roomId: null + }); + private readonly loadingSignal = signal(false); + private refreshAbortController: AbortController | null = null; + private refreshVersion = 0; + private installedLoadVersion = 0; + private autoUpdateInProgress = false; + private stateMutated = false; readonly sourceUrls = this.sourceUrlsSignal.asReadonly(); - readonly sources = this.sourcesSignal.asReadonly(); - readonly installedPlugins = computed(() => { const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal()); return installedPlugins.sort(sortInstalledPlugins); }); - readonly isLoading = this.loadingSignal.asReadonly(); - readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins)); - readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.()); - readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin]))); - readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device'); - readonly serverInstalledPluginsReadyForCurrentRoom = computed(() => { const roomId = this.currentRoomId?.() ?? null; const actorUserId = this.currentActorUserId(); @@ -106,67 +132,6 @@ export class PluginStoreService { && loadState.actorUserId === actorUserId; }); - private readonly electronBridge = inject(ElectronBridgeService); - - private readonly capabilities = inject(PluginCapabilityService); - - private readonly desktopState = inject(PluginDesktopStateService); - - private readonly destroyRef = inject(DestroyRef); - - private readonly host = inject(PluginHostService); - - private readonly jsonStorage = jsonStorage; - - private readonly pluginRequirements = inject(PluginRequirementService); - - private readonly realtime = inject(RealtimeSessionFacade, { optional: true }); - - private readonly registry = inject(PluginRegistryService); - - private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true }); - - private readonly store = inject(Store, { optional: true }); - - private readonly appI18n = inject(AppI18nService); - - private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null; - - private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null; - - private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null; - - private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null; - - private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null; - - private readonly sourceUrlsSignal = signal([]); - - private readonly sourcesSignal = signal([]); - - private readonly clientInstalledPluginsSignal = signal([]); - - private readonly serverInstalledPluginsSignal = signal([]); - - private readonly serverInstalledPluginsLoadStateSignal = signal({ - actorUserId: null, - loaded: false, - loading: false, - roomId: null - }); - - private readonly loadingSignal = signal(false); - - private refreshAbortController: AbortController | null = null; - - private refreshVersion = 0; - - private installedLoadVersion = 0; - - private autoUpdateInProgress = false; - - private stateMutated = false; - constructor() { const state = this.loadState(); @@ -278,6 +243,73 @@ export class PluginStoreService { return installedPlugin; } + private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null { + if (installScope !== 'server') { + return null; + } + + const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null; + + if (!targetServerId) { + throw new Error('Open a chat server before installing server-scoped plugins'); + } + + return targetServerId; + } + + private async persistInstallResult( + installScope: TojuPluginInstallScope, + targetServerId: string | null, + nextInstalledPlugins: InstalledStorePlugin[], + installedPlugin: InstalledStorePlugin, + options: PluginStoreInstallOptions + ): Promise { + if (installScope === 'server') { + await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required'); + return; + } + + await this.persistInstalledPlugins(nextInstalledPlugins, installScope); + } + + private async registerInstallResult( + installScope: TojuPluginInstallScope, + targetServerId: string | null, + nextInstalledPlugins: InstalledStorePlugin[], + installedPlugin: InstalledStorePlugin, + options: PluginStoreInstallOptions + ): Promise { + if (installScope === 'server' && targetServerId) { + await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins); + } + + if (installScope === 'server' && options.activate) { + this.registry.setEnabled(installedPlugin.manifest.id, true); + } + + if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) { + if (options.activate) { + await this.host.rememberActivation(installedPlugin.manifest.id); + } + + return; + } + + const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl; + + if (sourcePath?.startsWith('file://')) { + await this.ensurePluginSourceReadRoot(sourcePath); + } + + this.host.registerLocalManifest(installedPlugin.manifest, sourcePath); + + this.setInstalledPluginsForScope(installScope, nextInstalledPlugins); + + if (options.activate) { + await this.host.activatePluginById(installedPlugin.manifest.id); + } + } + async loadInstallManifest(plugin: PluginStoreEntry): Promise { if (!plugin.installUrl) { throw new Error('Plugin does not provide an install manifest URL'); @@ -446,73 +478,6 @@ export class PluginStoreService { return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope(); } - private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null { - if (installScope !== 'server') { - return null; - } - - const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null; - - if (!targetServerId) { - throw new Error('Open a chat server before installing server-scoped plugins'); - } - - return targetServerId; - } - - private async persistInstallResult( - installScope: TojuPluginInstallScope, - targetServerId: string | null, - nextInstalledPlugins: InstalledStorePlugin[], - installedPlugin: InstalledStorePlugin, - options: PluginStoreInstallOptions - ): Promise { - if (installScope === 'server') { - await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required'); - return; - } - - await this.persistInstalledPlugins(nextInstalledPlugins, installScope); - } - - private async registerInstallResult( - installScope: TojuPluginInstallScope, - targetServerId: string | null, - nextInstalledPlugins: InstalledStorePlugin[], - installedPlugin: InstalledStorePlugin, - options: PluginStoreInstallOptions - ): Promise { - if (installScope === 'server' && targetServerId) { - await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins); - } - - if (installScope === 'server' && options.activate) { - this.registry.setEnabled(installedPlugin.manifest.id, true); - } - - if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) { - if (options.activate) { - await this.host.rememberActivation(installedPlugin.manifest.id); - } - - return; - } - - const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl; - - if (sourcePath?.startsWith('file://')) { - await this.ensurePluginSourceReadRoot(sourcePath); - } - - this.host.registerLocalManifest(installedPlugin.manifest, sourcePath); - - this.setInstalledPluginsForScope(installScope, nextInstalledPlugins); - - if (options.activate) { - await this.host.activatePluginById(installedPlugin.manifest.id); - } - } - private async loadSource(sourceUrl: string, signal: AbortSignal): Promise { try { const sourceValue = await this.fetchJson(sourceUrl, signal); @@ -1103,7 +1068,6 @@ export class PluginStoreService { return null; } - } function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } { diff --git a/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts index a402834..0ae1f32 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, @@ -32,17 +33,14 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; export class PluginActionMenuComponent { readonly closed = output(); + private readonly logger = inject(PluginLoggerService); + private readonly pluginApi = inject(PluginClientApiService); + private readonly pluginRegistry = inject(PluginRegistryService); + private readonly pluginUi = inject(PluginUiRegistryService); + readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()] .sort((left, right) => this.sortActionRecords(left, right))); - private readonly logger = inject(PluginLoggerService); - - private readonly pluginApi = inject(PluginClientApiService); - - private readonly pluginRegistry = inject(PluginRegistryService); - - private readonly pluginUi = inject(PluginUiRegistryService); - @HostListener('document:keydown.escape') close(): void { this.closed.emit(undefined); @@ -97,7 +95,6 @@ export class PluginActionMenuComponent { return left.contribution.label.localeCompare(right.contribution.label); } - } function createInitials(pluginName: string, actionLabel: string): string { diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html index f7832e3..4bd0a71 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html @@ -1,3 +1,4 @@ +
(); - @Output() readonly storeOpened = new EventEmitter(); readonly scope = input('client'); readonly capabilities = inject(PluginCapabilityService); - readonly host = inject(PluginHostService); - readonly logger = inject(PluginLoggerService); - readonly registry = inject(PluginRegistryService); - readonly requirementState = inject(PluginRequirementStateService); - readonly router = inject(Router); - readonly uiRegistry = inject(PluginUiRegistryService); - + private readonly appI18n = inject(AppI18nService); readonly activeTab = signal('installed'); - readonly busyPluginId = signal(null); - readonly busyAll = signal(false); - readonly selectedPluginId = signal(null); - readonly allEntries = this.registry.entries; - readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry))); - readonly managerTitle = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.serverTitle') : this.appI18n.instant('plugins.manager.clientTitle')); - readonly managerDescription = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.serverDescription') : this.appI18n.instant('plugins.manager.clientDescription')); - readonly selectedPlugin = computed(() => { const selectedPluginId = this.selectedPluginId(); return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null; }); - readonly missingCapabilities = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : []; }); - readonly selectedLogs = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id) .slice(-20) : []; }); - readonly extensionCounts = computed(() => ({ appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, @@ -132,7 +114,6 @@ export class PluginManagerComponent { slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length })); - readonly extensionCountItems = computed(() => { const counts = this.extensionCounts(); @@ -148,20 +129,15 @@ export class PluginManagerComponent { { label: this.appI18n.instant('plugins.manager.extensionCounts.embedRenderers'), value: counts.embeds } ]; }); - readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []); - readonly uiConflicts = computed(() => this.uiRegistry.conflicts() .filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId)))); - readonly selectedRequirement = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null; }); - readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null); - readonly selectedSettingsPages = computed(() => { const selectedPlugin = this.selectedPlugin(); @@ -169,15 +145,12 @@ export class PluginManagerComponent { ? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id) : []; }); - readonly emptyTitle = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.empty.serverTitle') : this.appI18n.instant('plugins.manager.empty.clientTitle')); - readonly emptyBody = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.empty.serverBody') : this.appI18n.instant('plugins.manager.empty.clientBody')); - readonly selectedDocs = computed(() => { const manifest = this.selectedPlugin()?.manifest; @@ -193,8 +166,6 @@ export class PluginManagerComponent { ].filter((item): item is { label: string; url: string } => typeof item.url === 'string' && item.url.length > 0); }); - private readonly appI18n = inject(AppI18nService); - setTab(tab: PluginManagerTab): void { this.activeTab.set(tab); } @@ -294,5 +265,4 @@ export class PluginManagerComponent { private hasVisiblePlugin(pluginId: string): boolean { return this.entries().some((entry) => entry.manifest.id === pluginId); } - } diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html index 4aefffb..947cb47 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html @@ -1,4 +1,4 @@ - +
{ const user = this.currentUser(); @@ -121,11 +116,8 @@ export class PluginStoreComponent implements OnInit { return Array.from(roomsById.values()) .filter((room) => this.canManageServerPlugins(room, user)); }); - readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error)); - readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id))); - readonly filteredPlugins = computed(() => { const searchTerm = this.debouncedSearchTerm().trim() .toLowerCase(); @@ -142,25 +134,19 @@ export class PluginStoreComponent implements OnInit { return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm)); }); - readonly installedCount = computed(() => this.store.installedPlugins().length); - readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length); - readonly sourceCount = computed(() => this.store.sourceUrls().length); - readonly pendingSourceUrls = computed(() => { const loadedUrls = new Set(this.store.sources().map((source) => source.url)); return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl)); }); - readonly selectedReadmePlugin = computed(() => { const readme = this.readme(); return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null; }); - readonly selectedStoreServer = computed(() => { const selectedServerId = this.selectedStoreServerId(); @@ -168,41 +154,23 @@ export class PluginStoreComponent implements OnInit { }); newSourceUrl = ''; - readonly searchTerm = signal(''); - readonly selectedSourceUrl = signal(null); - readonly selectedStoreServerId = signal(null); - readonly selectedServerInstalledPlugins = signal([]); - readonly showInstalledOnly = signal(false); - readonly sourceError = signal(null); - readonly actionError = signal(null); - readonly actionBusyPluginId = signal(null); - readonly readme = signal(null); - readonly readmeRawMode = signal(false); - readonly readmeError = signal(null); - readonly readmeLoadingPluginId = signal(null); - readonly serverInstallDialog = signal(null); - readonly selectedCapabilityIds = signal>(new Set()); - readonly serverInstallOptional = signal(false); - readonly serverInstallError = signal(null); - readonly serverInstallBusy = signal(false); - readonly brokenImageKeys = signal>(new Set()); /** @@ -215,20 +183,12 @@ export class PluginStoreComponent implements OnInit { { initialValue: '' } ); - private readonly appI18n = inject(AppI18nService); - private destroyed = false; - private readonly destroyRef = inject(DestroyRef); - private readonly externalLinks = inject(ExternalLinkService); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly settingsModal = inject(SettingsModalService); - private selectedServerLoadVersion = 0; constructor() { @@ -684,7 +644,6 @@ export class PluginStoreComponent implements OnInit { } } } - } function comparePluginVersions(leftVersion: string, rightVersion: string): number { diff --git a/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts index 1cfdca3..aae98ed 100644 --- a/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts +++ b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts @@ -4,13 +4,12 @@ import type { LocalPluginDiscoveryResult, LocalPluginManifestDescriptor } from ' @Injectable({ providedIn: 'root' }) export class LocalPluginDiscoveryService { + private readonly electronBridge = inject(ElectronBridgeService); get isAvailable(): boolean { return this.electronBridge.isAvailable; } - private readonly electronBridge = inject(ElectronBridgeService); - async getPluginsPath(): Promise { const api = this.electronBridge.getApi(); @@ -44,5 +43,4 @@ export class LocalPluginDiscoveryService { pluginsPath: result.pluginsPath }; } - } diff --git a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts index 616ee05..2ca2e05 100644 --- a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts +++ b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts @@ -31,27 +31,21 @@ import { templateUrl: './profile-avatar-editor.component.html' }) export class ProfileAvatarEditorComponent { + private readonly appI18n = inject(AppI18nService); + private readonly avatar = inject(ProfileAvatarFacade); readonly source = input.required(); - readonly cancelled = output(); - readonly confirmed = output(); readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE; - readonly processing = signal(false); - readonly errorMessage = signal(null); - readonly preservesAnimation = computed(() => this.source().preservesAnimation); - readonly transform = signal({ zoom: 1, offsetX: 0, offsetY: 0 }); - readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform())); - readonly imageTransform = computed(() => { const source = this.source(); const transform = this.clampedTransform(); @@ -60,12 +54,7 @@ export class ProfileAvatarEditorComponent { return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`; }); - private readonly appI18n = inject(AppI18nService); - - private readonly avatar = inject(ProfileAvatarFacade); - private dragPointerId: number | null = null; - private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null; @HostListener('document:keydown.escape') @@ -172,5 +161,4 @@ export class ProfileAvatarEditorComponent { this.processing.set(false); } } - } diff --git a/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts b/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts index a02ee4a..6563156 100644 --- a/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts +++ b/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ import { Component, inject, @@ -50,32 +51,51 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; export class ScreenShareViewerComponent implements OnDestroy { @ViewChild('screenVideo') videoRef!: ElementRef; + private readonly screenShareService = inject(ScreenShareFacade); + private readonly voicePlayback = inject(VoicePlaybackService); + private readonly store = inject(Store); + private readonly appI18n = inject(AppI18nService); + private remoteStreamSub: Subscription | null = null; + onlineUsers = this.store.selectSignal(selectOnlineUsers); activeScreenSharer = signal(null); - - isFullscreen = signal(false); - - hasStream = signal(false); - - isLocalShare = signal(false); - - screenVolume = signal(DEFAULT_VOLUME); - - private readonly screenShareService = inject(ScreenShareFacade); - - private readonly voicePlayback = inject(VoicePlaybackService); - - private readonly store = inject(Store); - - private readonly appI18n = inject(AppI18nService); - - private remoteStreamSub: Subscription | null = null; - // Track the userId we're currently watching (for detecting when they stop sharing) private watchingUserId = signal(null); + isFullscreen = signal(false); + hasStream = signal(false); + isLocalShare = signal(false); + screenVolume = signal(DEFAULT_VOLUME); private streamSubscription: (() => void) | null = null; + private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => { + try { + const userId = evt.detail?.userId; + + if (!userId) + return; + + const stream = this.screenShareService.getRemoteScreenShareStream(userId); + const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null; + + if (stream && stream.getVideoTracks().length > 0) { + if (user) { + this.setRemoteStream(stream, user); + } else if (this.videoRef) { + this.videoRef.nativeElement.srcObject = stream; + this.videoRef.nativeElement.volume = 0; + this.videoRef.nativeElement.muted = true; + this.hasStream.set(true); + this.activeScreenSharer.set(null); + this.watchingUserId.set(userId); + this.screenVolume.set(this.voicePlayback.getUserVolume(userId)); + this.isLocalShare.set(false); + } + } + } catch (_error) { + // Failed to focus viewer on user stream + } + }; constructor() { // React to screen share stream changes @@ -272,34 +292,4 @@ export class ScreenShareViewerComponent implements OnDestroy { } } } - - private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => { - try { - const userId = evt.detail?.userId; - - if (!userId) - return; - - const stream = this.screenShareService.getRemoteScreenShareStream(userId); - const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null; - - if (stream && stream.getVideoTracks().length > 0) { - if (user) { - this.setRemoteStream(stream, user); - } else if (this.videoRef) { - this.videoRef.nativeElement.srcObject = stream; - this.videoRef.nativeElement.volume = 0; - this.videoRef.nativeElement.muted = true; - this.hasStream.set(true); - this.activeScreenSharer.set(null); - this.watchingUserId.set(userId); - this.screenVolume.set(this.voicePlayback.getUserVolume(userId)); - this.isLocalShare.set(false); - } - } - } catch { - // Failed to focus viewer on user stream - } - }; - } diff --git a/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts index 9fd3e28..7bf8079 100644 --- a/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts +++ b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts @@ -1,19 +1,16 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { ServerDirectoryService } from '../services/server-directory.service'; @Injectable({ providedIn: 'root' }) export class ServerDirectoryFacade { + private readonly service = inject(ServerDirectoryService); readonly servers = this.service.servers; - readonly activeServers = this.service.activeServers; - readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers; - readonly activeServer = this.service.activeServer; - private readonly service = inject(ServerDirectoryService); - awaitInitialServerHealthCheck( ...args: Parameters ): ReturnType { @@ -241,5 +238,4 @@ export class ServerDirectoryFacade { ): ReturnType { return this.service.sendHeartbeat(...args); } - } diff --git a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts index 6bf59da..d37e5f1 100644 --- a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, @@ -49,51 +50,34 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create- } }) export class CreateServerDialogComponent { + private store = inject(Store); + private router = inject(Router); + private serverDirectory = inject(ServerDirectoryFacade); readonly isMobile = inject(ViewportService).isMobile; readonly created = output(); - readonly cancelled = output(); readonly categories = CATEGORY_PRESETS; - activeEndpoints = this.serverDirectory.activeServers; name = signal(''); - description = signal(''); - topic = signal(''); - selectedCategoryId = signal(null); - isPrivate = signal(false); - password = signal(''); - sourceId = signal(''); - showAdvanced = signal(false); - /** True when the form has enough to create a server. */ - get canCreate(): boolean { - return this.name().trim().length > 0 && this.sourceId().length > 0; - } - - private store = inject(Store); - - private router = inject(Router); - - private serverDirectory = inject(ServerDirectoryFacade); - constructor() { this.sourceId.set(this.activeEndpoints()[0]?.id ?? ''); } - @HostListener('document:keydown.escape') - cancel(): void { - this.cancelled.emit(undefined); + /** True when the form has enough to create a server. */ + get canCreate(): boolean { + return this.name().trim().length > 0 && this.sourceId().length > 0; } selectCategory(category: ServerCategoryPreset): void { @@ -111,6 +95,11 @@ export class CreateServerDialogComponent { this.showAdvanced.update((shown) => !shown); } + @HostListener('document:keydown.escape') + cancel(): void { + this.cancelled.emit(undefined); + } + create(): void { if (!this.canCreate) { return; @@ -139,5 +128,4 @@ export class CreateServerDialogComponent { this.created.emit(undefined); } - } diff --git a/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts index 14b628b..11429f5 100644 --- a/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, @@ -58,44 +59,32 @@ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [ templateUrl: './create-server.component.html' }) export class CreateServerComponent implements OnInit { + private store = inject(Store); + private router = inject(Router); + private serverDirectory = inject(ServerDirectoryFacade); + private currentUser = this.store.selectSignal(selectCurrentUser); readonly categories = CATEGORY_PRESETS; - activeEndpoints = this.serverDirectory.activeServers; name = signal(''); - description = signal(''); - topic = signal(''); - selectedCategoryId = signal(null); - isPrivate = signal(false); - password = signal(''); - sourceId = ''; - showAdvanced = signal(false); + ngOnInit(): void { + this.sourceId = this.activeEndpoints()[0]?.id ?? ''; + } + /** True when the form has enough to create a server. */ get canCreate(): boolean { return this.name().trim().length > 0 && this.sourceId.length > 0; } - private store = inject(Store); - - private router = inject(Router); - - private serverDirectory = inject(ServerDirectoryFacade); - - private currentUser = this.store.selectSignal(selectCurrentUser); - - ngOnInit(): void { - this.sourceId = this.activeEndpoints()[0]?.id ?? ''; - } - selectCategory(category: ServerCategoryPreset): void { if (this.selectedCategoryId() === category.id) { this.selectedCategoryId.set(null); @@ -144,5 +133,4 @@ export class CreateServerComponent implements OnInit { }) ); } - } diff --git a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts index 8fa08b6..851d069 100644 --- a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -43,16 +44,15 @@ const RECENT_SERVER_LIMIT = 6; } }) export class FindServersComponent implements OnInit { + private store = inject(Store); + private serverDirectory = inject(ServerDirectoryFacade); + private readonly i18n = inject(AppI18nService); featured = signal([]); - trending = signal([]); - savedRooms = this.store.selectSignal(selectSavedRooms); readonly searchPlaceholder = this.i18n.instant('servers.find.searchPlaceholder'); - readonly emptyStateTitle = this.i18n.instant('servers.find.emptyTitle'); - readonly emptyStateMessage = this.i18n.instant('servers.find.emptyMessage'); /** Discovery sections shown when the user is not actively searching. */ @@ -95,12 +95,6 @@ export class FindServersComponent implements OnInit { /** True when there is nothing to recommend (a brand-new account). */ isNewUser = computed(() => this.discoverySections().length === 0); - private store = inject(Store); - - private serverDirectory = inject(ServerDirectoryFacade); - - private readonly i18n = inject(AppI18nService); - ngOnInit(): void { this.serverDirectory.getFeaturedServers().subscribe((servers) => this.featured.set(servers)); this.serverDirectory.getTrendingServers().subscribe((servers) => this.trending.set(servers)); @@ -126,5 +120,4 @@ export class FindServersComponent implements OnInit { sourceUrl: room.sourceUrl }; } - } diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts index 245fdb8..2116196 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, effect, @@ -114,19 +115,28 @@ export interface ServerDiscoverySection { templateUrl: './server-browser.component.html' }) export class ServerBrowserComponent implements OnInit { + private store = inject(Store); + private router = inject(Router); + private db = inject(DatabaseService); + private externalLinks = inject(ExternalLinkService); + private serverDirectory = inject(ServerDirectoryFacade); + private webrtc = inject(RealtimeSessionFacade); + private pluginRequirements = inject(PluginRequirementService); + private pluginStore = inject(PluginStoreService); + private injector = inject(Injector); + private readonly i18n = inject(AppI18nService); + private readonly signalServerAuthorize = inject(SignalServerAuthorizeService); + private searchSubject = new Subject(); + private banLookupRequestVersion = 0; /** Discovery sections shown when the search query is empty. */ @Input() discoverySections: ServerDiscoverySection[] = []; - /** Title for the onboarding empty state when there is nothing to show. */ @Input() emptyStateTitle?: string; - /** Supporting copy for the onboarding empty state. */ @Input() emptyStateMessage?: string; - /** Placeholder for the search input. */ @Input() searchPlaceholder?: string; - /** Whether the My Servers quick bar is shown. */ @Input() showMyServers = true; @@ -142,95 +152,6 @@ export class ServerBrowserComponent implements OnInit { return this.searchPlaceholder ?? this.i18n.instant('servers.browser.search.placeholder'); } - searchQuery = ''; - - searchResults = this.store.selectSignal(selectSearchResults); - - isSearching = this.store.selectSignal(selectIsSearching); - - error = this.store.selectSignal(selectRoomsError); - - savedRooms = this.store.selectSignal(selectSavedRooms); - - currentUser = this.store.selectSignal(selectCurrentUser); - - activeEndpoints = this.serverDirectory.activeServers; - - bannedServerLookup = signal>({}); - - bannedServerName = signal(''); - - showBannedDialog = signal(false); - - showPasswordDialog = signal(false); - - passwordPromptServer = signal(null); - - joinPassword = signal(''); - - joinPasswordError = signal(null); - - joinErrorMessage = signal(null); - - joinedServerMenuId = signal(null); - - leaveDialogRoom = signal(null); - - pluginConsentDialog = signal(null); - - selectedOptionalPluginIds = signal>(new Set()); - - pluginConsentBusy = signal(false); - - pluginConsentError = signal(null); - - pluginConsentReadme = signal(null); - - pluginConsentReadmeLoadingId = signal(null); - - pluginConsentReadmeError = signal(null); - - /** True while the user is actively searching (non-empty query). */ - get isSearchMode(): boolean { - return this.searchQuery.trim().length > 0; - } - - /** Discovery sections that actually contain servers. */ - get visibleSections(): ServerDiscoverySection[] { - return this.discoverySections.filter((section) => section.servers.length > 0); - } - - /** True when there is nothing to render outside of search mode. */ - get showEmptyState(): boolean { - return !this.isSearchMode && this.visibleSections.length === 0; - } - - private store = inject(Store); - - private router = inject(Router); - - private db = inject(DatabaseService); - - private externalLinks = inject(ExternalLinkService); - - private serverDirectory = inject(ServerDirectoryFacade); - - private webrtc = inject(RealtimeSessionFacade); - - private pluginRequirements = inject(PluginRequirementService); - - private pluginStore = inject(PluginStoreService); - - private injector = inject(Injector); - - private readonly i18n = inject(AppI18nService); - - private readonly signalServerAuthorize = inject(SignalServerAuthorizeService); - - private searchSubject = new Subject(); - - private banLookupRequestVersion = 0; - serverCardTitle(server: ServerInfo): string { return this.isJoinedServer(server) ? this.i18n.instant('servers.browser.card.doubleClickOpen', { name: server.name }) @@ -279,6 +200,31 @@ export class ServerBrowserComponent implements OnInit { : this.i18n.instant('servers.plugins.readme'); } + searchQuery = ''; + searchResults = this.store.selectSignal(selectSearchResults); + isSearching = this.store.selectSignal(selectIsSearching); + error = this.store.selectSignal(selectRoomsError); + savedRooms = this.store.selectSignal(selectSavedRooms); + currentUser = this.store.selectSignal(selectCurrentUser); + activeEndpoints = this.serverDirectory.activeServers; + bannedServerLookup = signal>({}); + bannedServerName = signal(''); + showBannedDialog = signal(false); + showPasswordDialog = signal(false); + passwordPromptServer = signal(null); + joinPassword = signal(''); + joinPasswordError = signal(null); + joinErrorMessage = signal(null); + joinedServerMenuId = signal(null); + leaveDialogRoom = signal(null); + pluginConsentDialog = signal(null); + selectedOptionalPluginIds = signal>(new Set()); + pluginConsentBusy = signal(false); + pluginConsentError = signal(null); + pluginConsentReadme = signal(null); + pluginConsentReadmeLoadingId = signal(null); + pluginConsentReadmeError = signal(null); + // The reactive effect is created in ngOnInit with an explicit injector so the // component can be instantiated outside a change-detection context (e.g. unit tests). ngOnInit(): void { @@ -301,6 +247,21 @@ export class ServerBrowserComponent implements OnInit { }); } + /** True while the user is actively searching (non-empty query). */ + get isSearchMode(): boolean { + return this.searchQuery.trim().length > 0; + } + + /** Discovery sections that actually contain servers. */ + get visibleSections(): ServerDiscoverySection[] { + return this.discoverySections.filter((section) => section.servers.length > 0); + } + + /** True when there is nothing to render outside of search mode. */ + get showEmptyState(): boolean { + return !this.isSearchMode && this.visibleSections.length === 0; + } + onSearchChange(query: string): void { this.searchSubject.next(query); } @@ -763,5 +724,4 @@ export class ServerBrowserComponent implements OnInit { return hasRoomBanForUser(bans, currentUser, currentUserId); } - } diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.spec.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.spec.ts index 40ff889..e7d0466 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.spec.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.spec.ts @@ -7,7 +7,7 @@ import { import { HttpClient, HttpParams } from '@angular/common/http'; import { Injector, runInInjectionContext } from '@angular/core'; import { of, throwError } from 'rxjs'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, lastValueFrom } from 'rxjs'; import { ServerDirectoryApiService } from './server-directory-api.service'; import { ServerEndpointStateService } from '../../application/services/server-endpoint-state.service'; @@ -237,3 +237,29 @@ describe('ServerDirectoryApiService discovery fallback', () => { expect(calledUrls).not.toContain('https://local.test/api/servers'); }); }); + +describe('ServerDirectoryApiService search fan-out', () => { + it('searches offline active endpoints so newly connected signal servers stay discoverable', async () => { + const localEndpoint = { id: 'ep-1', name: 'Local', url: 'https://local.test', status: 'online' }; + const secondaryEndpoint = { + id: 'ep-2', + name: 'Secondary', + url: 'https://secondary.test', + status: 'offline' + }; + const endpoints = [localEndpoint, secondaryEndpoint]; + const { service, get } = createMultiEndpointHarness(endpoints, (url) => { + if (url.startsWith('https://secondary.test')) { + return { servers: [{ id: 'room-secondary', name: 'Secondary Room' }], total: 1 }; + } + + return { servers: [], total: 0 }; + }); + const result = await lastValueFrom(service.searchServers('Secondary', true)); + + expect(result.map((server) => server.id)).toEqual(['room-secondary']); + const calledUrls = get.mock.calls.map((call) => call[0] as string); + + expect(calledUrls).toContain('https://secondary.test/api/servers'); + }); +}); diff --git a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts index 4ed8910..ec2a5d9 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/services/server-directory-api.service.ts @@ -432,7 +432,7 @@ export class ServerDirectoryApiService { } private getSearchableEndpoints(): ServerEndpoint[] { - const activeEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline'); + const activeEndpoints = this.endpointState.activeServers(); if (activeEndpoints.length > 0) { return activeEndpoints; diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts index 35db590..8f40f49 100644 --- a/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts +++ b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts @@ -49,45 +49,28 @@ type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout'; }) export class ThemeSettingsComponent { readonly modal = inject(SettingsModalService); - readonly theme = inject(ThemeService); - readonly themeLibrary = inject(ThemeLibraryService); - readonly registry = inject(ThemeRegistryService); - readonly picker = inject(ElementPickerService); - readonly layoutSync = inject(LayoutSyncService); + private readonly appI18n = inject(AppI18nService); readonly editorRef = viewChild('jsonEditorRef'); readonly draftText = this.theme.draftText; - readonly draftErrors = this.theme.draftErrors; - readonly draftIsValid = this.theme.draftIsValid; - readonly statusMessage = this.theme.statusMessage; - readonly isDraftDirty = this.theme.isDraftDirty; - readonly isFullscreen = this.modal.themeStudioFullscreen; - readonly activeTheme = this.theme.activeTheme; - readonly builtInPresets = this.theme.builtInPresets; - readonly draftTheme = this.theme.draftTheme; - readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS; - readonly animationKeys = this.theme.knownAnimationClasses; - readonly layoutContainers = this.layoutSync.containers(); - readonly themeEntries = this.registry.entries(); - readonly workspaceTabs = computed(() => [ { key: 'editor' as const, @@ -105,23 +88,15 @@ export class ThemeSettingsComponent { description: this.appI18n.instant('theme.studio.workspaces.layout.description') } ]); - readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts()); - readonly activeWorkspace = signal('editor'); - readonly activeEditorTab = signal('json'); - readonly cssOnlyText = signal(''); - readonly explorerQuery = signal(''); readonly selectedContainer = signal('roomLayout'); - readonly selectedElementKey = signal('chatRoomMainPanel'); - readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey())); - readonly selectedElementCapabilities = computed(() => { const selected = this.selectedElement(); @@ -136,31 +111,24 @@ export class ThemeSettingsComponent { selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null ].filter((value): value is string => value !== null); }); - readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer())); - readonly selectedLayoutContainer = computed(() => { return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0]; }); - readonly selectedElementGrid = computed(() => { return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null; }); - readonly activeWorkspaceInfo = computed(() => { return this.workspaceTabs().find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs()[0]; }); - readonly localizedFilteredEntries = computed(() => this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry)) ); - readonly localizedSelectedElement = computed(() => { const selected = this.selectedElement(); return selected ? this.localizeRegistryEntry(selected) : null; }); - readonly visiblePropertyHints = computed(() => { const selected = this.selectedElement(); @@ -180,11 +148,9 @@ export class ThemeSettingsComponent { return true; }); }); - readonly mountedEntries = computed(() => { return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable); }); - readonly filteredEntries = computed(() => { const query = this.explorerQuery().trim() .toLowerCase(); @@ -199,29 +165,17 @@ export class ThemeSettingsComponent { return haystack.includes(query); }); }); - readonly draftLineCount = computed(() => this.draftText().split('\n').length); - readonly draftCharacterCount = computed(() => this.draftText().length); - readonly draftErrorCount = computed(() => this.draftErrors().length); - readonly mountedEntryCount = computed(() => this.mountedEntries().length); - readonly llmGuideCopyMessage = signal(null); - readonly savedThemesAvailable = this.themeLibrary.isAvailable; - readonly savedThemes = this.themeLibrary.entries; - readonly savedThemesBusy = this.themeLibrary.isBusy; - readonly savedThemesPath = this.themeLibrary.savedThemesPath; - readonly selectedSavedTheme = this.themeLibrary.selectedEntry; - private readonly appI18n = inject(AppI18nService); - private llmGuideCopyTimeoutId: ReturnType | null = null; constructor() { @@ -531,26 +485,6 @@ export class ThemeSettingsComponent { return (this.mountedKeyCounts()[entry.key] ?? 0) > 0; } - presetDisplayName(presetKey: string, fallbackName: string): string { - const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`); - - return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized; - } - - presetDescription(presetKey: string, fallbackDescription?: string): string | undefined { - if (!fallbackDescription) { - return undefined; - } - - const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`); - - return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized; - } - - isDefaultPresetName(name: string): boolean { - return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name'); - } - private focusEditor(): void { this.withEditorReady((editor) => { editor.focus(); @@ -881,6 +815,26 @@ export class ThemeSettingsComponent { }, 4000); } + presetDisplayName(presetKey: string, fallbackName: string): string { + const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`); + + return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized; + } + + presetDescription(presetKey: string, fallbackDescription?: string): string | undefined { + if (!fallbackDescription) { + return undefined; + } + + const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`); + + return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized; + } + + isDefaultPresetName(name: string): boolean { + return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name'); + } + private localizeRegistryEntry(entry: ThemeRegistryEntry): ThemeRegistryEntry { return { ...entry, @@ -899,5 +853,4 @@ export class ThemeSettingsComponent { return text.indexOf(`"${key}"`, sectionIndex); } - } diff --git a/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts b/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts index b68daa2..7a9a73f 100644 --- a/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts +++ b/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts @@ -49,13 +49,12 @@ async function withTimeout(operation: Promise, label: string): Promise @Injectable({ providedIn: 'root' }) export class ThemeLibraryStorageService { + private readonly electronBridge = inject(ElectronBridgeService); get isAvailable(): boolean { return this.electronBridge.isAvailable; } - private readonly electronBridge = inject(ElectronBridgeService); - async getSavedThemesPath(): Promise { const electronApi = this.electronBridge.getApi(); @@ -219,5 +218,4 @@ export class ThemeLibraryStorageService { }; } } - } diff --git a/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts b/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts index ce1f9b2..38b81a4 100644 --- a/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts +++ b/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts @@ -27,6 +27,8 @@ import { import { Subscription } from 'rxjs'; import { VoiceConnectionFacade } from '../facades/voice-connection.facade'; import { DebuggingService } from '../../../../core/services/debugging.service'; +/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */ + const SPEAKING_THRESHOLD = 0.015; const SILENT_FRAME_GRACE = 8; const FFT_SIZE = 256; @@ -44,21 +46,16 @@ interface TrackedStream { @Injectable({ providedIn: 'root' }) export class VoiceActivityService implements OnDestroy { - - readonly speakingMap: Signal> = this._speakingMap; - private readonly voiceConnection = inject(VoiceConnectionFacade); - private readonly debugging = inject(DebuggingService); private readonly tracked = new Map(); - private animFrameId: number | null = null; - private readonly subs: Subscription[] = []; - private readonly _speakingMap = signal>(new Map()); + readonly speakingMap: Signal> = this._speakingMap; + constructor() { this.subs.push( this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => { @@ -179,11 +176,30 @@ export class VoiceActivityService implements OnDestroy { this.stopPolling(); } - ngOnDestroy(): void { - this.stopPolling(); - this.tracked.forEach((entry) => this.disposeEntry(entry)); - this.tracked.clear(); - this.subs.forEach((subscription) => subscription.unsubscribe()); + private ensureAllRemoteStreamsTracked(): void { + const peers = this.voiceConnection.getConnectedPeers(); + + for (const peerId of peers) { + const stream = this.voiceConnection.getRemoteVoiceStream(peerId); + + if (stream) { + this.trackStream(peerId, stream); + } + } + } + + private ensurePolling(): void { + if (this.animFrameId !== null) + return; + + this.poll(); + } + + private stopPolling(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } } private poll = (): void => { @@ -196,8 +212,8 @@ export class VoiceActivityService implements OnDestroy { let sumSquares = 0; - for (const sample of dataArray) { - const normalised = (sample - 128) / 128; + for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) { + const normalised = (dataArray[sampleIndex] - 128) / 128; sumSquares += normalised * normalised; } @@ -233,32 +249,6 @@ export class VoiceActivityService implements OnDestroy { this.animFrameId = requestAnimationFrame(this.poll); }; - private ensureAllRemoteStreamsTracked(): void { - const peers = this.voiceConnection.getConnectedPeers(); - - for (const peerId of peers) { - const stream = this.voiceConnection.getRemoteVoiceStream(peerId); - - if (stream) { - this.trackStream(peerId, stream); - } - } - } - - private ensurePolling(): void { - if (this.animFrameId !== null) - return; - - this.poll(); - } - - private stopPolling(): void { - if (this.animFrameId !== null) { - cancelAnimationFrame(this.animFrameId); - this.animFrameId = null; - } - } - private publishSpeakingMap(): void { const map = new Map(); @@ -279,18 +269,16 @@ export class VoiceActivityService implements OnDestroy { private disposeEntry(entry: TrackedStream): void { entry.sources.forEach((source) => { - try { - source.disconnect(); - } catch { - /* already disconnected */ - } + try { source.disconnect(); } catch { /* already disconnected */ } }); - try { - entry.ctx.close(); - } catch { - /* already closed */ - } + try { entry.ctx.close(); } catch { /* already closed */ } } + ngOnDestroy(): void { + this.stopPolling(); + this.tracked.forEach((entry) => this.disposeEntry(entry)); + this.tracked.clear(); + this.subs.forEach((subscription) => subscription.unsubscribe()); + } } diff --git a/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts b/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts index f568119..c6e0970 100644 --- a/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts +++ b/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering, */ import { Injectable, signal, @@ -19,6 +20,13 @@ import type { VoiceSessionInfo } from '../../domain/models/voice-session.model'; */ @Injectable({ providedIn: 'root' }) export class VoiceSessionFacade { + private readonly store = inject(Store); + + /** Current voice session metadata, or `null` when disconnected. */ + private readonly _voiceSession = signal(null); + + /** Whether the user is currently viewing the voice-connected server. */ + private readonly _isViewingVoiceServer = signal(true); /** Reactive read-only voice session. */ readonly voiceSession = computed(() => this._voiceSession()); @@ -35,14 +43,6 @@ export class VoiceSessionFacade { () => this._voiceSession() !== null && !this._isViewingVoiceServer() ); - private readonly store = inject(Store); - - /** Current voice session metadata, or `null` when disconnected. */ - private readonly _voiceSession = signal(null); - - /** Whether the user is currently viewing the voice-connected server. */ - private readonly _isViewingVoiceServer = signal(true); - /** * Begin tracking a voice session. * Called when the user joins a voice channel. @@ -111,5 +111,4 @@ export class VoiceSessionFacade { getVoiceServerId(): string | null { return this._voiceSession()?.serverId ?? null; } - } diff --git a/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts b/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts index df2ca5f..cc2a30c 100644 --- a/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts +++ b/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -22,6 +23,15 @@ const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = { @Injectable({ providedIn: 'root' }) export class VoiceWorkspaceService { + private readonly voiceSession = inject(VoiceSessionFacade); + + private readonly _mode = signal('hidden'); + private readonly _focusedStreamId = signal(null); + private readonly _connectRemoteShares = signal(false); + private readonly _miniWindowPosition = signal( + DEFAULT_MINI_WINDOW_POSITION + ); + private readonly _hasCustomMiniWindowPosition = signal(false); readonly mode = computed(() => { if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) { @@ -32,35 +42,15 @@ export class VoiceWorkspaceService { }); readonly isExpanded = computed(() => this.mode() === 'expanded'); - readonly isMinimized = computed(() => this.mode() === 'minimized'); - readonly isVisible = computed(() => this.mode() !== 'hidden'); - readonly focusedStreamId = computed(() => this._focusedStreamId()); - readonly shouldConnectRemoteShares = computed( () => this.isVisible() && this._connectRemoteShares() ); - readonly miniWindowPosition = computed(() => this._miniWindowPosition()); - readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition()); - private readonly voiceSession = inject(VoiceSessionFacade); - - private readonly _mode = signal('hidden'); - - private readonly _focusedStreamId = signal(null); - - private readonly _connectRemoteShares = signal(false); - - private readonly _miniWindowPosition = signal( - DEFAULT_MINI_WINDOW_POSITION - ); - - private readonly _hasCustomMiniWindowPosition = signal(false); - constructor() { effect(() => { if (this.voiceSession.voiceSession()) { @@ -135,5 +125,4 @@ export class VoiceWorkspaceService { this._connectRemoteShares.set(false); this.resetMiniWindowPosition(); } - } diff --git a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts index 0f430d1..df4caa2 100644 --- a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts +++ b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ import { Component, inject, @@ -59,45 +60,30 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; * Provides mute, deafen, screen-share, and disconnect actions in a compact overlay. */ export class FloatingVoiceControlsComponent implements OnInit { + private readonly webrtcService = inject(VoiceConnectionFacade); + private readonly screenShareService = inject(ScreenShareFacade); + private readonly viewport = inject(ViewportService); readonly isMobile = this.viewport.isMobile; + private readonly voiceSessionService = inject(VoiceSessionFacade); + private readonly voicePlayback = inject(VoicePlaybackService); + private readonly store = inject(Store); + private readonly appI18n = inject(AppI18nService); currentUser = this.store.selectSignal(selectCurrentUser); // Voice state from services showFloatingControls = this.voiceSessionService.showFloatingControls; - voiceSession = this.voiceSessionService.voiceSession; isConnected = computed(() => this.webrtcService.isVoiceConnected()); - isMuted = signal(false); - isDeafened = signal(false); - isScreenSharing = this.screenShareService.isScreenSharing; - includeSystemAudio = signal(false); - screenShareQuality = signal('balanced'); - askScreenShareQuality = signal(true); - showScreenShareQualityDialog = signal(false); - private readonly webrtcService = inject(VoiceConnectionFacade); - - private readonly screenShareService = inject(ScreenShareFacade); - - private readonly viewport = inject(ViewportService); - - private readonly voiceSessionService = inject(VoiceSessionFacade); - - private readonly voicePlayback = inject(VoicePlaybackService); - - private readonly store = inject(Store); - - private readonly appI18n = inject(AppI18nService); - /** Sync local mute/deafen state from the WebRTC service on init. */ ngOnInit(): void { // Sync mute/deafen state from webrtc service @@ -314,9 +300,8 @@ export class FloatingVoiceControlsComponent implements OnInit { includeSystemAudio: this.includeSystemAudio(), quality }); - } catch { + } catch (_error) { // Screen share request was denied or failed } } - } diff --git a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts index 43d7c28..4d3637f 100644 --- a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts +++ b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */ import { Component, ElementRef, @@ -74,15 +75,23 @@ interface AudioDevice { templateUrl: './voice-controls.component.html' }) export class VoiceControlsComponent implements OnInit, OnDestroy { + private readonly webrtcService = inject(VoiceConnectionFacade); + private readonly screenShareService = inject(ScreenShareFacade); + private readonly voiceSessionService = inject(VoiceSessionFacade); + private readonly voiceActivity = inject(VoiceActivityService); + private readonly voicePlayback = inject(VoicePlaybackService); + private readonly store = inject(Store); + private readonly settingsModal = inject(SettingsModalService); + private readonly hostEl = inject(ElementRef); + private readonly profileCard = inject(ProfileCardService); + private readonly mobileMedia = inject(MobileMediaService); + private readonly appI18n = inject(AppI18nService); currentUser = this.store.selectSignal(selectCurrentUser); - currentRoom = this.store.selectSignal(selectCurrentRoom); isConnected = computed(() => this.webrtcService.isVoiceConnected()); - showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError()); - connectionErrorMessage = computed(() => { const message = this.webrtcService.connectionErrorMessage(); @@ -92,65 +101,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { return this.appI18n.instant(message); }); - isMuted = signal(false); - isDeafened = signal(false); - isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled()); - isScreenSharing = this.screenShareService.isScreenSharing; - showSettings = signal(false); - inputDevices = signal([]); - - outputDevices = signal([]); - - selectedInputDevice = signal(''); - - selectedOutputDevice = signal(''); - - inputVolume = signal(100); - - outputVolume = signal(100); - - audioBitrate = signal(96); - - latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); - - includeSystemAudio = signal(false); - - noiseReduction = signal(true); - - screenShareQuality = signal('balanced'); - - askScreenShareQuality = signal(true); - - showScreenShareQualityDialog = signal(false); - - private readonly webrtcService = inject(VoiceConnectionFacade); - - private readonly screenShareService = inject(ScreenShareFacade); - - private readonly voiceSessionService = inject(VoiceSessionFacade); - - private readonly voiceActivity = inject(VoiceActivityService); - - private readonly voicePlayback = inject(VoicePlaybackService); - - private readonly store = inject(Store); - - private readonly settingsModal = inject(SettingsModalService); - - private readonly hostEl = inject(ElementRef); - - private readonly profileCard = inject(ProfileCardService); - - private readonly mobileMedia = inject(MobileMediaService); - - private readonly appI18n = inject(AppI18nService); - toggleProfileCard(): void { const user = this.currentUser(); @@ -160,6 +116,27 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true }); } + inputDevices = signal([]); + outputDevices = signal([]); + selectedInputDevice = signal(''); + selectedOutputDevice = signal(''); + inputVolume = signal(100); + outputVolume = signal(100); + audioBitrate = signal(96); + latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); + includeSystemAudio = signal(false); + noiseReduction = signal(true); + screenShareQuality = signal('balanced'); + askScreenShareQuality = signal(true); + showScreenShareQualityDialog = signal(false); + + private playbackOptions(): PlaybackOptions { + return { + isConnected: this.isConnected(), + outputVolume: this.outputVolume() / 100, + isDeafened: this.isDeafened() + }; + } async ngOnInit(): Promise { await this.loadAudioDevices(); @@ -189,7 +166,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.outputDevices.set( devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label })) ); - } catch { /* ignore device enumeration errors */ } + } catch (_error) {} } async connect(): Promise { @@ -286,7 +263,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { async retryConnection(): Promise { try { await this.webrtcService.ensureSignalingConnected(10000); - } catch { /* ignore device enumeration errors */ } + } catch (_error) {} } disconnect(): void { @@ -461,7 +438,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { }) ); } - } catch { /* ignore device enumeration errors */ } + } catch (_error) {} } async toggleScreenShare(): Promise { @@ -570,58 +547,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.saveSettings(); } - getMuteButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isMuted()) { - return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } - - getDeafenButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isDeafened()) { - return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } - - getCameraButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isCameraEnabled()) { - return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } - - getScreenShareButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isScreenSharing()) { - return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } - - private playbackOptions(): PlaybackOptions { - return { - isConnected: this.isConnected(), - outputVolume: this.outputVolume() / 100, - isDeafened: this.isDeafened() - }; - } - private loadSettings(): void { const settings = loadVoiceSettingsFromStorage(); @@ -689,7 +614,50 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { includeSystemAudio: this.includeSystemAudio(), quality }); - } catch { /* ignore device enumeration errors */ } + } catch (_error) {} } + getMuteButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isMuted()) { + return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } + + getDeafenButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isDeafened()) { + return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } + + getCameraButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isCameraEnabled()) { + return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } + + getScreenShareButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isScreenSharing()) { + return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } } diff --git a/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts b/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts index 87c59b7..19e4824 100644 --- a/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts +++ b/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts @@ -104,11 +104,11 @@ function schedulePersist(settings: VoiceSettings): void { timeRemaining(): number; } type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle; - interface IdleCallbackGlobal { + interface MaybeIdleGlobal { requestIdleCallback?: IdleRequest; } - const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as IdleCallbackGlobal; + const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as MaybeIdleGlobal; if (typeof idleGlobal.requestIdleCallback === 'function') { idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 }); diff --git a/toju-app/src/app/features/dashboard/dashboard.component.ts b/toju-app/src/app/features/dashboard/dashboard.component.ts index 05ec28a..e6b0ad1 100644 --- a/toju-app/src/app/features/dashboard/dashboard.component.ts +++ b/toju-app/src/app/features/dashboard/dashboard.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { Component, ElementRef, @@ -99,22 +100,24 @@ 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(); + private readonly searchInputRef = viewChild>('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([]); - recentSearches = signal(this.loadRecentSearches()); + private users = this.store.selectSignal(selectAllUsers); /** True while the user is actively typing a query. */ isSearchMode = computed(() => this.searchQuery().trim().length > 0); @@ -122,6 +125,37 @@ 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(() => { + const currentKey = this.currentUserKey(); + const byKey = new Map(); + + 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(() => { const query = this.searchQuery().trim() @@ -166,63 +200,6 @@ 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(); - - private readonly searchInputRef = viewChild>('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(() => { - const currentKey = this.currentUserKey(); - const byKey = new Map(); - - 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()); @@ -240,6 +217,14 @@ 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); @@ -372,5 +357,4 @@ export class DashboardComponent implements OnInit { .filter((value): value is string => typeof value === 'string') .some((value) => value.toLowerCase().includes(query)); } - } diff --git a/toju-app/src/app/features/direct-call/private-call.component.ts b/toju-app/src/app/features/direct-call/private-call.component.ts index 89c2420..8db019a 100644 --- a/toju-app/src/app/features/direct-call/private-call.component.ts +++ b/toju-app/src/app/features/direct-call/private-call.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -81,29 +82,34 @@ 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(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(); @@ -115,40 +121,25 @@ 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('balanced'); - readonly askScreenShareQuality = signal(true); - readonly showScreenShareQualityDialog = signal(false); - readonly inviteUserId = signal(''); - readonly focusedStreamId = signal(null); - readonly showAllStreamsMode = signal(false); - readonly chatWidthPx = signal(384); - readonly inviteCandidates = computed(() => { const participantIds = new Set(this.session()?.participantIds ?? []); const currentUserId = this.currentUserKey(); @@ -159,7 +150,6 @@ export class PrivateCallComponent { return userId !== currentUserId && !participantIds.has(userId); }); }); - readonly activeShares = computed(() => { this.remoteStreamRevision(); @@ -203,11 +193,8 @@ 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(); @@ -226,9 +213,7 @@ export class PrivateCallComponent { return null; }); - readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null); - readonly thumbnailShares = computed(() => { const focusedShareId = this.focusedShareId(); @@ -238,37 +223,6 @@ 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(); @@ -346,8 +300,6 @@ export class PrivateCallComponent { this.chatResizing = false; } - readonly trackUserKey = (index: number, user: User): string => this.userKey(user); - async join(): Promise { const session = this.session(); @@ -556,6 +508,8 @@ 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(); @@ -705,5 +659,4 @@ export class PrivateCallComponent { private bumpRemoteStreamRevision(): void { this.remoteStreamRevision.update((value) => value + 1); } - } diff --git a/toju-app/src/app/features/room/chat-room/chat-room.component.ts b/toju-app/src/app/features/room/chat-room/chat-room.component.ts index 11f1baa..c781536 100644 --- a/toju-app/src/app/features/room/chat-room/chat-room.component.ts +++ b/toju-app/src/app/features/room/chat-room/chat-room.component.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -96,25 +97,28 @@ 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('channels'); - readonly isMobile = this.viewport.isMobile; - readonly swiperRef = viewChild>('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. @@ -128,46 +132,19 @@ 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 @@ -260,6 +237,5 @@ export class ChatRoomComponent { this.settingsModal.open('server', room.id); } } - } diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 6b8897e..5855696 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -1,4 +1,4 @@ - +