Move toju-app into own its folder
This commit is contained in:
115
toju-app/src/app/features/servers/servers-rail.component.html
Normal file
115
toju-app/src/app/features/servers/servers-rail.component.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
||||
<!-- Create button -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Create Server"
|
||||
(click)="createServer()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||
@for (room of visibleSavedRooms(); track room.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||
[title]="room.name"
|
||||
(click)="joinSavedRoom(room)"
|
||||
(contextmenu)="openContextMenu($event, room)"
|
||||
>
|
||||
@if (room.icon) {
|
||||
<img
|
||||
[ngSrc]="room.icon"
|
||||
[alt]="room.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Context menu -->
|
||||
@if (showMenu()) {
|
||||
<app-context-menu
|
||||
[x]="menuX()"
|
||||
[y]="menuY()"
|
||||
(closed)="closeMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
(click)="openLeaveConfirm()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@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() && passwordPromptRoom()) {
|
||||
<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 text-left">
|
||||
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="rail-join-password"
|
||||
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Server password
|
||||
</label>
|
||||
<input
|
||||
id="rail-join-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>
|
||||
}
|
||||
|
||||
@if (showLeaveConfirm() && contextRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="contextRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeave($event)"
|
||||
(cancelled)="cancelLeave()"
|
||||
/>
|
||||
}
|
||||
349
toju-app/src/app/features/servers/servers-rail.component.ts
Normal file
349
toju-app/src/app/features/servers/servers-rail.component.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { VoiceSessionFacade } from '../../domains/voice-session';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent
|
||||
} from '../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent,
|
||||
NgOptimizedImage
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
export class ServersRailComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private banLookupRequestVersion = 0;
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
showMenu = signal(false);
|
||||
menuX = signal(72);
|
||||
menuY = signal(100);
|
||||
contextRoom = signal<Room | null>(null);
|
||||
showLeaveConfirm = signal(false);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||
bannedServerName = signal('');
|
||||
showBannedDialog = signal(false);
|
||||
showPasswordDialog = signal(false);
|
||||
passwordPromptRoom = signal<Room | null>(null);
|
||||
joinPassword = signal('');
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const rooms = this.savedRooms();
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
void this.refreshBannedLookup(rooms, currentUser ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name)
|
||||
return '?';
|
||||
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
|
||||
return ch || '?';
|
||||
}
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
createServer(): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
}
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
async joinSavedRoom(room: Room): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.isRoomBanned(room)) {
|
||||
this.bannedServerName.set(room.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
});
|
||||
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
|
||||
|
||||
this.prepareVoiceContext(room);
|
||||
|
||||
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
|
||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||
skipBanCheck: true }));
|
||||
} else {
|
||||
await this.attemptJoinRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
closeBannedDialog(): void {
|
||||
this.showBannedDialog.set(false);
|
||||
this.bannedServerName.set('');
|
||||
}
|
||||
|
||||
closePasswordDialog(): void {
|
||||
this.showPasswordDialog.set(false);
|
||||
this.passwordPromptRoom.set(null);
|
||||
this.joinPassword.set('');
|
||||
this.joinPasswordError.set(null);
|
||||
}
|
||||
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const room = this.passwordPromptRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
await this.attemptJoinRoom(room, this.joinPassword());
|
||||
}
|
||||
|
||||
isRoomMarkedBanned(room: Room): boolean {
|
||||
return !!this.bannedRoomLookup()[room.id];
|
||||
}
|
||||
|
||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
this.menuX.set(Math.max(evt.clientX + 8, 72));
|
||||
this.menuY.set(evt.clientY);
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.showMenu.set(false);
|
||||
}
|
||||
|
||||
isCurrentContextRoom(): boolean {
|
||||
const ctx = this.contextRoom();
|
||||
const cur = this.currentRoom();
|
||||
|
||||
return !!ctx && !!cur && ctx.id === cur.id;
|
||||
}
|
||||
|
||||
openLeaveConfirm(): void {
|
||||
this.closeMenu();
|
||||
|
||||
if (this.contextRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||
const ctx = this.contextRoom();
|
||||
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({
|
||||
roomId: ctx.id,
|
||||
nextOwnerKey: result.nextOwnerKey
|
||||
}));
|
||||
|
||||
if (isCurrentRoom) {
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
this.showLeaveConfirm.set(false);
|
||||
this.contextRoom.set(null);
|
||||
}
|
||||
|
||||
cancelLeave(): void {
|
||||
this.showLeaveConfirm.set(false);
|
||||
}
|
||||
|
||||
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
|
||||
const requestVersion = ++this.banLookupRequestVersion;
|
||||
|
||||
if (!currentUser || rooms.length === 0) {
|
||||
this.bannedRoomLookup.set({});
|
||||
return;
|
||||
}
|
||||
|
||||
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const entries = await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const bans = await this.db.getBansForRoom(room.id);
|
||||
|
||||
return [room.id, hasRoomBanForUser(bans, currentUser, persistedUserId)] as const;
|
||||
})
|
||||
);
|
||||
|
||||
if (requestVersion !== this.banLookupRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bannedRoomLookup.set(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
private async isRoomBanned(room: Room): Promise<boolean> {
|
||||
const currentUser = this.currentUser();
|
||||
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUser && !persistedUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bans = await this.db.getBansForRoom(room.id);
|
||||
|
||||
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
||||
}
|
||||
|
||||
private prepareVoiceContext(room: Room): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId && voiceServerId !== room.id) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
} else if (voiceServerId === room.id) {
|
||||
this.voiceSession.setViewingVoiceServer(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptJoinRoom(room: Room, password?: string): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUserId)
|
||||
return;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: room.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
password: password?.trim() || undefined
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
}));
|
||||
|
||||
this.closePasswordDialog();
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
...this.toServerInfo(room),
|
||||
...response.server
|
||||
}
|
||||
})
|
||||
);
|
||||
} 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.passwordPromptRoom.set(room);
|
||||
this.showPasswordDialog.set(true);
|
||||
this.joinPasswordError.set(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.bannedServerName.set(room.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldFallbackToOfflineView(error)) {
|
||||
this.closePasswordDialog();
|
||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||
skipBanCheck: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldFallbackToOfflineView(error: unknown): boolean {
|
||||
const serverError = error as {
|
||||
error?: { errorCode?: string };
|
||||
status?: number;
|
||||
};
|
||||
const errorCode = serverError?.error?.errorCode;
|
||||
const status = serverError?.status;
|
||||
|
||||
return errorCode === 'SERVER_NOT_FOUND'
|
||||
|| status === 0
|
||||
|| status === 404
|
||||
|| (typeof status === 'number' && status >= 500);
|
||||
}
|
||||
|
||||
private toServerInfo(room: Room) {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user