wip: optimizations
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
class="chat-layout relative h-full"
|
||||
>
|
||||
<app-chat-message-list
|
||||
[allMessages]="allMessages()"
|
||||
[allMessages]="roomMessages()"
|
||||
[channelMessages]="channelMessages()"
|
||||
[loading]="loading()"
|
||||
[syncing]="syncing()"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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']));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user