feat: dashboard
This commit is contained in:
428
toju-app/src/app/features/dashboard/dashboard.component.html
Normal file
428
toju-app/src/app/features/dashboard/dashboard.component.html
Normal 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" />
|
||||
}
|
||||
243
toju-app/src/app/features/dashboard/dashboard.component.spec.ts
Normal file
243
toju-app/src/app/features/dashboard/dashboard.component.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
342
toju-app/src/app/features/dashboard/dashboard.component.ts
Normal file
342
toju-app/src/app/features/dashboard/dashboard.component.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
29
toju-app/src/app/features/dashboard/invite-query.util.ts
Normal file
29
toju-app/src/app/features/dashboard/invite-query.util.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user