wip: optimizations

This commit is contained in:
2026-05-23 15:28:40 +02:00
parent 5bf506af03
commit 155fe20862
89 changed files with 7431 additions and 392 deletions

View File

@@ -3,7 +3,7 @@
class="chat-layout relative h-full"
>
<app-chat-message-list
[allMessages]="allMessages()"
[allMessages]="roomMessages()"
[channelMessages]="channelMessages()"
[loading]="loading()"
[syncing]="syncing()"

View File

@@ -19,7 +19,8 @@ import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
selectAllMessages,
selectActiveChannelMessages,
selectCurrentRoomMessages,
selectConversationExhausted,
selectMessagesLoading,
selectMessagesLoadingOlder,
@@ -70,7 +71,8 @@ export class ChatMessagesComponent {
readonly isMobile = this.viewport.isMobile;
readonly allMessages = this.store.selectSignal(selectAllMessages);
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);
@@ -80,13 +82,6 @@ export class ChatMessagesComponent {
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
readonly channelMessages = computed(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
});
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly conversationExhausted = toSignal(
toObservable(this.conversationKey).pipe(

View File

@@ -43,9 +43,7 @@ import {
} from '../../../../../attachment';
import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import {
ExperimentalMediaSettingsService
} from '../../../../../experimental-media';
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
import { KlipyService } from '../../../../application/services/klipy.service';
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
@@ -776,7 +774,6 @@ export class ChatMessageItemComponent implements OnDestroy {
}
const element = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
const canPlay = element.canPlayType(mime) !== '';
this.mediaSupportCache.set(mime, canPlay);

View File

@@ -12,7 +12,15 @@
</div>
}
@if (loading()) {
@if (refreshLoading()) {
<div class="pointer-events-none sticky top-0 z-10 flex justify-center py-1">
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">
Loading...
</div>
</div>
}
@if (initialLoading()) {
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>

View File

@@ -111,6 +111,9 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return all.slice(all.length - limit);
});
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());
readonly dateSeparatorLabels = computed(() => {

View File

@@ -87,9 +87,9 @@
<button
type="button"
(click)="selectGif(gif)"
[class]="isMobile()
[class]="(isMobile()
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'"
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30') + ' [content-visibility:auto] [contain-intrinsic-size:auto_180px]'"
>
<div
class="relative flex items-center justify-center overflow-hidden bg-secondary/30"

View File

@@ -88,7 +88,11 @@ describe('DirectCallService', () => {
});
it('ignores incoming call events when the current user is not a participant', async () => {
const context = createServiceContext({ currentUser: charlie, allUsers: [alice, bob, charlie] });
const context = createServiceContext({ currentUser: charlie, allUsers: [
alice,
bob,
charlie
] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));

View File

@@ -1,4 +1,8 @@
import { Component, computed, inject } from '@angular/core';
import {
Component,
computed,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { DmChatComponent } from '../dm-chat/dm-chat.component';

View File

@@ -22,19 +22,29 @@
<div
appThemeNode="dmConversationList"
class="min-h-0 flex-1 overflow-y-auto p-2"
class="flex min-h-0 flex-1 flex-col"
>
@if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else {
<div class="space-y-1">
@for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
<app-virtual-list
class="block h-full p-2"
[items]="directMessages.conversations()"
[estimateSize]="64"
[overscan]="6"
[trackBy]="trackConversationId"
>
<ng-template
#item
let-conversation
>
<app-dm-conversation-item
class="block pb-1"
[conversation]="conversation"
(conversationOpened)="conversationSelected.emit($event)"
/>
}
</div>
</ng-template>
</app-virtual-list>
}
</div>

View File

@@ -10,6 +10,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle } from '@ng-icons/lucide';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { VoiceControlsComponent } from '../../../voice-session';
import { VirtualListComponent } from '../../../../shared/components/virtual-list';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmConversationItemComponent } from './dm-conversation-item.component';
@@ -22,6 +23,7 @@ import { DmConversationItemComponent } from './dm-conversation-item.component';
DmConversationItemComponent,
NgIcon,
ThemeNodeDirective,
VirtualListComponent,
VoiceControlsComponent
],
viewProviders: [provideIcons({ lucideMessageCircle })],

View File

@@ -14,7 +14,7 @@
<div class="space-y-1.5">
@for (user of friendResults(); track user.id) {
<div
class="group flex items-center gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-2"
class="group flex items-center gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-2 [content-visibility:auto] [contain-intrinsic-size:auto_60px]"
[attr.data-testid]="'friend-card-' + userKey(user)"
>
<app-user-avatar
@@ -86,7 +86,7 @@
<div class="space-y-1.5">
@for (user of results(); track user.id) {
<div
class="group flex items-center gap-2 rounded-lg border border-border bg-card p-2 transition-colors hover:bg-card/80"
class="group flex items-center gap-2 rounded-lg border border-border bg-card p-2 transition-colors hover:bg-card/80 [content-visibility:auto] [contain-intrinsic-size:auto_64px]"
[attr.data-testid]="'user-card-' + userKey(user)"
>
<app-user-avatar

View File

@@ -1,10 +1,13 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
import type { Friend } from '../domain/models/direct-message.model';
const STORAGE_PREFIX = 'metoyou_friends';
@Injectable({ providedIn: 'root' })
export class FriendRepository {
private readonly storage = jsonStorage;
async loadFriends(ownerId: string): Promise<Friend[]> {
return this.read(ownerId);
}
@@ -24,23 +27,13 @@ export class FriendRepository {
}
private read(ownerId: string): Friend[] {
const rawValue = localStorage.getItem(this.key(ownerId));
const parsed = this.storage.read<Friend[]>(this.key(ownerId), []);
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue) as Friend[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
return Array.isArray(parsed) ? parsed : [];
}
private write(ownerId: string, friends: Friend[]): void {
localStorage.setItem(this.key(ownerId), JSON.stringify(friends));
this.storage.write(this.key(ownerId), friends);
}
private key(ownerId: string): string {

View File

@@ -1,9 +1,12 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
const STORAGE_PREFIX = 'metoyou_direct_message_queue';
@Injectable({ providedIn: 'root' })
export class OfflineQueueRepository {
private readonly storage = jsonStorage;
async load(ownerId: string): Promise<string[]> {
return this.read(ownerId);
}
@@ -24,23 +27,13 @@ export class OfflineQueueRepository {
}
private read(ownerId: string): string[] {
const rawValue = localStorage.getItem(this.key(ownerId));
const parsed = this.storage.read<string[]>(this.key(ownerId), []);
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue) as string[];
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
} catch {
return [];
}
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
}
private write(ownerId: string, messageIds: string[]): void {
localStorage.setItem(this.key(ownerId), JSON.stringify(messageIds));
this.storage.write(this.key(ownerId), messageIds);
}
private key(ownerId: string): string {

View File

@@ -1,4 +1,8 @@
import { Injectable, inject, signal } from '@angular/core';
import {
Injectable,
inject,
signal
} from '@angular/core';
import { ExperimentalVlcRuntimeService } from '../../infrastructure/services/experimental-vlc-runtime.service';
const STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS = 'metoyou_experimental_media_settings';

View File

@@ -17,10 +17,7 @@ import {
lucideX
} from '@ng-icons/lucide';
import {
ExperimentalVlcPlayerHandle,
ExperimentalVlcRuntimeService
} from '../../infrastructure/services/experimental-vlc-runtime.service';
import { ExperimentalVlcPlayerHandle, ExperimentalVlcRuntimeService } from '../../infrastructure/services/experimental-vlc-runtime.service';
@Component({
selector: 'app-experimental-vlc-player',
@@ -91,6 +88,7 @@ export class ExperimentalVlcPlayerComponent implements AfterViewInit, OnDestroy
filename: this.filename(),
mime: this.mime()
});
this.status.set('ready');
} catch (error) {
this.status.set('error');

View File

@@ -8,6 +8,7 @@ import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subscription, firstValueFrom } from 'rxjs';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../server-directory';
import { UsersActions } from '../../../store/users/users.actions';
@@ -88,6 +89,7 @@ const IGNORED_PROCESS_PATTERNS = [
export class GameActivityService implements OnDestroy {
private readonly electron = inject(ElectronBridgeService);
private readonly http = inject(HttpClient);
private readonly jsonStorage = jsonStorage;
private readonly ngZone = inject(NgZone);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
@@ -385,13 +387,9 @@ export class GameActivityService implements OnDestroy {
}
private readMatchCache(): Record<string, CachedGameMatch> {
try {
const parsed = JSON.parse(localStorage.getItem(GAME_MATCH_CACHE_STORAGE_KEY) ?? '{}') as unknown;
const parsed = this.jsonStorage.read<unknown>(GAME_MATCH_CACHE_STORAGE_KEY, null);
return this.normalizeMatchCache(parsed);
} catch {
return {};
}
return this.normalizeMatchCache(parsed);
}
private normalizeMatchCache(value: unknown): Record<string, CachedGameMatch> {
@@ -465,7 +463,7 @@ export class GameActivityService implements OnDestroy {
.sort((left, right) => right[1].expiresAt - left[1].expiresAt)
.slice(0, MAX_LOCAL_CACHE_ENTRIES);
localStorage.setItem(GAME_MATCH_CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
this.jsonStorage.write(GAME_MATCH_CACHE_STORAGE_KEY, Object.fromEntries(entries));
}
private normalizeCacheKey(value: string): string {

View File

@@ -1,36 +1,36 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../../core/constants';
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
@Injectable({ providedIn: 'root' })
export class NotificationSettingsStorageService {
private readonly storage = jsonStorage;
load(): NotificationsSettings {
const fallback = createDefaultNotificationSettings();
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_SETTINGS);
const parsed = this.storage.read<Partial<NotificationsSettings> | null>(
STORAGE_KEY_NOTIFICATION_SETTINGS,
null
);
if (!raw) {
if (!parsed) {
return fallback;
}
try {
const parsed = JSON.parse(raw) as Partial<NotificationsSettings>;
return {
enabled: parsed.enabled ?? fallback.enabled,
showPreview: parsed.showPreview ?? fallback.showPreview,
respectBusyStatus: parsed.respectBusyStatus ?? fallback.respectBusyStatus,
mutedRooms: normaliseBooleanRecord(parsed.mutedRooms),
mutedChannels: normaliseNestedBooleanRecord(parsed.mutedChannels),
roomBaselines: normaliseNumberRecord(parsed.roomBaselines),
lastReadByChannel: normaliseNestedNumberRecord(parsed.lastReadByChannel)
};
} catch {
return fallback;
}
return {
enabled: parsed.enabled ?? fallback.enabled,
showPreview: parsed.showPreview ?? fallback.showPreview,
respectBusyStatus: parsed.respectBusyStatus ?? fallback.respectBusyStatus,
mutedRooms: normaliseBooleanRecord(parsed.mutedRooms),
mutedChannels: normaliseNestedBooleanRecord(parsed.mutedChannels),
roomBaselines: normaliseNumberRecord(parsed.roomBaselines),
lastReadByChannel: normaliseNestedNumberRecord(parsed.lastReadByChannel)
};
}
save(settings: NotificationsSettings): void {
localStorage.setItem(STORAGE_KEY_NOTIFICATION_SETTINGS, JSON.stringify(settings));
this.storage.write(STORAGE_KEY_NOTIFICATION_SETTINGS, settings);
}
}

View File

@@ -14,6 +14,7 @@ import { environment } from '../../../../../environments/environment';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
import type {
PluginRequirementSummary,
TojuPluginInstallScope,
@@ -72,6 +73,7 @@ export class PluginStoreService {
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);
@@ -947,17 +949,16 @@ export class PluginStoreService {
}
private loadState(): PersistedPluginStoreState {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
const parsed = this.jsonStorage.read<unknown>(
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE),
null
);
if (!raw) {
return createDefaultStoreState();
}
return normalizePersistedState(JSON.parse(raw) as unknown);
} catch {
if (!parsed) {
return createDefaultStoreState();
}
return normalizePersistedState(parsed);
}
private saveState(): void {
@@ -969,10 +970,7 @@ export class PluginStoreService {
sourceUrls: this.sourceUrls()
};
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
} catch {}
this.jsonStorage.write(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), state);
void this.desktopState.writeJson(STORAGE_KEY_PLUGIN_STORE, state);
}
@@ -984,7 +982,7 @@ export class PluginStoreService {
}
const normalized = normalizePersistedState(state);
const sourceUrlsChanged = JSON.stringify(normalized.sourceUrls) !== JSON.stringify(this.sourceUrls());
const sourceUrlsChanged = !areStringArraysEqual(normalized.sourceUrls, this.sourceUrls());
if (sourceUrlsChanged) {
this.sourceUrlsSignal.set(normalized.sourceUrls);
@@ -1107,7 +1105,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
id,
imageUrl: normalizeImageUrl(resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner'))),
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
scope: readPluginInstallScope(value),
@@ -1128,6 +1126,24 @@ function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInst
return scope === 'server' || scope === 'client' ? scope : undefined;
}
function areStringArraysEqual(first: readonly string[], second: readonly string[]): boolean {
if (first === second) {
return true;
}
if (first.length !== second.length) {
return false;
}
for (let arrayIndex = 0; arrayIndex < first.length; arrayIndex++) {
if (first[arrayIndex] !== second[arrayIndex]) {
return false;
}
}
return true;
}
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
if (!isRecord(value)) {
return createDefaultStoreState();
@@ -1300,44 +1316,6 @@ function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
}
}
/**
* Rewrites human-friendly GitHub URLs so the browser can load the underlying
* binary asset. Users typically paste links copied from the GitHub web UI which
* point at the rendered HTML preview (`github.com/<owner>/<repo>/blob/...`) or
* the raw redirector (`github.com/<owner>/<repo>/raw/...`). Both forms must be
* mapped to `raw.githubusercontent.com` for `<img>` tags to work.
*/
function normalizeImageUrl(rawUrl: string | undefined): string | undefined {
if (!rawUrl) {
return undefined;
}
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return rawUrl;
}
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') {
return rawUrl;
}
const segments = url.pathname.split('/').filter(Boolean);
const kindIndex = segments.findIndex((segment) => segment === 'blob' || segment === 'raw');
if (kindIndex < 2 || kindIndex >= segments.length - 1) {
return rawUrl;
}
const owner = segments[0];
const repo = segments[1];
const ref = segments.slice(kindIndex + 1).join('/');
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}${url.search}`;
}
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
if (!rawUrl) {
return undefined;

View File

@@ -255,7 +255,7 @@
@if (filteredPlugins().length > 0) {
<div class="grid gap-3">
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)]">
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)] [content-visibility:auto] [contain-intrinsic-size:auto_140px]">
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
@if (plugin.imageUrl && !hasBrokenImage(plugin)) {
<img

View File

@@ -8,9 +8,11 @@ import {
inject,
signal
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Store as NgRxStore } from '@ngrx/store';
import { debounceTime } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
@@ -103,7 +105,7 @@ export class PluginStoreComponent implements OnInit {
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.searchTerm().trim()
const searchTerm = this.debouncedSearchTerm().trim()
.toLowerCase();
const sourceFilter = this.selectedSourceUrl();
const showInstalled = this.showInstalledOnly();
@@ -157,6 +159,16 @@ export class PluginStoreComponent implements OnInit {
readonly serverInstallBusy = signal(false);
readonly brokenImageKeys = signal<Set<string>>(new Set());
/**
* Debounced search term used by `filteredPlugins`. Keeps each keystroke
* from forcing a full re-filter of the (potentially large) plugin catalog
* while still letting the input update its bound model instantly.
*/
protected readonly debouncedSearchTerm = toSignal(
toObservable(this.searchTerm).pipe(debounceTime(200)),
{ initialValue: '' }
);
private destroyed = false;
private readonly destroyRef = inject(DestroyRef);
private readonly externalLinks = inject(ExternalLinkService);

View File

@@ -153,10 +153,10 @@
/>
<section
class="min-h-0 overflow-y-auto"
class="flex min-h-0 flex-col"
[class.hidden]="isMobile() && mobileTab() !== 'servers'"
>
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
<div class="z-10 flex shrink-0 items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
<div>
<h3 class="text-sm font-semibold text-foreground">Servers</h3>
<p class="text-xs text-muted-foreground">{{ searchResults().length }} found</p>
@@ -164,11 +164,11 @@
</div>
@if (isSearching()) {
<div class="flex items-center justify-center py-8">
<div class="flex flex-1 items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if (searchResults().length === 0) {
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
<div class="flex flex-1 flex-col items-center justify-center px-4 py-10 text-muted-foreground">
<ng-icon
name="lucideSearch"
class="mb-3 h-10 w-10 opacity-50"
@@ -176,10 +176,20 @@
<p class="text-sm font-medium">No servers found</p>
</div>
} @else {
<div class="space-y-2 p-3">
@for (server of searchResults(); track server.id) {
<div
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
<app-virtual-list
class="block min-h-0 flex-1 p-3"
[items]="searchResults()"
[estimateSize]="140"
[overscan]="4"
[trackBy]="trackServerById"
>
<ng-template
#item
let-server
>
<div class="pb-2">
<div
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
[class.border-border]="!isServerMarkedBanned(server)"
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
@@ -315,8 +325,9 @@
</div>
</div>
</div>
}
</div>
</div>
</ng-template>
</app-virtual-list>
}
</section>
</div>

View File

@@ -58,6 +58,7 @@ import {
import { ChatMessageMarkdownComponent } from '../../../chat';
import { hasRoomBanForUser } from '../../../access-control';
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
import { VirtualListComponent } from '../../../../shared/components/virtual-list';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import {
PluginRequirementService,
@@ -82,7 +83,8 @@ interface JoinPluginConsentDialog {
ChatMessageMarkdownComponent,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent
UserSearchListComponent,
VirtualListComponent
],
viewProviders: [
provideIcons({
@@ -187,6 +189,9 @@ export class ServerSearchComponent implements OnInit {
this.searchSubject.next(query);
}
/** Stable trackBy reference for the virtualized server results list. */
readonly trackServerById = (_index: number, server: ServerInfo): string => server.id;
/** Join a server from the search results. Redirects to login if unauthenticated. */
async joinServer(server: ServerInfo): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
import {
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
@@ -8,26 +9,16 @@ import type { ServerEndpoint } from '../../domain/models/server-directory.model'
@Injectable({ providedIn: 'root' })
export class ServerEndpointStorageService {
private readonly storage = jsonStorage;
loadEndpoints(): ServerEndpoint[] | null {
const stored = localStorage.getItem(SERVER_ENDPOINTS_STORAGE_KEY);
const parsed = this.storage.read<unknown>(SERVER_ENDPOINTS_STORAGE_KEY, null);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored) as unknown;
return Array.isArray(parsed)
? parsed as ServerEndpoint[]
: null;
} catch {
return null;
}
return Array.isArray(parsed) ? parsed as ServerEndpoint[] : null;
}
saveEndpoints(endpoints: ServerEndpoint[]): void {
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
this.storage.write(SERVER_ENDPOINTS_STORAGE_KEY, endpoints);
}
loadDisabledDefaultEndpointKeys(): Set<string> {
@@ -39,7 +30,7 @@ export class ServerEndpointStorageService {
}
clearDisabledDefaultEndpointKeys(): void {
localStorage.removeItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
this.storage.remove(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
loadRemovedDefaultEndpointKeys(): Set<string> {
@@ -51,35 +42,25 @@ export class ServerEndpointStorageService {
}
clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
this.storage.remove(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
private loadStringSet(storageKey: string): Set<string> {
const stored = localStorage.getItem(storageKey);
const parsed = this.storage.read<unknown>(storageKey, null);
if (!stored) {
if (!Array.isArray(parsed)) {
return new Set<string>();
}
try {
const parsed = JSON.parse(stored) as unknown;
if (!Array.isArray(parsed)) {
return new Set<string>();
}
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
} catch {
return new Set<string>();
}
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
}
private saveStringSet(storageKey: string, keys: Set<string>): void {
if (keys.size === 0) {
localStorage.removeItem(storageKey);
this.storage.remove(storageKey);
return;
}
localStorage.setItem(storageKey, JSON.stringify([...keys]));
this.storage.write(storageKey, [...keys]);
}
}

View File

@@ -5,6 +5,7 @@ import {
} from '@angular/core';
import { Store } from '@ngrx/store';
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
import { ScreenShareFacade } from '../../../../domains/screen-share';
import { User } from '../../../../shared-kernel';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
@@ -38,6 +39,7 @@ interface PeerAudioPipeline {
@Injectable({ providedIn: 'root' })
export class VoicePlaybackService {
private readonly store = inject(Store);
private readonly jsonStorage = jsonStorage;
private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly allUsers = this.store.selectSignal(selectAllUsers);
@@ -401,33 +403,31 @@ export class VoicePlaybackService {
}
});
localStorage.setItem(STORAGE_KEY_USER_VOLUMES, JSON.stringify(data));
this.jsonStorage.write(STORAGE_KEY_USER_VOLUMES, data);
} catch {
// localStorage not available
// storage not available
}
}
private loadPersistedVolumes(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY_USER_VOLUMES);
const data = this.jsonStorage.read<Record<string, { volume: number; muted: boolean }> | null>(
STORAGE_KEY_USER_VOLUMES,
null
);
if (!raw)
return;
const data = JSON.parse(raw) as Record<string, { volume: number; muted: boolean }>;
Object.entries(data).forEach(([id, entry]) => {
if (typeof entry.volume === 'number') {
this.userVolumes.set(id, entry.volume);
}
if (entry.muted) {
this.userMuted.set(id, true);
}
});
} catch {
// corrupted data - ignore
if (!data) {
return;
}
Object.entries(data).forEach(([id, entry]) => {
if (typeof entry.volume === 'number') {
this.userVolumes.set(id, entry.volume);
}
if (entry.muted) {
this.userMuted.set(id, true);
}
});
}
private hasAudio(stream: MediaStream): boolean {

View File

@@ -35,15 +35,23 @@ export const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
};
export function loadVoiceSettingsFromStorage(): VoiceSettings {
if (cachedSettings) {
return { ...cachedSettings };
}
try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (!raw)
return { ...DEFAULT_VOICE_SETTINGS };
if (!raw) {
cachedSettings = { ...DEFAULT_VOICE_SETTINGS };
return { ...cachedSettings };
}
return normaliseVoiceSettings(JSON.parse(raw) as Partial<VoiceSettings>);
cachedSettings = normaliseVoiceSettings(JSON.parse(raw) as Partial<VoiceSettings>);
return { ...cachedSettings };
} catch {
return { ...DEFAULT_VOICE_SETTINGS };
cachedSettings = { ...DEFAULT_VOICE_SETTINGS };
return { ...cachedSettings };
}
}
@@ -53,11 +61,60 @@ export function saveVoiceSettingsToStorage(patch: Partial<VoiceSettings>): Voice
...patch
});
try {
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(nextSettings));
} catch {}
cachedSettings = nextSettings;
schedulePersist(nextSettings);
return nextSettings;
return { ...nextSettings };
}
let cachedSettings: VoiceSettings | null = null;
let pendingSettings: VoiceSettings | null = null;
let persistScheduled = false;
function schedulePersist(settings: VoiceSettings): void {
pendingSettings = settings;
if (persistScheduled) {
return;
}
persistScheduled = true;
const runner = (): void => {
persistScheduled = false;
if (!pendingSettings) {
return;
}
const toPersist = pendingSettings;
pendingSettings = null;
try {
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(toPersist));
} catch {
// ignore quota / privacy errors
}
};
type IdleCallbackHandle = number;
interface IdleDeadline {
didTimeout: boolean;
timeRemaining(): number;
}
type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle;
interface MaybeIdleGlobal {
requestIdleCallback?: IdleRequest;
}
const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as MaybeIdleGlobal;
if (typeof idleGlobal.requestIdleCallback === 'function') {
idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 });
} else {
setTimeout(runner, 0);
}
}
function normaliseVoiceSettings(raw: Partial<VoiceSettings>): VoiceSettings {