wip: optimizations
This commit is contained in:
48
toju-app/AGENTS.md
Normal file
48
toju-app/AGENTS.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="chat-layout relative h-full"
|
||||
>
|
||||
<app-chat-message-list
|
||||
[allMessages]="allMessages()"
|
||||
[allMessages]="roomMessages()"
|
||||
[channelMessages]="channelMessages()"
|
||||
[loading]="loading()"
|
||||
[syncing]="syncing()"
|
||||
|
||||
@@ -19,7 +19,8 @@ import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import {
|
||||
selectAllMessages,
|
||||
selectActiveChannelMessages,
|
||||
selectCurrentRoomMessages,
|
||||
selectConversationExhausted,
|
||||
selectMessagesLoading,
|
||||
selectMessagesLoadingOlder,
|
||||
@@ -70,7 +71,8 @@ export class ChatMessagesComponent {
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||
readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
||||
readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
@@ -80,13 +82,6 @@ export class ChatMessagesComponent {
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
readonly channelMessages = computed(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
|
||||
});
|
||||
|
||||
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
||||
readonly conversationExhausted = toSignal(
|
||||
toObservable(this.conversationKey).pipe(
|
||||
|
||||
@@ -43,9 +43,7 @@ import {
|
||||
} from '../../../../../attachment';
|
||||
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import {
|
||||
ExperimentalMediaSettingsService
|
||||
} from '../../../../../experimental-media';
|
||||
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
|
||||
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
|
||||
import { KlipyService } from '../../../../application/services/klipy.service';
|
||||
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
|
||||
@@ -776,7 +774,6 @@ export class ChatMessageItemComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
const element = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
|
||||
|
||||
const canPlay = element.canPlayType(mime) !== '';
|
||||
|
||||
this.mediaSupportCache.set(mime, canPlay);
|
||||
|
||||
@@ -12,7 +12,15 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
@if (refreshLoading()) {
|
||||
<div class="pointer-events-none sticky top-0 z-10 flex justify-center py-1">
|
||||
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (initialLoading()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,9 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
return all.slice(all.length - limit);
|
||||
});
|
||||
|
||||
readonly initialLoading = computed(() => this.loading() && this.messages().length === 0);
|
||||
readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0);
|
||||
|
||||
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
|
||||
|
||||
readonly dateSeparatorLabels = computed(() => {
|
||||
|
||||
@@ -87,9 +87,9 @@
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectGif(gif)"
|
||||
[class]="isMobile()
|
||||
[class]="(isMobile()
|
||||
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
|
||||
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'"
|
||||
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30') + ' [content-visibility:auto] [contain-intrinsic-size:auto_180px]'"
|
||||
>
|
||||
<div
|
||||
class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
|
||||
|
||||
@@ -88,7 +88,11 @@ describe('DirectCallService', () => {
|
||||
});
|
||||
|
||||
it('ignores incoming call events when the current user is not a participant', async () => {
|
||||
const context = createServiceContext({ currentUser: charlie, allUsers: [alice, bob, charlie] });
|
||||
const context = createServiceContext({ currentUser: charlie, allUsers: [
|
||||
alice,
|
||||
bob,
|
||||
charlie
|
||||
] });
|
||||
|
||||
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { DmChatComponent } from '../dm-chat/dm-chat.component';
|
||||
|
||||
@@ -22,19 +22,29 @@
|
||||
|
||||
<div
|
||||
appThemeNode="dmConversationList"
|
||||
class="min-h-0 flex-1 overflow-y-auto p-2"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
@if (directMessages.conversations().length === 0) {
|
||||
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
||||
} @else {
|
||||
<div class="space-y-1">
|
||||
@for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
|
||||
<app-virtual-list
|
||||
class="block h-full p-2"
|
||||
[items]="directMessages.conversations()"
|
||||
[estimateSize]="64"
|
||||
[overscan]="6"
|
||||
[trackBy]="trackConversationId"
|
||||
>
|
||||
<ng-template
|
||||
#item
|
||||
let-conversation
|
||||
>
|
||||
<app-dm-conversation-item
|
||||
class="block pb-1"
|
||||
[conversation]="conversation"
|
||||
(conversationOpened)="conversationSelected.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-virtual-list>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle } from '@ng-icons/lucide';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { VoiceControlsComponent } from '../../../voice-session';
|
||||
import { VirtualListComponent } from '../../../../shared/components/virtual-list';
|
||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DmConversationItemComponent } from './dm-conversation-item.component';
|
||||
@@ -22,6 +23,7 @@ import { DmConversationItemComponent } from './dm-conversation-item.component';
|
||||
DmConversationItemComponent,
|
||||
NgIcon,
|
||||
ThemeNodeDirective,
|
||||
VirtualListComponent,
|
||||
VoiceControlsComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle })],
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="space-y-1.5">
|
||||
@for (user of friendResults(); track user.id) {
|
||||
<div
|
||||
class="group flex items-center gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-2"
|
||||
class="group flex items-center gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-2 [content-visibility:auto] [contain-intrinsic-size:auto_60px]"
|
||||
[attr.data-testid]="'friend-card-' + userKey(user)"
|
||||
>
|
||||
<app-user-avatar
|
||||
@@ -86,7 +86,7 @@
|
||||
<div class="space-y-1.5">
|
||||
@for (user of results(); track user.id) {
|
||||
<div
|
||||
class="group flex items-center gap-2 rounded-lg border border-border bg-card p-2 transition-colors hover:bg-card/80"
|
||||
class="group flex items-center gap-2 rounded-lg border border-border bg-card p-2 transition-colors hover:bg-card/80 [content-visibility:auto] [contain-intrinsic-size:auto_64px]"
|
||||
[attr.data-testid]="'user-card-' + userKey(user)"
|
||||
>
|
||||
<app-user-avatar
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
|
||||
import type { Friend } from '../domain/models/direct-message.model';
|
||||
|
||||
const STORAGE_PREFIX = 'metoyou_friends';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FriendRepository {
|
||||
private readonly storage = jsonStorage;
|
||||
|
||||
async loadFriends(ownerId: string): Promise<Friend[]> {
|
||||
return this.read(ownerId);
|
||||
}
|
||||
@@ -24,23 +27,13 @@ export class FriendRepository {
|
||||
}
|
||||
|
||||
private read(ownerId: string): Friend[] {
|
||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
||||
const parsed = this.storage.read<Friend[]>(this.key(ownerId), []);
|
||||
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as Friend[];
|
||||
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
private write(ownerId: string, friends: Friend[]): void {
|
||||
localStorage.setItem(this.key(ownerId), JSON.stringify(friends));
|
||||
this.storage.write(this.key(ownerId), friends);
|
||||
}
|
||||
|
||||
private key(ownerId: string): string {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
|
||||
|
||||
const STORAGE_PREFIX = 'metoyou_direct_message_queue';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OfflineQueueRepository {
|
||||
private readonly storage = jsonStorage;
|
||||
|
||||
async load(ownerId: string): Promise<string[]> {
|
||||
return this.read(ownerId);
|
||||
}
|
||||
@@ -24,23 +27,13 @@ export class OfflineQueueRepository {
|
||||
}
|
||||
|
||||
private read(ownerId: string): string[] {
|
||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
||||
const parsed = this.storage.read<string[]>(this.key(ownerId), []);
|
||||
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as string[];
|
||||
|
||||
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
|
||||
}
|
||||
|
||||
private write(ownerId: string, messageIds: string[]): void {
|
||||
localStorage.setItem(this.key(ownerId), JSON.stringify(messageIds));
|
||||
this.storage.write(this.key(ownerId), messageIds);
|
||||
}
|
||||
|
||||
private key(ownerId: string): string {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { ExperimentalVlcRuntimeService } from '../../infrastructure/services/experimental-vlc-runtime.service';
|
||||
|
||||
const STORAGE_KEY_EXPERIMENTAL_MEDIA_SETTINGS = 'metoyou_experimental_media_settings';
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import {
|
||||
ExperimentalVlcPlayerHandle,
|
||||
ExperimentalVlcRuntimeService
|
||||
} from '../../infrastructure/services/experimental-vlc-runtime.service';
|
||||
import { ExperimentalVlcPlayerHandle, ExperimentalVlcRuntimeService } from '../../infrastructure/services/experimental-vlc-runtime.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-experimental-vlc-player',
|
||||
@@ -91,6 +88,7 @@ export class ExperimentalVlcPlayerComponent implements AfterViewInit, OnDestroy
|
||||
filename: this.filename(),
|
||||
mime: this.mime()
|
||||
});
|
||||
|
||||
this.status.set('ready');
|
||||
} catch (error) {
|
||||
this.status.set('error');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subscription, firstValueFrom } from 'rxjs';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { jsonStorage } from '../../../infrastructure/persistence/json-storage.service';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../server-directory';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
@@ -88,6 +89,7 @@ const IGNORED_PROCESS_PATTERNS = [
|
||||
export class GameActivityService implements OnDestroy {
|
||||
private readonly electron = inject(ElectronBridgeService);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly jsonStorage = jsonStorage;
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly store = inject(Store);
|
||||
@@ -385,13 +387,9 @@ export class GameActivityService implements OnDestroy {
|
||||
}
|
||||
|
||||
private readMatchCache(): Record<string, CachedGameMatch> {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(GAME_MATCH_CACHE_STORAGE_KEY) ?? '{}') as unknown;
|
||||
const parsed = this.jsonStorage.read<unknown>(GAME_MATCH_CACHE_STORAGE_KEY, null);
|
||||
|
||||
return this.normalizeMatchCache(parsed);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return this.normalizeMatchCache(parsed);
|
||||
}
|
||||
|
||||
private normalizeMatchCache(value: unknown): Record<string, CachedGameMatch> {
|
||||
@@ -465,7 +463,7 @@ export class GameActivityService implements OnDestroy {
|
||||
.sort((left, right) => right[1].expiresAt - left[1].expiresAt)
|
||||
.slice(0, MAX_LOCAL_CACHE_ENTRIES);
|
||||
|
||||
localStorage.setItem(GAME_MATCH_CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
|
||||
this.jsonStorage.write(GAME_MATCH_CACHE_STORAGE_KEY, Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
private normalizeCacheKey(value: string): string {
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../../core/constants';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotificationSettingsStorageService {
|
||||
private readonly storage = jsonStorage;
|
||||
|
||||
load(): NotificationsSettings {
|
||||
const fallback = createDefaultNotificationSettings();
|
||||
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_SETTINGS);
|
||||
const parsed = this.storage.read<Partial<NotificationsSettings> | null>(
|
||||
STORAGE_KEY_NOTIFICATION_SETTINGS,
|
||||
null
|
||||
);
|
||||
|
||||
if (!raw) {
|
||||
if (!parsed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<NotificationsSettings>;
|
||||
|
||||
return {
|
||||
enabled: parsed.enabled ?? fallback.enabled,
|
||||
showPreview: parsed.showPreview ?? fallback.showPreview,
|
||||
respectBusyStatus: parsed.respectBusyStatus ?? fallback.respectBusyStatus,
|
||||
mutedRooms: normaliseBooleanRecord(parsed.mutedRooms),
|
||||
mutedChannels: normaliseNestedBooleanRecord(parsed.mutedChannels),
|
||||
roomBaselines: normaliseNumberRecord(parsed.roomBaselines),
|
||||
lastReadByChannel: normaliseNestedNumberRecord(parsed.lastReadByChannel)
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
enabled: parsed.enabled ?? fallback.enabled,
|
||||
showPreview: parsed.showPreview ?? fallback.showPreview,
|
||||
respectBusyStatus: parsed.respectBusyStatus ?? fallback.respectBusyStatus,
|
||||
mutedRooms: normaliseBooleanRecord(parsed.mutedRooms),
|
||||
mutedChannels: normaliseNestedBooleanRecord(parsed.mutedChannels),
|
||||
roomBaselines: normaliseNumberRecord(parsed.roomBaselines),
|
||||
lastReadByChannel: normaliseNestedNumberRecord(parsed.lastReadByChannel)
|
||||
};
|
||||
}
|
||||
|
||||
save(settings: NotificationsSettings): void {
|
||||
localStorage.setItem(STORAGE_KEY_NOTIFICATION_SETTINGS, JSON.stringify(settings));
|
||||
this.storage.write(STORAGE_KEY_NOTIFICATION_SETTINGS, settings);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { environment } from '../../../../../environments/environment';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import type {
|
||||
PluginRequirementSummary,
|
||||
TojuPluginInstallScope,
|
||||
@@ -72,6 +73,7 @@ export class PluginStoreService {
|
||||
private readonly desktopState = inject(PluginDesktopStateService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly host = inject(PluginHostService);
|
||||
private readonly jsonStorage = jsonStorage;
|
||||
private readonly pluginRequirements = inject(PluginRequirementService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade, { optional: true });
|
||||
private readonly registry = inject(PluginRegistryService);
|
||||
@@ -947,17 +949,16 @@ export class PluginStoreService {
|
||||
}
|
||||
|
||||
private loadState(): PersistedPluginStoreState {
|
||||
try {
|
||||
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
|
||||
const parsed = this.jsonStorage.read<unknown>(
|
||||
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE),
|
||||
null
|
||||
);
|
||||
|
||||
if (!raw) {
|
||||
return createDefaultStoreState();
|
||||
}
|
||||
|
||||
return normalizePersistedState(JSON.parse(raw) as unknown);
|
||||
} catch {
|
||||
if (!parsed) {
|
||||
return createDefaultStoreState();
|
||||
}
|
||||
|
||||
return normalizePersistedState(parsed);
|
||||
}
|
||||
|
||||
private saveState(): void {
|
||||
@@ -969,10 +970,7 @@ export class PluginStoreService {
|
||||
sourceUrls: this.sourceUrls()
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
|
||||
} catch {}
|
||||
|
||||
this.jsonStorage.write(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), state);
|
||||
void this.desktopState.writeJson(STORAGE_KEY_PLUGIN_STORE, state);
|
||||
}
|
||||
|
||||
@@ -984,7 +982,7 @@ export class PluginStoreService {
|
||||
}
|
||||
|
||||
const normalized = normalizePersistedState(state);
|
||||
const sourceUrlsChanged = JSON.stringify(normalized.sourceUrls) !== JSON.stringify(this.sourceUrls());
|
||||
const sourceUrlsChanged = !areStringArraysEqual(normalized.sourceUrls, this.sourceUrls());
|
||||
|
||||
if (sourceUrlsChanged) {
|
||||
this.sourceUrlsSignal.set(normalized.sourceUrls);
|
||||
@@ -1107,7 +1105,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
|
||||
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
|
||||
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
|
||||
id,
|
||||
imageUrl: normalizeImageUrl(resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner'))),
|
||||
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
|
||||
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
|
||||
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
|
||||
scope: readPluginInstallScope(value),
|
||||
@@ -1128,6 +1126,24 @@ function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInst
|
||||
return scope === 'server' || scope === 'client' ? scope : undefined;
|
||||
}
|
||||
|
||||
function areStringArraysEqual(first: readonly string[], second: readonly string[]): boolean {
|
||||
if (first === second) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first.length !== second.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let arrayIndex = 0; arrayIndex < first.length; arrayIndex++) {
|
||||
if (first[arrayIndex] !== second[arrayIndex]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
|
||||
if (!isRecord(value)) {
|
||||
return createDefaultStoreState();
|
||||
@@ -1300,44 +1316,6 @@ function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites human-friendly GitHub URLs so the browser can load the underlying
|
||||
* binary asset. Users typically paste links copied from the GitHub web UI which
|
||||
* point at the rendered HTML preview (`github.com/<owner>/<repo>/blob/...`) or
|
||||
* the raw redirector (`github.com/<owner>/<repo>/raw/...`). Both forms must be
|
||||
* mapped to `raw.githubusercontent.com` for `<img>` tags to work.
|
||||
*/
|
||||
function normalizeImageUrl(rawUrl: string | undefined): string | undefined {
|
||||
if (!rawUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
const kindIndex = segments.findIndex((segment) => segment === 'blob' || segment === 'raw');
|
||||
|
||||
if (kindIndex < 2 || kindIndex >= segments.length - 1) {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const owner = segments[0];
|
||||
const repo = segments[1];
|
||||
const ref = segments.slice(kindIndex + 1).join('/');
|
||||
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}${url.search}`;
|
||||
}
|
||||
|
||||
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
|
||||
if (!rawUrl) {
|
||||
return undefined;
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
@if (filteredPlugins().length > 0) {
|
||||
<div class="grid gap-3">
|
||||
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
|
||||
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)]">
|
||||
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)] [content-visibility:auto] [contain-intrinsic-size:auto_140px]">
|
||||
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
|
||||
@if (plugin.imageUrl && !hasBrokenImage(plugin)) {
|
||||
<img
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store as NgRxStore } from '@ngrx/store';
|
||||
import { debounceTime } from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideArrowLeft,
|
||||
@@ -103,7 +105,7 @@ export class PluginStoreComponent implements OnInit {
|
||||
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
|
||||
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
|
||||
readonly filteredPlugins = computed(() => {
|
||||
const searchTerm = this.searchTerm().trim()
|
||||
const searchTerm = this.debouncedSearchTerm().trim()
|
||||
.toLowerCase();
|
||||
const sourceFilter = this.selectedSourceUrl();
|
||||
const showInstalled = this.showInstalledOnly();
|
||||
@@ -157,6 +159,16 @@ export class PluginStoreComponent implements OnInit {
|
||||
readonly serverInstallBusy = signal(false);
|
||||
readonly brokenImageKeys = signal<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Debounced search term used by `filteredPlugins`. Keeps each keystroke
|
||||
* from forcing a full re-filter of the (potentially large) plugin catalog
|
||||
* while still letting the input update its bound model instantly.
|
||||
*/
|
||||
protected readonly debouncedSearchTerm = toSignal(
|
||||
toObservable(this.searchTerm).pipe(debounceTime(200)),
|
||||
{ initialValue: '' }
|
||||
);
|
||||
|
||||
private destroyed = false;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly externalLinks = inject(ExternalLinkService);
|
||||
|
||||
@@ -153,10 +153,10 @@
|
||||
/>
|
||||
|
||||
<section
|
||||
class="min-h-0 overflow-y-auto"
|
||||
class="flex min-h-0 flex-col"
|
||||
[class.hidden]="isMobile() && mobileTab() !== 'servers'"
|
||||
>
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
|
||||
<div class="z-10 flex shrink-0 items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">Servers</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ searchResults().length }} found</p>
|
||||
@@ -164,11 +164,11 @@
|
||||
</div>
|
||||
|
||||
@if (isSearching()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="flex flex-1 items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if (searchResults().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-4 py-10 text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="mb-3 h-10 w-10 opacity-50"
|
||||
@@ -176,10 +176,20 @@
|
||||
<p class="text-sm font-medium">No servers found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-2 p-3">
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<div
|
||||
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
|
||||
<app-virtual-list
|
||||
class="block min-h-0 flex-1 p-3"
|
||||
[items]="searchResults()"
|
||||
[estimateSize]="140"
|
||||
[overscan]="4"
|
||||
[trackBy]="trackServerById"
|
||||
>
|
||||
<ng-template
|
||||
#item
|
||||
let-server
|
||||
>
|
||||
<div class="pb-2">
|
||||
<div
|
||||
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
|
||||
[class.border-border]="!isServerMarkedBanned(server)"
|
||||
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
||||
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
||||
@@ -315,8 +325,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-virtual-list>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||
import { hasRoomBanForUser } from '../../../access-control';
|
||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
||||
import { VirtualListComponent } from '../../../../shared/components/virtual-list';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import {
|
||||
PluginRequirementService,
|
||||
@@ -82,7 +83,8 @@ interface JoinPluginConsentDialog {
|
||||
ChatMessageMarkdownComponent,
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
UserSearchListComponent
|
||||
UserSearchListComponent,
|
||||
VirtualListComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -187,6 +189,9 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.searchSubject.next(query);
|
||||
}
|
||||
|
||||
/** Stable trackBy reference for the virtualized server results list. */
|
||||
readonly trackServerById = (_index: number, server: ServerInfo): string => server.id;
|
||||
|
||||
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
||||
async joinServer(server: ServerInfo): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import {
|
||||
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
|
||||
REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
|
||||
@@ -8,26 +9,16 @@ import type { ServerEndpoint } from '../../domain/models/server-directory.model'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerEndpointStorageService {
|
||||
private readonly storage = jsonStorage;
|
||||
|
||||
loadEndpoints(): ServerEndpoint[] | null {
|
||||
const stored = localStorage.getItem(SERVER_ENDPOINTS_STORAGE_KEY);
|
||||
const parsed = this.storage.read<unknown>(SERVER_ENDPOINTS_STORAGE_KEY, null);
|
||||
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed as ServerEndpoint[]
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return Array.isArray(parsed) ? parsed as ServerEndpoint[] : null;
|
||||
}
|
||||
|
||||
saveEndpoints(endpoints: ServerEndpoint[]): void {
|
||||
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
|
||||
this.storage.write(SERVER_ENDPOINTS_STORAGE_KEY, endpoints);
|
||||
}
|
||||
|
||||
loadDisabledDefaultEndpointKeys(): Set<string> {
|
||||
@@ -39,7 +30,7 @@ export class ServerEndpointStorageService {
|
||||
}
|
||||
|
||||
clearDisabledDefaultEndpointKeys(): void {
|
||||
localStorage.removeItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
|
||||
this.storage.remove(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
loadRemovedDefaultEndpointKeys(): Set<string> {
|
||||
@@ -51,35 +42,25 @@ export class ServerEndpointStorageService {
|
||||
}
|
||||
|
||||
clearRemovedDefaultEndpointKeys(): void {
|
||||
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
|
||||
this.storage.remove(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private loadStringSet(storageKey: string): Set<string> {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
const parsed = this.storage.read<unknown>(storageKey, null);
|
||||
|
||||
if (!stored) {
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
|
||||
}
|
||||
|
||||
private saveStringSet(storageKey: string, keys: Set<string>): void {
|
||||
if (keys.size === 0) {
|
||||
localStorage.removeItem(storageKey);
|
||||
this.storage.remove(storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify([...keys]));
|
||||
this.storage.write(storageKey, [...keys]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
|
||||
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
|
||||
import { ScreenShareFacade } from '../../../../domains/screen-share';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
@@ -38,6 +39,7 @@ interface PeerAudioPipeline {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoicePlaybackService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly jsonStorage = jsonStorage;
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
@@ -401,33 +403,31 @@ export class VoicePlaybackService {
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_USER_VOLUMES, JSON.stringify(data));
|
||||
this.jsonStorage.write(STORAGE_KEY_USER_VOLUMES, data);
|
||||
} catch {
|
||||
// localStorage not available
|
||||
// storage not available
|
||||
}
|
||||
}
|
||||
|
||||
private loadPersistedVolumes(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_USER_VOLUMES);
|
||||
const data = this.jsonStorage.read<Record<string, { volume: number; muted: boolean }> | null>(
|
||||
STORAGE_KEY_USER_VOLUMES,
|
||||
null
|
||||
);
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const data = JSON.parse(raw) as Record<string, { volume: number; muted: boolean }>;
|
||||
|
||||
Object.entries(data).forEach(([id, entry]) => {
|
||||
if (typeof entry.volume === 'number') {
|
||||
this.userVolumes.set(id, entry.volume);
|
||||
}
|
||||
|
||||
if (entry.muted) {
|
||||
this.userMuted.set(id, true);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// corrupted data - ignore
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(data).forEach(([id, entry]) => {
|
||||
if (typeof entry.volume === 'number') {
|
||||
this.userVolumes.set(id, entry.volume);
|
||||
}
|
||||
|
||||
if (entry.muted) {
|
||||
this.userMuted.set(id, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hasAudio(stream: MediaStream): boolean {
|
||||
|
||||
@@ -35,15 +35,23 @@ export const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
||||
};
|
||||
|
||||
export function loadVoiceSettingsFromStorage(): VoiceSettings {
|
||||
if (cachedSettings) {
|
||||
return { ...cachedSettings };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
|
||||
if (!raw)
|
||||
return { ...DEFAULT_VOICE_SETTINGS };
|
||||
if (!raw) {
|
||||
cachedSettings = { ...DEFAULT_VOICE_SETTINGS };
|
||||
return { ...cachedSettings };
|
||||
}
|
||||
|
||||
return normaliseVoiceSettings(JSON.parse(raw) as Partial<VoiceSettings>);
|
||||
cachedSettings = normaliseVoiceSettings(JSON.parse(raw) as Partial<VoiceSettings>);
|
||||
return { ...cachedSettings };
|
||||
} catch {
|
||||
return { ...DEFAULT_VOICE_SETTINGS };
|
||||
cachedSettings = { ...DEFAULT_VOICE_SETTINGS };
|
||||
return { ...cachedSettings };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +61,60 @@ export function saveVoiceSettingsToStorage(patch: Partial<VoiceSettings>): Voice
|
||||
...patch
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(nextSettings));
|
||||
} catch {}
|
||||
cachedSettings = nextSettings;
|
||||
schedulePersist(nextSettings);
|
||||
|
||||
return nextSettings;
|
||||
return { ...nextSettings };
|
||||
}
|
||||
|
||||
let cachedSettings: VoiceSettings | null = null;
|
||||
let pendingSettings: VoiceSettings | null = null;
|
||||
let persistScheduled = false;
|
||||
|
||||
function schedulePersist(settings: VoiceSettings): void {
|
||||
pendingSettings = settings;
|
||||
|
||||
if (persistScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistScheduled = true;
|
||||
|
||||
const runner = (): void => {
|
||||
persistScheduled = false;
|
||||
|
||||
if (!pendingSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toPersist = pendingSettings;
|
||||
|
||||
pendingSettings = null;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(toPersist));
|
||||
} catch {
|
||||
// ignore quota / privacy errors
|
||||
}
|
||||
};
|
||||
|
||||
type IdleCallbackHandle = number;
|
||||
interface IdleDeadline {
|
||||
didTimeout: boolean;
|
||||
timeRemaining(): number;
|
||||
}
|
||||
type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle;
|
||||
interface MaybeIdleGlobal {
|
||||
requestIdleCallback?: IdleRequest;
|
||||
}
|
||||
|
||||
const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as MaybeIdleGlobal;
|
||||
|
||||
if (typeof idleGlobal.requestIdleCallback === 'function') {
|
||||
idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 });
|
||||
} else {
|
||||
setTimeout(runner, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseVoiceSettings(raw: Partial<VoiceSettings>): VoiceSettings {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
4
toju-app/src/app/shared/components/skeleton/index.ts
Normal file
4
toju-app/src/app/shared/components/skeleton/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
@@ -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))`);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()) }));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()) }));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<span
|
||||
[class]="classes()"
|
||||
[style.width]="width()"
|
||||
[style.height]="height()"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
@@ -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`;
|
||||
});
|
||||
}
|
||||
2
toju-app/src/app/shared/components/virtual-list/index.ts
Normal file
2
toju-app/src/app/shared/components/virtual-list/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { VirtualListComponent } from './virtual-list.component';
|
||||
export { VirtualRowMeasureDirective } from './virtual-row-measure.directive';
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
55
toju-app/src/app/shared/rxjs/idle.ts
Normal file
55
toju-app/src/app/shared/rxjs/idle.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
2
toju-app/src/app/shared/rxjs/index.ts
Normal file
2
toju-app/src/app/shared/rxjs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { documentVisible$, visibilityAwareInterval$ } from './visibility';
|
||||
export { runWhenIdle, whenIdle } from './idle';
|
||||
70
toju-app/src/app/shared/rxjs/visibility.ts
Normal file
70
toju-app/src/app/shared/rxjs/visibility.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -110,7 +110,6 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
|
||||
currentRoom: { id: 'room-a' },
|
||||
savedRooms: [{ id: 'room-a' }]
|
||||
});
|
||||
|
||||
const action = await firstValueFrom(
|
||||
dispatchIncomingMessage(
|
||||
{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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());
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user