Files
Toju/toju-app/src/app/features/dashboard/dashboard.component.ts

361 lines
11 KiB
TypeScript

/* 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<string>();
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('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<ServerInfo[]>([]);
recentSearches = signal<string[]>(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<User[]>(() => {
const currentKey = this.currentUserKey();
const byKey = new Map<string, User>();
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<User[]>(() => {
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<User[]>(() => {
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<User[]>(() => {
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<Room[]>(() => 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));
}
}