feat: dashboard

This commit is contained in:
2026-06-05 01:25:16 +02:00
parent 147858de2f
commit 2f6c52e73c
73 changed files with 3490 additions and 1061 deletions

View File

@@ -0,0 +1,428 @@
<ng-template #pageContent>
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
<header class="space-y-1">
<h1 class="text-2xl font-semibold text-foreground">
@if (currentUser()) {
Welcome back, {{ currentUser()!.displayName || 'there' }}
} @else {
Welcome to MetoYou
}
</h1>
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
</header>
<div>
<div class="relative">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
aria-label="Search people, servers, and invites"
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search for people, servers, or paste an invite..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
/>
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex">
Ctrl K
</kbd>
</div>
@if (!isSearchMode() && recentSearches().length > 0) {
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-muted-foreground">Recent:</span>
@for (term of recentSearches(); track term) {
<span class="group inline-flex items-center gap-1 rounded-full border border-border bg-secondary py-1 pl-3 pr-1 text-xs text-foreground">
<button
type="button"
class="max-w-[10rem] truncate hover:text-primary"
(click)="applyRecentSearch(term)"
>
{{ term }}
</button>
<button
type="button"
class="grid h-4 w-4 place-items-center rounded-full text-muted-foreground hover:bg-card hover:text-foreground"
[attr.aria-label]="'Remove ' + term"
(click)="removeRecentSearch(term)"
>
<ng-icon
name="lucideX"
class="h-3 w-3"
/>
</button>
</span>
}
<button
type="button"
class="text-xs font-medium text-muted-foreground hover:text-foreground hover:underline"
(click)="clearRecentSearches()"
>
Clear
</button>
</div>
}
</div>
@if (isSearchMode()) {
<section class="space-y-5">
@if (inviteResult(); as invite) {
<div>
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invite</h2>
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openInvite()"
>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<ng-icon
name="lucideTicket"
class="h-5 w-5"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">Open invite</p>
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
/>
</button>
</div>
}
@if (topServerResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>
</div>
<div class="space-y-2">
@for (server of topServerResults(); track server.id) {
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openServer(server)"
>
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
@if (server.icon) {
<div
aria-hidden="true"
class="h-full w-full bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + server.icon + ')'"
></div>
} @else {
{{ serverInitial(server) }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
</div>
</div>
}
@if (topPeopleResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">People</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>
</div>
<div class="space-y-2">
@for (person of topPeopleResults(); track person.id) {
<a
routerLink="/people"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
>
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
size="md"
[status]="person.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
</a>
}
</div>
</div>
}
@if (hasNoQuickResults() && !isSearching()) {
<div class="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground">
No people, servers, or invites match
<span class="font-medium text-foreground">{{ searchQuery() }}</span
>.
</div>
}
</section>
} @else {
<!-- Primary actions -->
<section class="grid gap-3 sm:grid-cols-3">
<a
routerLink="/people"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
<ng-icon
name="lucideUsers"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find People</p>
<p class="mt-0.5 text-xs text-muted-foreground">Connect with friends.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/servers"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
<ng-icon
name="lucideCompass"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find Servers</p>
<p class="mt-0.5 text-xs text-muted-foreground">Browse communities.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/create-server"
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
<ng-icon
name="lucidePlus"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Create Server</p>
<p class="mt-0.5 text-xs text-muted-foreground">Start your own.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-emerald-400 transition-transform group-hover:translate-x-0.5"
/>
</a>
</section>
@if (isNewUser()) {
<section class="rounded-xl border border-border bg-card p-6 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-secondary">
<ng-icon
name="lucideServer"
class="h-6 w-6 text-muted-foreground"
/>
</div>
<h2 class="text-base font-semibold text-foreground">Get started</h2>
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
You have not joined any servers yet. Find a community to join, or create your own to invite friends.
</p>
</section>
}
<!-- People + Popular servers -->
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>
</div>
@if (peopleYouMightKnow().length > 0) {
<div class="space-y-1">
@for (person of peopleYouMightKnow(); track person.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
size="md"
[status]="person.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
</div>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No people to suggest yet.</p>
}
</div>
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>
</div>
@if (popularServers().length > 0) {
<div class="space-y-1">
@for (server of popularServers(); track server.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
@if (server.icon) {
<div
aria-hidden="true"
class="h-full w-full bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + server.icon + ')'"
></div>
} @else {
{{ serverInitial(server) }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<button
type="button"
class="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="openServer(server)"
>
Join
</button>
</div>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No popular servers right now.</p>
}
</div>
</section>
<!-- Your friends -->
@if (friends().length > 0) {
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Your Friends</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>Manage</a
>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
@for (friend of friends(); track friend.id) {
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
<app-user-avatar
[name]="personLabel(friend)"
[avatarUrl]="friend.avatarUrl"
size="md"
[status]="friend.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(friend) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(friend) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="friend" />
</div>
}
</div>
</section>
}
<!-- Recently active servers -->
@if (recentlyActiveServers().length > 0) {
<section>
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@for (room of recentlyActiveServers(); track room.id) {
<button
type="button"
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openSavedRoom(room)"
>
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-secondary text-base font-semibold text-foreground">
@if (room.icon) {
<div
aria-hidden="true"
class="h-full w-full bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + room.icon + ')'"
></div>
} @else {
{{ room.name[0]?.toUpperCase() || '?' }}
}
</div>
<p class="w-full truncate text-sm font-medium text-foreground">{{ room.name }}</p>
<p class="text-xs text-muted-foreground">{{ room.userCount }} members</p>
</button>
}
</div>
</section>
}
}
</div>
</div>
</ng-template>
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full bg-background"
slides-per-view="1"
space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<ng-container [ngTemplateOutlet]="pageContent" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<ng-container [ngTemplateOutlet]="pageContent" />
}

View File

@@ -0,0 +1,243 @@
import '@angular/compiler';
import {
describe,
it,
expect,
vi
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { DashboardComponent } from './dashboard.component';
import { ViewportService } from '../../core/platform';
import { ServerDirectoryFacade } from '../../domains/server-directory/application/facades/server-directory.facade';
import { FriendService } from '../../domains/direct-message/application/services/friend.service';
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 { ServerInfo } from '../../domains/server-directory/domain/models/server-directory.model';
import type { Room, User } from '../../shared-kernel';
interface HarnessOptions {
searchResults?: ServerInfo[];
saved?: Room[];
users?: User[];
currentUser?: User | null;
featured?: ServerInfo[];
trending?: ServerInfo[];
friendIds?: string[];
isMobile?: boolean;
}
function makeServer(id: string, name = id): ServerInfo {
return { id, name, maxUsers: 50, userCount: 1, isPrivate: false } as unknown as ServerInfo;
}
function createHarness(options: HarnessOptions = {}) {
const dispatch = vi.fn();
const searchResultsSig = signal<ServerInfo[]>(options.searchResults ?? []);
const savedSig = signal<Room[]>(options.saved ?? []);
const usersSig = signal<User[]>(options.users ?? []);
const currentUserSig = signal<User | null>(options.currentUser ?? null);
const store = {
selectSignal: (selector: unknown) => {
if (selector === selectSearchResults) {
return searchResultsSig;
}
if (selector === selectIsSearching) {
return signal(false);
}
if (selector === selectSavedRooms) {
return savedSig;
}
if (selector === selectAllUsers) {
return usersSig;
}
if (selector === selectCurrentUser) {
return currentUserSig;
}
return signal(null);
},
dispatch
} as unknown as Store;
const router = { navigate: vi.fn() } as unknown as Router;
const getFeaturedServers = vi.fn(() => of(options.featured ?? []));
const getTrendingServers = vi.fn(() => of(options.trending ?? []));
const serverDirectory = { getFeaturedServers, getTrendingServers } as unknown as ServerDirectoryFacade;
const friendIds = new Set<string>(options.friendIds ?? []);
const friendService = { friendIds: () => friendIds, friends: () => [] } as unknown as FriendService;
const injector = Injector.create({
providers: [
DashboardComponent,
{ provide: Store, useValue: store },
{ provide: Router, useValue: router },
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
{ provide: FriendService, useValue: friendService },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
]
});
const component = runInInjectionContext(injector, () => injector.get(DashboardComponent));
return {
component,
dispatch,
router,
getFeaturedServers,
getTrendingServers
};
}
describe('DashboardComponent', () => {
it('exposes the mobile viewport flag', () => {
expect(createHarness().component.isMobile()).toBe(false);
expect(createHarness({ isMobile: true }).component.isMobile()).toBe(true);
});
it('reports a new user when there are no servers or users', () => {
const { component } = createHarness();
expect(component.isNewUser()).toBe(true);
});
it('filters people by the active query', () => {
const { component } = createHarness({
users: [{ id: 'u1', displayName: 'Alice' } as unknown as User, { id: 'u2', displayName: 'Bob' } as unknown as User]
});
component.onSearchChange('ali');
expect(component.topPeopleResults().map((user) => user.id)).toEqual(['u1']);
});
it('limits server quick results', () => {
const { component } = createHarness({
searchResults: Array.from({ length: 8 }, (_, index) => makeServer(`s${index}`))
});
component.onSearchChange('s');
expect(component.topServerResults()).toHaveLength(5);
});
it('exposes an invite result for invite-like queries', () => {
const { component } = createHarness();
component.onSearchChange('https://app.test/invite/Code_42');
expect(component.inviteResult()).toBe('Code_42');
});
it('opens a joined server in place and routes others to the servers page', () => {
const joined = { id: 's1', name: 'Joined' } as unknown as Room;
const { component, dispatch, router } = createHarness({ saved: [joined] });
component.openServer(makeServer('s1', 'Joined'));
expect(dispatch).toHaveBeenCalledWith(RoomsActions.viewServer({ room: joined }));
component.openServer(makeServer('s2', 'Other'));
expect(router.navigate).toHaveBeenCalledWith(['/servers']);
});
it('navigates to the invite route when opening an invite', () => {
const { component, router } = createHarness();
component.onSearchChange('abc123');
component.openInvite();
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'abc123']);
});
it('suggests people you might know independent of the query, excluding self', () => {
const { component } = createHarness({
users: [{ id: 'u1', oderId: 'u1', displayName: 'Alice' } as unknown as User, { id: 'u2', oderId: 'u2', displayName: 'Bob' } as unknown as User],
currentUser: { id: 'u1', oderId: 'u1' } as unknown as User
});
expect(component.peopleYouMightKnow().map((user) => user.id)).toEqual(['u2']);
});
it('loads popular servers from featured results on init', () => {
const featured = [makeServer('f1'), makeServer('f2')];
const { component } = createHarness({ featured });
component.ngOnInit();
expect(component.popularServers().map((server) => server.id)).toEqual(['f1', 'f2']);
});
it('falls back to trending servers when featured is empty', () => {
const trending = [makeServer('t1')];
const { component } = createHarness({ featured: [], trending });
component.ngOnInit();
expect(component.popularServers().map((server) => server.id)).toEqual(['t1']);
});
it('limits recently active servers to the discovery cap', () => {
const saved = Array.from({ length: 9 }, (_, index) => ({ id: `r${index}`, name: `Room ${index}`, userCount: 1 }) as unknown as Room);
const { component } = createHarness({ saved });
expect(component.recentlyActiveServers()).toHaveLength(5);
});
it('excludes existing friends from people you might know and lists them under friends', () => {
const { component } = createHarness({
users: [
{ id: 'u1', oderId: 'u1', displayName: 'Alice' } as unknown as User,
{ id: 'u2', oderId: 'u2', displayName: 'Bob' } as unknown as User,
{ id: 'u3', oderId: 'u3', displayName: 'Cara' } as unknown as User
],
currentUser: { id: 'u1', oderId: 'u1' } as unknown as User,
friendIds: ['u2']
});
expect(component.peopleYouMightKnow().map((user) => user.id)).toEqual(['u3']);
expect(component.friends().map((user) => user.id)).toEqual(['u2']);
});
it('records, removes, and clears recent searches', () => {
const { component } = createHarness();
component.onSearchChange('gaming');
component.submitSearch();
component.onSearchChange('music');
component.submitSearch();
expect(component.recentSearches()).toEqual(['music', 'gaming']);
component.removeRecentSearch('gaming');
expect(component.recentSearches()).toEqual(['music']);
component.clearRecentSearches();
expect(component.recentSearches()).toEqual([]);
});
it('deduplicates recent searches and keeps the most recent first', () => {
const { component } = createHarness();
component.onSearchChange('gaming');
component.submitSearch();
component.onSearchChange('music');
component.submitSearch();
component.onSearchChange('gaming');
component.submitSearch();
expect(component.recentSearches()).toEqual(['gaming', 'music']);
});
});

View File

@@ -0,0 +1,342 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
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 { ServersRailComponent } from '../servers/servers-rail/servers-rail.component';
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';
/** 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,
ServersRailComponent
],
viewProviders: [
provideIcons({
lucideUsers,
lucideCompass,
lucidePlus,
lucideSearch,
lucideArrowRight,
lucideTicket,
lucideServer,
lucideX
})
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit {
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) {
this.router.navigate(['/invite', invite]);
}
}
serverInitial(server: ServerInfo): string {
return server.name.trim()[0]?.toUpperCase() || '?';
}
serverMetaLabel(server: ServerInfo): string {
const members = `${server.userCount ?? 0} ${server.userCount === 1 ? 'member' : 'members'}`;
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));
}
}

View File

@@ -0,0 +1,31 @@
import {
describe,
it,
expect
} from 'vitest';
import { parseInviteQuery } from './invite-query.util';
describe('parseInviteQuery', () => {
it('returns null for empty or whitespace queries', () => {
expect(parseInviteQuery('')).toBeNull();
expect(parseInviteQuery(' ')).toBeNull();
});
it('returns null for short or space-containing queries', () => {
expect(parseInviteQuery('abc')).toBeNull();
expect(parseInviteQuery('hello world')).toBeNull();
});
it('treats a bare url-safe code as an invite', () => {
expect(parseInviteQuery('abc123')).toBe('abc123');
expect(parseInviteQuery('Team-Code_9')).toBe('Team-Code_9');
});
it('extracts the id from an invite path', () => {
expect(parseInviteQuery('/invite/xyz789')).toBe('xyz789');
});
it('extracts the id from a full invite URL', () => {
expect(parseInviteQuery('https://app.test/invite/Code_42?ref=1')).toBe('Code_42');
});
});

View File

@@ -0,0 +1,29 @@
/**
* Parses a dashboard search query into an invite identifier when it looks like an
* invite code or an invite URL. Returns `null` when the query is not invite-like.
*
* Accepted shapes:
* - A bare code: `abc123`, `Team-Code_9` (6+ url-safe chars, no whitespace)
* - A path containing `/invite/<id>`
* - A full URL whose path contains `/invite/<id>`
*/
export function parseInviteQuery(rawQuery: string): string | null {
const query = rawQuery.trim();
if (query.length === 0) {
return null;
}
const invitePathMatch = /\/invite\/([A-Za-z0-9_-]+)/.exec(query);
if (invitePathMatch) {
return invitePathMatch[1];
}
// A bare invite code: url-safe characters only, no whitespace, reasonably long.
if (/^[A-Za-z0-9_-]{6,}$/.test(query)) {
return query;
}
return null;
}

View File

@@ -1,15 +1,16 @@
<nav class="relative flex h-full min-w-14 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full">
<!-- Create button -->
<!-- Home / dashboard button -->
<button
appThemeNode="serversRailCreateButton"
type="button"
class="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90 md:h-10 md:w-10"
title="Create Server"
(click)="createServer()"
class="flex h-11 w-11 items-center justify-center overflow-hidden rounded-md bg-primary transition-colors hover:bg-primary/90 md:h-10 md:w-10"
title="Dashboard"
(click)="goToDashboard()"
>
<ng-icon
name="lucidePlus"
class="h-[22px] w-[22px] md:h-5 md:w-5"
<img
src="toju-icon.png"
alt="Toju"
class="h-full w-full object-contain p-1"
/>
</button>

View File

@@ -13,7 +13,7 @@ import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePlus } from '@ng-icons/lucide';
import { lucidePhone } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
@@ -61,7 +61,7 @@ const ACTIVATION_DEBOUNCE_MS = 150;
ThemeNodeDirective,
UserBarComponent
],
viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
viewProviders: [provideIcons({ lucidePhone })],
templateUrl: './servers-rail.component.html'
})
export class ServersRailComponent {
@@ -107,6 +107,13 @@ export class ServersRailComponent {
),
{ initialValue: this.isDirectMessageUrl(this.router.url) }
);
isOnDashboard = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dashboard'))
),
{ initialValue: this.router.url.startsWith('/dashboard') }
);
isOnCall = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -238,7 +245,7 @@ export class ServersRailComponent {
trackRoomId = (index: number, room: Room) => room.id;
createServer(): void {
goToDashboard(): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
this.optimisticSelectedRoomId.set(null);
@@ -247,7 +254,7 @@ export class ServersRailComponent {
this.voiceSession.setViewingVoiceServer(false);
}
this.router.navigate(['/search']);
this.router.navigate(['/dashboard']);
}
joinSavedRoom(room: Room): void {
@@ -407,7 +414,7 @@ export class ServersRailComponent {
}
isSelectedRoom(room: Room): boolean {
if (this.isOnDirectMessage() || this.isOnCall()) {
if (this.isOnDirectMessage() || this.isOnCall() || this.isOnDashboard()) {
return false;
}

View File

@@ -176,7 +176,7 @@ export class SettingsComponent implements OnInit {
}
openPluginStore(): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url;
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
}

View File

@@ -85,19 +85,6 @@
Login
</button>
<button
type="button"
class="flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-foreground transition-colors hover:bg-secondary"
[class.hidden]="!isAuthed()"
(click)="openPluginStore()"
title="Plugin Store"
>
<ng-icon
name="lucidePackage"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (hasServerPlugins()) {
<button
type="button"
@@ -116,20 +103,6 @@
</button>
}
@if (isElectron()) {
<button
type="button"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
title="Open Documentation"
(click)="openDocumentation()"
>
<ng-icon
name="lucideBookOpen"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
<div class="relative">
<button
type="button"

View File

@@ -14,7 +14,6 @@ import {
lucideSquare,
lucideX,
lucideChevronLeft,
lucideBookOpen,
lucideHash,
lucideMenu,
lucidePackage,
@@ -67,7 +66,6 @@ import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plu
lucideSquare,
lucideX,
lucideChevronLeft,
lucideBookOpen,
lucideHash,
lucideMenu,
lucidePackage,
@@ -119,7 +117,14 @@ export class TitleBarComponent {
),
{ initialValue: this.router.url.startsWith('/dm/') }
);
inRoom = computed(() => !!this.currentRoom() && !this.isInDirectMessage());
isInRoomView = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/room/'))
),
{ initialValue: this.router.url.startsWith('/room/') }
);
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
roomName = computed(() => this.currentRoom()?.name || '');
activeTextChannelName = computed(() => {
const textChannels = this.textChannels();
@@ -206,7 +211,7 @@ export class TitleBarComponent {
}
openPluginStore(): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url;
this._showMenu.set(false);
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
@@ -330,7 +335,7 @@ export class TitleBarComponent {
nextOwnerKey: result.nextOwnerKey
}));
this.router.navigate(['/search']);
this.router.navigate(['/dashboard']);
}
/** Cancel the leave-server confirmation dialog. */