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

48
toju-app/AGENTS.md Normal file
View File

@@ -0,0 +1,48 @@
# Product Client Guidelines
This package is the Angular 21 renderer for the Toju/MetoYou product client.
## Before You Edit
- Use the root commands from the repository root: `npm run build`, `npm run test`, `npm run lint`, and `npm run format` for template changes.
- Read `src/app/domains/README.md` before adding or moving business logic.
- See `../doc/typescript.md` for shared TypeScript rules.
## Architecture
- Keep business rules in `src/app/domains/<name>/domain/`.
- Keep orchestration in `src/app/domains/<name>/application/`.
- Keep technical adapters in `src/app/domains/<name>/infrastructure/` or `src/app/infrastructure/`.
- Keep domain-owned UI in `src/app/domains/<name>/feature/` and app-level shells or composition in `src/app/features/`.
- Outside a domain, import from `src/app/domains/<name>/index.ts` instead of internal files.
- Put cross-domain contracts in `src/app/shared-kernel/` when two or more domains need them.
## Key Docs
- `src/app/domains/README.md`
- `src/app/shared-kernel/README.md`
- `src/app/infrastructure/realtime/README.md`
## Electron Boundary
- In Angular DI code, use `src/app/core/platform/electron/electron-bridge.service.ts`.
- In non-DI renderer helpers, use `src/app/core/platform/electron/get-electron-api.ts`.
- When the renderer/Electron contract changes, keep the Angular bridge, preload API, and IPC handlers in sync.
## Mobile / Viewport
- Use `ViewportService` from `src/app/core/platform/viewport.service.ts` for mobile/touch detection. Breakpoint is `md` (max-width 767.98px); exposes `isMobile`, `isTouch`, `isDesktop` signals.
- Theme-driven grid layouts (`appShell`, `roomLayout`, `dmLayout`) are bypassed on mobile. Do not introduce mobile-specific theme layouts; gate via `@if (isMobile())` in templates instead.
- The mobile chat-room shell (`features/room/chat-room`) is a 3-page stack (channels -> main -> members); the DM workspace (`domains/direct-message/feature/dm-workspace`) is 2-page (conversations -> chat). Page state is a component-local signal kept in sync with a Swiper carousel (`<swiper-container>` / `<swiper-slide>` from `swiper/element/bundle`, registered in `src/main.ts`); both components declare `CUSTOM_ELEMENTS_SCHEMA`.
- The Electron-style title bar is hidden on mobile. Screen-share UI must stay hidden on mobile (browsers do not support it reliably on touch devices).
- Context menus and modal dialogs auto-render as bottom sheets on mobile. `ContextMenuComponent` and `ConfirmDialogComponent` (in `src/app/shared/components/`) inject `ViewportService` and switch their templates between the desktop popover/centered modal and `BottomSheetComponent` (`src/app/shared/components/bottom-sheet/`) on phone-sized viewports. New menus/dialogs should reuse these components rather than rolling their own `fixed inset-0` overlay. For one-off bespoke surfaces, render `<app-bottom-sheet>` directly when `isMobile()`.
- Tap targets on interactive controls should be at least 44px on mobile. Use `min-h-11` (or explicit `h-11 w-11`) for icon buttons that are tap-only on mobile; desktop sizes can remain smaller via `md:` overrides.
## Templates
- If you touch Angular HTML templates, run `npm run format`.
- If template property order matters, run `npm run sort:props` or the matching VS Code task.
## Before You Finish
- Validate whether relevant markdown docs or `AGENTS.md` files need updates. If behavior, workflows, commands, or architecture changed, update those docs in the same task.

View File

@@ -51,6 +51,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { ROOM_URL_PATTERN } from './core/constants';
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
import { runWhenIdle } from './shared/rxjs';
import {
ThemeNodeDirective,
ThemePickerOverlayComponent,
@@ -234,10 +235,35 @@ export class App implements OnInit, OnDestroy {
}
async ngOnInit(): Promise<void> {
// Wire the router subscription first so we never miss the initial
// NavigationEnd while async bootstrap is still running.
this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
this.currentRouteUrl.set(url);
const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null;
this.voiceSession.checkCurrentRoute(currentRoomId);
}
});
// Synchronous theme bootstrap so first paint has the right tokens.
this.theme.initialize();
// Fire-and-forget work that must never block the render thread:
// - desktop auto-updater handshake
// - server-time offset (UI tolerates the local clock until it arrives)
// - notifications subsystem (depends on savedRooms signal which is
// populated by the rooms effect after dispatch)
// - desktop deep-link bridge (only relevant after first paint)
// - background presence + game activity loops
void this.desktopUpdates.initialize();
void this.kickOffBackgroundBootstrap();
// The only thing we genuinely must await before deciding which route
// to show is the local database (it owns the persisted current user).
let currentUserId = getStoredCurrentUserId();
await this.databaseService.initialize();
@@ -251,18 +277,6 @@ export class App implements OnInit, OnDestroy {
}
}
try {
const apiBase = this.servers.getApiBaseUrl();
await this.timeSync.syncWithEndpoint(apiBase);
} catch {}
await this.notifications.initialize();
await this.setupDesktopDeepLinks();
this.userStatus.start();
this.gameActivity.start();
const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) {
@@ -299,17 +313,28 @@ export class App implements OnInit, OnDestroy {
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
}
}
}
this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
/**
* Runs services that the user does not actively wait on. Scheduled
* through `requestIdleCallback` so they yield to the renderer until
* the browser is idle, eliminating bootstrap stutter on Electron.
*/
private kickOffBackgroundBootstrap(): void {
runWhenIdle(() => {
try {
const apiBase = this.servers.getApiBaseUrl();
this.currentRouteUrl.set(url);
const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null;
this.voiceSession.checkCurrentRoute(currentRoomId);
void this.timeSync.syncWithEndpoint(apiBase).catch(() => {});
} catch {
// getApiBaseUrl can throw before endpoints are hydrated; ignore.
}
void this.notifications.initialize().catch(() => {});
void this.setupDesktopDeepLinks().catch(() => {});
this.userStatus.start();
this.gameActivity.start();
});
}

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 {

View File

@@ -1,4 +1,8 @@
import { Component, input, output } from '@angular/core';
import {
Component,
input,
output
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,

View File

@@ -64,6 +64,16 @@
}
</div>
<div class="space-y-1">
@if (showTextChannelSkeleton()) {
<app-skeleton-list
[rows]="3"
rowHeight="0.875rem"
primaryWidth="70%"
[showAvatar]="true"
avatarSize="1rem"
/>
}
@for (ch of textChannels(); track ch.id) {
<button
appThemeNode="roomTextChannelItem"
@@ -132,6 +142,16 @@
<p class="px-2 py-2 text-sm text-muted-foreground">Voice is disabled by host</p>
}
<div class="space-y-1">
@if (showVoiceChannelSkeleton()) {
<app-skeleton-list
[rows]="2"
rowHeight="0.875rem"
primaryWidth="65%"
[showAvatar]="true"
avatarSize="1rem"
/>
}
@for (ch of voiceChannels(); track ch.id) {
<div
class="rounded-md transition-colors"
@@ -259,7 +279,7 @@
</div>
</section>
@if (pluginChannelSections().length > 0 || pluginMenuActions().length > 0 || pluginSidePanels().length > 0) {
@if (pluginChannelSections().length > 0 || pluginMenuActions().length > 0 || pluginSidePanels().length > 0 || showPluginSkeleton()) {
<section
class="border-t border-border px-2 py-3"
data-testid="plugin-room-side-panel"
@@ -281,6 +301,28 @@
</button>
</div>
@if (showPluginSkeleton()) {
<div class="space-y-2 px-1 py-1">
<div class="flex items-center gap-2 rounded-md px-1 py-1.5">
<app-skeleton
width="1rem"
height="1rem"
rounded="md"
[block]="false"
/>
<app-skeleton
width="62%"
height="0.875rem"
/>
</div>
<app-skeleton
width="100%"
height="3.25rem"
rounded="md"
/>
</div>
}
@if (pluginChannelSections().length > 0) {
<div class="space-y-1">
@for (record of pluginChannelSections(); track record.id) {
@@ -496,7 +538,7 @@
<div class="space-y-1">
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
<div
class="group/user flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
class="group/user flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer [content-visibility:auto] [contain-intrinsic-size:auto_48px]"
[attr.data-testid]="'room-user-card-' + (member.oderId || member.id)"
role="button"
tabindex="0"

View File

@@ -1,13 +1,16 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
inject,
computed,
effect,
input,
OnDestroy,
output,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
@@ -33,8 +36,10 @@ import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
selectVoiceChannels
selectVoiceChannels,
selectIsConnecting
} from '../../../store/rooms/rooms.selectors';
import { selectMessagesLoading } from '../../../store/messages/messages.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions';
@@ -68,7 +73,9 @@ import {
UserAvatarComponent,
ConfirmDialogComponent,
UserVolumeMenuComponent,
ProfileCardService
ProfileCardService,
SkeletonComponent,
SkeletonListComponent
} from '../../../shared';
import {
Channel,
@@ -79,9 +86,12 @@ import {
User
} from '../../../shared-kernel';
import { v4 as uuidv4 } from 'uuid';
import { visibilityAwareInterval$ } from '../../../shared/rxjs';
type PanelMode = 'channels' | 'users';
const SKELETON_REVEAL_DELAY_MS = 180;
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
@@ -95,7 +105,9 @@ type PanelMode = 'channels' | 'users';
UserAvatarComponent,
ConfirmDialogComponent,
PluginRenderHostComponent,
ThemeNodeDirective
ThemeNodeDirective,
SkeletonComponent,
SkeletonListComponent
],
viewProviders: [
provideIcons({
@@ -135,7 +147,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private readonly pluginUi = inject(PluginUiRegistryService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
private readonly destroyRef = inject(DestroyRef);
readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true);
@@ -145,12 +158,19 @@ export class RoomsSidePanelComponent implements OnDestroy {
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isConnecting = this.store.selectSignal(selectIsConnecting);
messagesLoading = this.store.selectSignal(selectMessagesLoading);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
pluginChannelSections = this.pluginUi.channelSectionRecords;
pluginMenuActions = this.pluginUi.toolbarActionRecords;
pluginSidePanels = this.pluginUi.sidePanelRecords;
panelHydrating = computed(() => this.panelMode() === 'channels' && (this.isConnecting() || this.messagesLoading()));
delayedPanelHydrating = signal(false);
showTextChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.textChannels().length === 0);
showVoiceChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.voiceEnabled() && this.voiceChannels().length === 0);
showPluginSkeleton = computed(() => this.delayedPanelHydrating() && !this.hasPluginPanelContent());
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
roomMembers = computed(() => this.currentRoom()?.members ?? []);
roomMemberIdentifiers = computed(() => {
@@ -196,6 +216,10 @@ export class RoomsSidePanelComponent implements OnDestroy {
return memberIds.size;
});
private hasPluginPanelContent(): boolean {
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
}
showChannelMenu = signal(false);
channelMenuX = signal(0);
channelMenuY = signal(0);
@@ -222,12 +246,47 @@ export class RoomsSidePanelComponent implements OnDestroy {
dragTargetVoiceChannelId = signal<string | null>(null);
activityNow = signal(Date.now());
constructor() {
effect(() => {
if (!this.panelHydrating()) {
this.clearSkeletonRevealTimer();
this.delayedPanelHydrating.set(false);
return;
}
if (this.delayedPanelHydrating() || this.skeletonRevealTimer) {
return;
}
this.skeletonRevealTimer = setTimeout(() => {
this.skeletonRevealTimer = null;
if (this.panelHydrating()) {
this.delayedPanelHydrating.set(true);
}
}, SKELETON_REVEAL_DELAY_MS);
});
visibilityAwareInterval$(1_000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.activityNow.set(Date.now()));
}
ngOnDestroy(): void {
clearInterval(this.activityTimer);
this.clearSkeletonRevealTimer();
this.cancelQueuedProfileCardOpen();
this.pluginActionMenu.close();
}
private clearSkeletonRevealTimer(): void {
if (!this.skeletonRevealTimer) {
return;
}
clearTimeout(this.skeletonRevealTimer);
this.skeletonRevealTimer = null;
}
gameActivityElapsed(user: User | null | undefined): string {
const activity = user?.gameActivity;

View File

@@ -45,6 +45,8 @@ import {
LeaveServerDialogComponent
} from '../../../shared';
const ACTIVATION_DEBOUNCE_MS = 150;
@Component({
selector: 'app-servers-rail',
standalone: true,
@@ -72,6 +74,11 @@ export class ServersRailComponent {
private serverDirectory = inject(ServerDirectoryFacade);
private destroyRef = inject(DestroyRef);
private banLookupRequestVersion = 0;
private bannedLookupUserKey: string | null = null;
private activationRequestVersion = 0;
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
private joinRequestVersion = 0;
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
private visibleSavedRoomCache: Room[] = [];
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
savedRooms = this.store.selectSignal(selectSavedRooms);
@@ -208,6 +215,16 @@ export class ServersRailComponent {
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
this.destroyRef.onDestroy(() => {
if (this.activationTimer) {
window.clearTimeout(this.activationTimer);
}
if (this.joinRequestTimer) {
window.clearTimeout(this.joinRequestTimer);
}
});
}
initial(name?: string): string {
@@ -249,8 +266,8 @@ export class ServersRailComponent {
}
this.optimisticSelectedRoomId.set(targetRoom.id);
this.activateSavedRoom(targetRoom);
this.savedRoomJoinRequests.next({ room: targetRoom });
this.queueSavedRoomActivation(targetRoom);
this.queueSavedRoomJoin(targetRoom);
}
openCall(callId: string): void {
@@ -436,13 +453,36 @@ export class ServersRailComponent {
const requestVersion = ++this.banLookupRequestVersion;
if (!currentUser || rooms.length === 0) {
this.bannedLookupUserKey = null;
this.bannedRoomLookup.set({});
return;
}
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
const userKey = `${currentUser.id}:${currentUser.oderId}:${persistedUserId ?? ''}`;
const roomIds = new Set(rooms.map((room) => room.id));
if (this.bannedLookupUserKey !== userKey) {
this.bannedLookupUserKey = userKey;
this.bannedRoomLookup.set({});
}
const currentLookup = this.bannedRoomLookup();
const nextLookup = Object.fromEntries(
Object.entries(currentLookup).filter(([roomId]) => roomIds.has(roomId))
);
const roomsToLookup = rooms.filter((room) => nextLookup[room.id] === undefined);
if (roomsToLookup.length === 0) {
if (Object.keys(nextLookup).length !== Object.keys(currentLookup).length) {
this.bannedRoomLookup.set(nextLookup);
}
return;
}
const entries = await Promise.all(
rooms.map(async (room) => {
roomsToLookup.map(async (room) => {
const bans = await this.db.getBansForRoom(room.id);
return [room.id, hasRoomBanForUser(bans, currentUser, persistedUserId)] as const;
@@ -453,7 +493,10 @@ export class ServersRailComponent {
return;
}
this.bannedRoomLookup.set(Object.fromEntries(entries));
this.bannedRoomLookup.set({
...nextLookup,
...Object.fromEntries(entries)
});
}
private prepareVoiceContext(room: Room): void {
@@ -473,6 +516,40 @@ export class ServersRailComponent {
this.store.dispatch(RoomsActions.viewServer({ room, skipBanCheck: true }));
}
private queueSavedRoomActivation(room: Room): void {
const requestVersion = ++this.activationRequestVersion;
if (this.activationTimer) {
window.clearTimeout(this.activationTimer);
}
this.activationTimer = window.setTimeout(() => {
if (requestVersion !== this.activationRequestVersion) {
return;
}
this.activationTimer = null;
this.activateSavedRoom(room);
}, ACTIVATION_DEBOUNCE_MS);
}
private queueSavedRoomJoin(room: Room): void {
const requestVersion = ++this.joinRequestVersion;
if (this.joinRequestTimer) {
window.clearTimeout(this.joinRequestTimer);
}
this.joinRequestTimer = window.setTimeout(() => {
if (requestVersion !== this.joinRequestVersion) {
return;
}
this.joinRequestTimer = null;
this.savedRoomJoinRequests.next({ room });
}, ACTIVATION_DEBOUNCE_MS);
}
private requestJoinInBackground(room: Room, password?: string) {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();

View File

@@ -1,5 +1,6 @@
import { STORAGE_KEY_GENERAL_SETTINGS, STORAGE_KEY_LAST_VIEWED_CHAT } from '../../core/constants';
import { getUserScopedStorageKey } from '../../core/storage/current-user-storage';
import { runWhenIdle } from '../../shared/rxjs';
export interface GeneralSettings {
reopenLastViewedChat: boolean;
@@ -15,10 +16,65 @@ export interface LastViewedChatSnapshot {
channelId: string | null;
}
const pendingWrites = new Map<string, string | null>();
let flushScheduled = false;
let cancelScheduledFlush: (() => void) | null = null;
function scheduleStorageFlush(): void {
if (flushScheduled) {
return;
}
flushScheduled = true;
cancelScheduledFlush = runWhenIdle(() => {
flushScheduled = false;
cancelScheduledFlush = null;
const snapshot = Array.from(pendingWrites.entries());
pendingWrites.clear();
for (const [key, value] of snapshot) {
try {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} catch {
// storage not available
}
}
});
}
function scheduleStorageWrite(key: string, serialised: string): void {
pendingWrites.set(key, serialised);
scheduleStorageFlush();
}
function scheduleStorageRemove(key: string): void {
pendingWrites.set(key, null);
scheduleStorageFlush();
}
function readMaybePending(key: string): string | null {
if (pendingWrites.has(key)) {
return pendingWrites.get(key) ?? null;
}
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function loadGeneralSettingsFromStorage(): GeneralSettings {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
?? localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
const raw = readMaybePending(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
?? readMaybePending(STORAGE_KEY_GENERAL_SETTINGS);
if (!raw) {
return { ...DEFAULT_GENERAL_SETTINGS };
@@ -36,17 +92,15 @@ export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): G
...patch
});
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS), JSON.stringify(nextSettings));
} catch {}
scheduleStorageWrite(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS), JSON.stringify(nextSettings));
return nextSettings;
}
export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null {
try {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
?? localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
const raw = readMaybePending(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
?? readMaybePending(STORAGE_KEY_LAST_VIEWED_CHAT);
if (!raw) {
return null;
@@ -75,16 +129,41 @@ export function saveLastViewedChatToStorage(snapshot: LastViewedChatSnapshot): v
return;
}
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, normalised.userId), JSON.stringify(normalised));
} catch {}
scheduleStorageWrite(
getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, normalised.userId),
JSON.stringify(normalised)
);
}
export function clearLastViewedChatFromStorage(userId?: string | null): void {
try {
localStorage.removeItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId));
localStorage.removeItem(STORAGE_KEY_LAST_VIEWED_CHAT);
} catch {}
scheduleStorageRemove(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId));
scheduleStorageRemove(STORAGE_KEY_LAST_VIEWED_CHAT);
}
/** Force-flush any pending app-resume writes (e.g. before unload). */
export function flushAppResumeStorage(): void {
if (cancelScheduledFlush) {
cancelScheduledFlush();
cancelScheduledFlush = null;
}
flushScheduled = false;
const snapshot = Array.from(pendingWrites.entries());
pendingWrites.clear();
for (const [key, value] of snapshot) {
try {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} catch {
// storage not available
}
}
}
function normaliseGeneralSettings(raw: Partial<GeneralSettings>): GeneralSettings {

View File

@@ -10,6 +10,7 @@ import {
} from '../../shared-kernel';
import type { ChatAttachmentMeta } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import type { RoomMessageStats } from './database.service';
/** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou';
@@ -110,6 +111,14 @@ export class BrowserDatabaseService {
return this.hydrateMessages(messages);
}
async getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
return this.foldMessagesForRoom(roomId, (stats, message) => {
stats.count += 1;
stats.lastUpdated = Math.max(stats.lastUpdated, message.editedAt || message.timestamp || 0);
}, { count: 0,
lastUpdated: 0 });
}
/** Delete a message by its ID. */
async deleteMessage(messageId: string): Promise<void> {
await this.deleteRecord(STORE_MESSAGES, messageId);
@@ -533,6 +542,36 @@ export class BrowserDatabaseService {
});
}
private async foldMessagesForRoom<T>(
roomId: string,
visit: (state: T, message: Message) => void,
initialState: T
): Promise<T> {
const transaction = this.createTransaction(STORE_MESSAGES, 'readonly');
const request = transaction.objectStore(STORE_MESSAGES)
.index('roomId')
.openCursor(IDBKeyRange.only(roomId));
return new Promise<T>((resolve, reject) => {
const state = initialState;
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(state);
return;
}
visit(state, cursor.value as Message);
cursor.continue();
};
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error);
});
}
private async deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
const transaction = this.createTransaction(storeName, 'readwrite');

View File

@@ -16,6 +16,11 @@ import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-database.service';
export interface RoomMessageStats {
count: number;
lastUpdated: number;
}
/**
* Facade database service that transparently delegates to the correct
* storage backend based on the runtime platform.
@@ -66,6 +71,9 @@ export class DatabaseService {
/** Retrieve messages newer than a given timestamp for a room. */
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.backend.getMessagesSince(roomId, sinceTimestamp); }
/** Retrieve aggregate message stats for sync handshakes without loading history. */
getRoomMessageStats(roomId: string) { return this.backend.getRoomMessageStats(roomId); }
/** Permanently delete a message by ID. */
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }

View File

@@ -8,6 +8,7 @@ import {
} from '../../shared-kernel';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
import type { RoomMessageStats } from './database.service';
/**
* Database service for the Electron (desktop) runtime.
@@ -63,6 +64,10 @@ export class ElectronDatabaseService {
return this.api.query<Message[]>({ type: 'get-messages-since', payload: { roomId, sinceTimestamp } });
}
getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
return this.api.query<RoomMessageStats>({ type: 'get-room-message-stats', payload: { roomId } });
}
/** Permanently delete a message by ID. */
deleteMessage(messageId: string): Promise<void> {
return this.api.command({ type: 'delete-message', payload: { messageId } });

View File

@@ -0,0 +1,204 @@
import { runWhenIdle } from '../../shared/rxjs/idle';
/**
* Detects environments where writes should bypass the idle queue
* and flush synchronously (unit tests, SSR, etc.). Browsers with a
* real document/window get the deferred path.
*/
function isSyncFlushEnvironment(): boolean {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return true;
}
// Vitest / Jest set NODE_ENV=test
const proc = (globalThis as unknown as { process?: { env?: Record<string, string> } }).process;
return proc?.env?.['NODE_ENV'] === 'test' || proc?.env?.['VITEST'] === 'true';
}
/**
* Coalesced-write wrapper around `localStorage`.
*
* - **Reads** are still synchronous (you typically need the value during
* service initialisation) but cached after the first hit so repeat
* parses do not block the render thread.
* - **Writes** are queued and flushed in a single `requestIdleCallback`
* (or `setTimeout(0)` fallback). Rapid signal updates (e.g. drag-resize,
* typing) coalesce into one `JSON.stringify` + write.
* - **Equality** uses a cheap reference / shallow compare before scheduling
* a flush so the heavy `JSON.stringify` only runs when the value really
* changed.
*
* This service exists so domain stores (plugins, themes, voice settings,
* server endpoints, friend list, ICE servers, etc.) can stop hand-rolling
* `JSON.parse` / `JSON.stringify` calls on the render path.
*/
export class JsonStorageService {
private readonly pendingWrites = new Map<string, unknown>();
private readonly subscribers = new Map<string, Set<(value: unknown) => void>>();
private flushScheduled = false;
private cancelScheduledFlush: (() => void) | null = null;
read<T>(key: string, fallback: T): T {
if (this.pendingWrites.has(key)) {
return this.pendingWrites.get(key) as T;
}
const raw = this.safeReadRaw(key);
if (raw === null) {
return fallback;
}
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
/**
* Queue a write. Multiple writes to the same key within the same idle
* window collapse to a single stringify + `localStorage.setItem`.
*
* Pass `equals` for a fast custom equality test (e.g. an `id` compare)
* to skip flushes when the payload is structurally unchanged.
*/
write<T>(key: string, value: T, equals?: (prev: T, next: T) => boolean): void {
const previous = this.pendingWrites.has(key) ? this.pendingWrites.get(key) as T : undefined;
if (previous !== undefined && equals && equals(previous, value)) {
return;
}
if (previous === value) {
return;
}
this.pendingWrites.set(key, value);
this.notify(key, value);
this.scheduleFlush();
}
remove(key: string): void {
this.pendingWrites.delete(key);
try {
localStorage.removeItem(key);
} catch {
// ignore quota / access errors
}
this.notify(key, undefined);
}
/**
* Subscribe to value changes for a key. Fires synchronously on each
* `write` (with the queued value) so signal-bridges stay reactive even
* if the flush is deferred. Returns an unsubscribe function.
*/
subscribe<T>(key: string, listener: (value: T | undefined) => void): () => void {
let subs = this.subscribers.get(key);
if (!subs) {
subs = new Set();
this.subscribers.set(key, subs);
}
subs.add(listener as (value: unknown) => void);
return () => {
const current = this.subscribers.get(key);
if (!current) {
return;
}
current.delete(listener as (value: unknown) => void);
if (current.size === 0) {
this.subscribers.delete(key);
}
};
}
/** Force any queued writes to disk immediately. */
flush(): void {
if (this.cancelScheduledFlush) {
this.cancelScheduledFlush();
this.cancelScheduledFlush = null;
}
this.flushScheduled = false;
this.runFlush();
}
private scheduleFlush(): void {
if (isSyncFlushEnvironment()) {
this.runFlush();
return;
}
if (this.flushScheduled) {
return;
}
this.flushScheduled = true;
this.cancelScheduledFlush = runWhenIdle(() => {
this.cancelScheduledFlush = null;
this.flushScheduled = false;
this.runFlush();
});
}
private runFlush(): void {
if (this.pendingWrites.size === 0) {
return;
}
const snapshot = Array.from(this.pendingWrites.entries());
this.pendingWrites.clear();
for (const [key, value] of snapshot) {
try {
const serialized = JSON.stringify(value);
localStorage.setItem(key, serialized);
} catch {
// quota exceeded / privacy mode - keep the value in cache so the
// caller still observes the update, just no longer persisted
}
}
}
private safeReadRaw(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
private notify(key: string, value: unknown): void {
const subs = this.subscribers.get(key);
if (!subs) {
return;
}
for (const listener of subs) {
try {
listener(value);
} catch {
// a subscriber failure should not block the others
}
}
}
}
/**
* Process-wide singleton. Consumers should prefer this over instantiating
* the class directly so writes coalesce across services.
*/
export const jsonStorage = new JsonStorageService();

View File

@@ -1,11 +1,13 @@
import {
Injectable,
inject,
signal,
computed,
type Signal
} from '@angular/core';
import { environment } from '../../../environments/environment';
import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants';
import { jsonStorage } from '../persistence/json-storage.service';
export interface IceServerEntry {
id: string;
@@ -26,6 +28,7 @@ export class IceServerSettingsService {
readonly entries: Signal<IceServerEntry[]>;
readonly rtcIceServers: Signal<RTCIceServer[]>;
private readonly storageService = jsonStorage;
private readonly _entries = signal<IceServerEntry[]>(this.load());
constructor() {
@@ -90,33 +93,23 @@ export class IceServerSettingsService {
}
private load(): IceServerEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY_ICE_SERVERS);
const parsed = this.storageService.read<unknown>(STORAGE_KEY_ICE_SERVERS, null);
if (!raw) {
return [...DEFAULT_ENTRIES];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) {
return [...DEFAULT_ENTRIES];
}
return parsed.filter(
(entry: unknown): entry is IceServerEntry =>
typeof entry === 'object'
&& entry !== null
&& typeof (entry as IceServerEntry).id === 'string'
&& ((entry as IceServerEntry).type === 'stun' || (entry as IceServerEntry).type === 'turn')
&& typeof (entry as IceServerEntry).urls === 'string'
);
} catch {
if (!Array.isArray(parsed) || parsed.length === 0) {
return [...DEFAULT_ENTRIES];
}
return parsed.filter(
(entry: unknown): entry is IceServerEntry =>
typeof entry === 'object'
&& entry !== null
&& typeof (entry as IceServerEntry).id === 'string'
&& ((entry as IceServerEntry).type === 'stun' || (entry as IceServerEntry).type === 'turn')
&& typeof (entry as IceServerEntry).urls === 'string'
);
}
private save(entries: IceServerEntry[]): void {
localStorage.setItem(STORAGE_KEY_ICE_SERVERS, JSON.stringify(entries));
this.storageService.write(STORAGE_KEY_ICE_SERVERS, entries);
}
}

View File

@@ -1,13 +1,14 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
computed,
effect,
inject,
OnDestroy,
output,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -32,6 +33,7 @@ import { FriendService } from '../../../domains/direct-message/application/servi
import { DirectCallService } from '../../../domains/direct-call/application/services/direct-call.service';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { visibilityAwareInterval$ } from '../../rxjs';
import { UserStatusService } from '../../../core/services/user-status.service';
import {
EditableProfileAvatarSource,
@@ -64,7 +66,7 @@ import {
],
templateUrl: './profile-card-mobile.component.html'
})
export class ProfileCardMobileComponent implements OnDestroy {
export class ProfileCardMobileComponent {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly editable = signal(false);
readonly closed = output<undefined>();
@@ -118,7 +120,10 @@ export class ProfileCardMobileComponent implements OnDestroy {
readonly activityNow = signal(Date.now());
readonly busy = signal(false);
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private readonly destroyRef = inject(DestroyRef);
private readonly activityTimerSub = visibilityAwareInterval$(1_000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.activityNow.set(Date.now()));
private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser();
@@ -135,10 +140,6 @@ export class ProfileCardMobileComponent implements OnDestroy {
{ allowSignalWrites: true }
);
ngOnDestroy(): void {
clearInterval(this.activityTimer);
}
currentStatusColor(): string {
switch (this.displayedUser().status) {
case 'online':

View File

@@ -1,11 +1,12 @@
import {
Component,
DestroyRef,
computed,
effect,
inject,
OnDestroy,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -33,6 +34,7 @@ import { selectUsersEntities } from '../../../store/users/users.selectors';
import { ThemeNodeDirective } from '../../../domains/theme';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { visibilityAwareInterval$ } from '../../rxjs';
@Component({
selector: 'app-profile-card',
@@ -46,7 +48,7 @@ import { ExternalLinkService } from '../../../core/platform/external-link.servic
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })],
templateUrl: './profile-card.component.html'
})
export class ProfileCardComponent implements OnDestroy {
export class ProfileCardComponent {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly displayedUser = computed(() => {
const snapshot = this.user();
@@ -78,7 +80,10 @@ export class ProfileCardComponent implements OnDestroy {
private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private readonly destroyRef = inject(DestroyRef);
private readonly activityTimerSub = visibilityAwareInterval$(1_000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.activityNow.set(Date.now()));
private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser();
@@ -143,10 +148,6 @@ export class ProfileCardComponent implements OnDestroy {
}
}
ngOnDestroy(): void {
clearInterval(this.activityTimer);
}
toggleStatusMenu(): void {
this.showStatusMenu.update((isOpen) => !isOpen);
}

View File

@@ -0,0 +1,4 @@
export { SkeletonComponent } from './skeleton.component';
export { SkeletonListComponent } from './skeleton-list.component';
export { SkeletonMessageComponent } from './skeleton-message.component';
export { SkeletonCardComponent } from './skeleton-card.component';

View File

@@ -0,0 +1,22 @@
<div
class="grid gap-4"
[style.grid-template-columns]="gridTemplate()"
[attr.aria-busy]="true"
>
@for (card of placeholders(); track $index) {
<div class="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<div class="flex items-center gap-3">
<app-skeleton width="2.5rem" height="2.5rem" rounded="md" />
<div class="flex-1 flex flex-col gap-1.5">
<app-skeleton width="60%" height="0.875rem" />
<app-skeleton width="40%" height="0.625rem" />
</div>
</div>
<app-skeleton width="100%" height="0.75rem" />
<app-skeleton width="80%" height="0.75rem" />
<div class="flex justify-end pt-1">
<app-skeleton width="5rem" height="2rem" rounded="md" [block]="false" />
</div>
</div>
}
</div>

View File

@@ -0,0 +1,25 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input
} from '@angular/core';
import { SkeletonComponent } from './skeleton.component';
/**
* Card-shaped skeleton for grids like the plugin store or server directory.
*/
@Component({
selector: 'app-skeleton-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkeletonComponent],
templateUrl: './skeleton-card.component.html'
})
export class SkeletonCardComponent {
cards = input<number>(6);
minColumnWidth = input<string>('16rem');
readonly placeholders = computed(() => Array.from({ length: Math.max(0, this.cards()) }));
readonly gridTemplate = computed(() => `repeat(auto-fill, minmax(${this.minColumnWidth()}, 1fr))`);
}

View File

@@ -0,0 +1,19 @@
<div class="flex flex-col gap-2" [attr.aria-busy]="true">
@for (row of placeholders(); track $index) {
<div class="flex items-center gap-3">
@if (showAvatar()) {
<app-skeleton
[width]="avatarSize()"
[height]="avatarSize()"
rounded="full"
/>
}
<div class="flex-1 flex flex-col gap-1.5">
<app-skeleton [width]="primaryWidth()" [height]="rowHeight()" />
@if (showSubtitle()) {
<app-skeleton width="40%" height="0.625rem" />
}
</div>
</div>
}
</div>

View File

@@ -0,0 +1,29 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input
} from '@angular/core';
import { SkeletonComponent } from './skeleton.component';
/**
* Repeats a row-shaped skeleton `rows` times. Designed for vertical lists
* like the channel list, member list, or DM conversations panel.
*/
@Component({
selector: 'app-skeleton-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkeletonComponent],
templateUrl: './skeleton-list.component.html'
})
export class SkeletonListComponent {
rows = input<number>(6);
rowHeight = input<string>('0.875rem');
primaryWidth = input<string>('70%');
showAvatar = input(true);
avatarSize = input<string>('2rem');
showSubtitle = input(false);
readonly placeholders = computed(() => Array.from({ length: Math.max(0, this.rows()) }));
}

View File

@@ -0,0 +1,17 @@
<div class="flex flex-col gap-3 px-4 py-3" [attr.aria-busy]="true">
@for (row of placeholders(); track $index) {
<div class="flex items-start gap-3">
<app-skeleton width="2.25rem" height="2.25rem" rounded="full" />
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<app-skeleton width="6rem" height="0.75rem" />
<app-skeleton width="3rem" height="0.625rem" />
</div>
<app-skeleton width="85%" height="0.75rem" />
@if ($index % 3 !== 2) {
<app-skeleton width="55%" height="0.75rem" />
}
</div>
</div>
}
</div>

View File

@@ -0,0 +1,24 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input
} from '@angular/core';
import { SkeletonComponent } from './skeleton.component';
/**
* Skeleton placeholder for chat-message-list rows. Mimics the avatar +
* author + multi-line body so first-paint of a channel does not jump.
*/
@Component({
selector: 'app-skeleton-message',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkeletonComponent],
templateUrl: './skeleton-message.component.html'
})
export class SkeletonMessageComponent {
rows = input<number>(5);
readonly placeholders = computed(() => Array.from({ length: Math.max(0, this.rows()) }));
}

View File

@@ -0,0 +1,6 @@
<span
[class]="classes()"
[style.width]="width()"
[style.height]="height()"
aria-hidden="true"
></span>

View File

@@ -0,0 +1,41 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input
} from '@angular/core';
/**
* Generic skeleton placeholder.
*
* Use `<app-skeleton>` while async data loads. Renders as an `animate-pulse`
* rounded surface that respects the active theme tokens (`bg-muted`).
*
* - `width` / `height` accept any CSS length (e.g. `"100%"`, `"3rem"`).
* - `rounded` toggles between `rounded-md` (default) and `rounded-full`
* (useful for avatar placeholders).
* - `block` controls `display: block` vs `display: inline-block`.
*/
@Component({
selector: 'app-skeleton',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './skeleton.component.html'
})
export class SkeletonComponent {
width = input<string>('100%');
height = input<string>('1rem');
rounded = input<'sm' | 'md' | 'lg' | 'full'>('md');
block = input(true);
readonly classes = computed(() => {
const radius
= this.rounded() === 'full' ? 'rounded-full'
: this.rounded() === 'lg' ? 'rounded-lg'
: this.rounded() === 'sm' ? 'rounded-sm'
: 'rounded-md';
const display = this.block() ? 'block' : 'inline-block';
return `${display} ${radius} bg-muted animate-pulse`;
});
}

View File

@@ -0,0 +1,2 @@
export { VirtualListComponent } from './virtual-list.component';
export { VirtualRowMeasureDirective } from './virtual-row-measure.directive';

View File

@@ -0,0 +1,27 @@
<div
#measure
class="tk-virtual-list__inner relative w-full"
[style.height.px]="totalSize()"
>
<div
class="tk-virtual-list__window absolute left-0 top-0 w-full"
[style.transform]="'translateY(' + translateY() + 'px)'"
>
@for (virtualRow of virtualItems(); track virtualRow.key) {
<div
[attr.data-index]="virtualRow.index"
[attr.data-key]="virtualRow.key"
#row
[appVirtualRowMeasure]="virtualizer"
[virtualRowIndex]="virtualRow.index"
>
<ng-container
*ngTemplateOutlet="
itemTemplate || null;
context: { $implicit: items()[virtualRow.index], index: virtualRow.index }
"
/>
</div>
}
</div>
</div>

View File

@@ -0,0 +1,127 @@
import { NgTemplateOutlet } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ContentChild,
ElementRef,
TemplateRef,
ViewChild,
computed,
effect,
inject,
input,
signal,
untracked
} from '@angular/core';
import { injectVirtualizer } from '@tanstack/angular-virtual';
import { VirtualRowMeasureDirective } from './virtual-row-measure.directive';
/**
* Headless virtual scroller for vertical lists. Wraps `@tanstack/angular-virtual`
* and exposes the visible window to a consumer-provided `<ng-template #item>`.
*
* Why this exists: large `@for` loops over hundreds of rows freeze the render
* thread on Electron. Virtualization keeps the DOM bounded to the viewport so
* pointer/scroll input stays responsive even with multi-thousand-item lists.
*
* Usage:
* ```html
* <app-virtual-list
* [items]="rows()"
* [estimateSize]="48"
* [trackBy]="trackById"
* class="block h-full"
* >
* <ng-template #item let-row let-index="index">
* <my-row [row]="row" />
* </ng-template>
* </app-virtual-list>
* ```
*
* The host element is the scrollable viewport. Apply `class="block h-full"`
* (or any fixed-height styling) on the host so the inner virtualizer can
* measure a real scroll height. Variable row heights are measured
* automatically via `ResizeObserver` (TanStack's `measureElement`).
*/
@Component({
selector: 'app-virtual-list',
standalone: true,
imports: [NgTemplateOutlet, VirtualRowMeasureDirective],
templateUrl: './virtual-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'tk-virtual-list block overflow-y-auto',
style: 'contain: strict;'
}
})
export class VirtualListComponent<T> implements AfterViewInit {
@ContentChild('item', { static: true })
protected itemTemplate?: TemplateRef<{ $implicit: T; index: number }>;
@ViewChild('measure', { static: true })
protected readonly measureRef!: ElementRef<HTMLDivElement>;
readonly items = input.required<readonly T[]>();
readonly estimateSize = input<number>(48);
readonly overscan = input<number>(6);
readonly trackBy = input<(index: number, item: T) => string | number>();
readonly paddingStart = input<number>(0);
readonly paddingEnd = input<number>(0);
readonly horizontal = input<boolean>(false);
readonly initialOffset = input<number | undefined>(undefined);
protected readonly virtualizer = injectVirtualizer<HTMLElement, HTMLElement>(() => ({
count: this.items().length,
scrollElement: this.host() ?? undefined,
estimateSize: () => this.estimateSize(),
overscan: this.overscan(),
paddingStart: this.paddingStart(),
paddingEnd: this.paddingEnd(),
horizontal: this.horizontal(),
initialOffset: this.initialOffset(),
getItemKey: (index: number) => {
const track = this.trackBy();
const list = this.items();
if (track && index < list.length)
return track(index, list[index]);
return index;
}
}));
protected readonly virtualItems = this.virtualizer.getVirtualItems;
protected readonly totalSize = this.virtualizer.getTotalSize;
protected readonly translateY = computed(() => {
const first = this.virtualItems()[0];
return first ? first.start : 0;
});
private readonly hostRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly host = signal<HTMLElement | null>(null);
// Force re-measurement when items change identity (e.g. data refresh).
private readonly onItemsChanged = effect(() => {
void this.items();
untracked(() => {
this.virtualizer().measure();
});
});
ngAfterViewInit(): void {
this.host.set(this.hostRef.nativeElement);
}
/** Imperative API: scroll to a given item index. */
scrollToIndex(index: number, align: 'auto' | 'start' | 'center' | 'end' = 'auto'): void {
this.virtualizer().scrollToIndex(index, { align });
}
/** Imperative API: scroll to a raw pixel offset. */
scrollToOffset(offset: number, align: 'auto' | 'start' | 'center' | 'end' = 'auto'): void {
this.virtualizer().scrollToOffset(offset, { align });
}
}

View File

@@ -0,0 +1,35 @@
import {
AfterViewInit,
Directive,
ElementRef,
inject,
input
} from '@angular/core';
import type { AngularVirtualizer } from '@tanstack/angular-virtual';
/**
* Attaches a virtual row to TanStack's `measureElement` so that variable
* row heights are observed via `ResizeObserver` and the virtualizer
* recalculates its size cache automatically (handles late image loads,
* code-block highlighting, reactions added, etc.).
*
* Used internally by `VirtualListComponent`; not exported as a public
* API on the shared barrel.
*/
@Directive({
selector: '[appVirtualRowMeasure]',
standalone: true
})
export class VirtualRowMeasureDirective implements AfterViewInit {
readonly appVirtualRowMeasure = input.required<AngularVirtualizer<HTMLElement, HTMLElement>>();
readonly virtualRowIndex = input.required<number>();
private readonly hostRef = inject<ElementRef<HTMLElement>>(ElementRef);
ngAfterViewInit(): void {
const element = this.hostRef.nativeElement;
element.dataset['index'] = String(this.virtualRowIndex());
this.appVirtualRowMeasure()().measureElement(element);
}
}

View File

@@ -15,3 +15,9 @@ export { ScreenShareSourcePickerComponent } from './components/screen-share-sour
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';
export { ProfileCardComponent } from './components/profile-card/profile-card.component';
export { ProfileCardService } from './components/profile-card/profile-card.service';
export {
SkeletonComponent,
SkeletonListComponent,
SkeletonMessageComponent,
SkeletonCardComponent
} from './components/skeleton';

View File

@@ -0,0 +1,55 @@
/**
* Schedules `work` for an idle moment on the render thread. Prefers
* `requestIdleCallback` when available (Chromium, Electron). Falls back to
* `setTimeout(fn, 0)` so callers can rely on always-eventually scheduling.
*
* Returns a `cancel` function so callers can abort a pending callback.
*/
export function runWhenIdle(work: () => void, timeoutMs = 1500): () => void {
type IdleCallbackHandle = number;
interface IdleDeadline {
didTimeout: boolean;
timeRemaining(): number;
}
type IdleRequest = (handler: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle;
type IdleCancel = (handle: IdleCallbackHandle) => void;
interface MaybeIdleGlobal {
requestIdleCallback?: IdleRequest;
cancelIdleCallback?: IdleCancel;
}
const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as MaybeIdleGlobal;
const idleRequest = idleGlobal.requestIdleCallback;
const idleCancel = idleGlobal.cancelIdleCallback;
if (typeof idleRequest === 'function') {
const handle = idleRequest(() => work(), { timeout: timeoutMs });
return () => {
if (typeof idleCancel === 'function') {
idleCancel(handle);
}
};
}
const fallback = setTimeout(work, 0);
return () => clearTimeout(fallback);
}
/**
* Promise wrapper around `runWhenIdle`. Resolves once `work` finishes
* (or its returned promise resolves). Errors propagate.
*/
export function whenIdle<T>(work: () => T | Promise<T>, timeoutMs = 1500): Promise<T> {
return new Promise<T>((resolve, reject) => {
runWhenIdle(() => {
try {
Promise.resolve(work()).then(resolve, reject);
} catch (err) {
reject(err);
}
}, timeoutMs);
});
}

View File

@@ -0,0 +1,2 @@
export { documentVisible$, visibilityAwareInterval$ } from './visibility';
export { runWhenIdle, whenIdle } from './idle';

View File

@@ -0,0 +1,70 @@
import {
defer,
fromEvent,
map,
Observable,
share,
startWith
} from 'rxjs';
/**
* `document.visibilityState === 'visible'` as an observable.
*
* Emits on every `visibilitychange` event, starting with the current
* value. `share()` is applied so multiple subscribers reuse the single
* underlying DOM listener.
*/
export const documentVisible$ = ((): Observable<boolean> => {
if (typeof document === 'undefined') {
return defer(() => new Observable<boolean>((subscriber) => subscriber.next(true)));
}
return fromEvent(document, 'visibilitychange').pipe(
startWith(null),
map(() => document.visibilityState === 'visible'),
share()
);
})();
/**
* Builds an interval-like observable that only ticks while the document
* is visible. Useful for replacing `setInterval` callbacks that should
* pause when the window is hidden (heartbeat, polling).
*/
export function visibilityAwareInterval$(periodMs: number): Observable<number> {
return new Observable<number>((subscriber) => {
let counter = 0;
let handle: ReturnType<typeof setInterval> | null = null;
const start = (): void => {
if (handle !== null) {
return;
}
handle = setInterval(() => subscriber.next(counter++), periodMs);
};
const stop = (): void => {
if (handle === null) {
return;
}
clearInterval(handle);
handle = null;
};
const handleVisibility = (): void => {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
start();
} else {
stop();
}
};
handleVisibility();
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stop();
document.removeEventListener('visibilitychange', handleVisibility);
};
});
}

View File

@@ -110,7 +110,6 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-a' }]
});
const action = await firstValueFrom(
dispatchIncomingMessage(
{

View File

@@ -573,12 +573,7 @@ function handleSyncSummary(
return from(
(async () => {
const local = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0);
const localCount = local.length;
const localLastUpdated = local.reduce(
(maxTimestamp, message) => Math.max(maxTimestamp, message.editedAt || message.timestamp || 0),
0
);
const { count: localCount, lastUpdated: localLastUpdated } = await db.getRoomMessageStats(targetRoomId);
const remoteLastUpdated = event.lastUpdated || 0;
const remoteCount = event.count || 0;
const identical =

View File

@@ -26,7 +26,6 @@ import {
import {
map,
mergeMap,
catchError,
withLatestFrom,
tap,
filter,
@@ -43,12 +42,9 @@ import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { DebuggingService } from '../../core/services/debugging.service';
import {
INVENTORY_LIMIT,
FULL_SYNC_LIMIT,
SYNC_POLL_FAST_MS,
SYNC_POLL_SLOW_MS,
SYNC_TIMEOUT_MS,
getLatestTimestamp
SYNC_TIMEOUT_MS
} from './messages.helpers';
@Injectable()
@@ -77,13 +73,8 @@ export class MessagesSyncEffects {
if (!room)
return EMPTY;
return from(
this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0)
).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
return from(this.db.getRoomMessageStats(room.id)).pipe(
tap(({ count, lastUpdated }) => {
this.webrtc.sendToPeer(peerId, {
type: 'chat-sync-summary',
roomId: room.id,
@@ -124,11 +115,8 @@ export class MessagesSyncEffects {
return EMPTY;
}
return from(this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
return from(this.db.getRoomMessageStats(activeRoom.id)).pipe(
tap(({ count, lastUpdated }) => {
for (const pid of peers) {
try {
this.webrtc.sendToPeer(pid, {
@@ -202,37 +190,22 @@ export class MessagesSyncEffects {
return of(MessagesActions.syncComplete());
}
return from(
this.db.getMessages(room.id, INVENTORY_LIMIT, 0)
).pipe(
map(() => {
for (const pid of peers) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: room.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error,
peerId: pid,
roomId: room.id
});
}
}
return MessagesActions.startSync();
}),
catchError((error) => {
this.lastSyncClean = false;
this.debugging.warn('messages', 'Periodic sync poll failed', {
error,
for (const pid of peers) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: room.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
error,
peerId: pid,
roomId: room.id
});
}
}
return of(MessagesActions.syncComplete());
})
);
return of(MessagesActions.startSync());
})
)
)

View File

@@ -23,6 +23,15 @@ export const MessagesActions = createActionGroup({
'Load Messages Success': props<{ messages: Message[] }>(),
'Load Messages Failure': props<{ error: string }>(),
/**
* Background-prefetches the initial page of messages for a room without
* touching the active-room `loading` flag. Fired for every saved room
* after `loadRoomsSuccess` so subsequent room navigations resolve from
* the in-memory cache instead of paying an IPC round-trip.
*/
'Prefetch Room Messages': props<{ roomId: string }>(),
'Prefetch Room Messages Success': props<{ messages: Message[] }>(),
/**
* Fetches a page of messages strictly older than `beforeTimestamp` for a
* given conversation (room + channel). Used by the chat scroll-up handler

View File

@@ -25,12 +25,14 @@ import {
mergeMap,
catchError,
withLatestFrom,
switchMap
switchMap,
filter
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from './messages.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { RoomsActions } from '../rooms/rooms.actions';
import { selectMessagesEntities } from './messages.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
@@ -40,6 +42,7 @@ import { AttachmentFacade } from '../../domains/attachment';
import { hasDedicatedChatEmbed } from '../../domains/chat/domain/rules/link-embed.rules';
import { LinkMetadataService } from '../../domains/chat/application/services/link-metadata.service';
import { TimeSyncService } from '../../core/services/time-sync.service';
import { PlatformService } from '../../core/platform';
import {
DELETED_MESSAGE_CONTENT,
Message,
@@ -52,6 +55,8 @@ import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
/** Cap on simultaneous browser-cache prefetches for apps with many saved rooms. */
const PREFETCH_CONCURRENCY = 3;
@Injectable()
export class MessagesEffects {
@@ -63,14 +68,26 @@ export class MessagesEffects {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly timeSync = inject(TimeSyncService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly platform = inject(PlatformService);
/** Loads messages for a room from the local database, hydrating reactions. */
loadMessages$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.loadMessages),
withLatestFrom(this.store.select(selectCurrentRoom)),
switchMap(([{ roomId }, currentRoom]) =>
from(this.loadInitialMessages(roomId, currentRoom)).pipe(
withLatestFrom(
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
switchMap(([
{ roomId },
currentRoom,
savedRooms
]) => {
const targetRoom = currentRoom?.id === roomId
? currentRoom
: savedRooms.find((room) => room.id === roomId) ?? null;
return from(this.loadInitialMessages(roomId, targetRoom)).pipe(
mergeMap(async (messages) => {
const hydrated = await hydrateMessages(messages, this.db);
@@ -78,18 +95,73 @@ export class MessagesEffects {
this.attachments.rememberMessageRoom(message.id, message.roomId);
}
void this.attachments.requestAutoDownloadsForRoom(roomId);
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
}),
catchError((error) =>
of(MessagesActions.loadMessagesFailure({ error: error.message }))
)
);
})
)
);
/**
* Background-prefetch initial messages for every saved room after the
* rooms list loads in browser. Electron avoids this path because startup
* IPC prefetch competes with foreground room switches. Results are merged
* into the messages slice via `upsertMany`, leaving the active-room loading
* flag untouched.
*/
prefetchSavedRoomsOnLoad$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.loadRoomsSuccess),
filter(() => this.platform.isBrowser),
mergeMap(({ rooms }) =>
from(rooms).pipe(
mergeMap(
(room) => of(MessagesActions.prefetchRoomMessages({ roomId: room.id })),
PREFETCH_CONCURRENCY
)
)
)
)
);
prefetchRoomMessages$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.prefetchRoomMessages),
withLatestFrom(this.store.select(selectSavedRooms)),
mergeMap(
([{ roomId }, savedRooms]) =>
from(this.fetchRoomMessagesForPrefetch(roomId, savedRooms.find((room) => room.id === roomId) ?? null)),
PREFETCH_CONCURRENCY
)
)
);
private async fetchRoomMessagesForPrefetch(roomId: string, targetRoom: Room | null) {
try {
const messages = await this.loadInitialMessages(roomId, targetRoom);
const hydrated = await hydrateMessages(messages, this.db);
for (const message of hydrated) {
this.attachments.rememberMessageRoom(message.id, message.roomId);
}
return MessagesActions.prefetchRoomMessagesSuccess({ messages: hydrated });
} catch (error) {
reportDebuggingError(
this.debugging,
'MessagesEffects.prefetchRoomMessages',
'Failed to prefetch room messages',
{ roomId },
error
);
return MessagesActions.prefetchRoomMessagesSuccess({ messages: [] });
}
}
/** Paginates older messages from the local DB for scroll-up history loading. */
loadOlderMessages$ = createEffect(() =>
this.actions$.pipe(
@@ -119,9 +191,9 @@ export class MessagesEffects {
)
);
private async loadInitialMessages(roomId: string, currentRoom: Room | null): Promise<Message[]> {
const textChannels = currentRoom?.id === roomId
? (currentRoom.channels ?? []).filter((channel) => channel.type === 'text')
private async loadInitialMessages(roomId: string, targetRoom: Room | null): Promise<Message[]> {
const textChannels = targetRoom?.id === roomId
? (targetRoom.channels ?? []).filter((channel) => channel.type === 'text')
: [];
if (textChannels.length <= 1) {

View File

@@ -44,33 +44,35 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
export const messagesReducer = createReducer(
initialState,
// Load messages - clear stale messages when switching to a different room
on(MessagesActions.loadMessages, (state, { roomId }) => {
if (state.currentRoomId && state.currentRoomId !== roomId) {
return messagesAdapter.removeAll({
...state,
loading: true,
error: null,
currentRoomId: roomId,
exhaustedConversations: {}
});
}
return {
...state,
loading: true,
error: null,
currentRoomId: roomId
};
}),
// Load messages - keep cached messages from other rooms in the slice so a
// return visit (or a prefetched room) renders immediately from memory. The
// selectors (`selectChannelMessages`, `channelMessages` computed) already
// filter by `currentRoom.id`, so leaving stale rooms in the entity adapter
// is safe. Memory cost is ~30 messages per saved room; tracked at
// /memories/repo/electron-server-switch-performance.md.
on(MessagesActions.loadMessages, (state, { roomId }) => ({
...state,
loading: true,
error: null,
currentRoomId: roomId,
exhaustedConversations: state.currentRoomId === roomId
? state.exhaustedConversations
: {}
})),
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
messagesAdapter.setAll(messages, {
messagesAdapter.upsertMany(messages, {
...state,
loading: false
})
),
// Background prefetch result: merge into the cache without touching the
// active-room loading flag or currentRoomId.
on(MessagesActions.prefetchRoomMessagesSuccess, (state, { messages }) =>
messagesAdapter.upsertMany(messages, state)
),
on(MessagesActions.loadMessagesFailure, (state, { error }) => ({
...state,
loading: false,

View File

@@ -1,5 +1,6 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { MessagesState, messagesAdapter } from './messages.reducer';
import { selectActiveChannelId, selectCurrentRoomId as selectViewedRoomId } from '../rooms/rooms.selectors';
/** Selects the top-level messages feature state. */
export const selectMessagesState = createFeatureSelector<MessagesState>('messages');
@@ -60,25 +61,24 @@ export const selectCurrentRoomId = createSelector(
/** Selects all messages belonging to the currently active room. */
export const selectCurrentRoomMessages = createSelector(
selectAllMessages,
selectCurrentRoomId,
selectViewedRoomId,
(messages, roomId) => roomId ? messages.filter((message) => message.roomId === roomId) : []
);
/** Creates a selector that returns messages for a specific text channel within the current room. */
export const selectChannelMessages = (channelId: string) =>
createSelector(
selectAllMessages,
selectCurrentRoomId,
(messages, roomId) => {
if (!roomId)
return [];
return messages.filter(
(message) => message.roomId === roomId && (message.channelId || 'general') === channelId
);
}
selectCurrentRoomMessages,
(messages) => messages.filter((message) => (message.channelId || 'general') === channelId)
);
/** Selects messages in the currently viewed room and active text channel. */
export const selectActiveChannelMessages = createSelector(
selectCurrentRoomMessages,
selectActiveChannelId,
(messages, channelId) => messages.filter((message) => (message.channelId || 'general') === channelId)
);
/** Creates a selector that returns a single message by its ID. */
export const selectMessageById = (id: string) =>
createSelector(selectMessagesEntities, (entities) => entities[id]);

View File

@@ -417,6 +417,63 @@ export function areRoomMembersEqual(
secondMembers: RoomMember[] = []
): boolean {
const now = Date.now();
const first = pruneRoomMembers(firstMembers, now);
const second = pruneRoomMembers(secondMembers, now);
return JSON.stringify(pruneRoomMembers(firstMembers, now)) === JSON.stringify(pruneRoomMembers(secondMembers, now));
if (first.length !== second.length) {
return false;
}
for (let memberIndex = 0; memberIndex < first.length; memberIndex++) {
if (!areRoomMembersIdentical(first[memberIndex], second[memberIndex])) {
return false;
}
}
return true;
}
function areRoomMembersIdentical(firstMember: RoomMember, secondMember: RoomMember): boolean {
if (firstMember === secondMember) {
return true;
}
return firstMember.id === secondMember.id
&& firstMember.oderId === secondMember.oderId
&& firstMember.username === secondMember.username
&& firstMember.displayName === secondMember.displayName
&& firstMember.role === secondMember.role
&& firstMember.avatarUrl === secondMember.avatarUrl
&& firstMember.avatarHash === secondMember.avatarHash
&& firstMember.avatarMime === secondMember.avatarMime
&& firstMember.avatarUpdatedAt === secondMember.avatarUpdatedAt
&& firstMember.profileUpdatedAt === secondMember.profileUpdatedAt
&& firstMember.description === secondMember.description
&& firstMember.joinedAt === secondMember.joinedAt
&& firstMember.lastSeenAt === secondMember.lastSeenAt
&& areRoleIdsEqual(firstMember.roleIds, secondMember.roleIds);
}
function areRoleIdsEqual(
firstRoleIds: string[] | undefined,
secondRoleIds: string[] | undefined
): boolean {
if (firstRoleIds === secondRoleIds) {
return true;
}
const first = firstRoleIds ?? [];
const second = secondRoleIds ?? [];
if (first.length !== second.length) {
return false;
}
for (let roleIndex = 0; roleIndex < first.length; roleIndex++) {
if (first[roleIndex] !== second[roleIndex]) {
return false;
}
}
return true;
}

View File

@@ -61,7 +61,7 @@ type BlockedRoomAccessAction =
| ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof RoomsActions.joinRoomFailure>;
const VIEW_SERVER_LOAD_DELAY_MS = 75;
const VIEW_SERVER_LOAD_DELAY_MS = 0;
@Injectable()
export class RoomsEffects {
@@ -642,9 +642,11 @@ export class RoomsEffects {
onViewServerSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.viewServerSuccess),
switchMap(({ room }) => timer(VIEW_SERVER_LOAD_DELAY_MS).pipe(
mergeMap(() => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
))
switchMap(({ room }) => VIEW_SERVER_LOAD_DELAY_MS > 0
? timer(VIEW_SERVER_LOAD_DELAY_MS).pipe(
mergeMap(() => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
)
: of(MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()))
)
);