/* eslint-disable @typescript-eslint/member-ordering */ import { Component, ElementRef, HostListener, computed, inject, OnInit, signal, viewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideUsers, lucideCompass, lucidePlus, lucideSearch, lucideArrowRight, lucideTicket, lucideServer, lucideX } from '@ng-icons/lucide'; import { RoomsActions } from '../../store/rooms/rooms.actions'; import { selectSearchResults, selectIsSearching, selectSavedRooms } from '../../store/rooms/rooms.selectors'; import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors'; import type { Room, User } from '../../shared-kernel'; import type { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model'; import { ServerDirectoryFacade } from '../../domains/server-directory/application/facades/server-directory.facade'; import { ViewportService } from '../../core/platform'; import { FriendService } from '../../domains/direct-message/application/services/friend.service'; import { FriendButtonComponent } from '../../domains/direct-message/feature/friend-button/friend-button.component'; import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component'; import { parseInviteQuery } from './invite-query.util'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n'; import { AutoFocusDirective, SelectOnFocusDirective, SubmitOnEnterDirective } from '../../shared/directives'; /** Maximum quick-search rows shown per group on the dashboard. */ const QUICK_RESULT_LIMIT = 5; /** Maximum entries shown in the discovery panels (people / popular / friends / recent servers). */ const DISCOVERY_LIMIT = 5; /** Maximum remembered recent searches. */ const RECENT_SEARCH_LIMIT = 8; /** localStorage key backing the recent-searches list. */ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches'; /** * Application landing page. Presents the three primary actions (find people, find * servers, create a server), a global quick-search across people / servers / invites, * discovery panels (people you might know, popular servers, recently active servers), * and an onboarding state for brand-new accounts. */ @Component({ selector: 'app-dashboard', standalone: true, imports: [ CommonModule, FormsModule, RouterLink, NgIcon, FriendButtonComponent, UserAvatarComponent, AutoFocusDirective, SelectOnFocusDirective, SubmitOnEnterDirective, ...APP_TRANSLATE_IMPORTS ], viewProviders: [ provideIcons({ lucideUsers, lucideCompass, lucidePlus, lucideSearch, lucideArrowRight, lucideTicket, lucideServer, lucideX }) ], templateUrl: './dashboard.component.html', host: { class: 'block h-full min-h-0 min-w-0 w-full overflow-hidden' } }) export class DashboardComponent implements OnInit { private readonly appI18n = inject(AppI18nService); private store = inject(Store); private router = inject(Router); private serverDirectory = inject(ServerDirectoryFacade); private friendsService = inject(FriendService); private readonly viewport = inject(ViewportService); private searchSubject = new Subject(); private readonly searchInputRef = viewChild>('searchInput'); readonly isMobile = this.viewport.isMobile; searchQuery = signal(''); serverResults = this.store.selectSignal(selectSearchResults); isSearching = this.store.selectSignal(selectIsSearching); savedRooms = this.store.selectSignal(selectSavedRooms); currentUser = this.store.selectSignal(selectCurrentUser); popularServers = signal([]); recentSearches = signal(this.loadRecentSearches()); private users = this.store.selectSignal(selectAllUsers); /** True while the user is actively typing a query. */ isSearchMode = computed(() => this.searchQuery().trim().length > 0); /** Server matches limited for the quick-search list. */ topServerResults = computed(() => this.serverResults().slice(0, QUICK_RESULT_LIMIT)); /** Every distinct person known to the account (known users plus saved-room members), excluding self. */ private discoveredPeople = computed(() => { const currentKey = this.currentUserKey(); const byKey = new Map(); for (const user of this.users()) { byKey.set(user.oderId || user.id, user); } for (const room of this.savedRooms()) { for (const member of room.members ?? []) { const key = member.oderId || member.id; if (byKey.has(key)) { continue; } byKey.set(key, { id: member.id, oderId: key, username: member.username, displayName: member.displayName, avatarUrl: member.avatarUrl, status: 'disconnected' } as User); } } return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey); }); /** People matches derived from known users and saved-room members. */ topPeopleResults = computed(() => { const query = this.searchQuery().trim() .toLowerCase(); if (!query) { return []; } return this.discoveredPeople() .filter((user) => this.matchesQuery(user, query)) .slice(0, QUICK_RESULT_LIMIT); }); /** Suggested people for the discovery panel (excludes self and existing friends). */ peopleYouMightKnow = computed(() => { const friendIds = this.friendsService.friendIds(); return this.discoveredPeople() .filter((user) => !friendIds.has(user.oderId || user.id)) .slice(0, DISCOVERY_LIMIT); }); /** People the user has added as friends. */ friends = computed(() => { const friendIds = this.friendsService.friendIds(); return this.discoveredPeople().filter((user) => friendIds.has(user.oderId || user.id)); }); /** Recently joined servers surfaced as horizontal cards. */ recentlyActiveServers = computed(() => this.savedRooms().slice(0, DISCOVERY_LIMIT)); /** Parsed invite when the query looks like an invite code or URL. */ inviteResult = computed(() => parseInviteQuery(this.searchQuery())); /** True when quick-search yielded nothing across every group. */ hasNoQuickResults = computed( () => this.topServerResults().length === 0 && this.topPeopleResults().length === 0 && !this.inviteResult() ); /** True for a brand-new account with no servers and no known people. */ isNewUser = computed(() => this.savedRooms().length === 0 && this.users().length === 0); ngOnInit(): void { this.store.dispatch(RoomsActions.loadRooms()); this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => { this.store.dispatch(RoomsActions.searchServers({ query })); }); this.serverDirectory.getFeaturedServers(DISCOVERY_LIMIT).subscribe((servers) => { if (servers.length > 0) { this.popularServers.set(servers); return; } this.serverDirectory.getTrendingServers(DISCOVERY_LIMIT).subscribe((trending) => this.popularServers.set(trending)); }); } @HostListener('document:keydown', ['$event']) onGlobalKeydown(event: KeyboardEvent): void { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); this.searchInputRef()?.nativeElement.focus(); } } onSearchChange(query: string): void { this.searchQuery.set(query); this.searchSubject.next(query); } submitSearch(): void { this.rememberSearch(this.searchQuery()); } applyRecentSearch(term: string): void { this.onSearchChange(term); this.rememberSearch(term); this.searchInputRef()?.nativeElement.focus(); } removeRecentSearch(term: string): void { this.recentSearches.update((terms) => terms.filter((entry) => entry !== term)); this.persistRecentSearches(); } clearRecentSearches(): void { this.recentSearches.set([]); this.persistRecentSearches(); } openServer(server: ServerInfo): void { const joined = this.savedRooms().find((room) => room.id === server.id); if (joined) { this.store.dispatch(RoomsActions.viewServer({ room: joined })); return; } this.router.navigate(['/servers']); } openSavedRoom(room: Room): void { this.store.dispatch(RoomsActions.viewServer({ room })); } openInvite(): void { const invite = this.inviteResult(); if (!invite) { return; } this.router.navigate(['/invite', invite.inviteId], { queryParams: invite.sourceUrl ? { server: invite.sourceUrl } : undefined }); } serverInitial(server: ServerInfo): string { return server.name.trim()[0]?.toUpperCase() || '?'; } serverMetaLabel(server: ServerInfo): string { const count = server.userCount ?? 0; const members = this.appI18n.instant( count === 1 ? 'dashboard.serverMeta.member' : 'dashboard.serverMeta.members', { count } ); const detail = server.description?.trim(); return detail ? `${members} • ${detail}` : members; } personLabel(user: User): string { return user.displayName || user.username || user.oderId || user.id; } isOnline(user: User): boolean { return user.isOnline === true || [ 'online', 'away', 'busy' ].includes(user.status); } private rememberSearch(rawTerm: string): void { const term = rawTerm.trim(); if (!term) { return; } this.recentSearches.update((terms) => [term, ...terms.filter((entry) => entry !== term)].slice(0, RECENT_SEARCH_LIMIT)); this.persistRecentSearches(); } private loadRecentSearches(): string[] { if (typeof localStorage === 'undefined') { return []; } try { const stored = localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY); const parsed: unknown = stored ? JSON.parse(stored) : []; return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : []; } catch { return []; } } private persistRecentSearches(): void { if (typeof localStorage === 'undefined') { return; } try { localStorage.setItem(RECENT_SEARCHES_STORAGE_KEY, JSON.stringify(this.recentSearches())); } catch { // Persistence is best-effort; ignore storage failures. } } private currentUserKey(): string { const currentUser = this.currentUser(); return currentUser ? currentUser.oderId || currentUser.id : ''; } private matchesQuery(user: User, query: string): boolean { return [ user.displayName, user.username, user.oderId ] .filter((value): value is string => typeof value === 'string') .some((value) => value.toLowerCase().includes(query)); } }