Move toju-app into own its folder
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<div class="min-h-full bg-background px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
|
||||
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
|
||||
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
|
||||
<div
|
||||
class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground"
|
||||
>
|
||||
Invite link
|
||||
</div>
|
||||
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
||||
@if (invite()) {
|
||||
Join {{ invite()!.server.name }}
|
||||
} @else {
|
||||
Toju server invite
|
||||
}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
@switch (status()) {
|
||||
@case ('redirecting') {
|
||||
Sign in to continue with this invite.
|
||||
}
|
||||
@case ('joining') {
|
||||
We are connecting you to the invited server.
|
||||
}
|
||||
@case ('error') {
|
||||
This invite could not be completed automatically.
|
||||
}
|
||||
@default {
|
||||
Loading invite details and preparing the correct signal server.
|
||||
}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 px-6 py-8 sm:px-10 lg:grid-cols-[1.2fr,0.8fr]">
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Status</h2>
|
||||
<p class="mt-3 text-lg font-medium text-foreground">{{ message() }}</p>
|
||||
</div>
|
||||
|
||||
@if (invite()) {
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Server</h2>
|
||||
<p class="mt-3 text-xl font-semibold text-foreground">{{ invite()!.server.name }}</p>
|
||||
@if (invite()!.server.description) {
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ invite()!.server.description }}</p>
|
||||
}
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
@if (invite()!.server.isPrivate) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Private</span>
|
||||
}
|
||||
@if (invite()!.server.hasPassword) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
|
||||
}
|
||||
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary"> Expires {{ invite()!.expiresAt | date: 'medium' }} </span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">What happens next</h2>
|
||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<li>• The linked signal server is added to your configured server list if needed.</li>
|
||||
<li>• Invite links bypass private and password restrictions.</li>
|
||||
<li>• Banned users still cannot join through invites.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if (status() === 'error') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="goToSearch()"
|
||||
class="inline-flex w-full items-center justify-center rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Back to server search
|
||||
</button>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import type { ServerInviteInfo } from '../../domain/server-directory.models';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './invite.component.html'
|
||||
})
|
||||
export class InviteComponent implements OnInit {
|
||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||
readonly invite = signal<ServerInviteInfo | null>(null);
|
||||
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
||||
readonly message = signal('Loading invite…');
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const inviteContext = this.resolveInviteContext();
|
||||
|
||||
if (!inviteContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
|
||||
if (!currentUserId) {
|
||||
await this.redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.joinInvite(inviteContext, currentUserId);
|
||||
} catch (error: unknown) {
|
||||
this.applyInviteError(error);
|
||||
}
|
||||
}
|
||||
|
||||
goToSearch(): void {
|
||||
this.router.navigate(['/search']).catch(() => {});
|
||||
}
|
||||
|
||||
private buildEndpointName(sourceUrl: string): string {
|
||||
try {
|
||||
const url = new URL(sourceUrl);
|
||||
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return 'Signal Server';
|
||||
}
|
||||
}
|
||||
|
||||
private applyInviteError(error: unknown): void {
|
||||
const inviteError = error as {
|
||||
error?: { error?: string; errorCode?: string };
|
||||
};
|
||||
const errorCode = inviteError?.error?.errorCode;
|
||||
const fallbackMessage = inviteError?.error?.error || 'Unable to accept this invite.';
|
||||
|
||||
this.status.set('error');
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.message.set('You are banned from this server and cannot accept this invite.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'INVITE_EXPIRED') {
|
||||
this.message.set('This invite has expired. Ask for a fresh invite link.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.message.set(fallbackMessage);
|
||||
}
|
||||
|
||||
private async hydrateCurrentUser(): Promise<User | null> {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (currentUser) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
const storedUser = await this.databaseService.getCurrentUser();
|
||||
|
||||
if (storedUser) {
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user: storedUser }));
|
||||
}
|
||||
|
||||
return storedUser;
|
||||
}
|
||||
|
||||
private async joinInvite(
|
||||
context: { endpoint: { id: string; name: string }; inviteId: string; sourceUrl: string },
|
||||
currentUserId: string
|
||||
): Promise<void> {
|
||||
const invite = await firstValueFrom(this.serverDirectory.getInvite(context.inviteId, {
|
||||
sourceId: context.endpoint.id,
|
||||
sourceUrl: context.sourceUrl
|
||||
}));
|
||||
|
||||
this.invite.set(invite);
|
||||
this.status.set('joining');
|
||||
this.message.set(`Joining ${invite.server.name}…`);
|
||||
|
||||
const currentUser = await this.hydrateCurrentUser();
|
||||
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: invite.server.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
inviteId: context.inviteId
|
||||
}, {
|
||||
sourceId: context.endpoint.id,
|
||||
sourceUrl: context.sourceUrl
|
||||
}));
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: joinResponse.server.id,
|
||||
serverInfo: {
|
||||
...joinResponse.server,
|
||||
sourceId: context.endpoint.id,
|
||||
sourceName: context.endpoint.name,
|
||||
sourceUrl: context.sourceUrl
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async redirectToLogin(): Promise<void> {
|
||||
this.status.set('redirecting');
|
||||
this.message.set('Redirecting to login…');
|
||||
|
||||
await this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInviteContext(): {
|
||||
endpoint: { id: string; name: string };
|
||||
inviteId: string;
|
||||
sourceUrl: string;
|
||||
} | null {
|
||||
const inviteId = this.route.snapshot.paramMap.get('inviteId')?.trim() || '';
|
||||
const sourceUrl = this.route.snapshot.queryParamMap.get('server')?.trim() || '';
|
||||
|
||||
if (!inviteId || !sourceUrl) {
|
||||
this.status.set('error');
|
||||
this.message.set('This invite link is missing required server information.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||
name: this.buildEndpointName(sourceUrl),
|
||||
url: sourceUrl
|
||||
}, {
|
||||
setActive: !localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: {
|
||||
id: endpoint.id,
|
||||
name: endpoint.name
|
||||
},
|
||||
inviteId,
|
||||
sourceUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- My Servers -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
|
||||
@if (savedRooms().length === 0) {
|
||||
<p class="text-sm text-muted-foreground">No joined servers yet</p>
|
||||
} @else {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
(click)="joinSavedRoom(room)"
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
|
||||
>
|
||||
{{ room.name }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Search Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="Search servers..."
|
||||
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
type="button"
|
||||
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Server Button -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<button
|
||||
(click)="openCreateDialog()"
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Create New Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@if (isSearching()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if (searchResults().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="w-12 h-12 mb-4 opacity-50"
|
||||
/>
|
||||
<p class="text-lg">No servers found</p>
|
||||
<p class="text-sm">Try a different search or create your own</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="p-4 space-y-3">
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<button
|
||||
(click)="joinServer(server)"
|
||||
type="button"
|
||||
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
|
||||
[class.border-border]="!isServerMarkedBanned(server)"
|
||||
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
||||
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
||||
[class.border-destructive/40]="isServerMarkedBanned(server)"
|
||||
[class.bg-destructive/5]="isServerMarkedBanned(server)"
|
||||
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
|
||||
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3
|
||||
class="font-semibold transition-colors"
|
||||
[class.text-foreground]="!isServerMarkedBanned(server)"
|
||||
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
|
||||
[class.text-destructive]="isServerMarkedBanned(server)"
|
||||
>
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
@if (isServerMarkedBanned(server)) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-destructive"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
|
||||
>Banned</span
|
||||
>
|
||||
} @else if (server.isPrivate) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>Private</span
|
||||
>
|
||||
} @else if (server.hasPassword) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>Password</span
|
||||
>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (server.description) {
|
||||
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{{ server.description }}
|
||||
</p>
|
||||
}
|
||||
@if (server.topic) {
|
||||
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
|
||||
{{ server.topic }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs">
|
||||
<div class="text-muted-foreground">
|
||||
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||
</div>
|
||||
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
|
||||
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (joinErrorMessage() || error()) {
|
||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showBannedDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Banned"
|
||||
confirmLabel="OK"
|
||||
cancelLabel="Close"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="closeBannedDialog()"
|
||||
(cancelled)="closeBannedDialog()"
|
||||
>
|
||||
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@if (showPasswordDialog() && passwordPromptServer()) {
|
||||
<app-confirm-dialog
|
||||
title="Password required"
|
||||
confirmLabel="Join server"
|
||||
cancelLabel="Cancel"
|
||||
[widthClass]="'w-[420px] max-w-[92vw]'"
|
||||
(confirmed)="confirmPasswordJoin()"
|
||||
(cancelled)="closePasswordDialog()"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="join-server-password"
|
||||
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Server password
|
||||
</label>
|
||||
<input
|
||||
id="join-server-password"
|
||||
type="password"
|
||||
[(ngModel)]="joinPassword"
|
||||
placeholder="Enter password"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (joinPasswordError()) {
|
||||
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
(click)="closeCreateDialog()"
|
||||
(keydown.enter)="closeCreateDialog()"
|
||||
(keydown.space)="closeCreateDialog()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close create server dialog"
|
||||
>
|
||||
<div
|
||||
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
(keydown.space)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="create-server-name"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="My Awesome Server"
|
||||
id="create-server-name"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="create-server-description"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
rows="3"
|
||||
id="create-server-description"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="create-server-topic"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Topic (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
placeholder="gaming, music, coding..."
|
||||
id="create-server-topic"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="create-server-signal-endpoint"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Signal Server Endpoint</label
|
||||
>
|
||||
<select
|
||||
id="create-server-signal-endpoint"
|
||||
[(ngModel)]="newServerSourceId"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@for (endpoint of activeEndpoints(); track endpoint.id) {
|
||||
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
|
||||
}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this chat server.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="newServerPrivate"
|
||||
id="private"
|
||||
class="w-4 h-4 rounded border-border bg-secondary"
|
||||
/>
|
||||
<label
|
||||
for="private"
|
||||
class="text-sm text-foreground"
|
||||
>Private server</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="create-server-password"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Password (optional)</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Leave blank to allow joining without a password"
|
||||
id="create-server-password"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeCreateDialog()"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="createServer()"
|
||||
[disabled]="!newServerName() || !newServerSourceId"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { Room, User } from '../../../../shared-kernel';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/server-directory.models';
|
||||
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { hasRoomBanForUser } from '../../../../core/helpers/room-ban.helpers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings
|
||||
})
|
||||
],
|
||||
templateUrl: './server-search.component.html'
|
||||
})
|
||||
/**
|
||||
* Server search and discovery view with server creation dialog.
|
||||
* Allows users to search for, join, and create new servers.
|
||||
*/
|
||||
export class ServerSearchComponent implements OnInit {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
searchQuery = '';
|
||||
searchResults = this.store.selectSignal(selectSearchResults);
|
||||
isSearching = this.store.selectSignal(selectIsSearching);
|
||||
error = this.store.selectSignal(selectRoomsError);
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
activeEndpoints = this.serverDirectory.activeServers;
|
||||
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||
bannedServerName = signal('');
|
||||
showBannedDialog = signal(false);
|
||||
showPasswordDialog = signal(false);
|
||||
passwordPromptServer = signal<ServerInfo | null>(null);
|
||||
joinPassword = signal('');
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
joinErrorMessage = signal<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
showCreateDialog = signal(false);
|
||||
newServerName = signal('');
|
||||
newServerDescription = signal('');
|
||||
newServerTopic = signal('');
|
||||
newServerPrivate = signal(false);
|
||||
newServerPassword = signal('');
|
||||
newServerSourceId = '';
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const servers = this.searchResults();
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
void this.refreshBannedLookup(servers, currentUser ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
/** Initialize server search, load saved rooms, and set up debounced search. */
|
||||
ngOnInit(): void {
|
||||
// Initial load
|
||||
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
// Setup debounced search
|
||||
this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe((query) => {
|
||||
this.store.dispatch(RoomsActions.searchServers({ query }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Emit a search query to the debounced search subject. */
|
||||
onSearchChange(query: string): void {
|
||||
this.searchSubject.next(query);
|
||||
}
|
||||
|
||||
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
||||
async joinServer(server: ServerInfo): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.isServerBanned(server)) {
|
||||
this.bannedServerName.set(server.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.attemptJoinServer(server);
|
||||
}
|
||||
|
||||
/** Open the create-server dialog. */
|
||||
openCreateDialog(): void {
|
||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||
this.showCreateDialog.set(true);
|
||||
}
|
||||
|
||||
/** Close the create-server dialog and reset the form. */
|
||||
closeCreateDialog(): void {
|
||||
this.showCreateDialog.set(false);
|
||||
this.resetCreateForm();
|
||||
}
|
||||
|
||||
/** Submit the new server creation form and dispatch the create action. */
|
||||
createServer(): void {
|
||||
if (!this.newServerName())
|
||||
return;
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.createRoom({
|
||||
name: this.newServerName(),
|
||||
description: this.newServerDescription() || undefined,
|
||||
topic: this.newServerTopic() || undefined,
|
||||
isPrivate: this.newServerPrivate(),
|
||||
password: this.newServerPassword().trim() || undefined,
|
||||
sourceId: this.newServerSourceId || undefined
|
||||
})
|
||||
);
|
||||
|
||||
this.closeCreateDialog();
|
||||
}
|
||||
|
||||
/** Open the unified settings modal to the Network page. */
|
||||
openSettings(): void {
|
||||
this.settingsModal.open('network');
|
||||
}
|
||||
|
||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
void this.joinServer(this.toServerInfo(room));
|
||||
}
|
||||
|
||||
closeBannedDialog(): void {
|
||||
this.showBannedDialog.set(false);
|
||||
this.bannedServerName.set('');
|
||||
}
|
||||
|
||||
closePasswordDialog(): void {
|
||||
this.showPasswordDialog.set(false);
|
||||
this.passwordPromptServer.set(null);
|
||||
this.joinPassword.set('');
|
||||
this.joinPasswordError.set(null);
|
||||
}
|
||||
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const server = this.passwordPromptServer();
|
||||
|
||||
if (!server)
|
||||
return;
|
||||
|
||||
await this.attemptJoinServer(server, this.joinPassword());
|
||||
}
|
||||
|
||||
isServerMarkedBanned(server: ServerInfo): boolean {
|
||||
return !!this.bannedServerLookup()[server.id];
|
||||
}
|
||||
|
||||
getServerUserCount(server: ServerInfo): number {
|
||||
const candidate = server as ServerInfo & { currentUsers?: number };
|
||||
|
||||
if (typeof server.userCount === 'number')
|
||||
return server.userCount;
|
||||
|
||||
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
|
||||
}
|
||||
|
||||
getServerCapacityLabel(server: ServerInfo): string {
|
||||
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
|
||||
}
|
||||
|
||||
private toServerInfo(room: Room): ServerInfo {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? 50,
|
||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||
isPrivate: room.isPrivate,
|
||||
createdAt: room.createdAt,
|
||||
ownerId: room.hostId,
|
||||
sourceId: room.sourceId,
|
||||
sourceName: room.sourceName,
|
||||
sourceUrl: room.sourceUrl
|
||||
};
|
||||
}
|
||||
|
||||
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinErrorMessage.set(null);
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: server.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
password: password?.trim() || undefined
|
||||
}, {
|
||||
sourceId: server.sourceId,
|
||||
sourceUrl: server.sourceUrl
|
||||
}));
|
||||
const resolvedServer = response.server ?? server;
|
||||
|
||||
this.closePasswordDialog();
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: resolvedServer.id,
|
||||
serverInfo: resolvedServer
|
||||
})
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as {
|
||||
error?: { error?: string; errorCode?: string };
|
||||
};
|
||||
const errorCode = serverError?.error?.errorCode;
|
||||
const message = serverError?.error?.error || 'Failed to join server';
|
||||
|
||||
if (errorCode === 'PASSWORD_REQUIRED') {
|
||||
this.passwordPromptServer.set(server);
|
||||
this.showPasswordDialog.set(true);
|
||||
this.joinPasswordError.set(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.bannedServerName.set(server.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinErrorMessage.set(message);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||
const requestVersion = ++this.banLookupRequestVersion;
|
||||
|
||||
if (!currentUser || servers.length === 0) {
|
||||
this.bannedServerLookup.set({});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const entries = await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
const bans = await this.db.getBansForRoom(server.id);
|
||||
const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId);
|
||||
|
||||
return [server.id, isBanned] as const;
|
||||
})
|
||||
);
|
||||
|
||||
if (requestVersion !== this.banLookupRequestVersion)
|
||||
return;
|
||||
|
||||
this.bannedServerLookup.set(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
private async isServerBanned(server: ServerInfo): Promise<boolean> {
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUser && !currentUserId)
|
||||
return false;
|
||||
|
||||
const bans = await this.db.getBansForRoom(server.id);
|
||||
|
||||
return hasRoomBanForUser(bans, currentUser, currentUserId);
|
||||
}
|
||||
|
||||
private resetCreateForm(): void {
|
||||
this.newServerName.set('');
|
||||
this.newServerDescription.set('');
|
||||
this.newServerTopic.set('');
|
||||
this.newServerPrivate.set(false);
|
||||
this.newServerPassword.set('');
|
||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user