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:
2026-06-11 12:16:40 +02:00
parent 79c6f91cd6
commit 31962aeb1a
131 changed files with 2483 additions and 3896 deletions

View File

@@ -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);
});
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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()) {

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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))
};
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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>
);
}

View File

@@ -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';
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
};
}
}

View File

@@ -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}`;
}
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,8 @@
export function isConversationBound(
conversationId: string | null | undefined,
selectedConversationId: string | null | undefined
): boolean {
return !!conversationId
&& !!selectedConversationId
&& conversationId === selectedConversationId;
}

View File

@@ -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}`;
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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 } {

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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);
}
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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
};
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
}
};
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {
})
);
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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 {
};
}
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}
}

View File

@@ -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`;
}
}

View File

@@ -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 });