361 lines
11 KiB
TypeScript
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));
|
|
}
|
|
}
|