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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }],
|
||||
|
||||
@@ -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<AppSound, HTMLAudioElement>();
|
||||
|
||||
@@ -47,10 +41,51 @@ export class NotificationAudioService {
|
||||
|
||||
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
return this.isAvailable ? BROWSER_APP_DATA_ROOT : null;
|
||||
}
|
||||
@@ -229,5 +225,4 @@ export class BrowserAttachmentFileStore implements AttachmentFileStore {
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<CapacitorAttachmentFilesystem | null> = loadCapacitorAttachmentFilesystem;
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return isCapacitorNativeRuntime();
|
||||
}
|
||||
|
||||
private readonly loadFilesystem: () => Promise<CapacitorAttachmentFilesystem | null> = loadCapacitorAttachmentFilesystem;
|
||||
|
||||
async getAppDataPath(): Promise<string | null> {
|
||||
return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null;
|
||||
}
|
||||
@@ -203,5 +200,4 @@ export class CapacitorAttachmentFileStore implements AttachmentFileStore {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
@@ -172,5 +169,4 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<RoomSignalSourceInput | null>(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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Message | null>(null);
|
||||
|
||||
readonly showKlipyGifPicker = signal(false);
|
||||
|
||||
readonly lightboxState = signal<ChatLightboxState | null>(null);
|
||||
|
||||
readonly galleryAttachments = signal<Attachment[] | null>(null);
|
||||
|
||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<HTMLTextAreaElement>;
|
||||
|
||||
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
||||
|
||||
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
|
||||
|
||||
readonly replyTo = input<Message | null>(null);
|
||||
|
||||
readonly showKlipyGifPicker = input(false);
|
||||
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
|
||||
readonly klipyEnabled = input(false);
|
||||
|
||||
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
||||
|
||||
readonly textareaTestId = input<string | null>(null);
|
||||
|
||||
readonly commandSurface = input<SlashCommandSurface>('server');
|
||||
|
||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||
|
||||
readonly typingStarted = output();
|
||||
|
||||
readonly replyCleared = output();
|
||||
|
||||
readonly heightChanged = output<number>();
|
||||
|
||||
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<KlipyGif | null>(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<string | null>(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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
||||
@let msg = message();
|
||||
@let attachmentsList = attachmentViewModels();
|
||||
@if (isSystemMessage()) {
|
||||
|
||||
@@ -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<HTMLTextAreaElement>;
|
||||
|
||||
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
|
||||
|
||||
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<string | null>(null);
|
||||
private readonly mediaSupportCache = new Map<string, boolean>();
|
||||
|
||||
readonly message = input.required<Message>();
|
||||
|
||||
readonly repliedMessage = input<Message | undefined>();
|
||||
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
|
||||
readonly isAdmin = input(false);
|
||||
|
||||
readonly userLookup = input<ReadonlyMap<string, User>>(new Map());
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
|
||||
readonly editSaved = output<ChatMessageEditEvent>();
|
||||
|
||||
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
||||
|
||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||
|
||||
readonly referenceRequested = output<string>();
|
||||
|
||||
readonly downloadRequested = output<Attachment>();
|
||||
|
||||
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||
|
||||
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
||||
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
|
||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||
|
||||
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<User>(() => {
|
||||
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<ChatMessageAttachmentViewModel[]>(() => {
|
||||
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<string | null>(null);
|
||||
|
||||
private readonly mediaSupportCache = new Map<string, boolean>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>;
|
||||
|
||||
@ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>;
|
||||
|
||||
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<Message[]>();
|
||||
|
||||
readonly channelMessages = input.required<Message[]>();
|
||||
|
||||
readonly loading = input(false);
|
||||
|
||||
readonly syncing = input(false);
|
||||
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
|
||||
readonly isAdmin = input(false);
|
||||
|
||||
readonly bottomPadding = input(120);
|
||||
|
||||
readonly conversationKey = input.required<string>();
|
||||
|
||||
readonly userLookupOverrides = input<User[]>([]);
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
|
||||
readonly editSaved = output<ChatMessageEditEvent>();
|
||||
|
||||
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
||||
|
||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||
|
||||
readonly downloadRequested = output<Attachment>();
|
||||
|
||||
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||
|
||||
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
||||
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
|
||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||
|
||||
/**
|
||||
* 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<typeof setTimeout> | 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>;
|
||||
|
||||
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
||||
|
||||
readonly gifSelected = output<KlipyGif>();
|
||||
|
||||
readonly closed = output<undefined>();
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
searchQuery = '';
|
||||
|
||||
results = signal<KlipyGif[]>([]);
|
||||
|
||||
loading = signal(false);
|
||||
|
||||
errorMessage = signal('');
|
||||
|
||||
hasNext = signal(false);
|
||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
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<typeof setTimeout> | null = null;
|
||||
|
||||
private requestId = 0;
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.close();
|
||||
}
|
||||
searchQuery = '';
|
||||
results = signal<KlipyGif[]>([]);
|
||||
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))
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string, { name: string; channelId: string; expiresAt: number }>();
|
||||
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<string[]>([]);
|
||||
|
||||
typingOthersCount = signal<number>(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<string, { name: string; channelId: string; expiresAt: number }>();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<User[]>;
|
||||
|
||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
|
||||
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
showUserMenu = signal<string | null>(null);
|
||||
|
||||
showBanDialog = signal(false);
|
||||
|
||||
userToBan = signal<User | null>(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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ChatEvent>
|
||||
);
|
||||
|
||||
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<ChatEvent>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<CustomEmoji[]>([]);
|
||||
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
|
||||
private readonly savedIdsState = signal<ReadonlySet<string>>(new Set());
|
||||
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
|
||||
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<CustomEmoji[]>([]);
|
||||
|
||||
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
|
||||
|
||||
private readonly savedIdsState = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
|
||||
|
||||
private activeUserId: string | null = null;
|
||||
|
||||
private loaded = false;
|
||||
|
||||
async loadForUser(userId: string | null | undefined): Promise<void> {
|
||||
this.activeUserId = userId ?? null;
|
||||
|
||||
@@ -582,5 +574,4 @@ export class CustomEmojiService {
|
||||
|
||||
return baseName || 'emoji';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
|
||||
readonly compact = input(true);
|
||||
|
||||
/** Render the picker panel in normal document flow for bottom-sheet embedding. */
|
||||
readonly inline = input(false);
|
||||
|
||||
readonly emojiSelected = output<string>();
|
||||
|
||||
readonly dismissed = output();
|
||||
|
||||
readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE;
|
||||
|
||||
readonly modalOpen = signal(false);
|
||||
|
||||
readonly uploadError = signal<string | null>(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<HTMLElement>>(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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<DirectCallSession[]>([]);
|
||||
private readonly mobileOverlayCallId = signal<string | null>(null);
|
||||
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
|
||||
private readonly declinedCallIds = new Set<string>();
|
||||
|
||||
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<DirectCallSession | null>(() => {
|
||||
if (this.isDoNotDisturb()) {
|
||||
return null;
|
||||
@@ -61,11 +80,8 @@ export class DirectCallService {
|
||||
&& !session.participants[meId]?.joined
|
||||
&& this.hasConnectedParticipant(session)) ?? null;
|
||||
});
|
||||
|
||||
readonly currentSession = signal<DirectCallSession | null>(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<DirectCallSession[]>([]);
|
||||
|
||||
private readonly mobileOverlayCallId = signal<string | null>(null);
|
||||
|
||||
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
|
||||
|
||||
private readonly declinedCallIds = new Set<string>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (this.pendingIncomingCallPayloads.length === 0) {
|
||||
return;
|
||||
@@ -1067,5 +1041,4 @@ export class DirectCallService {
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<DirectMessageConversation[]>([]);
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
||||
private readonly lastSyncRequestAt = new Map<string, number>();
|
||||
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<DirectMessageConversation[]>([]);
|
||||
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
|
||||
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
||||
|
||||
private readonly lastSyncRequestAt = new Map<string, number>();
|
||||
|
||||
private loadedOwnerId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const ownerId = this.getCurrentUserId();
|
||||
@@ -1007,5 +991,4 @@ export class DirectMessageService {
|
||||
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Friend[]>([]);
|
||||
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<Friend[]>([]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<void>();
|
||||
|
||||
readonly directMessageEvents$: Observable<ChatEvent> = 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<void>();
|
||||
|
||||
constructor() {
|
||||
this.installNetworkTestHooks();
|
||||
}
|
||||
@@ -178,5 +174,4 @@ export class PeerDeliveryService {
|
||||
this.networkRestoredSubject.next();
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
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<string | null>(null);
|
||||
|
||||
readonly showCallButton = input(true);
|
||||
|
||||
readonly composerBottomPadding = signal(140);
|
||||
|
||||
readonly gifPickerAnchorRight = signal(16);
|
||||
|
||||
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
||||
|
||||
readonly replyTo = signal<Message | null>(null);
|
||||
|
||||
readonly lightboxState = signal<ChatLightboxState | null>(null);
|
||||
|
||||
readonly galleryAttachments = signal<Attachment[] | null>(null);
|
||||
|
||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(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<User[]>(() => {
|
||||
const conversation = this.conversation();
|
||||
const knownUsers = this.allUsers();
|
||||
@@ -176,7 +165,6 @@ export class DmChatComponent {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
|
||||
const conversation = this.conversation();
|
||||
const currentUserId = this.currentUserId();
|
||||
@@ -192,12 +180,12 @@ export class DmChatComponent {
|
||||
status: message.status
|
||||
}));
|
||||
});
|
||||
|
||||
readonly chatMessages = computed<Message[]>(() => {
|
||||
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<string>();
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export function isConversationBound(
|
||||
conversationId: string | null | undefined,
|
||||
selectedConversationId: string | null | undefined
|
||||
): boolean {
|
||||
return !!conversationId
|
||||
&& !!selectedConversationId
|
||||
&& conversationId === selectedConversationId;
|
||||
}
|
||||
@@ -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<string, ReturnType<typeof setTimeout>>();
|
||||
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<DmRailItem[]>([]);
|
||||
|
||||
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
|
||||
|
||||
readonly unreadRailItems = computed<DmRailItem[]>(() => {
|
||||
const currentUserId = this.currentUserId();
|
||||
const items = new Map<string, DmRailItem>();
|
||||
@@ -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<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<DirectMessageConversation>();
|
||||
|
||||
readonly conversationOpened = output<string>();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
private readonly theme = inject(ThemeService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
readonly conversationSelected = output<string>();
|
||||
|
||||
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
||||
return conversation.id;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ElementRef<SwiperElement>>('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<DmWorkspaceMobilePage>('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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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<User>();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string, User>();
|
||||
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string, Message[]>
|
||||
|
||||
@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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<NotificationsService['initialize']>
|
||||
): ReturnType<NotificationsService['initialize']> {
|
||||
@@ -99,5 +98,4 @@ export class NotificationsFacade {
|
||||
): ReturnType<NotificationsService['setChannelMuted']> {
|
||||
return this.service.setChannelMuted(...args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<NotificationsSettings>(createDefaultNotificationSettings());
|
||||
|
||||
private readonly _unread = signal<NotificationsUnreadState>(createEmptyUnreadState());
|
||||
|
||||
private readonly _windowFocused = signal<boolean>(typeof document === 'undefined' ? true : document.hasFocus());
|
||||
|
||||
private readonly _documentVisible = signal<boolean>(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
||||
|
||||
private readonly _windowMinimized = signal<boolean>(false);
|
||||
|
||||
private readonly platformKind = detectPlatform();
|
||||
|
||||
private readonly notifiedMessageIds = new Set<string>();
|
||||
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Record<string, PluginRequirementsSnapshot>>({});
|
||||
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
||||
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
|
||||
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(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<PluginRequirementComparison[]>(() => {
|
||||
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<Record<string, PluginRequirementsSnapshot>>({});
|
||||
|
||||
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
||||
|
||||
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
|
||||
|
||||
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(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 {
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
||||
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
||||
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
||||
private readonly serverInstalledPluginsLoadStateSignal = signal<ServerInstalledPluginsLoadState>({
|
||||
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<string[]>([]);
|
||||
|
||||
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
||||
|
||||
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
||||
|
||||
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
||||
|
||||
private readonly serverInstalledPluginsLoadStateSignal = signal<ServerInstalledPluginsLoadState>({
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TojuPluginManifest> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<PluginStoreSourceResult> {
|
||||
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 } {
|
||||
|
||||
@@ -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<undefined>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<section
|
||||
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
|
||||
data-testid="plugin-manager"
|
||||
|
||||
@@ -63,64 +63,46 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
|
||||
})
|
||||
export class PluginManagerComponent {
|
||||
@Output() readonly closed = new EventEmitter<void>();
|
||||
|
||||
@Output() readonly storeOpened = new EventEmitter<void>();
|
||||
|
||||
readonly scope = input<TojuPluginInstallScope>('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<PluginManagerTab>('installed');
|
||||
|
||||
readonly busyPluginId = signal<string | null>(null);
|
||||
|
||||
readonly busyAll = signal(false);
|
||||
|
||||
readonly selectedPluginId = signal<string | null>(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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
||||
<main
|
||||
class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6"
|
||||
data-testid="plugin-store-page"
|
||||
|
||||
@@ -93,17 +93,12 @@ interface ServerPluginInstallDialog {
|
||||
})
|
||||
export class PluginStoreComponent implements OnInit {
|
||||
readonly store = inject(PluginStoreService);
|
||||
|
||||
readonly capabilities = inject(PluginCapabilityService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly ngrxStore = inject(NgRxStore);
|
||||
|
||||
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
|
||||
|
||||
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
|
||||
|
||||
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
|
||||
|
||||
readonly manageableServers = computed(() => {
|
||||
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<string | null>(null);
|
||||
|
||||
readonly selectedStoreServerId = signal<string | null>(null);
|
||||
|
||||
readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]);
|
||||
|
||||
readonly showInstalledOnly = signal(false);
|
||||
|
||||
readonly sourceError = signal<string | null>(null);
|
||||
|
||||
readonly actionError = signal<string | null>(null);
|
||||
|
||||
readonly actionBusyPluginId = signal<string | null>(null);
|
||||
|
||||
readonly readme = signal<PluginStoreReadme | null>(null);
|
||||
|
||||
readonly readmeRawMode = signal(false);
|
||||
|
||||
readonly readmeError = signal<string | null>(null);
|
||||
|
||||
readonly readmeLoadingPluginId = signal<string | null>(null);
|
||||
|
||||
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
||||
|
||||
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
|
||||
|
||||
readonly serverInstallOptional = signal(false);
|
||||
|
||||
readonly serverInstallError = signal<string | null>(null);
|
||||
|
||||
readonly serverInstallBusy = signal(false);
|
||||
|
||||
readonly brokenImageKeys = signal<Set<string>>(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 {
|
||||
|
||||
@@ -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<string | null> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
@@ -44,5 +43,4 @@ export class LocalPluginDiscoveryService {
|
||||
pluginsPath: result.pluginsPath
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<EditableProfileAvatarSource>();
|
||||
|
||||
readonly cancelled = output<undefined>();
|
||||
|
||||
readonly confirmed = output<ProcessedProfileAvatar>();
|
||||
|
||||
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||
|
||||
readonly processing = signal(false);
|
||||
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
|
||||
|
||||
readonly transform = signal<ProfileAvatarTransform>({ 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<HTMLVideoElement>;
|
||||
|
||||
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<User | null>(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<string | null>(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
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ServerDirectoryService['awaitInitialServerHealthCheck']>
|
||||
): ReturnType<ServerDirectoryService['awaitInitialServerHealthCheck']> {
|
||||
@@ -241,5 +238,4 @@ export class ServerDirectoryFacade {
|
||||
): ReturnType<ServerDirectoryService['sendHeartbeat']> {
|
||||
return this.service.sendHeartbeat(...args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<undefined>();
|
||||
|
||||
readonly cancelled = output<undefined>();
|
||||
|
||||
readonly categories = CATEGORY_PRESETS;
|
||||
|
||||
activeEndpoints = this.serverDirectory.activeServers;
|
||||
|
||||
name = signal('');
|
||||
|
||||
description = signal('');
|
||||
|
||||
topic = signal('');
|
||||
|
||||
selectedCategoryId = signal<string | null>(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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ServerInfo[]>([]);
|
||||
|
||||
trending = signal<ServerInfo[]>([]);
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
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<Record<string, boolean>>({});
|
||||
|
||||
bannedServerName = signal('');
|
||||
|
||||
showBannedDialog = signal(false);
|
||||
|
||||
showPasswordDialog = signal(false);
|
||||
|
||||
passwordPromptServer = signal<ServerInfo | null>(null);
|
||||
|
||||
joinPassword = signal('');
|
||||
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
|
||||
joinErrorMessage = signal<string | null>(null);
|
||||
|
||||
joinedServerMenuId = signal<string | null>(null);
|
||||
|
||||
leaveDialogRoom = signal<Room | null>(null);
|
||||
|
||||
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
||||
|
||||
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
||||
|
||||
pluginConsentBusy = signal(false);
|
||||
|
||||
pluginConsentError = signal<string | null>(null);
|
||||
|
||||
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
|
||||
|
||||
pluginConsentReadmeLoadingId = signal<string | null>(null);
|
||||
|
||||
pluginConsentReadmeError = signal<string | null>(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<string>();
|
||||
|
||||
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<Record<string, boolean>>({});
|
||||
bannedServerName = signal('');
|
||||
showBannedDialog = signal(false);
|
||||
showPasswordDialog = signal(false);
|
||||
passwordPromptServer = signal<ServerInfo | null>(null);
|
||||
joinPassword = signal('');
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
joinErrorMessage = signal<string | null>(null);
|
||||
joinedServerMenuId = signal<string | null>(null);
|
||||
leaveDialogRoom = signal<Room | null>(null);
|
||||
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
||||
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
||||
pluginConsentBusy = signal(false);
|
||||
pluginConsentError = signal<string | null>(null);
|
||||
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
|
||||
pluginConsentReadmeLoadingId = signal<string | null>(null);
|
||||
pluginConsentReadmeError = signal<string | null>(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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ThemeJsonCodeEditorComponent>('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<ThemeStudioWorkspace>('editor');
|
||||
|
||||
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
||||
|
||||
readonly cssOnlyText = signal('');
|
||||
|
||||
readonly explorerQuery = signal('');
|
||||
|
||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||
|
||||
readonly selectedElementKey = signal<string>('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<string | null>(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<typeof setTimeout> | 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -49,13 +49,12 @@ async function withTimeout<T>(operation: Promise<T>, label: string): Promise<T>
|
||||
|
||||
@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<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
@@ -219,5 +218,4 @@ export class ThemeLibraryStorageService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ReadonlyMap<string, boolean>> = this._speakingMap;
|
||||
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly tracked = new Map<string, TrackedStream>();
|
||||
|
||||
private animFrameId: number | null = null;
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||
|
||||
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = 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<string, boolean>();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<VoiceSessionInfo | null>(null);
|
||||
|
||||
/** Whether the user is currently viewing the voice-connected server. */
|
||||
private readonly _isViewingVoiceServer = signal<boolean>(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<VoiceSessionInfo | null>(null);
|
||||
|
||||
/** Whether the user is currently viewing the voice-connected server. */
|
||||
private readonly _isViewingVoiceServer = signal<boolean>(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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<VoiceWorkspaceMode>('hidden');
|
||||
private readonly _focusedStreamId = signal<string | null>(null);
|
||||
private readonly _connectRemoteShares = signal(false);
|
||||
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
|
||||
DEFAULT_MINI_WINDOW_POSITION
|
||||
);
|
||||
private readonly _hasCustomMiniWindowPosition = signal(false);
|
||||
|
||||
readonly mode = computed<VoiceWorkspaceMode>(() => {
|
||||
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<VoiceWorkspaceMode>('hidden');
|
||||
|
||||
private readonly _focusedStreamId = signal<string | null>(null);
|
||||
|
||||
private readonly _connectRemoteShares = signal(false);
|
||||
|
||||
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ScreenShareQuality>('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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<AudioDevice[]>([]);
|
||||
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
|
||||
selectedInputDevice = signal<string>('');
|
||||
|
||||
selectedOutputDevice = signal<string>('');
|
||||
|
||||
inputVolume = signal(100);
|
||||
|
||||
outputVolume = signal(100);
|
||||
|
||||
audioBitrate = signal(96);
|
||||
|
||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||
|
||||
includeSystemAudio = signal(false);
|
||||
|
||||
noiseReduction = signal(true);
|
||||
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
|
||||
askScreenShareQuality = signal(true);
|
||||
|
||||
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<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
selectedInputDevice = signal<string>('');
|
||||
selectedOutputDevice = signal<string>('');
|
||||
inputVolume = signal(100);
|
||||
outputVolume = signal(100);
|
||||
audioBitrate = signal(96);
|
||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||
includeSystemAudio = signal(false);
|
||||
noiseReduction = signal(true);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
private playbackOptions(): PlaybackOptions {
|
||||
return {
|
||||
isConnected: this.isConnected(),
|
||||
outputVolume: this.outputVolume() / 100,
|
||||
isDeafened: this.isDeafened()
|
||||
};
|
||||
}
|
||||
async ngOnInit(): Promise<void> {
|
||||
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<void> {
|
||||
@@ -286,7 +263,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
async retryConnection(): Promise<void> {
|
||||
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<void> {
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string>();
|
||||
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
searchQuery = signal('');
|
||||
|
||||
serverResults = this.store.selectSignal(selectSearchResults);
|
||||
|
||||
isSearching = this.store.selectSignal(selectIsSearching);
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
popularServers = signal<ServerInfo[]>([]);
|
||||
|
||||
recentSearches = signal<string[]>(this.loadRecentSearches());
|
||||
private users = this.store.selectSignal(selectAllUsers);
|
||||
|
||||
/** True while the user is actively typing a query. */
|
||||
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
|
||||
@@ -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<User[]>(() => {
|
||||
const currentKey = this.currentUserKey();
|
||||
const byKey = new Map<string, User>();
|
||||
|
||||
for (const user of this.users()) {
|
||||
byKey.set(user.oderId || user.id, user);
|
||||
}
|
||||
|
||||
for (const room of this.savedRooms()) {
|
||||
for (const member of room.members ?? []) {
|
||||
const key = member.oderId || member.id;
|
||||
|
||||
if (byKey.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byKey.set(key, {
|
||||
id: member.id,
|
||||
oderId: key,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
avatarUrl: member.avatarUrl,
|
||||
status: 'disconnected'
|
||||
} as User);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey);
|
||||
});
|
||||
|
||||
/** People matches derived from known users and saved-room members. */
|
||||
topPeopleResults = computed<User[]>(() => {
|
||||
const query = this.searchQuery().trim()
|
||||
@@ -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<string>();
|
||||
|
||||
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
private users = this.store.selectSignal(selectAllUsers);
|
||||
|
||||
/** Every distinct person known to the account (known users plus saved-room members), excluding self. */
|
||||
private discoveredPeople = computed<User[]>(() => {
|
||||
const currentKey = this.currentUserKey();
|
||||
const byKey = new Map<string, User>();
|
||||
|
||||
for (const user of this.users()) {
|
||||
byKey.set(user.oderId || user.id, user);
|
||||
}
|
||||
|
||||
for (const room of this.savedRooms()) {
|
||||
for (const member of room.members ?? []) {
|
||||
const key = member.oderId || member.id;
|
||||
|
||||
if (byKey.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byKey.set(key, {
|
||||
id: member.id,
|
||||
oderId: key,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
avatarUrl: member.avatarUrl,
|
||||
status: 'disconnected'
|
||||
} as User);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey);
|
||||
});
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKeydown(event: KeyboardEvent): void {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
this.searchInputRef()?.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
readonly overlayMode = input(false);
|
||||
|
||||
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('callId')
|
||||
});
|
||||
|
||||
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
|
||||
|
||||
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
||||
|
||||
readonly participantUsers = computed(() => {
|
||||
const session = this.session();
|
||||
|
||||
@@ -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<ScreenShareQuality>('balanced');
|
||||
|
||||
readonly askScreenShareQuality = signal(true);
|
||||
|
||||
readonly showScreenShareQualityDialog = signal(false);
|
||||
|
||||
readonly inviteUserId = signal('');
|
||||
|
||||
readonly focusedStreamId = signal<string | null>(null);
|
||||
|
||||
readonly showAllStreamsMode = signal(false);
|
||||
|
||||
readonly chatWidthPx = signal(384);
|
||||
|
||||
readonly inviteCandidates = computed(() => {
|
||||
const participantIds = new Set(this.session()?.participantIds ?? []);
|
||||
const currentUserId = this.currentUserKey();
|
||||
@@ -159,7 +150,6 @@ export class PrivateCallComponent {
|
||||
return userId !== currentUserId && !participantIds.has(userId);
|
||||
});
|
||||
});
|
||||
|
||||
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ChatRoomMobilePage>('channels');
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
/**
|
||||
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
|
||||
* with the selected channel name instead of the room name.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||
<div
|
||||
appThemeNode="roomPanelHeader"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -140,53 +141,52 @@ const SKELETON_REVEAL_DELAY_MS = 180;
|
||||
templateUrl: './rooms-side-panel.component.html'
|
||||
})
|
||||
export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private store = inject(Store);
|
||||
private signalServerAuth = inject(SignalServerAuthService);
|
||||
private router = inject(Router);
|
||||
private realtime = inject(RealtimeSessionFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private screenShare = inject(ScreenShareFacade);
|
||||
private notifications = inject(NotificationsFacade);
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private directCalls = inject(DirectCallService);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
private directMessages = inject(DirectMessageService);
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
private readonly pluginActionMenu = inject(PluginActionMenuService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
|
||||
readonly showVoiceControls = input(true);
|
||||
|
||||
readonly textChannelSelected = output<string>();
|
||||
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
isConnecting = this.store.selectSignal(selectIsConnecting);
|
||||
|
||||
messagesLoading = this.store.selectSignal(selectMessagesLoading);
|
||||
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
|
||||
pluginChannelSections = this.pluginUi.channelSectionRecords;
|
||||
|
||||
pluginMenuActions = this.pluginUi.toolbarActionRecords;
|
||||
|
||||
pluginSidePanels = this.pluginUi.sidePanelRecords;
|
||||
|
||||
panelHydrating = computed(() => this.panelMode() === 'channels' && (this.isConnecting() || this.messagesLoading()));
|
||||
|
||||
delayedPanelHydrating = signal(false);
|
||||
|
||||
showTextChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.textChannels().length === 0);
|
||||
|
||||
showVoiceChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.voiceEnabled() && this.voiceChannels().length === 0);
|
||||
|
||||
showPluginSkeleton = computed(() => this.delayedPanelHydrating() && !this.hasPluginPanelContent());
|
||||
|
||||
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
||||
|
||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||
|
||||
roomMemberIdentifiers = computed(() => {
|
||||
const identifiers = new Set<string>();
|
||||
|
||||
@@ -196,7 +196,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
|
||||
return identifiers;
|
||||
});
|
||||
|
||||
onlineRoomUsers = computed(() => {
|
||||
const memberIdentifiers = this.roomMemberIdentifiers();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
@@ -205,7 +204,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
(user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId)
|
||||
);
|
||||
});
|
||||
|
||||
offlineRoomMembers = computed(() => {
|
||||
const onlineIdentifiers = new Set<string>();
|
||||
|
||||
@@ -217,7 +215,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
|
||||
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
|
||||
});
|
||||
|
||||
knownUserCount = computed(() => {
|
||||
const memberIds = new Set(
|
||||
this.roomMembers()
|
||||
@@ -233,92 +230,36 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return memberIds.size;
|
||||
});
|
||||
|
||||
private hasPluginPanelContent(): boolean {
|
||||
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
|
||||
}
|
||||
|
||||
showChannelMenu = signal(false);
|
||||
|
||||
channelMenuX = signal(0);
|
||||
|
||||
channelMenuY = signal(0);
|
||||
|
||||
contextChannel = signal<Channel | null>(null);
|
||||
|
||||
renamingChannelId = signal<string | null>(null);
|
||||
|
||||
channelNameError = signal<string | null>(null);
|
||||
|
||||
showCreateChannelDialog = signal(false);
|
||||
|
||||
createChannelType = signal<'text' | 'voice'>('text');
|
||||
|
||||
newChannelName = '';
|
||||
|
||||
showUserMenu = signal(false);
|
||||
|
||||
userMenuX = signal(0);
|
||||
|
||||
userMenuY = signal(0);
|
||||
|
||||
contextMenuUser = signal<User | null>(null);
|
||||
|
||||
showVolumeMenu = signal(false);
|
||||
|
||||
volumeMenuX = signal(0);
|
||||
|
||||
volumeMenuY = signal(0);
|
||||
|
||||
volumeMenuPeerId = signal('');
|
||||
|
||||
volumeMenuDisplayName = signal('');
|
||||
|
||||
draggedVoiceUserId = signal<string | null>(null);
|
||||
|
||||
dragTargetVoiceChannelId = signal<string | null>(null);
|
||||
|
||||
activityNow = signal(Date.now());
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private realtime = inject(RealtimeSessionFacade);
|
||||
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
|
||||
private screenShare = inject(ScreenShareFacade);
|
||||
|
||||
private notifications = inject(NotificationsFacade);
|
||||
|
||||
private voiceSessionService = inject(VoiceSessionFacade);
|
||||
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
|
||||
private directCalls = inject(DirectCallService);
|
||||
|
||||
private profileCard = inject(ProfileCardService);
|
||||
|
||||
private directMessages = inject(DirectMessageService);
|
||||
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
|
||||
private readonly pluginActionMenu = inject(PluginActionMenuService);
|
||||
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.panelHydrating()) {
|
||||
@@ -351,6 +292,15 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.pluginActionMenu.close();
|
||||
}
|
||||
|
||||
private clearSkeletonRevealTimer(): void {
|
||||
if (!this.skeletonRevealTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.skeletonRevealTimer);
|
||||
this.skeletonRevealTimer = null;
|
||||
}
|
||||
|
||||
gameActivityElapsed(user: User | null | undefined): string {
|
||||
const activity = user?.gameActivity;
|
||||
|
||||
@@ -411,6 +361,102 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
await this.openDirectMessage(event, this.roomMemberToUser(member));
|
||||
}
|
||||
|
||||
private roomMemberToUser(member: RoomMember): User {
|
||||
return {
|
||||
id: member.id,
|
||||
oderId: member.oderId || member.id,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
description: member.description,
|
||||
profileUpdatedAt: member.profileUpdatedAt,
|
||||
avatarUrl: member.avatarUrl,
|
||||
avatarHash: member.avatarHash,
|
||||
avatarMime: member.avatarMime,
|
||||
avatarUpdatedAt: member.avatarUpdatedAt,
|
||||
status: 'disconnected',
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt
|
||||
};
|
||||
}
|
||||
|
||||
private roomMemberKey(member: RoomMember): string {
|
||||
return member.oderId || member.id;
|
||||
}
|
||||
|
||||
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
|
||||
if (!entity)
|
||||
return;
|
||||
|
||||
if (entity.id) {
|
||||
identifiers.add(entity.id);
|
||||
}
|
||||
|
||||
if (entity.oderId) {
|
||||
identifiers.add(entity.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
|
||||
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
|
||||
}
|
||||
|
||||
private isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean {
|
||||
if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return entity.presenceServerIds.includes(roomId);
|
||||
}
|
||||
|
||||
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
);
|
||||
|
||||
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
|
||||
}
|
||||
|
||||
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addIdentifiers(identifiers, current);
|
||||
|
||||
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
)) {
|
||||
identifiers.add(selfId);
|
||||
}
|
||||
}
|
||||
|
||||
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
|
||||
this.cancelQueuedProfileCardOpen();
|
||||
this.profileCardOpenTimer = setTimeout(() => {
|
||||
this.profileCardOpenTimer = null;
|
||||
this.profileCard.open(anchor, user, { placement: 'left', editable });
|
||||
}, 180);
|
||||
}
|
||||
|
||||
private cancelQueuedProfileCardOpen(): void {
|
||||
if (!this.profileCardOpenTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.profileCardOpenTimer);
|
||||
this.profileCardOpenTimer = null;
|
||||
}
|
||||
|
||||
hasConnectivityIssue(user: User): boolean {
|
||||
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
|
||||
}
|
||||
@@ -597,6 +643,25 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
|
||||
if (!name) {
|
||||
return 'room.channel.nameRequired';
|
||||
}
|
||||
|
||||
const channels = this.currentRoom()?.channels ?? [];
|
||||
const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType();
|
||||
|
||||
if (!channelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
|
||||
return 'room.channel.nameUnique';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
|
||||
@@ -657,6 +722,21 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
|
||||
if (
|
||||
!room
|
||||
|| !current?.voiceState?.isConnected
|
||||
|| current.voiceState.roomId !== roomId
|
||||
|| current.voiceState.serverId !== room.id
|
||||
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.voiceWorkspace.open(null, { connectRemoteShares: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
isPassiveInVoiceRoom(roomId: string): boolean {
|
||||
const current = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
@@ -698,6 +778,24 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return this.appI18n.instant('room.panel.joinVoiceChannel');
|
||||
}
|
||||
|
||||
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
|
||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||
}
|
||||
|
||||
private prepareVoiceJoin(room: Room, current: User | null): void {
|
||||
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
|
||||
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
|
||||
|
||||
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
|
||||
}
|
||||
|
||||
joinVoice(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
const current = this.currentUser();
|
||||
@@ -735,6 +833,94 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
startJoin();
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
this.voiceConnection.clearConnectionError();
|
||||
this.updateVoiceStateStore(roomId, room, current);
|
||||
this.trackCurrentUserMic();
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
this.broadcastVoiceConnected(roomId, room, current);
|
||||
this.startVoiceSession(roomId, room);
|
||||
}
|
||||
|
||||
private handleVoiceJoinFailure(error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
|
||||
|
||||
this.voiceConnection.reportConnectionError(message);
|
||||
}
|
||||
|
||||
private trackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
const micStream = this.voiceConnection.getRawMicStream();
|
||||
|
||||
if (userId && micStream) {
|
||||
this.voiceActivity.trackLocalMic(userId, micStream);
|
||||
}
|
||||
}
|
||||
|
||||
private untrackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
||||
if (!current?.id)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id,
|
||||
clientInstanceId: this.realtime.getClientInstanceId()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
const clientInstanceId = this.realtime.getClientInstanceId();
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id,
|
||||
clientInstanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startVoiceSession(roomId: string, room: Room): void {
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
|
||||
@@ -744,6 +930,53 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private disconnectCurrentVoiceTarget(current: User | null): void {
|
||||
const previousVoiceState = current?.voiceState;
|
||||
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
this.untrackCurrentUserMic();
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
if (current?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateCameraState({
|
||||
userId: current.id,
|
||||
cameraState: { isEnabled: false }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
voiceOccupancy(roomId: string): number {
|
||||
return this.voiceUsersInRoom(roomId).length;
|
||||
}
|
||||
@@ -826,6 +1059,47 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.moveVoiceUserToChannel(draggedUserId, channelId);
|
||||
}
|
||||
|
||||
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
|
||||
const room = this.currentRoom();
|
||||
const actor = this.currentUser();
|
||||
|
||||
if (!room || !actor || !this.canMoveVoiceUsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
|
||||
|
||||
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedVoiceState: Partial<User['voiceState']> = {
|
||||
isConnected: true,
|
||||
isMuted: targetUser.voiceState.isMuted,
|
||||
isDeafened: targetUser.voiceState.isDeafened,
|
||||
isSpeaking: targetUser.voiceState.isSpeaking,
|
||||
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
|
||||
volume: targetUser.voiceState.volume,
|
||||
roomId: channelId,
|
||||
serverId: room.id
|
||||
};
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: targetUser.id,
|
||||
voiceState: movedVoiceState
|
||||
})
|
||||
);
|
||||
|
||||
this.realtime.broadcastMessage({
|
||||
type: 'voice-channel-move',
|
||||
roomId: room.id,
|
||||
targetUserId: targetUser.oderId || targetUser.id,
|
||||
voiceState: movedVoiceState,
|
||||
displayName: targetUser.displayName
|
||||
});
|
||||
}
|
||||
|
||||
isUserLocallyMuted(user: User): boolean {
|
||||
const peerId = user.oderId || user.id;
|
||||
|
||||
@@ -1025,343 +1299,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return this.appI18n.instant('room.panel.messageUser', { name: displayName });
|
||||
}
|
||||
|
||||
private hasPluginPanelContent(): boolean {
|
||||
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
|
||||
}
|
||||
|
||||
private clearSkeletonRevealTimer(): void {
|
||||
if (!this.skeletonRevealTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.skeletonRevealTimer);
|
||||
this.skeletonRevealTimer = null;
|
||||
}
|
||||
|
||||
private roomMemberToUser(member: RoomMember): User {
|
||||
return {
|
||||
id: member.id,
|
||||
oderId: member.oderId || member.id,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
description: member.description,
|
||||
profileUpdatedAt: member.profileUpdatedAt,
|
||||
avatarUrl: member.avatarUrl,
|
||||
avatarHash: member.avatarHash,
|
||||
avatarMime: member.avatarMime,
|
||||
avatarUpdatedAt: member.avatarUpdatedAt,
|
||||
status: 'disconnected',
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt
|
||||
};
|
||||
}
|
||||
|
||||
private roomMemberKey(member: RoomMember): string {
|
||||
return member.oderId || member.id;
|
||||
}
|
||||
|
||||
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
|
||||
if (!entity)
|
||||
return;
|
||||
|
||||
if (entity.id) {
|
||||
identifiers.add(entity.id);
|
||||
}
|
||||
|
||||
if (entity.oderId) {
|
||||
identifiers.add(entity.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
|
||||
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
|
||||
}
|
||||
|
||||
private isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean {
|
||||
if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return entity.presenceServerIds.includes(roomId);
|
||||
}
|
||||
|
||||
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
);
|
||||
|
||||
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
|
||||
}
|
||||
|
||||
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addIdentifiers(identifiers, current);
|
||||
|
||||
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||
current,
|
||||
this.currentRoom()?.sourceUrl
|
||||
)) {
|
||||
identifiers.add(selfId);
|
||||
}
|
||||
}
|
||||
|
||||
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
|
||||
this.cancelQueuedProfileCardOpen();
|
||||
this.profileCardOpenTimer = setTimeout(() => {
|
||||
this.profileCardOpenTimer = null;
|
||||
this.profileCard.open(anchor, user, { placement: 'left', editable });
|
||||
}, 180);
|
||||
}
|
||||
|
||||
private cancelQueuedProfileCardOpen(): void {
|
||||
if (!this.profileCardOpenTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.profileCardOpenTimer);
|
||||
this.profileCardOpenTimer = null;
|
||||
}
|
||||
|
||||
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
|
||||
if (!name) {
|
||||
return 'room.channel.nameRequired';
|
||||
}
|
||||
|
||||
const channels = this.currentRoom()?.channels ?? [];
|
||||
const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType();
|
||||
|
||||
if (!channelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
|
||||
return 'room.channel.nameUnique';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
|
||||
if (
|
||||
!room
|
||||
|| !current?.voiceState?.isConnected
|
||||
|| current.voiceState.roomId !== roomId
|
||||
|| current.voiceState.serverId !== room.id
|
||||
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.voiceWorkspace.open(null, { connectRemoteShares: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
|
||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||
}
|
||||
|
||||
private prepareVoiceJoin(room: Room, current: User | null): void {
|
||||
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disconnectCurrentVoiceTarget(current);
|
||||
}
|
||||
|
||||
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
|
||||
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
|
||||
|
||||
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
this.voiceConnection.clearConnectionError();
|
||||
this.updateVoiceStateStore(roomId, room, current);
|
||||
this.trackCurrentUserMic();
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
this.broadcastVoiceConnected(roomId, room, current);
|
||||
this.startVoiceSession(roomId, room);
|
||||
}
|
||||
|
||||
private handleVoiceJoinFailure(error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
|
||||
|
||||
this.voiceConnection.reportConnectionError(message);
|
||||
}
|
||||
|
||||
private trackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
const micStream = this.voiceConnection.getRawMicStream();
|
||||
|
||||
if (userId && micStream) {
|
||||
this.voiceActivity.trackLocalMic(userId, micStream);
|
||||
}
|
||||
}
|
||||
|
||||
private untrackCurrentUserMic(): void {
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
||||
if (!current?.id)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id,
|
||||
clientInstanceId: this.realtime.getClientInstanceId()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
const clientInstanceId = this.realtime.getClientInstanceId();
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id,
|
||||
clientInstanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startVoiceSession(roomId: string, room: Room): void {
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
|
||||
private disconnectCurrentVoiceTarget(current: User | null): void {
|
||||
const previousVoiceState = current?.voiceState;
|
||||
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
this.untrackCurrentUserMic();
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
if (current?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateCameraState({
|
||||
userId: current.id,
|
||||
cameraState: { isEnabled: false }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
|
||||
const room = this.currentRoom();
|
||||
const actor = this.currentUser();
|
||||
|
||||
if (!room || !actor || !this.canMoveVoiceUsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
|
||||
|
||||
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedVoiceState: Partial<User['voiceState']> = {
|
||||
isConnected: true,
|
||||
isMuted: targetUser.voiceState.isMuted,
|
||||
isDeafened: targetUser.voiceState.isDeafened,
|
||||
isSpeaking: targetUser.voiceState.isSpeaking,
|
||||
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
|
||||
volume: targetUser.voiceState.volume,
|
||||
roomId: channelId,
|
||||
serverId: room.id
|
||||
};
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: targetUser.id,
|
||||
voiceState: movedVoiceState
|
||||
})
|
||||
);
|
||||
|
||||
this.realtime.broadcastMessage({
|
||||
type: 'voice-channel-move',
|
||||
roomId: room.id,
|
||||
targetUserId: targetUser.oderId || targetUser.id,
|
||||
voiceState: movedVoiceState,
|
||||
displayName: targetUser.displayName
|
||||
});
|
||||
}
|
||||
|
||||
private isVoiceUserSpeaking(user: User): boolean {
|
||||
const userKey = user.oderId || user.id;
|
||||
|
||||
@@ -1408,5 +1345,4 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
private hasActiveVideoStream(stream: MediaStream | null): boolean {
|
||||
return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div
|
||||
#tileRoot
|
||||
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
@@ -55,47 +56,29 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
}
|
||||
})
|
||||
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
||||
|
||||
readonly focused = input(false);
|
||||
|
||||
readonly featured = input(false);
|
||||
|
||||
readonly compact = input(false);
|
||||
|
||||
readonly mini = input(false);
|
||||
|
||||
readonly immersive = input(false);
|
||||
|
||||
readonly focusRequested = output<string>();
|
||||
|
||||
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
|
||||
|
||||
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
||||
|
||||
readonly isFullscreen = signal(false);
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
readonly showFullscreenHeader = signal(true);
|
||||
|
||||
readonly volume = signal(100);
|
||||
|
||||
readonly muted = signal(false);
|
||||
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||
|
||||
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
void this.mobileLifecycle.initialize();
|
||||
this.mobileLifecycle.onAppStateChange((isActive) => {
|
||||
@@ -189,6 +172,24 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
this.unlockOrientation();
|
||||
}
|
||||
|
||||
private async handleAppStateChange(isActive: boolean): Promise<void> {
|
||||
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
|
||||
if (isActive) {
|
||||
await this.mobilePictureInPicture.exit();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const video = this.videoRef()?.nativeElement;
|
||||
|
||||
if (!video || !this.item().stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mobilePictureInPicture.enter(video);
|
||||
}
|
||||
|
||||
canToggleFullscreen(): boolean {
|
||||
return !this.mini() && !this.compact();
|
||||
}
|
||||
@@ -373,24 +374,6 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
return !item.isLocal && item.hasAudio;
|
||||
}
|
||||
|
||||
private async handleAppStateChange(isActive: boolean): Promise<void> {
|
||||
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
|
||||
if (isActive) {
|
||||
await this.mobilePictureInPicture.exit();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const video = this.videoRef()?.nativeElement;
|
||||
|
||||
if (!video || !this.item().stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mobilePictureInPicture.enter(video);
|
||||
}
|
||||
|
||||
private async enterFullscreen(): Promise<void> {
|
||||
const tile = this.tileRef()?.nativeElement;
|
||||
|
||||
@@ -442,7 +425,6 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
clearTimeout(this.fullscreenHeaderHideTimeoutId);
|
||||
this.fullscreenHeaderHideTimeoutId = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface WebKitFullscreenVideoElement extends HTMLVideoElement {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div class="absolute inset-0">
|
||||
@if (showExpanded()) {
|
||||
<section
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, complexity */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
@@ -87,42 +88,55 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
||||
}
|
||||
})
|
||||
export class VoiceWorkspaceComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private readonly remoteStreamRevision = signal(0);
|
||||
|
||||
private readonly miniWindowWidth = 320;
|
||||
private readonly miniWindowHeight = 228;
|
||||
private miniWindowDragging = false;
|
||||
private miniDragOffsetX = 0;
|
||||
private miniDragOffsetY = 0;
|
||||
private wasExpanded = false;
|
||||
private wasAutoHideChrome = false;
|
||||
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly observedRemoteStreams = new Map<string, {
|
||||
stream: MediaStream;
|
||||
cleanup: () => void;
|
||||
}>();
|
||||
|
||||
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
|
||||
|
||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
readonly voiceSessionInfo = this.voiceSession.voiceSession;
|
||||
|
||||
readonly showExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
|
||||
|
||||
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
|
||||
|
||||
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
|
||||
|
||||
readonly showWorkspaceHeader = signal(true);
|
||||
|
||||
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
||||
|
||||
readonly isMuted = computed(() => this.webrtc.isMuted());
|
||||
|
||||
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
||||
|
||||
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
||||
|
||||
readonly includeSystemAudio = signal(false);
|
||||
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
|
||||
readonly askScreenShareQuality = signal(true);
|
||||
|
||||
readonly showScreenShareQualityDialog = signal(false);
|
||||
|
||||
readonly connectedVoiceUsers = computed(() => {
|
||||
@@ -276,23 +290,18 @@ export class VoiceWorkspaceComponent {
|
||||
});
|
||||
|
||||
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
|
||||
|
||||
readonly shouldAutoHideChrome = computed(
|
||||
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
|
||||
);
|
||||
|
||||
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
||||
|
||||
readonly widescreenShare = computed(
|
||||
() => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null
|
||||
);
|
||||
|
||||
readonly focusedAudioShare = computed(() => {
|
||||
const share = this.widescreenShare();
|
||||
|
||||
return share && !share.isLocal && share.hasAudio ? share : null;
|
||||
});
|
||||
|
||||
readonly focusedShareTitle = computed(() => {
|
||||
const share = this.widescreenShare();
|
||||
|
||||
@@ -308,7 +317,6 @@ export class VoiceWorkspaceComponent {
|
||||
? this.appI18n.instant('voice.workspace.yourCamera')
|
||||
: this.appI18n.instant('voice.workspace.yourScreen');
|
||||
});
|
||||
|
||||
readonly thumbnailShares = computed(() => {
|
||||
const widescreenShareId = this.widescreenShareId();
|
||||
|
||||
@@ -318,11 +326,9 @@ export class VoiceWorkspaceComponent {
|
||||
|
||||
return this.activeShares().filter((share) => share.id !== widescreenShareId);
|
||||
});
|
||||
|
||||
readonly miniPreviewShare = computed(
|
||||
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
|
||||
);
|
||||
|
||||
readonly miniPreviewTitle = computed(() => {
|
||||
const previewShare = this.miniPreviewShare();
|
||||
|
||||
@@ -338,9 +344,7 @@ export class VoiceWorkspaceComponent {
|
||||
? this.appI18n.instant('voice.workspace.yourCamera')
|
||||
: this.appI18n.instant('voice.workspace.yourScreen');
|
||||
});
|
||||
|
||||
readonly liveShareCount = computed(() => this.activeShares().length);
|
||||
|
||||
readonly connectedVoiceChannelName = computed(() => {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
@@ -357,55 +361,21 @@ export class VoiceWorkspaceComponent {
|
||||
|
||||
return sessionRoomName || this.appI18n.instant('voice.workspace.voiceLounge');
|
||||
});
|
||||
|
||||
readonly serverName = computed(
|
||||
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || this.appI18n.instant('voice.workspace.voiceServer')
|
||||
);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
liveStreamCountLabel(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
|
||||
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
|
||||
private readonly store = inject(Store);
|
||||
miniWindowStreamHint(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
|
||||
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private readonly remoteStreamRevision = signal(0);
|
||||
|
||||
private readonly miniWindowWidth = 320;
|
||||
|
||||
private readonly miniWindowHeight = 228;
|
||||
|
||||
private miniWindowDragging = false;
|
||||
|
||||
private miniDragOffsetX = 0;
|
||||
|
||||
private miniDragOffsetY = 0;
|
||||
|
||||
private wasExpanded = false;
|
||||
|
||||
private wasAutoHideChrome = false;
|
||||
|
||||
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly observedRemoteStreams = new Map<string, {
|
||||
stream: MediaStream;
|
||||
cleanup: () => void;
|
||||
}>();
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
@@ -539,6 +509,14 @@ export class VoiceWorkspaceComponent {
|
||||
});
|
||||
}
|
||||
|
||||
onWorkspacePointerMove(): void {
|
||||
if (!this.shouldAutoHideChrome()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealWorkspaceChrome();
|
||||
}
|
||||
|
||||
@HostListener('window:mousemove', ['$event'])
|
||||
onWindowMouseMove(event: MouseEvent): void {
|
||||
if (!this.miniWindowDragging) {
|
||||
@@ -570,26 +548,6 @@ export class VoiceWorkspaceComponent {
|
||||
this.ensureMiniWindowPosition();
|
||||
}
|
||||
|
||||
liveStreamCountLabel(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
|
||||
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
|
||||
miniWindowStreamHint(count: number): string {
|
||||
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
|
||||
|
||||
return this.appI18n.instant(key, { count });
|
||||
}
|
||||
|
||||
onWorkspacePointerMove(): void {
|
||||
if (!this.shouldAutoHideChrome()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealWorkspaceChrome();
|
||||
}
|
||||
|
||||
trackUser(index: number, user: User): string {
|
||||
return this.getUserPeerKey(user) || `${index}`;
|
||||
}
|
||||
@@ -1099,5 +1057,4 @@ export class VoiceWorkspaceComponent {
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -69,32 +70,36 @@ const ACTIVATION_DEBOUNCE_MS = 150;
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
export class ServersRailComponent {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private notifications = inject(NotificationsFacade);
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private banLookupRequestVersion = 0;
|
||||
private bannedLookupUserKey: string | null = null;
|
||||
private activationRequestVersion = 0;
|
||||
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
private joinRequestVersion = 0;
|
||||
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
private visibleSavedRoomCache: Room[] = [];
|
||||
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
showMenu = signal(false);
|
||||
|
||||
menuX = signal(72);
|
||||
|
||||
menuY = signal(100);
|
||||
|
||||
contextRoom = signal<Room | null>(null);
|
||||
|
||||
optimisticSelectedRoomId = signal<string | null>(null);
|
||||
|
||||
showLeaveConfirm = signal(false);
|
||||
|
||||
showCreateDialog = signal(false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||
|
||||
isOnServers = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -102,7 +107,6 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/servers') }
|
||||
);
|
||||
|
||||
isOnDirectMessage = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -110,7 +114,6 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.isDirectMessageUrl(this.router.url) }
|
||||
);
|
||||
|
||||
isOnDashboard = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -118,7 +121,6 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/dashboard') }
|
||||
);
|
||||
|
||||
isOnCall = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -126,7 +128,6 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/call/') }
|
||||
);
|
||||
|
||||
currentCallId = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -134,7 +135,6 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.callIdFromUrl(this.router.url) }
|
||||
);
|
||||
|
||||
selectedCallIndex = computed(() => {
|
||||
const routeCallId = this.currentCallId();
|
||||
const visibleCalls = this.directCalls.visibleActiveSessions();
|
||||
@@ -155,21 +155,13 @@ export class ServersRailComponent {
|
||||
|
||||
return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
|
||||
});
|
||||
|
||||
bannedServerName = signal('');
|
||||
|
||||
showBannedDialog = signal(false);
|
||||
|
||||
showPasswordDialog = signal(false);
|
||||
|
||||
passwordPromptRoom = signal<Room | null>(null);
|
||||
|
||||
joinPassword = signal('');
|
||||
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
|
||||
visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
|
||||
|
||||
voicePresenceByRoom = computed(() => {
|
||||
const presence: Record<string, number> = {};
|
||||
const seenByRoom = new Map<string, Set<string>>();
|
||||
@@ -211,38 +203,6 @@ export class ServersRailComponent {
|
||||
return presence;
|
||||
});
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
private notifications = inject(NotificationsFacade);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
private bannedLookupUserKey: string | null = null;
|
||||
|
||||
private activationRequestVersion = 0;
|
||||
|
||||
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
private joinRequestVersion = 0;
|
||||
|
||||
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
private visibleSavedRoomCache: Room[] = [];
|
||||
|
||||
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const rooms = this.savedRooms();
|
||||
@@ -281,8 +241,6 @@ export class ServersRailComponent {
|
||||
});
|
||||
}
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name)
|
||||
return '?';
|
||||
@@ -292,6 +250,8 @@ export class ServersRailComponent {
|
||||
return ch || '?';
|
||||
}
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
goToDashboard(): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
@@ -382,6 +342,13 @@ export class ServersRailComponent {
|
||||
return !!this.bannedRoomLookup()[room.id];
|
||||
}
|
||||
|
||||
private callIdFromUrl(url: string): string | null {
|
||||
const path = url.split(/[?#]/, 1)[0];
|
||||
const match = path.match(/^\/call\/([^/]+)/);
|
||||
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
@@ -487,13 +454,6 @@ export class ServersRailComponent {
|
||||
return this.currentRoom()?.id === room.id;
|
||||
}
|
||||
|
||||
private callIdFromUrl(url: string): string | null {
|
||||
const path = url.split(/[?#]/, 1)[0];
|
||||
const match = path.match(/^\/call\/([^/]+)/);
|
||||
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
|
||||
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
|
||||
const stabilizedRooms = nextRooms.map((room) => {
|
||||
@@ -846,5 +806,4 @@ export class ServersRailComponent {
|
||||
|
||||
return nextRoom;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
@@ -34,21 +35,17 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
templateUrl: './bans-settings.component.html'
|
||||
})
|
||||
export class BansSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private actions$ = inject(Actions);
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
bannedUsers = signal<BanEntry[]>([]);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private actions$ = inject(Actions);
|
||||
|
||||
private db = inject(DatabaseService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const roomId = this.server()?.id;
|
||||
@@ -85,6 +82,10 @@ export class BansSettingsComponent {
|
||||
oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
private async loadBansForServer(roomId: string): Promise<void> {
|
||||
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
@@ -95,9 +96,4 @@ export class BansSettingsComponent {
|
||||
minute: '2-digit' })
|
||||
);
|
||||
}
|
||||
|
||||
private async loadBansForServer(roomId: string): Promise<void> {
|
||||
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -40,23 +41,16 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
||||
templateUrl: './data-settings.component.html'
|
||||
})
|
||||
export class DataSettingsComponent {
|
||||
private readonly electron = inject(ElectronBridgeService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly isElectron = this.electron.isAvailable;
|
||||
|
||||
readonly dataPath = signal<string | null>(null);
|
||||
|
||||
readonly busyAction = signal<DataAction | null>(null);
|
||||
|
||||
readonly statusMessage = signal<string | null>(null);
|
||||
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
readonly restartRequired = signal(false);
|
||||
|
||||
private readonly electron = inject(ElectronBridgeService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
void this.loadDataPath();
|
||||
}
|
||||
@@ -151,5 +145,4 @@ export class DataSettingsComponent {
|
||||
this.busyAction.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -48,46 +49,35 @@ const APP_METRICS_POLL_INTERVAL_MS = 2_000;
|
||||
templateUrl: './debugging-settings.component.html'
|
||||
})
|
||||
export class DebuggingSettingsComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly debugging = inject(DebuggingService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
|
||||
readonly ramLabel = signal<string | null>(null);
|
||||
|
||||
readonly enabled = this.debugging.enabled;
|
||||
|
||||
readonly isConsoleOpen = this.debugging.isConsoleOpen;
|
||||
|
||||
readonly entryCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0);
|
||||
});
|
||||
|
||||
readonly errorCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => {
|
||||
return sum + (entry.level === 'error' ? entry.count : 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly warningCount = computed(() => {
|
||||
return this.debugging.entries().reduce((sum, entry) => {
|
||||
return sum + (entry.level === 'warn' ? entry.count : 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly lastUpdatedLabel = computed(() => {
|
||||
const lastEntry = this.debugging.entries().at(-1);
|
||||
|
||||
return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet');
|
||||
});
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly platform = inject(PlatformService);
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
if (this.isElectron)
|
||||
this.startRamPolling();
|
||||
@@ -128,5 +118,4 @@ export class DebuggingSettingsComponent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -33,30 +34,20 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
||||
templateUrl: './general-settings.component.html'
|
||||
})
|
||||
export class GeneralSettingsComponent {
|
||||
private platform = inject(PlatformService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
|
||||
reopenLastViewedChat = signal(true);
|
||||
|
||||
autoStart = signal(false);
|
||||
|
||||
closeToTray = signal(true);
|
||||
|
||||
savingAutoStart = signal(false);
|
||||
|
||||
savingCloseToTray = signal(false);
|
||||
|
||||
ignoredGameProcesses = signal<string[]>([]);
|
||||
|
||||
ignoredProcessDraft = signal('');
|
||||
|
||||
savingIgnoredGameProcesses = signal(false);
|
||||
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
constructor() {
|
||||
this.loadGeneralSettings();
|
||||
|
||||
@@ -128,31 +119,6 @@ export class GeneralSettingsComponent {
|
||||
input.checked = this.experimentalMedia.vlcJsPlaybackEnabled();
|
||||
}
|
||||
|
||||
onIgnoredProcessDraftChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.ignoredProcessDraft.set(input.value);
|
||||
}
|
||||
|
||||
async addIgnoredProcess(): Promise<void> {
|
||||
const draft = this.ignoredProcessDraft().trim();
|
||||
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = Array.from(new Set([...this.ignoredGameProcesses(), draft]));
|
||||
|
||||
await this.saveIgnoredGameProcesses(next);
|
||||
this.ignoredProcessDraft.set('');
|
||||
}
|
||||
|
||||
async removeIgnoredProcess(name: string): Promise<void> {
|
||||
const next = this.ignoredGameProcesses().filter((entry) => entry !== name);
|
||||
|
||||
await this.saveIgnoredGameProcesses(next);
|
||||
}
|
||||
|
||||
private async loadDesktopSettings(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
@@ -178,6 +144,31 @@ export class GeneralSettingsComponent {
|
||||
this.closeToTray.set(snapshot.closeToTray);
|
||||
}
|
||||
|
||||
onIgnoredProcessDraftChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.ignoredProcessDraft.set(input.value);
|
||||
}
|
||||
|
||||
async addIgnoredProcess(): Promise<void> {
|
||||
const draft = this.ignoredProcessDraft().trim();
|
||||
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = Array.from(new Set([...this.ignoredGameProcesses(), draft]));
|
||||
|
||||
await this.saveIgnoredGameProcesses(next);
|
||||
this.ignoredProcessDraft.set('');
|
||||
}
|
||||
|
||||
async removeIgnoredProcess(name: string): Promise<void> {
|
||||
const next = this.ignoredGameProcesses().filter((entry) => entry !== name);
|
||||
|
||||
await this.saveIgnoredGameProcesses(next);
|
||||
}
|
||||
|
||||
private async loadIgnoredGameProcesses(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
@@ -209,5 +200,4 @@ export class GeneralSettingsComponent {
|
||||
this.savingIgnoredGameProcesses.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -51,23 +52,17 @@ import {
|
||||
templateUrl: './ice-server-settings.component.html'
|
||||
})
|
||||
export class IceServerSettingsComponent {
|
||||
private iceSettings = inject(IceServerSettingsService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
entries = this.iceSettings.entries;
|
||||
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newType: 'stun' | 'turn' = 'stun';
|
||||
|
||||
newUrl = '';
|
||||
|
||||
newUsername = '';
|
||||
|
||||
newCredential = '';
|
||||
|
||||
private iceSettings = inject(IceServerSettingsService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
addEntry(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
@@ -139,5 +134,4 @@ export class IceServerSettingsComponent {
|
||||
trackEntry(_index: number, entry: IceServerEntry): string {
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
@@ -28,6 +29,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
templateUrl: './local-api-settings.component.html'
|
||||
})
|
||||
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
private readonly bridge = inject(ElectronBridgeService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly isElectron = this.bridge.isAvailable;
|
||||
|
||||
@@ -52,15 +55,10 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
readonly allowedServersText = signal('');
|
||||
|
||||
readonly hasPendingAllowedServersChanges = signal(false);
|
||||
|
||||
readonly portText = signal('17878');
|
||||
|
||||
readonly hasPendingPortChange = signal(false);
|
||||
|
||||
readonly busy = signal(false);
|
||||
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
@@ -83,10 +81,6 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
private readonly bridge = inject(ElectronBridgeService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private statusPollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -284,5 +278,4 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
@@ -55,27 +56,23 @@ interface ServerMemberView extends RoomMember {
|
||||
templateUrl: './members-settings.component.html'
|
||||
})
|
||||
export class MembersSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
accessRole = input<string | null>(null);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
usersEntities = this.store.selectSignal(selectUsersEntities);
|
||||
|
||||
normalizedServer = computed(() => {
|
||||
const room = this.server();
|
||||
|
||||
return room ? normalizeRoomAccessControl(room) : null;
|
||||
});
|
||||
|
||||
assignableRoles = computed<RoomRole[]>(() => findAssignableRoles(this.normalizedServer()?.roles ?? []));
|
||||
|
||||
members = computed<ServerMemberView[]>(() => {
|
||||
@@ -107,10 +104,6 @@ export class MembersSettingsComponent {
|
||||
});
|
||||
});
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
|
||||
canChangeRoles(member: ServerMemberView): boolean {
|
||||
const room = this.normalizedServer();
|
||||
const currentUser = this.currentUser();
|
||||
@@ -170,5 +163,4 @@ export class MembersSettingsComponent {
|
||||
roomId: room.id,
|
||||
displayName: member.displayName }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -52,40 +53,25 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
||||
templateUrl: './network-settings.component.html'
|
||||
})
|
||||
export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
|
||||
readonly provisionNotice = this.provisionNoticeService.notice;
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
|
||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||
|
||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||
|
||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||
|
||||
isTesting = signal(false);
|
||||
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newServerName = '';
|
||||
|
||||
newServerUrl = '';
|
||||
|
||||
autoReconnect = true;
|
||||
|
||||
searchAllServers = true;
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||
|
||||
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
|
||||
|
||||
constructor() {
|
||||
this.loadConnectionSettings();
|
||||
}
|
||||
@@ -180,5 +166,4 @@ export class NetworkSettingsComponent {
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
@@ -91,13 +92,12 @@ function upsertRoleChannelOverride(
|
||||
templateUrl: './permissions-settings.component.html'
|
||||
})
|
||||
export class PermissionsSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
server = input<Room | null>(null);
|
||||
|
||||
isAdmin = input(false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
permissionDefinitions = computed(() =>
|
||||
ROOM_PERMISSION_DEFINITIONS.map((definition) => ({
|
||||
key: definition.key,
|
||||
@@ -105,30 +105,24 @@ export class PermissionsSettingsComponent {
|
||||
description: this.appI18n.instant(`permissions.${definition.key}.description`)
|
||||
}))
|
||||
);
|
||||
|
||||
permissionStates: PermissionState[] = [
|
||||
'inherit',
|
||||
'allow',
|
||||
'deny'
|
||||
];
|
||||
|
||||
normalizedServer = computed(() => {
|
||||
const room = this.server();
|
||||
|
||||
return room ? normalizeRoomAccessControl(room) : null;
|
||||
});
|
||||
|
||||
roles = computed<RoomRole[]>(() => sortRolesForDisplay(this.normalizedServer()?.roles ?? []));
|
||||
|
||||
channels = computed(() => this.normalizedServer()?.channels ?? []);
|
||||
|
||||
canManageRoles = computed(() => {
|
||||
const room = this.normalizedServer();
|
||||
const user = this.currentUser();
|
||||
|
||||
return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageRoles'));
|
||||
});
|
||||
|
||||
canManageServer = computed(() => {
|
||||
const room = this.normalizedServer();
|
||||
const user = this.currentUser();
|
||||
@@ -137,17 +131,10 @@ export class PermissionsSettingsComponent {
|
||||
});
|
||||
|
||||
selectedRoleKey: string | null = null;
|
||||
|
||||
selectedChannelKey = '';
|
||||
|
||||
roleName = '';
|
||||
|
||||
roleColor = '#94a3b8';
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const room = this.normalizedServer();
|
||||
@@ -419,5 +406,4 @@ export class PermissionsSettingsComponent {
|
||||
trackRole(_: number, role: RoomRole): string {
|
||||
return role.id;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
@@ -53,54 +54,37 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
||||
templateUrl: './server-settings.component.html'
|
||||
})
|
||||
export class ServerSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private modal = inject(SettingsModalService);
|
||||
private serverIconImages = inject(ServerIconImageService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
|
||||
/** Whether the current user is admin of this server. */
|
||||
isAdmin = input(false);
|
||||
|
||||
/** Whether the current user can manage this server's icon. */
|
||||
canManageIcon = input(false);
|
||||
|
||||
/** Whether the current user can delete this server. */
|
||||
canDeleteServer = input(false);
|
||||
|
||||
roomName = '';
|
||||
|
||||
roomDescription = '';
|
||||
|
||||
isPrivate = signal(false);
|
||||
|
||||
hasPassword = signal(false);
|
||||
|
||||
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
||||
|
||||
passwordError = signal<string | null>(null);
|
||||
|
||||
roomPassword = '';
|
||||
|
||||
maxUsers = 0;
|
||||
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
iconError = signal<string | null>(null);
|
||||
|
||||
saveSuccess = signal<string | null>(null);
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Reload form fields whenever the server input changes. */
|
||||
readonly serverData = this.server;
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private modal = inject(SettingsModalService);
|
||||
|
||||
private serverIconImages = inject(ServerIconImageService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const room = this.server();
|
||||
@@ -259,5 +243,4 @@ export class ServerSettingsComponent {
|
||||
|
||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -103,8 +104,14 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
||||
})
|
||||
export class SettingsModalComponent {
|
||||
readonly modal = inject(SettingsModalService);
|
||||
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private theme = inject(ThemeService);
|
||||
private themeLibrary = inject(ThemeLibraryService);
|
||||
private viewport = inject(ViewportService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
@@ -117,30 +124,21 @@ export class SettingsModalComponent {
|
||||
*/
|
||||
readonly mobilePage = signal<'menu' | 'detail'>('menu');
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
isOpen = this.modal.isOpen;
|
||||
|
||||
activePage = this.modal.activePage;
|
||||
|
||||
themeStudioFullscreen = this.modal.themeStudioFullscreen;
|
||||
|
||||
themeStudioMinimized = this.modal.themeStudioMinimized;
|
||||
|
||||
isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen());
|
||||
|
||||
activeThemeName = this.theme.activeThemeName;
|
||||
|
||||
savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||
|
||||
savedThemes = this.themeLibrary.entries;
|
||||
|
||||
savedThemesBusy = this.themeLibrary.isBusy;
|
||||
|
||||
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||
|
||||
readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
||||
@@ -155,7 +153,6 @@ export class SettingsModalComponent {
|
||||
{ id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' },
|
||||
{ id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' }
|
||||
]);
|
||||
|
||||
readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
||||
{ id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' },
|
||||
{ id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' },
|
||||
@@ -195,7 +192,6 @@ export class SettingsModalComponent {
|
||||
});
|
||||
|
||||
selectedServerId = signal<string | null>(null);
|
||||
|
||||
selectedServer = computed<Room | null>(() => {
|
||||
const id = this.selectedServerId();
|
||||
const currentRoom = this.currentRoom();
|
||||
@@ -298,25 +294,8 @@ export class SettingsModalComponent {
|
||||
});
|
||||
|
||||
animating = signal(false);
|
||||
|
||||
showThirdPartyLicenses = signal(false);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private theme = inject(ThemeService);
|
||||
|
||||
private themeLibrary = inject(ThemeLibraryService);
|
||||
|
||||
private viewport = inject(ViewportService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.isOpen()) {
|
||||
@@ -503,5 +482,4 @@ export class SettingsModalComponent {
|
||||
|
||||
this.themeLibrary.select(matchingTheme?.fileName ?? null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,34 +31,23 @@ type DesktopUpdateStatus =
|
||||
templateUrl: './updates-settings.component.html'
|
||||
})
|
||||
export class UpdatesSettingsComponent {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly desktopUpdates = inject(DesktopAppUpdateService);
|
||||
|
||||
readonly mobileUpdates = inject(MobileAppUpdateService);
|
||||
|
||||
readonly isElectron = this.desktopUpdates.isElectron;
|
||||
|
||||
readonly isCapacitor = this.mobileUpdates.isCapacitor;
|
||||
|
||||
readonly state = this.desktopUpdates.state;
|
||||
|
||||
readonly mobileState = this.mobileUpdates.state;
|
||||
|
||||
readonly mobileStatusLabel = computed(() =>
|
||||
this.getMobileStatusLabel(this.mobileState().status)
|
||||
);
|
||||
|
||||
readonly hasPendingManifestUrlChanges = signal(false);
|
||||
|
||||
readonly manifestUrlsText = signal('');
|
||||
|
||||
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
|
||||
|
||||
readonly isUsingConnectedServerDefaults = computed(() => {
|
||||
return this.state().configuredManifestUrls.length === 0;
|
||||
});
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.hasPendingManifestUrlChanges()) {
|
||||
@@ -189,5 +178,4 @@ export class UpdatesSettingsComponent {
|
||||
|
||||
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -52,10 +53,13 @@ interface AudioDevice {
|
||||
templateUrl: './voice-settings.component.html'
|
||||
})
|
||||
export class VoiceSettingsComponent {
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private platform = inject(PlatformService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
readonly audioService = inject(NotificationAudioService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
|
||||
readonly screenShareQualityOptions = computed(() =>
|
||||
SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({
|
||||
id: option.id,
|
||||
@@ -65,51 +69,26 @@ export class VoiceSettingsComponent {
|
||||
);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
|
||||
selectedInputDevice = signal<string>('');
|
||||
|
||||
selectedOutputDevice = signal<string>('');
|
||||
|
||||
inputVolume = signal(100);
|
||||
|
||||
outputVolume = signal(100);
|
||||
|
||||
audioBitrate = signal(96);
|
||||
|
||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||
|
||||
includeSystemAudio = signal(false);
|
||||
|
||||
noiseReduction = signal(true);
|
||||
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
|
||||
askScreenShareQuality = signal(true);
|
||||
|
||||
hardwareAcceleration = signal(true);
|
||||
|
||||
hardwareAccelerationRestartRequired = signal(false);
|
||||
|
||||
readonly selectedScreenShareQualityDescription = computed(
|
||||
() => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? ''
|
||||
);
|
||||
|
||||
readonly notificationVolumePercent = computed(() =>
|
||||
String(Math.round(this.audioService.notificationVolume() * 100))
|
||||
);
|
||||
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
constructor() {
|
||||
this.loadVoiceSettings();
|
||||
this.loadAudioDevices();
|
||||
@@ -312,5 +291,4 @@ export class VoiceSettingsComponent {
|
||||
this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
|
||||
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -62,40 +63,26 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../shared/dir
|
||||
* Settings page for managing signaling servers and connection preferences.
|
||||
*/
|
||||
export class SettingsComponent implements OnInit {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private router = inject(Router);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
audioService = inject(NotificationAudioService);
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
|
||||
activeServers = this.serverDirectory.activeServers;
|
||||
|
||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||
|
||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||
|
||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||
|
||||
isTesting = signal(false);
|
||||
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newServerName = '';
|
||||
|
||||
newServerUrl = '';
|
||||
|
||||
autoReconnect = true;
|
||||
|
||||
searchAllServers = true;
|
||||
|
||||
noiseReduction = true;
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
/** Load persisted connection settings on component init. */
|
||||
ngOnInit(): void {
|
||||
this.loadConnectionSettings();
|
||||
@@ -246,5 +233,4 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -59,19 +59,13 @@ const NON_TEXT_INPUT_TYPES = new Set([
|
||||
})
|
||||
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
params = signal<ContextMenuParams | null>(null);
|
||||
|
||||
customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null);
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
private cleanup: (() => void) | null = null;
|
||||
|
||||
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
||||
|
||||
@HostListener('document:contextmenu', ['$event'])
|
||||
@@ -715,5 +709,4 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
return typeof navigator !== 'undefined'
|
||||
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div
|
||||
appThemeNode="titleBar"
|
||||
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -81,37 +82,39 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||
* Electron-style title bar with window controls, navigation, and server menu.
|
||||
*/
|
||||
export class TitleBarComponent {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private store = inject(Store);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private router = inject(Router);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private platform = inject(PlatformService);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private pluginRegistry = inject(PluginRegistryService);
|
||||
private pluginRequirements = inject(PluginRequirementStateService);
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
|
||||
isElectron = computed(() => this.platform.isElectron);
|
||||
|
||||
showMenuState = computed(() => false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest'));
|
||||
|
||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer'));
|
||||
|
||||
isConnected = computed(() => this.webrtc.isConnected());
|
||||
|
||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||
|
||||
isAuthed = computed(() => !!this.currentUser());
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
|
||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||
|
||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||
|
||||
isInDirectMessage = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -119,7 +122,6 @@ export class TitleBarComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/dm/') }
|
||||
);
|
||||
|
||||
isInRoomView = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
@@ -127,11 +129,8 @@ export class TitleBarComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/room/') }
|
||||
);
|
||||
|
||||
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
|
||||
|
||||
roomName = computed(() => this.currentRoom()?.name || '');
|
||||
|
||||
activeTextChannelName = computed(() => {
|
||||
const textChannels = this.textChannels();
|
||||
|
||||
@@ -144,14 +143,12 @@ export class TitleBarComponent {
|
||||
|
||||
return activeChannel ? activeChannel.name : id;
|
||||
});
|
||||
|
||||
connectedVoiceChannelName = computed(() => {
|
||||
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
|
||||
|
||||
return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge');
|
||||
});
|
||||
|
||||
roomContextMeta = computed(() => {
|
||||
if (!this.currentRoom()) {
|
||||
return '';
|
||||
@@ -165,11 +162,9 @@ export class TitleBarComponent {
|
||||
|
||||
return parts.join(' | ');
|
||||
});
|
||||
|
||||
showRoomCompatibilityNotice = computed(() =>
|
||||
this.inRoom() && !!this.signalServerCompatibilityError()
|
||||
);
|
||||
|
||||
showRoomReconnectNotice = computed(() =>
|
||||
this.inRoom()
|
||||
&& !this.signalServerCompatibilityError()
|
||||
@@ -179,56 +174,20 @@ export class TitleBarComponent {
|
||||
|| this.isReconnecting()
|
||||
)
|
||||
);
|
||||
|
||||
serverPluginCount = computed(() => this.pluginRegistry.entries()
|
||||
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
|
||||
.length);
|
||||
|
||||
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
|
||||
|
||||
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
|
||||
|
||||
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
|
||||
|
||||
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
|
||||
|
||||
showMenu = computed(() => this._showMenu());
|
||||
|
||||
showLeaveConfirm = signal(false);
|
||||
|
||||
inviteStatus = signal<string | null>(null);
|
||||
|
||||
creatingInvite = signal(false);
|
||||
|
||||
pluginRequirementBusy = signal(false);
|
||||
|
||||
pluginRequirementError = signal<string | null>(null);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private store = inject(Store);
|
||||
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
|
||||
private pluginRegistry = inject(PluginRegistryService);
|
||||
|
||||
private pluginRequirements = inject(PluginRequirementStateService);
|
||||
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
|
||||
private _showMenu = signal(false);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
showLeaveConfirm = signal(false);
|
||||
inviteStatus = signal<string | null>(null);
|
||||
creatingInvite = signal(false);
|
||||
pluginRequirementBusy = signal(false);
|
||||
pluginRequirementError = signal<string | null>(null);
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
@@ -297,6 +256,15 @@ export class TitleBarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
if (this.currentRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the server dropdown menu. */
|
||||
toggleMenu() {
|
||||
this.inviteStatus.set(null);
|
||||
@@ -386,34 +354,6 @@ export class TitleBarComponent {
|
||||
this._showMenu.set(false);
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
clearStoredCurrentUserId();
|
||||
this.store.dispatch(MessagesActions.clearMessages());
|
||||
this.store.dispatch(RoomsActions.resetRoomsState());
|
||||
this.store.dispatch(UsersActions.resetUsersState());
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
if (this.currentRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
|
||||
const room = this.currentRoom();
|
||||
|
||||
@@ -433,6 +373,21 @@ export class TitleBarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
clearStoredCurrentUserId();
|
||||
this.store.dispatch(MessagesActions.clearMessages());
|
||||
this.store.dispatch(RoomsActions.resetRoomsState());
|
||||
this.store.dispatch(UsersActions.resetUsersState());
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
@@ -472,5 +427,4 @@ export class TitleBarComponent {
|
||||
sourceUrl: room.sourceUrl
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,15 +8,14 @@ import { MobileSqliteConnectionService } from '../../services/mobile-sqlite-conn
|
||||
* Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells.
|
||||
*/
|
||||
export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter {
|
||||
private initialized = false;
|
||||
|
||||
constructor(private readonly connection: MobileSqliteConnectionService) {}
|
||||
|
||||
get isNativeSqlite(): boolean {
|
||||
return this.connection.isAvailable;
|
||||
}
|
||||
|
||||
private initialized = false;
|
||||
|
||||
constructor(private readonly connection: MobileSqliteConnectionService) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
@@ -33,5 +32,4 @@ export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapt
|
||||
this.initialized = true;
|
||||
console.info('[mobile] native SQLite persistence initialized');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,22 +22,17 @@ const DEFAULT_POLL_INTERVAL_MS = 30 * 60_000;
|
||||
export class MobileAppUpdateService {
|
||||
readonly state = signal<MobileUpdateState>(createInitialMobileUpdateState());
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private adapter: MobileAppUpdateAdapter = new WebMobileAppUpdateAdapter();
|
||||
private adapterReady: Promise<MobileAppUpdateAdapter> | null = null;
|
||||
private initialized = false;
|
||||
private pollTimerId: number | null = null;
|
||||
|
||||
get isCapacitor(): boolean {
|
||||
return this.mobilePlatform.isCapacitor();
|
||||
}
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
|
||||
private adapter: MobileAppUpdateAdapter = new WebMobileAppUpdateAdapter();
|
||||
|
||||
private adapterReady: Promise<MobileAppUpdateAdapter> | null = null;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
private pollTimerId: number | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
@@ -175,5 +170,4 @@ export class MobileAppUpdateService {
|
||||
void this.checkForUpdates();
|
||||
}, DEFAULT_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,19 +9,15 @@ import { MobileSqliteConnectionService } from './mobile-sqlite-connection.servic
|
||||
/** Facade for native SQLite persistence on mobile shells. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobilePersistenceService {
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
|
||||
private adapter: MobilePersistenceAdapter = new WebMobilePersistenceAdapter();
|
||||
private adapterReady: Promise<MobilePersistenceAdapter> | null = null;
|
||||
|
||||
get isNativeSqlite(): boolean {
|
||||
return this.adapter.isNativeSqlite;
|
||||
}
|
||||
|
||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||
|
||||
private readonly sqliteConnection = inject(MobileSqliteConnectionService);
|
||||
|
||||
private adapter: MobilePersistenceAdapter = new WebMobilePersistenceAdapter();
|
||||
|
||||
private adapterReady: Promise<MobilePersistenceAdapter> | null = null;
|
||||
|
||||
initialize(): Promise<void> {
|
||||
return this.ensureAdapter().then((adapter) => adapter.initialize());
|
||||
}
|
||||
@@ -44,5 +40,4 @@ export class MobilePersistenceService {
|
||||
|
||||
return this.adapterReady;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,19 +7,15 @@ import { getStoredCurrentUserId } from '../../../core/storage/current-user-stora
|
||||
/** Shared native SQLite connection used by mobile persistence and DatabaseService. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileSqliteConnectionService {
|
||||
private store: MobileSqliteStore | null = null;
|
||||
private activeDatabaseName: string | null = null;
|
||||
private initializationPromise: Promise<MobileSqliteStore | null> | null = null;
|
||||
private initializationFailed = false;
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.store?.isAvailable === true;
|
||||
}
|
||||
|
||||
private store: MobileSqliteStore | null = null;
|
||||
|
||||
private activeDatabaseName: string | null = null;
|
||||
|
||||
private initializationPromise: Promise<MobileSqliteStore | null> | null = null;
|
||||
|
||||
private initializationFailed = false;
|
||||
|
||||
async initialize(): Promise<MobileSqliteStore | null> {
|
||||
if (this.initializationFailed) {
|
||||
return null;
|
||||
@@ -70,5 +66,4 @@ export class MobileSqliteConnectionService {
|
||||
|
||||
return this.store;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ function scheduleStorageRemove(key: string): void {
|
||||
scheduleStorageFlush();
|
||||
}
|
||||
|
||||
function readPendingOrStored(key: string): string | null {
|
||||
function readMaybePending(key: string): string | null {
|
||||
if (pendingWrites.has(key)) {
|
||||
return pendingWrites.get(key) ?? null;
|
||||
}
|
||||
@@ -73,8 +73,8 @@ function readPendingOrStored(key: string): string | null {
|
||||
|
||||
export function loadGeneralSettingsFromStorage(): GeneralSettings {
|
||||
try {
|
||||
const raw = readPendingOrStored(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
|
||||
?? readPendingOrStored(STORAGE_KEY_GENERAL_SETTINGS);
|
||||
const raw = readMaybePending(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
|
||||
?? readMaybePending(STORAGE_KEY_GENERAL_SETTINGS);
|
||||
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_GENERAL_SETTINGS };
|
||||
@@ -99,8 +99,8 @@ export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): G
|
||||
|
||||
export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null {
|
||||
try {
|
||||
const raw = readPendingOrStored(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
|
||||
?? readPendingOrStored(STORAGE_KEY_LAST_VIEWED_CHAT);
|
||||
const raw = readMaybePending(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
|
||||
?? readMaybePending(STORAGE_KEY_LAST_VIEWED_CHAT);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||
import {
|
||||
inject,
|
||||
Injectable,
|
||||
@@ -37,22 +38,16 @@ export interface RoomMessageStats {
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DatabaseService {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly browserDb = inject(BrowserDatabaseService);
|
||||
private readonly capacitorDb = inject(CapacitorDatabaseService);
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
private validatedUserScope: string | null | undefined;
|
||||
|
||||
/** Reactive flag: `true` once {@link initialize} has completed. */
|
||||
isReady = signal(false);
|
||||
|
||||
private readonly platform = inject(PlatformService);
|
||||
|
||||
private readonly browserDb = inject(BrowserDatabaseService);
|
||||
|
||||
private readonly capacitorDb = inject(CapacitorDatabaseService);
|
||||
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
private validatedUserScope: string | null | undefined;
|
||||
|
||||
/** The active storage backend for the current platform. */
|
||||
private get backend() {
|
||||
const backendKind = resolveDatabaseBackend({
|
||||
@@ -99,6 +94,22 @@ export class DatabaseService {
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
private async ensureReady(): Promise<void> {
|
||||
const userScope = getStoredCurrentUserId();
|
||||
|
||||
if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
private async withReady<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await this.ensureReady();
|
||||
|
||||
return operation();
|
||||
}
|
||||
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message) { return this.withReady(() => this.backend.saveMessage(message)); }
|
||||
|
||||
@@ -221,21 +232,4 @@ export class DatabaseService {
|
||||
|
||||
/** Wipe all persisted data. */
|
||||
clearAllData() { return this.withReady(() => this.backend.clearAllData()); }
|
||||
|
||||
private async ensureReady(): Promise<void> {
|
||||
const userScope = getStoredCurrentUserId();
|
||||
|
||||
if (this.isReady() && this.validatedUserScope === userScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
private async withReady<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await this.ensureReady();
|
||||
|
||||
return operation();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
@@ -27,6 +28,13 @@ import type { IncomingSignalingMessage } from '../signaling/signaling-message-ha
|
||||
|
||||
@Injectable()
|
||||
export class AccountSyncEffects {
|
||||
private readonly actions$ = inject(Actions);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly friends = inject(FriendService);
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
|
||||
broadcastSavedRoom$ = createEffect(
|
||||
() =>
|
||||
@@ -113,20 +121,6 @@ export class AccountSyncEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
private readonly actions$ = inject(Actions);
|
||||
|
||||
private readonly store = inject(Store);
|
||||
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
private readonly db = inject(DatabaseService);
|
||||
|
||||
private readonly friends = inject(FriendService);
|
||||
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
|
||||
private isPeerOnlineMessage(message: IncomingSignalingMessage): message is IncomingSignalingMessage & {
|
||||
type: 'account_sync_peer_online';
|
||||
clientInstanceId?: string;
|
||||
@@ -194,5 +188,4 @@ export class AccountSyncEffects {
|
||||
customEmoji: emoji
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, */
|
||||
/**
|
||||
* Manages local voice and camera media: getUserMedia, mute, deafen,
|
||||
* attaching/detaching tracks to peer connections, bitrate tuning,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
VOLUME_MIN,
|
||||
VOLUME_MAX,
|
||||
VOICE_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_CAMERA_STATE,
|
||||
P2P_TYPE_VOICE_STATE
|
||||
} from '../realtime.constants';
|
||||
@@ -46,10 +48,6 @@ export interface MediaManagerCallbacks {
|
||||
}
|
||||
|
||||
export class MediaManager {
|
||||
|
||||
/** Emitted when voice is successfully connected. */
|
||||
readonly voiceConnected$ = new Subject<void>();
|
||||
|
||||
/** The stream sent to peers (may be raw or denoised). */
|
||||
private localMediaStream: MediaStream | null = null;
|
||||
|
||||
@@ -69,21 +67,19 @@ export class MediaManager {
|
||||
// -- Input gain pipeline (mic volume) --
|
||||
/** The stream BEFORE gain is applied (for identity checks). */
|
||||
private preGainStream: MediaStream | null = null;
|
||||
|
||||
private inputGainCtx: AudioContext | null = null;
|
||||
|
||||
private inputGainSourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
|
||||
private inputGainNode: GainNode | null = null;
|
||||
|
||||
private inputGainDest: MediaStreamAudioDestinationNode | null = null;
|
||||
|
||||
/** Normalised 0-1 input gain (1 = 100%). */
|
||||
private inputGainVolume = 1.0;
|
||||
|
||||
/** Voice-presence heartbeat timer. */
|
||||
private voicePresenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Emitted when voice is successfully connected. */
|
||||
readonly voiceConnected$ = new Subject<void>();
|
||||
|
||||
/** RNNoise noise-reduction processor. */
|
||||
private readonly noiseReduction: NoiseReductionManager;
|
||||
|
||||
@@ -96,19 +92,14 @@ export class MediaManager {
|
||||
|
||||
// State tracked locally (the service exposes these via signals)
|
||||
private isVoiceActive = false;
|
||||
|
||||
private isMicMuted = false;
|
||||
|
||||
private isSelfDeafened = false;
|
||||
|
||||
private isCameraActive = false;
|
||||
|
||||
/** Current voice channel room ID (set when joining voice). */
|
||||
private currentVoiceRoomId: string | undefined;
|
||||
|
||||
/** Current voice channel server ID (set when joining voice). */
|
||||
private currentVoiceServerId: string | undefined;
|
||||
|
||||
private allowedVoicePeerIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@@ -138,52 +129,42 @@ export class MediaManager {
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.localMediaStream;
|
||||
}
|
||||
|
||||
/** Returns the raw microphone stream before processing, if available. */
|
||||
getRawMicStream(): MediaStream | null {
|
||||
return this.rawMicStream;
|
||||
}
|
||||
|
||||
/** Returns the current local camera stream, or `null` if the camera is disabled. */
|
||||
getLocalCameraStream(): MediaStream | null {
|
||||
return this.localCameraStream;
|
||||
}
|
||||
|
||||
/** Whether voice is currently active (mic captured). */
|
||||
getIsVoiceActive(): boolean {
|
||||
return this.isVoiceActive;
|
||||
}
|
||||
|
||||
/** Whether the local microphone is muted. */
|
||||
getIsMicMuted(): boolean {
|
||||
return this.isMicMuted;
|
||||
}
|
||||
|
||||
/** Whether the user has self-deafened. */
|
||||
getIsSelfDeafened(): boolean {
|
||||
return this.isSelfDeafened;
|
||||
}
|
||||
|
||||
/** Whether the local camera is currently active. */
|
||||
getIsCameraActive(): boolean {
|
||||
return this.isCameraActive;
|
||||
}
|
||||
|
||||
/** Current remote audio output volume (normalised 0-1). */
|
||||
getRemoteAudioVolume(): number {
|
||||
return this.remoteAudioVolume;
|
||||
}
|
||||
|
||||
/** The voice channel room ID, if currently in voice. */
|
||||
getCurrentVoiceRoomId(): string | undefined {
|
||||
return this.currentVoiceRoomId;
|
||||
}
|
||||
|
||||
/** The voice channel server ID, if currently in voice. */
|
||||
getCurrentVoiceServerId(): string | undefined {
|
||||
return this.currentVoiceServerId;
|
||||
}
|
||||
|
||||
/** Whether the user wants noise reduction (may or may not be running yet). */
|
||||
getIsNoiseReductionEnabled(): boolean {
|
||||
return this._noiseReductionDesired;
|
||||
@@ -618,15 +599,6 @@ export class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.teardownInputGain();
|
||||
this.disableVoice();
|
||||
this.stopVoiceHeartbeat();
|
||||
this.noiseReduction.destroy();
|
||||
this.voiceConnected$.complete();
|
||||
}
|
||||
|
||||
/** Bind any active local mic/camera tracks to the current peer set. */
|
||||
private bindLocalTracksToAllPeers(): void {
|
||||
this.syncVoiceRouting();
|
||||
@@ -984,4 +956,12 @@ export class MediaManager {
|
||||
this.localCameraStream = null;
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.teardownInputGain();
|
||||
this.disableVoice();
|
||||
this.stopVoiceHeartbeat();
|
||||
this.noiseReduction.destroy();
|
||||
this.voiceConnected$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,6 @@ const RNNOISE_SAMPLE_RATE = 48_000;
|
||||
const WORKLET_MODULE_PATH = 'rnnoise-worklet.js';
|
||||
|
||||
export class NoiseReductionManager {
|
||||
|
||||
/** Whether noise reduction is currently active. */
|
||||
get isEnabled(): boolean {
|
||||
return this._isEnabled;
|
||||
}
|
||||
|
||||
/** The AudioContext used for the noise-reduction graph. */
|
||||
private audioContext: AudioContext | null = null;
|
||||
|
||||
@@ -53,6 +47,11 @@ export class NoiseReductionManager {
|
||||
|
||||
constructor(private readonly logger: WebRTCLogger) {}
|
||||
|
||||
/** Whether noise reduction is currently active. */
|
||||
get isEnabled(): boolean {
|
||||
return this._isEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable noise reduction on a raw microphone stream.
|
||||
*
|
||||
@@ -202,5 +201,4 @@ export class NoiseReductionManager {
|
||||
this.audioContext = null;
|
||||
this.workletLoaded = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user