This commit is contained in:
2025-12-28 05:37:19 +01:00
commit 87c722b5ae
74 changed files with 10264 additions and 0 deletions

View File

@@ -0,0 +1,467 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideShield,
lucideBan,
lucideUserX,
lucideSettings,
lucideUsers,
lucideTrash2,
lucideCheck,
lucideX,
lucideLock,
lucideUnlock,
} from '@ng-icons/lucide';
import * as UsersActions from '../../store/users/users.actions';
import * as RoomsActions from '../../store/rooms/rooms.actions';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import {
selectBannedUsers,
selectIsCurrentUserAdmin,
selectCurrentUser,
} from '../../store/users/users.selectors';
import { BanEntry, Room } from '../../core/models';
type AdminTab = 'settings' | 'bans' | 'permissions';
@Component({
selector: 'app-admin-panel',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideShield,
lucideBan,
lucideUserX,
lucideSettings,
lucideUsers,
lucideTrash2,
lucideCheck,
lucideX,
lucideLock,
lucideUnlock,
}),
],
template: `
@if (isAdmin()) {
<div class="h-full flex flex-col bg-card">
<!-- Header -->
<div class="p-4 border-b border-border flex items-center gap-2">
<ng-icon name="lucideShield" class="w-5 h-5 text-primary" />
<h2 class="font-semibold text-foreground">Admin Panel</h2>
</div>
<!-- Tabs -->
<div class="flex border-b border-border">
<button
(click)="activeTab.set('settings')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'settings'"
[class.border-b-2]="activeTab() === 'settings'"
[class.border-primary]="activeTab() === 'settings'"
[class.text-muted-foreground]="activeTab() !== 'settings'"
>
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
Settings
</button>
<button
(click)="activeTab.set('bans')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'bans'"
[class.border-b-2]="activeTab() === 'bans'"
[class.border-primary]="activeTab() === 'bans'"
[class.text-muted-foreground]="activeTab() !== 'bans'"
>
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" />
Bans
</button>
<button
(click)="activeTab.set('permissions')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'permissions'"
[class.border-b-2]="activeTab() === 'permissions'"
[class.border-primary]="activeTab() === 'permissions'"
[class.text-muted-foreground]="activeTab() !== 'permissions'"
>
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
Permissions
</button>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-y-auto p-4">
@switch (activeTab()) {
@case ('settings') {
<div class="space-y-6">
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
<!-- Room Name -->
<div>
<label class="block text-sm text-muted-foreground mb-1">Room Name</label>
<input
type="text"
[(ngModel)]="roomName"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Room Description -->
<div>
<label class="block text-sm text-muted-foreground mb-1">Description</label>
<textarea
[(ngModel)]="roomDescription"
rows="3"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
></textarea>
</div>
<!-- Private Room Toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
</div>
<button
(click)="togglePrivate()"
class="p-2 rounded-lg transition-colors"
[class.bg-primary]="isPrivate()"
[class.text-primary-foreground]="isPrivate()"
[class.bg-secondary]="!isPrivate()"
[class.text-muted-foreground]="!isPrivate()"
>
@if (isPrivate()) {
<ng-icon name="lucideLock" class="w-4 h-4" />
} @else {
<ng-icon name="lucideUnlock" class="w-4 h-4" />
}
</button>
</div>
<!-- Max Users -->
<div>
<label class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
<input
type="number"
[(ngModel)]="maxUsers"
min="0"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Save Button -->
<button
(click)="saveSettings()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
Save Settings
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
<button
(click)="confirmDeleteRoom()"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
>
<ng-icon name="lucideTrash2" class="w-4 h-4" />
Delete Room
</button>
</div>
</div>
}
@case ('bans') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
@if (bannedUsers().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">
No banned users
</p>
} @else {
@for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
{{ ban.displayName || 'Unknown User' }}
</p>
@if (ban.reason) {
<p class="text-xs text-muted-foreground truncate">
Reason: {{ ban.reason }}
</p>
}
@if (ban.expiresAt) {
<p class="text-xs text-muted-foreground">
Expires: {{ formatExpiry(ban.expiresAt) }}
</p>
} @else {
<p class="text-xs text-destructive">Permanent</p>
}
</div>
<button
(click)="unbanUser(ban)"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
>
<ng-icon name="lucideX" class="w-4 h-4" />
</button>
</div>
}
}
</div>
}
@case ('permissions') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
<!-- Permission Toggles -->
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowVoice"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
<p class="text-xs text-muted-foreground">Users can share their screen</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowScreenShare"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
<p class="text-xs text-muted-foreground">Users can upload files</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowFileUploads"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Limit message frequency</p>
</div>
<select
[(ngModel)]="slowModeInterval"
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
</select>
</div>
<!-- Management Permissions -->
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
</div>
<input type="checkbox" [(ngModel)]="adminsManageRooms" class="w-4 h-4 accent-primary" />
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
</div>
<input type="checkbox" [(ngModel)]="moderatorsManageRooms" class="w-4 h-4 accent-primary" />
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
</div>
<input type="checkbox" [(ngModel)]="adminsManageIcon" class="w-4 h-4 accent-primary" />
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
</div>
<input type="checkbox" [(ngModel)]="moderatorsManageIcon" class="w-4 h-4 accent-primary" />
</div>
</div>
<!-- Save Permissions -->
<button
(click)="savePermissions()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
Save Permissions
</button>
</div>
}
}
</div>
</div>
<!-- Delete Confirmation Modal -->
@if (showDeleteConfirm()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="showDeleteConfirm.set(false)">
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
<h3 class="text-lg font-semibold text-foreground mb-2">Delete Room</h3>
<p class="text-sm text-muted-foreground mb-4">
Are you sure you want to delete this room? This action cannot be undone.
</p>
<div class="flex gap-2 justify-end">
<button
(click)="showDeleteConfirm.set(false)"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Cancel
</button>
<button
(click)="deleteRoom()"
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
>
Delete Room
</button>
</div>
</div>
</div>
}
} @else {
<div class="h-full flex items-center justify-center text-muted-foreground">
<p>You don't have admin permissions</p>
</div>
}
`,
})
export class AdminPanelComponent {
private store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
bannedUsers = this.store.selectSignal(selectBannedUsers);
activeTab = signal<AdminTab>('settings');
showDeleteConfirm = signal(false);
// Settings
roomName = '';
roomDescription = '';
isPrivate = signal(false);
maxUsers = 0;
// Permissions
allowVoice = true;
allowScreenShare = true;
allowFileUploads = true;
slowModeInterval = '0';
adminsManageRooms = false;
moderatorsManageRooms = false;
adminsManageIcon = false;
moderatorsManageIcon = false;
constructor() {
// Initialize from current room
const room = this.currentRoom();
if (room) {
this.roomName = room.name;
this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate);
this.maxUsers = room.maxUsers || 0;
const perms = room.permissions || {};
this.allowVoice = perms.allowVoice !== false;
this.allowScreenShare = perms.allowScreenShare !== false;
this.allowFileUploads = perms.allowFileUploads !== false;
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
this.adminsManageRooms = !!perms.adminsManageRooms;
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
this.adminsManageIcon = !!perms.adminsManageIcon;
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
}
}
togglePrivate(): void {
this.isPrivate.update((v) => !v);
}
saveSettings(): void {
const room = this.currentRoom();
if (!room) return;
this.store.dispatch(
RoomsActions.updateRoom({
roomId: room.id,
changes: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers,
},
})
);
}
savePermissions(): void {
const room = this.currentRoom();
if (!room) return;
this.store.dispatch(
RoomsActions.updateRoomPermissions({
roomId: room.id,
permissions: {
allowVoice: this.allowVoice,
allowScreenShare: this.allowScreenShare,
allowFileUploads: this.allowFileUploads,
slowModeInterval: parseInt(this.slowModeInterval, 10),
adminsManageRooms: this.adminsManageRooms,
moderatorsManageRooms: this.moderatorsManageRooms,
adminsManageIcon: this.adminsManageIcon,
moderatorsManageIcon: this.moderatorsManageIcon,
},
})
);
}
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
}
confirmDeleteRoom(): void {
this.showDeleteConfirm.set(true);
}
deleteRoom(): void {
const room = this.currentRoom();
if (!room) return;
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
this.showDeleteConfirm.set(false);
}
formatExpiry(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}

View File

@@ -0,0 +1,94 @@
import { Component, inject, 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 { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide';
import { AuthService } from '../../core/services/auth.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import * as UsersActions from '../../store/users/users.actions';
import { User } from '../../core/models';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [provideIcons({ lucideLogIn })],
template: `
<div class="h-full grid place-items-center bg-background">
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideLogIn" class="w-5 h-5 text-primary" />
<h1 class="text-lg font-semibold text-foreground">Login</h1>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs text-muted-foreground mb-1">Username</label>
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Password</label>
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
</select>
</div>
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Login</button>
<div class="text-xs text-muted-foreground text-center mt-2">
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
</div>
</div>
</div>
</div>
`,
})
export class LoginComponent {
private auth = inject(AuthService);
private serversSvc = inject(ServerDirectoryService);
private store = inject(Store);
private router = inject(Router);
servers = this.serversSvc.servers;
username = '';
password = '';
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
trackById(_index: number, item: { id: string }) { return item.id; }
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
next: (resp) => {
if (sid) this.serversSvc.setActiveServer(sid);
const user: User = {
id: resp.id,
oderId: resp.id,
username: resp.username,
displayName: resp.displayName,
status: 'online',
role: 'member',
joinedAt: Date.now(),
};
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Login failed');
},
});
}
goRegister() {
this.router.navigate(['/register']);
}
}

View File

@@ -0,0 +1,99 @@
import { Component, inject, 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 { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide';
import { AuthService } from '../../core/services/auth.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import * as UsersActions from '../../store/users/users.actions';
import { User } from '../../core/models';
@Component({
selector: 'app-register',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [provideIcons({ lucideUserPlus })],
template: `
<div class="h-full grid place-items-center bg-background">
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideUserPlus" class="w-5 h-5 text-primary" />
<h1 class="text-lg font-semibold text-foreground">Register</h1>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs text-muted-foreground mb-1">Username</label>
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
<input [(ngModel)]="displayName" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Password</label>
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
</select>
</div>
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Create Account</button>
<div class="text-xs text-muted-foreground text-center mt-2">
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
</div>
</div>
</div>
</div>
`,
})
export class RegisterComponent {
private auth = inject(AuthService);
private serversSvc = inject(ServerDirectoryService);
private store = inject(Store);
private router = inject(Router);
servers = this.serversSvc.servers;
username = '';
displayName = '';
password = '';
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
trackById(_index: number, item: { id: string }) { return item.id; }
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
next: (resp) => {
if (sid) this.serversSvc.setActiveServer(sid);
const user: User = {
id: resp.id,
oderId: resp.id,
username: resp.username,
displayName: resp.displayName,
status: 'online',
role: 'member',
joinedAt: Date.now(),
};
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Registration failed');
},
});
}
goLogin() {
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,43 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
import { selectCurrentUser } from '../../store/users/users.selectors';
@Component({
selector: 'app-user-bar',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
template: `
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
<div class="flex-1"></div>
@if (user()) {
<div class="flex items-center gap-2 text-sm">
<ng-icon name="lucideUser" class="w-4 h-4 text-muted-foreground" />
<span class="text-foreground">{{ user()?.displayName }}</span>
</div>
} @else {
<button (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1">
<ng-icon name="lucideLogIn" class="w-4 h-4" />
Login
</button>
<button (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1">
<ng-icon name="lucideUserPlus" class="w-4 h-4" />
Register
</button>
}
</div>
`,
})
export class UserBarComponent {
private store = inject(Store);
private router = inject(Router);
user = this.store.selectSignal(selectCurrentUser);
goto(path: 'login' | 'register') {
this.router.navigate([`/${path}`]);
}
}

View File

@@ -0,0 +1,554 @@
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSend,
lucideSmile,
lucideEdit,
lucideTrash2,
lucideReply,
lucideMoreVertical,
lucideCheck,
lucideX,
} from '@ng-icons/lucide';
import * as MessagesActions from '../../store/messages/messages.actions';
import { selectAllMessages, selectMessagesLoading } from '../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
import { Message } from '../../core/models';
import { WebRTCService } from '../../core/services/webrtc.service';
import { Subscription } from 'rxjs';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
@Component({
selector: 'app-chat-messages',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideSend,
lucideSmile,
lucideEdit,
lucideTrash2,
lucideReply,
lucideMoreVertical,
lucideCheck,
lucideX,
}),
],
template: `
<div class="flex flex-col h-full">
<!-- Messages List -->
<div #messagesContainer class="flex-1 overflow-y-auto p-4 space-y-4 relative" (scroll)="onScroll()">
@if (loading()) {
<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 (messages().length === 0) {
<div class="flex flex-col items-center justify-center h-full text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
@for (message of messages(); track message.id) {
<div
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
[class.opacity-50]="message.isDeleted"
>
<!-- Avatar -->
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold">
{{ message.senderName.charAt(0).toUpperCase() }}
</div>
<!-- Message Content -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
<span class="text-xs text-muted-foreground">
{{ formatTimestamp(message.timestamp) }}
</span>
@if (message.editedAt) {
<span class="text-xs text-muted-foreground">(edited)</span>
}
</div>
@if (editingMessageId() === message.id) {
<!-- Edit Mode -->
<div class="mt-1 flex gap-2">
<input
type="text"
[(ngModel)]="editContent"
(keydown.enter)="saveEdit(message.id)"
(keydown.escape)="cancelEdit()"
class="flex-1 px-3 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
(click)="saveEdit(message.id)"
class="p-1 text-primary hover:bg-primary/10 rounded"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
</button>
<button
(click)="cancelEdit()"
class="p-1 text-muted-foreground hover:bg-secondary rounded"
>
<ng-icon name="lucideX" class="w-4 h-4" />
</button>
</div>
} @else {
<p class="text-foreground break-words whitespace-pre-wrap mt-1">
{{ message.content }}
</p>
}
<!-- Reactions -->
@if (message.reactions.length > 0) {
<div class="flex flex-wrap gap-1 mt-2">
@for (reaction of getGroupedReactions(message); track reaction.emoji) {
<button
(click)="toggleReaction(message.id, reaction.emoji)"
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-secondary hover:bg-secondary/80 transition-colors"
[class.ring-1]="reaction.hasCurrentUser"
[class.ring-primary]="reaction.hasCurrentUser"
>
<span>{{ reaction.emoji }}</span>
<span class="text-muted-foreground">{{ reaction.count }}</span>
</button>
}
</div>
}
</div>
<!-- Message Actions (visible on hover) -->
@if (!message.isDeleted) {
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg">
<!-- Emoji Picker Toggle -->
<div class="relative">
<button
(click)="toggleEmojiPicker(message.id)"
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
>
<ng-icon name="lucideSmile" class="w-4 h-4 text-muted-foreground" />
</button>
@if (showEmojiPicker() === message.id) {
<div class="absolute bottom-full right-0 mb-2 p-2 bg-card border border-border rounded-lg shadow-lg flex gap-1 z-10">
@for (emoji of commonEmojis; track emoji) {
<button
(click)="addReaction(message.id, emoji)"
class="p-1 hover:bg-secondary rounded transition-colors text-lg"
>
{{ emoji }}
</button>
}
</div>
}
</div>
<!-- Reply -->
<button
(click)="setReplyTo(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
</button>
<!-- Edit (own messages only) -->
@if (isOwnMessage(message)) {
<button
(click)="startEdit(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon name="lucideEdit" class="w-4 h-4 text-muted-foreground" />
</button>
}
<!-- Delete (own messages or admin) -->
@if (isOwnMessage(message) || isAdmin()) {
<button
(click)="deleteMessage(message)"
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
>
<ng-icon name="lucideTrash2" class="w-4 h-4 text-destructive" />
</button>
}
</div>
}
</div>
}
}
<!-- New messages snackbar (center bottom inside container) -->
@if (showNewMessagesBar()) {
<div class="sticky bottom-4 flex justify-center pointer-events-none">
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
<span class="text-sm text-muted-foreground">New messages</span>
<button (click)="readLatest()" class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm">Read latest</button>
</div>
</div>
}
</div>
<!-- Reply Preview -->
@if (replyTo()) {
<div class="px-4 py-2 bg-secondary/50 border-t border-border flex items-center gap-2">
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground flex-1">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
</span>
<button (click)="clearReply()" class="p-1 hover:bg-secondary rounded">
<ng-icon name="lucideX" class="w-4 h-4 text-muted-foreground" />
</button>
</div>
}
<!-- Typing Indicator -->
@if (typingDisplay().length > 0) {
<div class="px-4 py-2 text-sm text-muted-foreground">
<span>
{{ typingDisplay().join(', ') }}
@if (typingOthersCount() > 0) {
and {{ typingOthersCount() }} others are typing...
}
</span>
</div>
}
<!-- Message Input -->
<div class="p-4 border-t border-border">
<div class="flex gap-2">
<input
type="text"
[(ngModel)]="messageContent"
(keydown.enter)="sendMessage()"
(input)="onInputChange()"
placeholder="Type a message..."
class="flex-1 px-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"
/>
<button
(click)="sendMessage()"
[disabled]="!messageContent.trim()"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ng-icon name="lucideSend" class="w-4 h-4" />
</button>
</div>
</div>
</div>
`,
})
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
private store = inject(Store);
private webrtc = inject(WebRTCService);
messages = this.store.selectSignal(selectAllMessages);
loading = this.store.selectSignal(selectMessagesLoading);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
messageContent = '';
editContent = '';
editingMessageId = signal<string | null>(null);
replyTo = signal<Message | null>(null);
showEmojiPicker = signal<string | null>(null);
readonly commonEmojis = COMMON_EMOJIS;
private shouldScrollToBottom = true;
private typingSub?: Subscription;
private lastTypingSentAt = 0;
private readonly typingTTL = 3000; // ms to keep a user as typing
private lastMessageCount = 0;
private initialScrollPending = true;
// Track typing users by name and expire them
private typingMap = new Map<string, { name: string; expiresAt: number }>();
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
// New messages snackbar state
showNewMessagesBar = signal(false);
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
nowRef = signal<number>(Date.now());
private nowTimer: any;
// Messages length signal and effect to detect new messages without blocking change detection
messagesLength = computed(() => this.messages().length);
private onMessagesChanged = effect(() => {
const currentCount = this.messagesLength();
const el = this.messagesContainer?.nativeElement;
if (!el) {
this.lastMessageCount = currentCount;
return;
}
// Skip during initial scroll setup
if (this.initialScrollPending) {
this.lastMessageCount = currentCount;
return;
}
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
if (newMessages) {
if (distanceFromBottom <= 300) {
// Smooth auto-scroll only when near bottom; schedule after render
this.scheduleScrollToBottomSmooth();
this.showNewMessagesBar.set(false);
} else {
// Schedule snackbar update to avoid blocking change detection
queueMicrotask(() => this.showNewMessagesBar.set(true));
}
}
this.lastMessageCount = currentCount;
});
ngAfterViewChecked(): void {
const el = this.messagesContainer?.nativeElement;
if (!el) return;
// First render after connect: scroll to bottom by default (no animation)
if (this.initialScrollPending) {
this.initialScrollPending = false;
this.scrollToBottom();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
return;
}
}
ngOnInit(): void {
this.typingSub = this.webrtc.onSignalingMessage.subscribe((msg: any) => {
if (msg?.type === 'user_typing' && msg.displayName && msg.oderId) {
const now = Date.now();
this.typingMap.set(String(msg.oderId), { name: String(msg.displayName), expiresAt: now + this.typingTTL });
this.recomputeTypingDisplay(now);
}
});
// Periodically purge expired typing entries
const purge = () => {
const now = Date.now();
let changed = false;
for (const [key, entry] of Array.from(this.typingMap.entries())) {
if (entry.expiresAt <= now) {
this.typingMap.delete(key);
changed = true;
}
}
if (changed) this.recomputeTypingDisplay(now);
// schedule next purge
setTimeout(purge, 1000);
};
setTimeout(purge, 1000);
// Initialize message count for snackbar trigger
this.lastMessageCount = this.messages().length;
// Update reference time periodically (minute granularity)
this.nowTimer = setInterval(() => {
this.nowRef.set(Date.now());
}, 60000);
}
ngOnDestroy(): void {
this.typingSub?.unsubscribe();
if (this.nowTimer) {
clearInterval(this.nowTimer);
this.nowTimer = null;
}
}
sendMessage(): void {
if (!this.messageContent.trim()) return;
this.store.dispatch(
MessagesActions.sendMessage({
content: this.messageContent.trim(),
replyToId: this.replyTo()?.id,
})
);
this.messageContent = '';
this.clearReply();
this.shouldScrollToBottom = true;
this.showNewMessagesBar.set(false);
}
onInputChange(): void {
const now = Date.now();
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
try {
this.webrtc.sendRawMessage({ type: 'typing' });
this.lastTypingSentAt = now;
} catch {}
}
}
startEdit(message: Message): void {
this.editingMessageId.set(message.id);
this.editContent = message.content;
}
saveEdit(messageId: string): void {
if (!this.editContent.trim()) return;
this.store.dispatch(
MessagesActions.editMessage({
messageId,
content: this.editContent.trim(),
})
);
this.cancelEdit();
}
cancelEdit(): void {
this.editingMessageId.set(null);
this.editContent = '';
}
deleteMessage(message: Message): void {
if (this.isOwnMessage(message)) {
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
} else if (this.isAdmin()) {
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
}
}
setReplyTo(message: Message): void {
this.replyTo.set(message);
}
clearReply(): void {
this.replyTo.set(null);
}
toggleEmojiPicker(messageId: string): void {
this.showEmojiPicker.update((current) =>
current === messageId ? null : messageId
);
}
addReaction(messageId: string, emoji: string): void {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
this.showEmojiPicker.set(null);
}
toggleReaction(messageId: string, emoji: string): void {
const message = this.messages().find((m) => m.id === messageId);
const currentUserId = this.currentUser()?.id;
if (!message || !currentUserId) return;
const hasReacted = message.reactions.some(
(r) => r.emoji === emoji && r.userId === currentUserId
);
if (hasReacted) {
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
} else {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
}
}
isOwnMessage(message: Message): boolean {
return message.senderId === this.currentUser()?.id;
}
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
const currentUserId = this.currentUser()?.id;
message.reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
groups.set(reaction.emoji, {
count: existing.count + 1,
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
});
});
return Array.from(groups.entries()).map(([emoji, data]) => ({
emoji,
...data,
}));
}
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date(this.nowRef());
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
private scrollToBottom(): void {
if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement;
el.scrollTop = el.scrollHeight;
this.shouldScrollToBottom = false;
}
}
private scrollToBottomSmooth(): void {
if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement;
try {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} catch {
// Fallback if smooth not supported
el.scrollTop = el.scrollHeight;
}
this.shouldScrollToBottom = false;
}
}
private scheduleScrollToBottomSmooth(): void {
// Use double rAF to ensure DOM updated and layout computed before scrolling
requestAnimationFrame(() => {
requestAnimationFrame(() => this.scrollToBottomSmooth());
});
}
onScroll(): void {
if (!this.messagesContainer) return;
const el = this.messagesContainer.nativeElement;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
this.shouldScrollToBottom = distanceFromBottom <= 300;
if (this.shouldScrollToBottom) {
this.showNewMessagesBar.set(false);
}
}
private recomputeTypingDisplay(now: number): void {
const entries = Array.from(this.typingMap.values())
.filter(e => e.expiresAt > now)
.map(e => e.name);
const maxShow = 4;
const shown = entries.slice(0, maxShow);
const others = Math.max(0, entries.length - shown.length);
this.typingDisplay.set(shown);
this.typingOthersCount.set(others);
}
// Snackbar: scroll to latest
readLatest(): void {
this.shouldScrollToBottom = true;
this.scrollToBottomSmooth();
this.showNewMessagesBar.set(false);
}
}

View File

@@ -0,0 +1,276 @@
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
} from '@ng-icons/lucide';
import * as UsersActions from '../../store/users/users.actions';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin,
} from '../../store/users/users.selectors';
import { User } from '../../core/models';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
}),
],
template: `
<div class="h-full flex flex-col bg-card border-l border-border">
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }}
</span>
}
</div>
}
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<div
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
(click)="toggleUserMenu(user.id)"
>
<!-- Avatar with online indicator -->
<div class="relative">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
{{ user.displayName.charAt(0).toUpperCase() }}
</div>
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
}
@if (user.isRoomOwner) {
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
}
</div>
</div>
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
} @else if (user.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
} @else if (user.voiceState?.isConnected) {
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
}
@if (user.screenShareState?.isSharing) {
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()"
>
@if (user.voiceState?.isConnected) {
<button
(click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon name="lucideVolume2" class="w-4 h-4" />
<span>Unmute</span>
} @else {
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
<span>Mute</span>
}
</button>
}
<button
(click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
<span>Kick</span>
</button>
<button
(click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
<span>Ban</span>
</button>
</div>
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">
No users online
</div>
}
</div>
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeBanDialog()">
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
<h3 class="text-lg font-semibold text-foreground mb-4">Ban User</h3>
<p class="text-sm text-muted-foreground mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
<select
[(ngModel)]="banDuration"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
</div>
<div class="flex gap-2 justify-end">
<button
(click)="closeBanDialog()"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Cancel
</button>
<button
(click)="confirmBan()"
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
>
Ban User
</button>
</div>
</div>
</div>
}
`,
})
export class UserListComponent {
private store = inject(Store);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
voiceUsers = computed(() => this.onlineUsers().filter(u => !!u.voiceState?.isConnected));
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
showUserMenu = signal<string | null>(null);
showBanDialog = signal(false);
userToBan = signal<User | null>(null);
banReason = '';
banDuration = '86400000'; // Default 1 day
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));
}
isCurrentUser(user: User): boolean {
return user.id === this.currentUser()?.id;
}
muteUser(user: User): void {
if (user.voiceState?.isMutedByAdmin) {
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
} else {
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
}
this.showUserMenu.set(null);
}
kickUser(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.showUserMenu.set(null);
}
banUser(user: User): void {
this.userToBan.set(user);
this.showBanDialog.set(true);
this.showUserMenu.set(null);
}
closeBanDialog(): void {
this.showBanDialog.set(false);
this.userToBan.set(null);
this.banReason = '';
this.banDuration = '86400000';
}
confirmBan(): void {
const user = this.userToBan();
if (!user) return;
const duration = parseInt(this.banDuration, 10);
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
this.store.dispatch(
UsersActions.banUser({
userId: user.id,
reason: this.banReason || undefined,
expiresAt,
})
);
this.closeBanDialog();
}
}

View File

@@ -0,0 +1,97 @@
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideHash,
lucideSettings,
lucideUsers,
lucideMenu,
lucideX,
lucideChevronLeft,
} from '@ng-icons/lucide';
import { ChatMessagesComponent } from '../chat/chat-messages.component';
import { UserListComponent } from '../chat/user-list.component';
import { ScreenShareViewerComponent } from '../voice/screen-share-viewer.component';
import { AdminPanelComponent } from '../admin/admin-panel.component';
import { RoomsSidePanelComponent } from './rooms-side-panel.component';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
@Component({
selector: 'app-chat-room',
standalone: true,
imports: [
CommonModule,
NgIcon,
ChatMessagesComponent,
ScreenShareViewerComponent,
RoomsSidePanelComponent,
],
viewProviders: [
provideIcons({
lucideHash,
lucideSettings,
lucideUsers,
lucideMenu,
lucideX,
lucideChevronLeft,
}),
],
template: `
<div class="h-full flex flex-col bg-background">
@if (currentRoom()) {
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Left rail is global; chat area fills remaining space -->
<!-- Chat Area -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Screen Share Viewer -->
<app-screen-share-viewer />
<!-- Messages -->
<div class="flex-1 overflow-hidden">
<app-chat-messages />
</div>
</main>
<!-- Sidebar always visible -->
<aside class="w-80 flex-shrink-0 border-l border-border">
<app-rooms-side-panel class="h-full" />
</aside>
</div>
<!-- Voice Controls moved to sidebar bottom -->
<!-- Mobile overlay removed; sidebar remains visible by default -->
} @else {
<!-- No Room Selected -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-muted-foreground">
<ng-icon name="lucideHash" class="w-16 h-16 mx-auto mb-4 opacity-30" />
<h2 class="text-xl font-medium mb-2">No room selected</h2>
<p class="text-sm">Select or create a room to start chatting</p>
</div>
</div>
}
</div>
`,
})
export class ChatRoomComponent {
private store = inject(Store);
private router = inject(Router);
showMenu = signal(false);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
// Sidebar always visible; panel toggles removed
// Header moved to TitleBar
}

View File

@@ -0,0 +1,259 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import * as UsersActions from '../../store/users/users.actions';
import { WebRTCService } from '../../core/services/webrtc.service';
import { VoiceControlsComponent } from '../voice/voice-controls.component';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [CommonModule, NgIcon, VoiceControlsComponent],
viewProviders: [
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor })
],
template: `
<aside class="w-80 bg-card h-full flex flex-col">
<div class="p-4 border-b border-border flex items-center justify-between">
<h3 class="font-semibold text-foreground">Rooms</h3>
<button class="p-2 hover:bg-secondary rounded" (click)="backToServers()">
<ng-icon name="lucideChevronLeft" class="w-4 h-4" />
</button>
</div>
<div class="p-3 flex-1 overflow-auto">
<h4 class="text-xs text-muted-foreground mb-1">Chat Rooms</h4>
<div class="space-y-1">
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># general</button>
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># random</button>
</div>
</div>
<div class="p-3">
<h4 class="text-xs text-muted-foreground mb-1">Voice Rooms</h4>
@if (!voiceEnabled()) {
<p class="text-xs text-muted-foreground mb-2">Voice is disabled by host</p>
}
<div class="space-y-1">
<div>
<button
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
(click)="joinVoice('general')"
[class.bg-secondary/30]="isCurrentRoom('general')"
[class.border-l-2]="isCurrentRoom('general')"
[class.border-primary]="isCurrentRoom('general')"
[disabled]="!voiceEnabled()"
>
<span class="text-foreground/90">🔊 General</span>
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('general') }}</span>
</button>
@if (voiceUsersInRoom('general').length > 0) {
<div class="mt-1 ml-6 space-y-1">
@for (u of voiceUsersInRoom('general'); track u.id) {
<div class="flex items-center gap-2 p-2 rounded-md hover:bg-secondary/60 transition-colors">
<!-- Avatar with status-colored border -->
@if (u.avatarUrl) {
<img
[src]="u.avatarUrl"
alt="avatar"
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
/>
} @else {
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-xs ring-2"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
>
{{ u.displayName.charAt(0).toUpperCase() }}
</div>
}
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
</div>
}
</div>
}
</div>
<div>
<button
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
(click)="joinVoice('afk')"
[class.bg-secondary/30]="isCurrentRoom('afk')"
[class.border-l-2]="isCurrentRoom('afk')"
[class.border-primary]="isCurrentRoom('afk')"
[disabled]="!voiceEnabled()"
>
<span class="text-foreground/90">🔕 AFK</span>
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('afk') }}</span>
</button>
@if (voiceUsersInRoom('afk').length > 0) {
<div class="mt-1 ml-6 space-y-1">
@for (u of voiceUsersInRoom('afk'); track u.id) {
<div class="flex items-center gap-2 p-2 rounded-md hover:bg-secondary/60 transition-colors">
<!-- Avatar with status-colored border -->
@if (u.avatarUrl) {
<img
[src]="u.avatarUrl"
alt="avatar"
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
/>
} @else {
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-xs ring-2"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
>
{{ u.displayName.charAt(0).toUpperCase() }}
</div>
}
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
</div>
}
</div>
}
</div>
</div>
</div>
<div class="p-3 border-t border-border">
<h4 class="text-xs text-muted-foreground mb-1">In Voice</h4>
<div class="space-y-1">
@for (u of onlineUsers(); track u.id) {
@if (u.voiceState?.isConnected) {
<div class="px-3 py-2 text-sm rounded-lg flex items-center gap-2 hover:bg-secondary/60">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
<span class="truncate">{{ u.displayName }}</span>
<span class="flex-1"></span>
@if (u.voiceState?.isMuted) {
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" /></span>
} @else if (u.voiceState?.isSpeaking) {
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" /></span>
} @else if (u.voiceState?.isConnected) {
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" /></span>
}
@if (isUserSharing(u.id)) {
<button class="ml-2 inline-flex items-center justify-center w-8 h-8 rounded hover:bg-secondary" (click)="viewShare(u.id)">
<ng-icon name="lucideMonitor" class="w-4 h-4 text-red-500" />
</button>
}
</div>
}
}
</div>
</div>
<!-- Voice controls pinned to sidebar bottom -->
@if (voiceEnabled()) {
<app-voice-controls />
}
</aside>
`,
})
export class RoomsSidePanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
// room selection is stored in voiceState.roomId in the store; no local tracking needed
backToServers() {
// Simple navigation: emit a custom event; wire to router/navigation in parent
window.dispatchEvent(new CustomEvent('navigate:servers'));
}
joinVoice(roomId: string) {
// Gate by room permissions
const room = this.currentRoom();
if (room && room.permissions && room.permissions.allowVoice === false) {
console.warn('Voice is disabled by room permissions');
return;
}
// Enable microphone and broadcast voice-state
this.webrtc.enableVoice().then(() => {
const current = this.currentUser();
if (current?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: true, isMuted: false, isDeafened: false, roomId: roomId }
}));
}
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
voiceState: { isConnected: true, isMuted: false, isDeafened: false, roomId: roomId }
});
}).catch((e) => console.error('Failed to join voice room', roomId, e));
}
leaveVoice(roomId: string) {
const current = this.currentUser();
// Only leave if currently in this room
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
// Disable voice locally
this.webrtc.disableVoice();
// Update store voice state
if (current?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
}));
}
// Broadcast disconnect
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
});
}
voiceOccupancy(roomId: string): number {
const users = this.onlineUsers();
return users.filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId).length;
}
viewShare(userId: string) {
// Focus viewer on a user's stream if present
// Requires WebRTCService to expose a remote streams registry.
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt);
}
isUserSharing(userId: string): boolean {
const me = this.currentUser();
if (me?.id === userId) {
// Local user: use signal
return this.webrtc.isScreenSharing();
}
const stream = this.webrtc.getRemoteStream(userId);
return !!stream && stream.getVideoTracks().length > 0;
}
voiceUsersInRoom(roomId: string) {
return this.onlineUsers().filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId);
}
isCurrentRoom(roomId: string): boolean {
const me = this.currentUser();
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId);
}
voiceEnabled(): boolean {
const room = this.currentRoom();
return room?.permissions?.allowVoice !== false;
}
}

View File

@@ -0,0 +1,340 @@
import { Component, 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, Subject } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
} from '@ng-icons/lucide';
import * as RoomsActions from '../../store/rooms/rooms.actions';
import {
selectSearchResults,
selectIsSearching,
selectRoomsError,
selectSavedRooms,
} from '../../store/rooms/rooms.selectors';
import { Room } from '../../core/models';
import { ServerInfo } from '../../core/models';
@Component({
selector: 'app-server-search',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({ lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings }),
],
template: `
<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)"
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()"
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()"
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)"
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
{{ server.name }}
</h3>
@if (server.isPrivate) {
<ng-icon name="lucideLock" class="w-4 h-4 text-muted-foreground" />
} @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>{{ server.userCount }}/{{ server.maxUsers }}</span>
</div>
</div>
<div class="mt-2 text-xs text-muted-foreground">
Hosted by {{ server.hostName }}
</div>
</button>
}
</div>
}
</div>
@if (error()) {
<div class="p-4 bg-destructive/10 border-t border-destructive">
<p class="text-sm text-destructive">{{ error() }}</p>
</div>
}
</div>
<!-- Create Server Dialog -->
@if (showCreateDialog()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeCreateDialog()">
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()">
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-1">Server Name</label>
<input
type="text"
[(ngModel)]="newServerName"
placeholder="My Awesome Server"
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 class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
<textarea
[(ngModel)]="newServerDescription"
placeholder="What's your server about?"
rows="3"
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 class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
<input
type="text"
[(ngModel)]="newServerTopic"
placeholder="gaming, music, coding..."
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 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>
@if (newServerPrivate()) {
<div>
<label class="block text-sm font-medium text-foreground mb-1">Password</label>
<input
type="password"
[(ngModel)]="newServerPassword"
placeholder="Enter 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"
/>
</div>
}
</div>
<div class="flex gap-3 mt-6">
<button
(click)="closeCreateDialog()"
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()"
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>
}
`,
})
export class ServerSearchComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
private searchSubject = new Subject<string>();
searchQuery = '';
searchResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching);
error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms);
// Create dialog state
showCreateDialog = signal(false);
newServerName = signal('');
newServerDescription = signal('');
newServerTopic = signal('');
newServerPrivate = signal(false);
newServerPassword = signal('');
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 }));
});
}
onSearchChange(query: string): void {
this.searchSubject.next(query);
}
joinServer(server: ServerInfo): void {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.store.dispatch(RoomsActions.joinRoom({
roomId: server.id,
serverInfo: {
name: server.name,
description: server.description,
hostName: server.hostName,
}
}));
}
openCreateDialog(): void {
this.showCreateDialog.set(true);
}
closeCreateDialog(): void {
this.showCreateDialog.set(false);
this.resetCreateForm();
}
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.newServerPrivate() ? this.newServerPassword() : undefined,
})
);
this.closeCreateDialog();
}
openSettings(): void {
this.router.navigate(['/settings']);
}
joinSavedRoom(room: Room): void {
this.joinServer({
id: room.id,
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
userCount: room.userCount,
maxUsers: room.maxUsers || 50,
isPrivate: !!room.password,
createdAt: room.createdAt,
} as any);
}
private resetCreateForm(): void {
this.newServerName.set('');
this.newServerDescription.set('');
this.newServerTopic.set('');
this.newServerPrivate.set(false);
this.newServerPassword.set('');
}
}

View File

@@ -0,0 +1,170 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import { Room } from '../../core/models';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import * as RoomsActions from '../../store/rooms/rooms.actions';
@Component({
selector: 'app-servers-rail',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucidePlus })],
template: `
<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
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">
<ng-container *ngFor="let room of savedRooms(); trackBy: trackRoomId">
<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)"
>
<ng-container *ngIf="room.icon; else noIcon">
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
</ng-container>
<ng-template #noIcon>
<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>
</ng-template>
</button>
</ng-container>
</div>
</nav>
<!-- Context menu -->
<div *ngIf="showMenu()" class="">
<div class="fixed inset-0 z-40" (click)="closeMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-md w-44" [style.left.px]="menuX()" [style.top.px]="menuY()">
<button *ngIf="isCurrentContextRoom()" (click)="leaveServer()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Leave Server</button>
<button (click)="openForgetConfirm()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Forget Server</button>
</div>
</div>
<!-- Forget confirmation dialog -->
<div *ngIf="showConfirm()">
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelForget()"></div>
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[280px]">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">Forget Server?</h4>
<p class="text-sm text-muted-foreground">
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
</p>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button (click)="cancelForget()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors">Cancel</button>
<button (click)="confirmForget()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors">Forget</button>
</div>
</div>
</div>
`,
})
export class ServersRailComponent {
private store = inject(Store);
private router = inject(Router);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
// Context menu state
showMenu = signal(false);
menuX = signal(72); // rail width (~64px) + padding, position menu to the right
menuY = signal(100);
contextRoom = signal<Room | null>(null);
// Confirmation dialog state
showConfirm = signal(false);
initial(name?: string): string {
if (!name) return '?';
const ch = name.trim()[0]?.toUpperCase();
return ch || '?';
}
trackRoomId = (index: number, room: Room) => room.id;
createServer(): void {
// Navigate to server list (has create button)
this.router.navigate(['/search']);
}
joinSavedRoom(room: Room): void {
// Require auth: if no current user, go to login
const current = this.currentRoom();
// currentRoom presence does not indicate auth; check localStorage for currentUserId
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.store.dispatch(RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
},
}));
}
openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault();
this.contextRoom.set(room);
// Position menu slightly to the right of cursor to avoid overlapping the rail
this.menuX.set(Math.max((evt.clientX + 8), 72));
this.menuY.set(evt.clientY);
this.showMenu.set(true);
}
closeMenu(): void {
this.showMenu.set(false);
// keep contextRoom for potential confirmation dialog
}
isCurrentContextRoom(): boolean {
const ctx = this.contextRoom();
const cur = this.currentRoom();
return !!ctx && !!cur && ctx.id === cur.id;
}
leaveServer(): void {
this.closeMenu();
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
}
openForgetConfirm(): void {
this.showConfirm.set(true);
this.closeMenu();
}
confirmForget(): void {
const ctx = this.contextRoom();
if (!ctx) return;
if (this.currentRoom()?.id === ctx.id) {
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
}
this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id }));
this.showConfirm.set(false);
this.contextRoom.set(null);
}
cancelForget(): void {
this.showConfirm.set(false);
}
}

View File

@@ -0,0 +1,297 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideServer,
lucidePlus,
lucideTrash2,
lucideCheck,
lucideX,
lucideSettings,
lucideRefreshCw,
lucideGlobe,
lucideArrowLeft,
} from '@ng-icons/lucide';
import { ServerDirectoryService, ServerEndpoint } from '../../core/services/server-directory.service';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideServer,
lucidePlus,
lucideTrash2,
lucideCheck,
lucideX,
lucideSettings,
lucideRefreshCw,
lucideGlobe,
lucideArrowLeft,
}),
],
template: `
<div class="p-6 max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<button
(click)="goBack()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Go back"
>
<ng-icon name="lucideArrowLeft" class="w-5 h-5 text-muted-foreground" />
</button>
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" />
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
</div>
<!-- Server Endpoints Section -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
</div>
<button
(click)="testAllServers()"
[disabled]="isTesting()"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
>
<ng-icon name="lucideRefreshCw" class="w-4 h-4" [class.animate-spin]="isTesting()" />
Test All
</button>
</div>
<p class="text-sm text-muted-foreground mb-4">
Add multiple server directories to search for rooms across different networks.
The active server will be used for creating and registering new rooms.
</p>
<!-- Server List -->
<div class="space-y-3 mb-4">
@for (server of servers(); track server.id) {
<div
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
[class.border-primary]="server.isActive"
[class.bg-primary/5]="server.isActive"
[class.border-border]="!server.isActive"
[class.bg-secondary/30]="!server.isActive"
>
<!-- Status Indicator -->
<div
class="w-3 h-3 rounded-full flex-shrink-0"
[class.bg-green-500]="server.status === 'online'"
[class.bg-red-500]="server.status === 'offline'"
[class.bg-yellow-500]="server.status === 'checking'"
[class.bg-muted]="server.status === 'unknown'"
[title]="server.status"
></div>
<!-- Server Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
@if (server.isActive) {
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
}
</div>
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
@if (server.latency !== undefined && server.status === 'online') {
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
}
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
@if (!server.isActive) {
<button
(click)="setActiveServer(server.id)"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
title="Set as active"
>
<ng-icon name="lucideCheck" class="w-4 h-4 text-muted-foreground hover:text-primary" />
</button>
}
@if (!server.isDefault) {
<button
(click)="removeServer(server.id)"
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
title="Remove server"
>
<ng-icon name="lucideTrash2" class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</button>
}
</div>
</div>
}
</div>
<!-- Add New Server -->
<div class="border-t border-border pt-4">
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
<div class="flex gap-3">
<div class="flex-1 space-y-2">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name (e.g., My Server)"
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"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
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>
<button
(click)="addServer()"
[disabled]="!newServerName || !newServerUrl"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
>
<ng-icon name="lucidePlus" class="w-5 h-5" />
</button>
</div>
@if (addError()) {
<p class="text-sm text-destructive mt-2">{{ addError() }}</p>
}
</div>
</div>
<!-- Connection Settings -->
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center gap-2 mb-4">
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Auto-reconnect</p>
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="autoReconnect"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-foreground">Search all servers</p>
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="searchAllServers"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
</label>
</div>
</div>
</div>
</div>
`,
})
export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryService);
private router = inject(Router);
servers = this.serverDirectory.servers;
isTesting = signal(false);
addError = signal<string | null>(null);
newServerName = '';
newServerUrl = '';
autoReconnect = true;
searchAllServers = true;
ngOnInit(): void {
this.loadConnectionSettings();
}
addServer(): void {
this.addError.set(null);
// Validate URL
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
return;
}
// Check for duplicates
if (this.servers().some((s) => s.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
return;
}
this.serverDirectory.addServer({
name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, ''), // Remove trailing slash
});
// Clear form
this.newServerName = '';
this.newServerUrl = '';
// Test the new server
const servers = this.servers();
const newServer = servers[servers.length - 1];
if (newServer) {
this.serverDirectory.testServer(newServer.id);
}
}
removeServer(id: string): void {
this.serverDirectory.removeServer(id);
}
setActiveServer(id: string): void {
this.serverDirectory.setActiveServer(id);
}
async testAllServers(): Promise<void> {
this.isTesting.set(true);
await this.serverDirectory.testAllServers();
this.isTesting.set(false);
}
loadConnectionSettings(): void {
const settings = localStorage.getItem('metoyou_connection_settings');
if (settings) {
const parsed = JSON.parse(settings);
this.autoReconnect = parsed.autoReconnect ?? true;
this.searchAllServers = parsed.searchAllServers ?? true;
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
}
saveConnectionSettings(): void {
localStorage.setItem(
'metoyou_connection_settings',
JSON.stringify({
autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers,
})
);
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
goBack(): void {
this.router.navigate(['/']);
}
}

View File

@@ -0,0 +1,127 @@
import { Component, inject, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide';
import { Router } from '@angular/router';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import * as RoomsActions from '../../store/rooms/rooms.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { WebRTCService } from '../../core/services/webrtc.service';
@Component({
selector: 'app-title-bar',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
template: `
<div class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none" style="-webkit-app-region: drag;">
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
<button *ngIf="inRoom()" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
</button>
<ng-container *ngIf="inRoom(); else userServer">
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
<span *ngIf="roomDescription()" class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">{{ roomDescription() }}</span>
<button (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
</button>
<!-- Anchored dropdown under the menu button -->
<div *ngIf="showMenu()" class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
<button (click)="leaveServer()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Leave Server</button>
<div class="border-t border-border"></div>
<button (click)="logout()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Logout</button>
</div>
</ng-container>
<ng-template #userServer>
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
<span *ngIf="!isConnected()" class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
</div>
</ng-template>
</div>
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
<button *ngIf="!isAuthed()" class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground" (click)="goLogin()" title="Login">Login</button>
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
<ng-icon name="lucideMinus" class="w-4 h-4" />
</button>
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
<ng-icon name="lucideSquare" class="w-4 h-4" />
</button>
<button class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
</button>
</div>
</div>
<!-- Click-away overlay to close dropdown -->
<div *ngIf="showMenu()" class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
`,
})
export class TitleBarComponent {
private store = inject(Store);
private serverDirectory = inject(ServerDirectoryService);
private router = inject(Router);
private webrtc = inject(WebRTCService);
showMenuState = computed(() => false);
private currentUserSig = this.store.selectSignal(selectCurrentUser);
username = computed(() => this.currentUserSig()?.displayName || 'Guest');
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
isConnected = computed(() => this.webrtc.isConnected());
isAuthed = computed(() => !!this.currentUserSig());
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
inRoom = computed(() => !!this.currentRoomSig());
roomName = computed(() => this.currentRoomSig()?.name || '');
roomDescription = computed(() => this.currentRoomSig()?.description || '');
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
minimize() {
const api = (window as any).electronAPI;
if (api?.minimizeWindow) api.minimizeWindow();
}
maximize() {
const api = (window as any).electronAPI;
if (api?.maximizeWindow) api.maximizeWindow();
}
close() {
const api = (window as any).electronAPI;
if (api?.closeWindow) api.closeWindow();
}
goLogin() {
this.router.navigate(['/login']);
}
onBack() {
// Leave room to ensure header switches to user/server view
this.store.dispatch(RoomsActions.leaveRoom());
this.router.navigate(['/search']);
}
toggleMenu() {
this._showMenu.set(!this._showMenu());
}
leaveServer() {
this._showMenu.set(false);
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
}
closeMenu() {
this._showMenu.set(false);
}
logout() {
this._showMenu.set(false);
try {
localStorage.removeItem('metoyou_currentUserId');
} catch {}
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,231 @@
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Subscription } from 'rxjs';
import {
lucideMaximize,
lucideMinimize,
lucideX,
lucideMonitor,
} from '@ng-icons/lucide';
import { WebRTCService } from '../../core/services/webrtc.service';
import { selectOnlineUsers } from '../../store/users/users.selectors';
import { User } from '../../core/models';
@Component({
selector: 'app-screen-share-viewer',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideMaximize,
lucideMinimize,
lucideX,
lucideMonitor,
}),
],
template: `
<div
class="relative bg-black rounded-lg overflow-hidden"
[class.fixed]="isFullscreen()"
[class.inset-0]="isFullscreen()"
[class.z-50]="isFullscreen()"
[class.hidden]="!hasStream()"
>
<!-- Video Element -->
<video
#screenVideo
autoplay
playsinline
class="w-full h-full object-contain"
[class.max-h-[400px]]="!isFullscreen()"
></video>
<!-- Overlay Controls -->
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-white">
<ng-icon name="lucideMonitor" class="w-4 h-4" />
<span class="text-sm font-medium" *ngIf="activeScreenSharer(); else sharingUnknown">
{{ activeScreenSharer()?.displayName }} is sharing their screen
</span>
<ng-template #sharingUnknown>
<span class="text-sm font-medium">Someone is sharing their screen</span>
</ng-template>
</div>
<div class="flex items-center gap-2">
<button
(click)="toggleFullscreen()"
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
>
@if (isFullscreen()) {
<ng-icon name="lucideMinimize" class="w-4 h-4 text-white" />
} @else {
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
}
</button>
@if (isLocalShare()) {
<button
(click)="stopSharing()"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
>
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
</button>
}
</div>
</div>
</div>
<!-- No Stream Placeholder -->
<div *ngIf="!hasStream()" class="absolute inset-0 flex items-center justify-center bg-secondary">
<div class="text-center text-muted-foreground">
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Waiting for screen share...</p>
</div>
</div>
</div>
`,
})
export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
private webrtcService = inject(WebRTCService);
private store = inject(Store);
private remoteStreamSub: Subscription | null = null;
onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeScreenSharer = signal<User | null>(null);
isFullscreen = signal(false);
hasStream = signal(false);
isLocalShare = signal(false);
private streamSubscription: (() => void) | null = null;
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
try {
const userId = evt.detail?.userId;
if (!userId) return;
const stream = this.webrtcService.getRemoteStream(userId);
const user = this.onlineUsers().find((u) => u.id === userId || u.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) {
if (user) this.setRemoteStream(stream, user);
else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.hasStream.set(true);
this.activeScreenSharer.set(null);
this.isLocalShare.set(false);
}
}
} catch (e) {
console.error('Failed to focus viewer on user stream:', e);
}
};
constructor() {
// React to screen share stream changes
effect(() => {
const screenStream = this.webrtcService.screenStream();
if (screenStream && this.videoRef) {
this.videoRef.nativeElement.srcObject = screenStream;
this.hasStream.set(true);
} else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null;
this.hasStream.set(false);
}
});
// Subscribe to remote streams with video (screen shares)
this.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId, stream }) => {
try {
const hasVideo = stream.getVideoTracks().length > 0;
if (!hasVideo) return;
// Find the user by peerId (oderId)
const user = this.onlineUsers().find((u) => u.id === peerId || u.oderId === peerId) || null;
// If we have a video stream, show it in the viewer
if (user) {
this.setRemoteStream(stream, user);
} else {
// Fallback: still show the stream without user details
this.activeScreenSharer.set(null);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.hasStream.set(true);
}
}
} catch (e) {
console.error('Failed to display remote screen share:', e);
}
});
// Listen for focus events dispatched by other components
window.addEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
ngOnDestroy(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
}
// Cleanup subscription
this.remoteStreamSub?.unsubscribe();
// Remove event listener
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
toggleFullscreen(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
}
enterFullscreen(): void {
this.isFullscreen.set(true);
// Request browser fullscreen if available
if (this.videoRef?.nativeElement.requestFullscreen) {
this.videoRef.nativeElement.requestFullscreen().catch(() => {
// Fallback to CSS fullscreen
});
}
}
exitFullscreen(): void {
this.isFullscreen.set(false);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
stopSharing(): void {
this.webrtcService.stopScreenShare();
this.activeScreenSharer.set(null);
this.hasStream.set(false);
this.isLocalShare.set(false);
}
// Called by parent when a remote peer starts sharing
setRemoteStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
this.isLocalShare.set(false);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.hasStream.set(true);
}
}
// Called when local user starts sharing
setLocalStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
this.isLocalShare.set(true);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.hasStream.set(true);
}
}
}

View File

@@ -0,0 +1,551 @@
import { Component, inject, signal, OnInit, OnDestroy, ElementRef, ViewChild, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Subscription } from 'rxjs';
import {
lucideMic,
lucideMicOff,
lucideVideo,
lucideVideoOff,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideSettings,
lucideHeadphones,
} from '@ng-icons/lucide';
import { WebRTCService } from '../../core/services/webrtc.service';
import * as UsersActions from '../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
interface AudioDevice {
deviceId: string;
label: string;
}
@Component({
selector: 'app-voice-controls',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideVideo,
lucideVideoOff,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideSettings,
lucideHeadphones,
}),
],
template: `
<div class="bg-card border-t border-border p-4">
<!-- User Info -->
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm text-foreground truncate">
{{ currentUser()?.displayName || 'Unknown' }}
</p>
<p class="text-xs text-muted-foreground">
@if (isConnected()) {
<span class="text-green-500">● Connected</span>
} @else {
<span class="text-muted-foreground">● Disconnected</span>
}
</p>
</div>
<button
(click)="toggleSettings()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
>
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" />
</button>
</div>
<!-- Voice Controls -->
<div class="flex items-center justify-center gap-2">
@if (isConnected()) {
<!-- Mute Toggle -->
<button
(click)="toggleMute()"
[class]="getMuteButtonClass()"
>
@if (isMuted()) {
<ng-icon name="lucideMicOff" class="w-5 h-5" />
} @else {
<ng-icon name="lucideMic" class="w-5 h-5" />
}
</button>
<!-- Deafen Toggle -->
<button
(click)="toggleDeafen()"
[class]="getDeafenButtonClass()"
>
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
</button>
<!-- Screen Share Toggle -->
<button
(click)="toggleScreenShare()"
[class]="getScreenShareButtonClass()"
>
@if (isScreenSharing()) {
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
} @else {
<ng-icon name="lucideMonitor" class="w-5 h-5" />
}
</button>
<!-- Disconnect -->
<button
(click)="disconnect()"
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
>
<ng-icon name="lucidePhoneOff" class="w-5 h-5" />
</button>
}
</div>
<!-- Settings Modal -->
@if (showSettings()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeSettings()">
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()">
<h2 class="text-xl font-semibold text-foreground mb-4">Voice Settings</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-1">Microphone</label>
<select
(change)="onInputDeviceChange($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (device of inputDevices(); track device.deviceId) {
<option [value]="device.deviceId" [selected]="device.deviceId === selectedInputDevice()">
{{ device.label || 'Microphone ' + $index }}
</option>
}
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Speaker</label>
<select
(change)="onOutputDeviceChange($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (device of outputDevices(); track device.deviceId) {
<option [value]="device.deviceId" [selected]="device.deviceId === selectedOutputDevice()">
{{ device.label || 'Speaker ' + $index }}
</option>
}
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Input Volume: {{ inputVolume() }}%
</label>
<input
type="range"
[value]="inputVolume()"
(input)="onInputVolumeChange($event)"
min="0"
max="100"
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Output Volume: {{ outputVolume() }}%
</label>
<input
type="range"
[value]="outputVolume()"
(input)="onOutputVolumeChange($event)"
min="0"
max="100"
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Latency</label>
<select (change)="onLatencyProfileChange($event)" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm">
<option value="low">Low (fast)</option>
<option value="balanced" selected>Balanced</option>
<option value="high">High (quality)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Audio Bitrate: {{ audioBitrate() }} kbps
</label>
<input type="range" [value]="audioBitrate()" (input)="onAudioBitrateChange($event)" min="32" max="256" step="8" class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" />
</div>
</div>
<div class="flex gap-3 mt-6">
<button
(click)="closeSettings()"
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Close
</button>
</div>
</div>
</div>
}
</div>
`,
})
export class VoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
private store = inject(Store);
private remoteStreamSubscription: Subscription | null = null;
private remoteAudioElements = new Map<string, HTMLAudioElement>();
private pendingRemoteStreams = new Map<string, MediaStream>();
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isConnected = computed(() => this.webrtcService.isVoiceConnected());
isMuted = signal(false);
isDeafened = signal(false);
isScreenSharing = signal(false);
showSettings = signal(false);
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>('');
inputVolume = signal(100);
outputVolume = signal(100);
audioBitrate = signal(96);
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
async ngOnInit(): Promise<void> {
await this.loadAudioDevices();
// Subscribe to remote streams to play audio from peers
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
({ peerId, stream }) => {
console.log('Received remote stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
this.playRemoteAudio(peerId, stream);
}
);
// Clean up audio when peer disconnects
this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
this.removeRemoteAudio(peerId);
});
}
ngOnDestroy(): void {
if (this.isConnected()) {
this.disconnect();
}
// Clean up audio elements
this.remoteAudioElements.forEach((audio) => {
audio.srcObject = null;
audio.remove();
});
this.remoteAudioElements.clear();
this.remoteStreamSubscription?.unsubscribe();
}
private removeRemoteAudio(peerId: string): void {
// Remove from pending streams
this.pendingRemoteStreams.delete(peerId);
// Remove audio element
const audio = this.remoteAudioElements.get(peerId);
if (audio) {
audio.srcObject = null;
audio.remove();
this.remoteAudioElements.delete(peerId);
console.log('Removed remote audio for:', peerId);
}
}
private playRemoteAudio(peerId: string, stream: MediaStream): void {
// Only play remote audio if we have joined voice
if (!this.isConnected()) {
console.log('Not connected to voice, storing pending stream from:', peerId);
// Store the stream to play later when we connect
this.pendingRemoteStreams.set(peerId, stream);
return;
}
// Check if stream has audio tracks
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
console.log('No audio tracks in stream from:', peerId);
return;
}
// Remove existing audio element for this peer if any
const existingAudio = this.remoteAudioElements.get(peerId);
if (existingAudio) {
existingAudio.srcObject = null;
existingAudio.remove();
}
// Create a new audio element for this peer
const audio = new Audio();
audio.srcObject = stream;
audio.autoplay = true;
audio.volume = this.outputVolume() / 100;
// Mute if deafened
if (this.isDeafened()) {
audio.muted = true;
}
// Play the audio
audio.play().then(() => {
console.log('Playing remote audio from:', peerId);
}).catch((error) => {
console.error('Failed to play remote audio from:', peerId, error);
});
this.remoteAudioElements.set(peerId, audio);
}
async loadAudioDevices(): Promise<void> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set(
devices
.filter((d) => d.kind === 'audioinput')
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
);
this.outputDevices.set(
devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
);
} catch (error) {
console.error('Failed to enumerate devices:', error);
}
}
async connect(): Promise<void> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: this.selectedInputDevice() || undefined,
echoCancellation: true,
noiseSuppression: true,
},
});
this.webrtcService.setLocalStream(stream);
// Broadcast voice state to other users
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
voiceState: {
isConnected: true,
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
},
});
// Play any pending remote streams now that we're connected
this.pendingRemoteStreams.forEach((pendingStream, peerId) => {
console.log('Playing pending stream from:', peerId);
this.playRemoteAudio(peerId, pendingStream);
});
this.pendingRemoteStreams.clear();
} catch (error) {
console.error('Failed to get user media:', error);
}
}
disconnect(): void {
// Broadcast voice disconnect to other users
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
},
});
// Stop screen sharing if active
if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare();
}
// Disable voice (stops audio tracks but keeps peer connections open for chat)
this.webrtcService.disableVoice();
// Clear all remote audio elements
this.remoteAudioElements.forEach((audio) => {
audio.srcObject = null;
audio.remove();
});
this.remoteAudioElements.clear();
this.pendingRemoteStreams.clear();
const user = this.currentUser();
if (user?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
}));
}
this.isScreenSharing.set(false);
this.isMuted.set(false);
this.isDeafened.set(false);
}
toggleMute(): void {
this.isMuted.update((v) => !v);
this.webrtcService.toggleMute(this.isMuted());
// Broadcast mute state change
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
},
});
}
toggleDeafen(): void {
this.isDeafened.update((v) => !v);
this.webrtcService.toggleDeafen(this.isDeafened());
// Mute/unmute all remote audio elements
this.remoteAudioElements.forEach((audio) => {
audio.muted = this.isDeafened();
});
// When deafening, also mute
if (this.isDeafened() && !this.isMuted()) {
this.isMuted.set(true);
this.webrtcService.toggleMute(true);
}
// Broadcast deafen state change
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
},
});
}
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare();
this.isScreenSharing.set(false);
} else {
try {
await this.webrtcService.startScreenShare();
this.isScreenSharing.set(true);
} catch (error) {
console.error('Failed to start screen share:', error);
}
}
}
toggleSettings(): void {
this.showSettings.update((v) => !v);
}
closeSettings(): void {
this.showSettings.set(false);
}
onInputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedInputDevice.set(select.value);
// Reconnect with new device if connected
if (this.isConnected()) {
this.disconnect();
this.connect();
}
}
onOutputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value);
}
onInputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10));
}
onOutputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.outputVolume.set(parseInt(input.value, 10));
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
// Update volume on all remote audio elements
this.remoteAudioElements.forEach((audio) => {
audio.volume = this.outputVolume() / 100;
});
}
onLatencyProfileChange(event: Event): void {
const select = event.target as HTMLSelectElement;
const profile = select.value as 'low'|'balanced'|'high';
this.latencyProfile.set(profile);
this.webrtcService.setLatencyProfile(profile);
}
onAudioBitrateChange(event: Event): void {
const input = event.target as HTMLInputElement;
const kbps = parseInt(input.value, 10);
this.audioBitrate.set(kbps);
this.webrtcService.setAudioBitrate(kbps);
}
getMuteButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
if (this.isMuted()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
}
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
}
getDeafenButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
if (this.isDeafened()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
}
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
}
getScreenShareButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
if (this.isScreenSharing()) {
return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
}
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
}
}