diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 2b5bb46..14c0b17 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -9,12 +9,12 @@ export const routes: Routes = [ { path: 'login', loadComponent: () => - import('./features/auth/login.component').then((m) => m.LoginComponent), + import('./features/auth/login/login.component').then((m) => m.LoginComponent), }, { path: 'register', loadComponent: () => - import('./features/auth/register.component').then((m) => m.RegisterComponent), + import('./features/auth/register/register.component').then((m) => m.RegisterComponent), }, { path: 'search', @@ -26,7 +26,7 @@ export const routes: Routes = [ { path: 'room/:roomId', loadComponent: () => - import('./features/room/chat-room.component').then((m) => m.ChatRoomComponent), + import('./features/room/chat-room/chat-room.component').then((m) => m.ChatRoomComponent), }, { path: 'settings', diff --git a/src/app/app.ts b/src/app/app.ts index f660f59..e27564c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -9,7 +9,7 @@ import { TimeSyncService } from './core/services/time-sync.service'; import { VoiceSessionService } from './core/services/voice-session.service'; import { ServersRailComponent } from './features/servers/servers-rail.component'; import { TitleBarComponent } from './features/shell/title-bar.component'; -import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls.component'; +import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component'; import * as UsersActions from './store/users/users.actions'; import * as RoomsActions from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index 484a37e..06f2983 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -1119,7 +1119,7 @@ export class WebRTCService { this._screenStream = null; this._screenStreamSignal.set(null); this._isScreenSharing.set(false); - + // Immediately broadcast that we stopped sharing this.broadcastCurrentStates(); } diff --git a/src/app/features/admin/admin-panel.component.ts b/src/app/features/admin/admin-panel.component.ts deleted file mode 100644 index 35a4823..0000000 --- a/src/app/features/admin/admin-panel.component.ts +++ /dev/null @@ -1,467 +0,0 @@ -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()) { -
- -
- -

Admin Panel

-
- - -
- - - -
- - -
- @switch (activeTab()) { - @case ('settings') { -
-

Room Settings

- - -
- - -
- - -
- - -
- - -
-
-

Private Room

-

Require approval to join

-
- -
- - -
- - -
- - - - - -
-

Danger Zone

- -
-
- } - @case ('bans') { -
-

Banned Users

- - @if (bannedUsers().length === 0) { -

- No banned users -

- } @else { - @for (ban of bannedUsers(); track ban.oderId) { -
-
- {{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }} -
-
-

- {{ ban.displayName || 'Unknown User' }} -

- @if (ban.reason) { -

- Reason: {{ ban.reason }} -

- } - @if (ban.expiresAt) { -

- Expires: {{ formatExpiry(ban.expiresAt) }} -

- } @else { -

Permanent

- } -
- -
- } - } -
- } - @case ('permissions') { -
-

Room Permissions

- - -
-
-
-

Allow Voice Chat

-

Users can join voice channels

-
- -
- -
-
-

Allow Screen Share

-

Users can share their screen

-
- -
- -
-
-

Allow File Uploads

-

Users can upload files

-
- -
- -
-
-

Slow Mode

-

Limit message frequency

-
- -
- - -
-
-

Admins Can Manage Rooms

-

Allow admins to create/modify chat & voice rooms

-
- -
- -
-
-

Moderators Can Manage Rooms

-

Allow moderators to create/modify chat & voice rooms

-
- -
- -
-
-

Admins Can Change Server Icon

-

Grant icon management to admins

-
- -
- -
-
-

Moderators Can Change Server Icon

-

Grant icon management to moderators

-
- -
-
- - - -
- } - } -
-
- - - @if (showDeleteConfirm()) { -
-
-

Delete Room

-

- Are you sure you want to delete this room? This action cannot be undone. -

-
- - -
-
-
- } - } @else { -
-

You don't have admin permissions

-
- } - `, -}) -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('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' }); - } -} diff --git a/src/app/features/admin/admin-panel/admin-panel.component.html b/src/app/features/admin/admin-panel/admin-panel.component.html new file mode 100644 index 0000000..2060bbb --- /dev/null +++ b/src/app/features/admin/admin-panel/admin-panel.component.html @@ -0,0 +1,306 @@ +@if (isAdmin()) { +
+ +
+ +

Admin Panel

+
+ + +
+ + + +
+ + +
+ @switch (activeTab()) { + @case ('settings') { +
+

Room Settings

+ + +
+ + +
+ + +
+ + +
+ + +
+
+

Private Room

+

Require approval to join

+
+ +
+ + +
+ + +
+ + + + + +
+

Danger Zone

+ +
+
+ } + @case ('bans') { +
+

Banned Users

+ + @if (bannedUsers().length === 0) { +

+ No banned users +

+ } @else { + @for (ban of bannedUsers(); track ban.oderId) { +
+
+ {{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }} +
+
+

+ {{ ban.displayName || 'Unknown User' }} +

+ @if (ban.reason) { +

+ Reason: {{ ban.reason }} +

+ } + @if (ban.expiresAt) { +

+ Expires: {{ formatExpiry(ban.expiresAt) }} +

+ } @else { +

Permanent

+ } +
+ +
+ } + } +
+ } + @case ('permissions') { +
+

Room Permissions

+ + +
+
+
+

Allow Voice Chat

+

Users can join voice channels

+
+ +
+ +
+
+

Allow Screen Share

+

Users can share their screen

+
+ +
+ +
+
+

Allow File Uploads

+

Users can upload files

+
+ +
+ +
+
+

Slow Mode

+

Limit message frequency

+
+ +
+ + +
+
+

Admins Can Manage Rooms

+

Allow admins to create/modify chat & voice rooms

+
+ +
+ +
+
+

Moderators Can Manage Rooms

+

Allow moderators to create/modify chat & voice rooms

+
+ +
+ +
+
+

Admins Can Change Server Icon

+

Grant icon management to admins

+
+ +
+ +
+
+

Moderators Can Change Server Icon

+

Grant icon management to moderators

+
+ +
+
+ + + +
+ } + } +
+
+ + + @if (showDeleteConfirm()) { +
+
+

Delete Room

+

+ Are you sure you want to delete this room? This action cannot be undone. +

+
+ + +
+
+
+ } +} @else { +
+

You don't have admin permissions

+
+} diff --git a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts new file mode 100644 index 0000000..6bba8e4 --- /dev/null +++ b/src/app/features/admin/admin-panel/admin-panel.component.ts @@ -0,0 +1,160 @@ +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, + }), + ], + templateUrl: './admin-panel.component.html', +}) +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('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' }); + } +} diff --git a/src/app/features/auth/login.component.ts b/src/app/features/auth/login.component.ts deleted file mode 100644 index ad4770a..0000000 --- a/src/app/features/auth/login.component.ts +++ /dev/null @@ -1,94 +0,0 @@ -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: ` -
-
-
- -

Login

-
- -
-
- - -
-
- - -
-
- - -
-

{{ error() }}

- -
- No account? Register -
-
-
-
- `, -}) -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(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']); - } -} diff --git a/src/app/features/auth/login/login.component.html b/src/app/features/auth/login/login.component.html new file mode 100644 index 0000000..9040f98 --- /dev/null +++ b/src/app/features/auth/login/login.component.html @@ -0,0 +1,30 @@ +
+
+
+ +

Login

+
+ +
+
+ + +
+
+ + +
+
+ + +
+

{{ error() }}

+ +
+ No account? Register +
+
+
+
diff --git a/src/app/features/auth/login/login.component.ts b/src/app/features/auth/login/login.component.ts new file mode 100644 index 0000000..41b899b --- /dev/null +++ b/src/app/features/auth/login/login.component.ts @@ -0,0 +1,63 @@ +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 })], + templateUrl: './login.component.html', +}) +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(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']); + } +} diff --git a/src/app/features/auth/register.component.ts b/src/app/features/auth/register.component.ts deleted file mode 100644 index 49ad3c7..0000000 --- a/src/app/features/auth/register.component.ts +++ /dev/null @@ -1,99 +0,0 @@ -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: ` -
-
-
- -

Register

-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-

{{ error() }}

- -
- Have an account? Login -
-
-
-
- `, -}) -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(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']); - } -} diff --git a/src/app/features/auth/register/register.component.html b/src/app/features/auth/register/register.component.html new file mode 100644 index 0000000..877b802 --- /dev/null +++ b/src/app/features/auth/register/register.component.html @@ -0,0 +1,34 @@ +
+
+
+ +

Register

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

{{ error() }}

+ +
+ Have an account? Login +
+
+
+
diff --git a/src/app/features/auth/register/register.component.ts b/src/app/features/auth/register/register.component.ts new file mode 100644 index 0000000..4457aaf --- /dev/null +++ b/src/app/features/auth/register/register.component.ts @@ -0,0 +1,64 @@ +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 })], + templateUrl: './register.component.html', +}) +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(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']); + } +} diff --git a/src/app/features/auth/user-bar.component.ts b/src/app/features/auth/user-bar.component.ts deleted file mode 100644 index e59cdf9..0000000 --- a/src/app/features/auth/user-bar.component.ts +++ /dev/null @@ -1,43 +0,0 @@ -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: ` -
-
- @if (user()) { -
- - {{ user()?.displayName }} -
- } @else { - - - } -
- `, -}) -export class UserBarComponent { - private store = inject(Store); - private router = inject(Router); - user = this.store.selectSignal(selectCurrentUser); - - goto(path: 'login' | 'register') { - this.router.navigate([`/${path}`]); - } -} diff --git a/src/app/features/auth/user-bar/user-bar.component.html b/src/app/features/auth/user-bar/user-bar.component.html new file mode 100644 index 0000000..52aa899 --- /dev/null +++ b/src/app/features/auth/user-bar/user-bar.component.html @@ -0,0 +1,18 @@ +
+
+ @if (user()) { +
+ + {{ user()?.displayName }} +
+ } @else { + + + } +
diff --git a/src/app/features/auth/user-bar/user-bar.component.ts b/src/app/features/auth/user-bar/user-bar.component.ts new file mode 100644 index 0000000..9d0bcd1 --- /dev/null +++ b/src/app/features/auth/user-bar/user-bar.component.ts @@ -0,0 +1,24 @@ +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 })], + templateUrl: './user-bar.component.html', +}) +export class UserBarComponent { + private store = inject(Store); + private router = inject(Router); + user = this.store.selectSignal(selectCurrentUser); + + goto(path: 'login' | 'register') { + this.router.navigate([`/${path}`]); + } +} diff --git a/src/app/features/chat/chat-messages.component.ts b/src/app/features/chat/chat-messages.component.ts index 3801c08..9b3223d 100644 --- a/src/app/features/chat/chat-messages.component.ts +++ b/src/app/features/chat/chat-messages.component.ts @@ -752,7 +752,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro const safeHtml = DOMPurify.sanitize(container.innerHTML); return this.sanitizer.bypassSecurityTrustHtml(safeHtml); } - + // Resolve images marked for CSP-safe loading by converting to blob URLs private async loadCspImages(): Promise { const root = this.messagesContainer?.nativeElement; diff --git a/src/app/features/room/chat-room.component.ts b/src/app/features/room/chat-room.component.ts deleted file mode 100644 index a854e71..0000000 --- a/src/app/features/room/chat-room.component.ts +++ /dev/null @@ -1,97 +0,0 @@ -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: ` -
- @if (currentRoom()) { - -
- - - -
- - - - -
- -
-
- - - -
- - - - - } @else { - -
-
- -

No room selected

-

Select or create a room to start chatting

-
-
- } -
- `, -}) -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 -} diff --git a/src/app/features/room/chat-room/chat-room.component.html b/src/app/features/room/chat-room/chat-room.component.html new file mode 100644 index 0000000..54bb5c4 --- /dev/null +++ b/src/app/features/room/chat-room/chat-room.component.html @@ -0,0 +1,37 @@ +
+ @if (currentRoom()) { + +
+ + + +
+ + + + +
+ +
+
+ + + +
+ + + + + } @else { + +
+
+ +

No room selected

+

Select or create a room to start chatting

+
+
+ } +
diff --git a/src/app/features/room/chat-room/chat-room.component.ts b/src/app/features/room/chat-room/chat-room.component.ts new file mode 100644 index 0000000..565498d --- /dev/null +++ b/src/app/features/room/chat-room/chat-room.component.ts @@ -0,0 +1,59 @@ +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/screen-share-viewer.component'; +import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component'; +import { RoomsSidePanelComponent } from '../rooms-side-panel/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, + }), + ], + templateUrl: './chat-room.component.html', +}) +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 +} diff --git a/src/app/features/room/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel.component.ts deleted file mode 100644 index aaf6408..0000000 --- a/src/app/features/room/rooms-side-panel.component.ts +++ /dev/null @@ -1,472 +0,0 @@ -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, lucideHash, lucideUsers } 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 { VoiceSessionService } from '../../core/services/voice-session.service'; -import { VoiceControlsComponent } from '../voice/voice-controls.component'; - -type TabView = 'channels' | 'users'; - -@Component({ - selector: 'app-rooms-side-panel', - standalone: true, - imports: [CommonModule, NgIcon, VoiceControlsComponent], - viewProviders: [ - provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers }) - ], - template: ` - - `, -}) -export class RoomsSidePanelComponent { - private store = inject(Store); - private webrtc = inject(WebRTCService); - private voiceSessionService = inject(VoiceSessionService); - - activeTab = signal('channels'); - showFloatingControls = this.voiceSessionService.showFloatingControls; - onlineUsers = this.store.selectSignal(selectOnlineUsers); - currentUser = this.store.selectSignal(selectCurrentUser); - currentRoom = this.store.selectSignal(selectCurrentRoom); - - // Filter out current user from online users list - onlineUsersFiltered() { - const current = this.currentUser(); - const currentId = current?.id; - const currentOderId = current?.oderId; - return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId); - } - - 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; - } - - const current = this.currentUser(); - - // Check if already connected to voice in a DIFFERENT server - must disconnect first - if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { - // Connected to voice in a different server - user must disconnect first - console.warn('Already connected to voice in another server. Disconnect first before joining.'); - return; - } - - // If switching channels within the same server, just update the room - const isSwitchingChannels = current?.voiceState?.isConnected && - current.voiceState.serverId === room?.id && - current.voiceState.roomId !== roomId; - - // Enable microphone and broadcast voice-state - const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); - - enableVoicePromise.then(() => { - if (current?.id && room) { - this.store.dispatch(UsersActions.updateVoiceState({ - userId: current.id, - voiceState: { isConnected: true, isMuted: current.voiceState?.isMuted ?? false, isDeafened: current.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room.id } - })); - } - // Start voice heartbeat to broadcast presence every 5 seconds - this.webrtc.startVoiceHeartbeat(roomId); - this.webrtc.broadcastMessage({ - type: 'voice-state', - oderId: current?.oderId || current?.id, - displayName: current?.displayName || 'User', - voiceState: { isConnected: true, isMuted: current?.voiceState?.isMuted ?? false, isDeafened: current?.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room?.id } - }); - - // Update voice session for floating controls - if (room) { - const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId; - this.voiceSessionService.startSession({ - serverId: room.id, - serverName: room.name, - roomId: roomId, - roomName: voiceRoomName, - serverIcon: room.icon, - serverDescription: room.description, - serverRoute: `/room/${room.id}`, - }); - } - }).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; - - // Stop voice heartbeat - this.webrtc.stopVoiceHeartbeat(); - - // 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, serverId: undefined } - })); - } - - // Broadcast disconnect - this.webrtc.broadcastMessage({ - type: 'voice-state', - oderId: current?.oderId || current?.id, - displayName: current?.displayName || 'User', - voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } - }); - - // End voice session - this.voiceSessionService.endSession(); - } - - voiceOccupancy(roomId: string): number { - const users = this.onlineUsers(); - const room = this.currentRoom(); - // Only count users connected to voice in this specific server and room - return users.filter(u => - !!u.voiceState?.isConnected && - u.voiceState?.roomId === roomId && - u.voiceState?.serverId === room?.id - ).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); - } - - viewStream(userId: string) { - // Focus viewer on a user's stream - dispatches event to screen-share-viewer - 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(); - } - - // For remote users, check the store state first (authoritative) - const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId); - if (user?.screenShareState?.isSharing === false) { - // Store says not sharing - trust this over stream presence - return false; - } - - // Fall back to checking stream if store state is undefined - const stream = this.webrtc.getRemoteStream(userId); - return !!stream && stream.getVideoTracks().length > 0; - } - - voiceUsersInRoom(roomId: string) { - const room = this.currentRoom(); - // Only show users connected to voice in this specific server and room - return this.onlineUsers().filter(u => - !!u.voiceState?.isConnected && - u.voiceState?.roomId === roomId && - u.voiceState?.serverId === room?.id - ); - } - - isCurrentRoom(roomId: string): boolean { - const me = this.currentUser(); - const room = this.currentRoom(); - // Check that voice is connected AND both the server AND room match - return !!( - me?.voiceState?.isConnected && - me.voiceState?.roomId === roomId && - me.voiceState?.serverId === room?.id - ); - } - - voiceEnabled(): boolean { - const room = this.currentRoom(); - return room?.permissions?.allowVoice !== false; - } -} diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html new file mode 100644 index 0000000..688c583 --- /dev/null +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -0,0 +1,272 @@ + diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts new file mode 100644 index 0000000..6452108 --- /dev/null +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -0,0 +1,199 @@ +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, lucideHash, lucideUsers } 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 { VoiceSessionService } from '../../../core/services/voice-session.service'; +import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; + +type TabView = 'channels' | 'users'; + +@Component({ + selector: 'app-rooms-side-panel', + standalone: true, + imports: [CommonModule, NgIcon, VoiceControlsComponent], + viewProviders: [ + provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers }) + ], + templateUrl: './rooms-side-panel.component.html', +}) +export class RoomsSidePanelComponent { + private store = inject(Store); + private webrtc = inject(WebRTCService); + private voiceSessionService = inject(VoiceSessionService); + + activeTab = signal('channels'); + showFloatingControls = this.voiceSessionService.showFloatingControls; + onlineUsers = this.store.selectSignal(selectOnlineUsers); + currentUser = this.store.selectSignal(selectCurrentUser); + currentRoom = this.store.selectSignal(selectCurrentRoom); + + // Filter out current user from online users list + onlineUsersFiltered() { + const current = this.currentUser(); + const currentId = current?.id; + const currentOderId = current?.oderId; + return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId); + } + + 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; + } + + const current = this.currentUser(); + + // Check if already connected to voice in a DIFFERENT server - must disconnect first + if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { + // Connected to voice in a different server - user must disconnect first + console.warn('Already connected to voice in another server. Disconnect first before joining.'); + return; + } + + // If switching channels within the same server, just update the room + const isSwitchingChannels = current?.voiceState?.isConnected && + current.voiceState.serverId === room?.id && + current.voiceState.roomId !== roomId; + + // Enable microphone and broadcast voice-state + const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); + + enableVoicePromise.then(() => { + if (current?.id && room) { + this.store.dispatch(UsersActions.updateVoiceState({ + userId: current.id, + voiceState: { isConnected: true, isMuted: current.voiceState?.isMuted ?? false, isDeafened: current.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room.id } + })); + } + // Start voice heartbeat to broadcast presence every 5 seconds + this.webrtc.startVoiceHeartbeat(roomId); + this.webrtc.broadcastMessage({ + type: 'voice-state', + oderId: current?.oderId || current?.id, + displayName: current?.displayName || 'User', + voiceState: { isConnected: true, isMuted: current?.voiceState?.isMuted ?? false, isDeafened: current?.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room?.id } + }); + + // Update voice session for floating controls + if (room) { + const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId; + this.voiceSessionService.startSession({ + serverId: room.id, + serverName: room.name, + roomId: roomId, + roomName: voiceRoomName, + serverIcon: room.icon, + serverDescription: room.description, + serverRoute: `/room/${room.id}`, + }); + } + }).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; + + // Stop voice heartbeat + this.webrtc.stopVoiceHeartbeat(); + + // 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, serverId: undefined } + })); + } + + // Broadcast disconnect + this.webrtc.broadcastMessage({ + type: 'voice-state', + oderId: current?.oderId || current?.id, + displayName: current?.displayName || 'User', + voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } + }); + + // End voice session + this.voiceSessionService.endSession(); + } + + voiceOccupancy(roomId: string): number { + const users = this.onlineUsers(); + const room = this.currentRoom(); + // Only count users connected to voice in this specific server and room + return users.filter(u => + !!u.voiceState?.isConnected && + u.voiceState?.roomId === roomId && + u.voiceState?.serverId === room?.id + ).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); + } + + viewStream(userId: string) { + // Focus viewer on a user's stream - dispatches event to screen-share-viewer + 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(); + } + + // For remote users, check the store state first (authoritative) + const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId); + if (user?.screenShareState?.isSharing === false) { + // Store says not sharing - trust this over stream presence + return false; + } + + // Fall back to checking stream if store state is undefined + const stream = this.webrtc.getRemoteStream(userId); + return !!stream && stream.getVideoTracks().length > 0; + } + + voiceUsersInRoom(roomId: string) { + const room = this.currentRoom(); + // Only show users connected to voice in this specific server and room + return this.onlineUsers().filter(u => + !!u.voiceState?.isConnected && + u.voiceState?.roomId === roomId && + u.voiceState?.serverId === room?.id + ); + } + + isCurrentRoom(roomId: string): boolean { + const me = this.currentUser(); + const room = this.currentRoom(); + // Check that voice is connected AND both the server AND room match + return !!( + me?.voiceState?.isConnected && + me.voiceState?.roomId === roomId && + me.voiceState?.serverId === room?.id + ); + } + + voiceEnabled(): boolean { + const room = this.currentRoom(); + return room?.permissions?.allowVoice !== false; + } +} diff --git a/src/app/features/server-search/server-search.component.html b/src/app/features/server-search/server-search.component.html new file mode 100644 index 0000000..3d2d9a2 --- /dev/null +++ b/src/app/features/server-search/server-search.component.html @@ -0,0 +1,197 @@ +
+ +
+

My Servers

+ @if (savedRooms().length === 0) { +

No joined servers yet

+ } @else { +
+ @for (room of savedRooms(); track room.id) { + + } +
+ } +
+ +
+
+
+ + +
+ +
+
+ + +
+ +
+ + +
+ @if (isSearching()) { +
+
+
+ } @else if (searchResults().length === 0) { +
+ +

No servers found

+

Try a different search or create your own

+
+ } @else { +
+ @for (server of searchResults(); track server.id) { + + } +
+ } +
+ + @if (error()) { +
+

{{ error() }}

+
+ } +
+ + +@if (showCreateDialog()) { +
+
+

Create Server

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (newServerPrivate()) { +
+ + +
+ } +
+ +
+ + +
+
+
+} diff --git a/src/app/features/server-search/server-search.component.ts b/src/app/features/server-search/server-search.component.ts index ff46b61..6182933 100644 --- a/src/app/features/server-search/server-search.component.ts +++ b/src/app/features/server-search/server-search.component.ts @@ -31,205 +31,7 @@ import { ServerInfo } from '../../core/models'; viewProviders: [ provideIcons({ lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings }), ], - template: ` -
- -
-

My Servers

- @if (savedRooms().length === 0) { -

No joined servers yet

- } @else { -
- @for (room of savedRooms(); track room.id) { - - } -
- } -
- -
-
-
- - -
- -
-
- - -
- -
- - -
- @if (isSearching()) { -
-
-
- } @else if (searchResults().length === 0) { -
- -

No servers found

-

Try a different search or create your own

-
- } @else { -
- @for (server of searchResults(); track server.id) { - - } -
- } -
- - @if (error()) { -
-

{{ error() }}

-
- } -
- - - @if (showCreateDialog()) { -
-
-

Create Server

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - @if (newServerPrivate()) { -
- - -
- } -
- -
- - -
-
-
- } - `, + templateUrl: './server-search.component.html', }) export class ServerSearchComponent implements OnInit { private store = inject(Store); diff --git a/src/app/features/servers/servers-rail.component.html b/src/app/features/servers/servers-rail.component.html new file mode 100644 index 0000000..99a1b89 --- /dev/null +++ b/src/app/features/servers/servers-rail.component.html @@ -0,0 +1,57 @@ + + + +
+
+
+ + +
+
+ + +
+
+
+
+

Forget Server?

+

+ Remove {{ contextRoom()?.name }} from your My Servers list. +

+
+
+ + +
+
+
diff --git a/src/app/features/servers/servers-rail.component.ts b/src/app/features/servers/servers-rail.component.ts index 9d97568..371a497 100644 --- a/src/app/features/servers/servers-rail.component.ts +++ b/src/app/features/servers/servers-rail.component.ts @@ -15,65 +15,7 @@ import * as RoomsActions from '../../store/rooms/rooms.actions'; standalone: true, imports: [CommonModule, NgIcon], viewProviders: [provideIcons({ lucidePlus })], - template: ` - - - -
-
-
- - -
-
- - -
-
-
-
-

Forget Server?

-

- Remove {{ contextRoom()?.name }} from your My Servers list. -

-
-
- - -
-
-
- `, + templateUrl: './servers-rail.component.html', }) export class ServersRailComponent { private store = inject(Store); diff --git a/src/app/features/settings/settings.component.html b/src/app/features/settings/settings.component.html new file mode 100644 index 0000000..ba2096a --- /dev/null +++ b/src/app/features/settings/settings.component.html @@ -0,0 +1,168 @@ +
+
+ + +

Settings

+
+ + +
+
+
+ +

Server Endpoints

+
+ +
+ +

+ Add multiple server directories to search for rooms across different networks. + The active server will be used for creating and registering new rooms. +

+ + +
+ @for (server of servers(); track server.id) { +
+ +
+ + +
+
+ {{ server.name }} + @if (server.isActive) { + Active + } +
+

{{ server.url }}

+ @if (server.latency !== undefined && server.status === 'online') { +

{{ server.latency }}ms

+ } +
+ + +
+ @if (!server.isActive) { + + } + @if (!server.isDefault) { + + } +
+
+ } +
+ + +
+

Add New Server

+
+
+ + +
+ +
+ @if (addError()) { +

{{ addError() }}

+ } +
+
+ + +
+
+ +

Connection Settings

+
+ +
+
+
+

Auto-reconnect

+

Automatically reconnect when connection is lost

+
+ +
+ +
+
+

Search all servers

+

Search across all configured server directories

+
+ +
+
+
+
diff --git a/src/app/features/settings/settings.component.ts b/src/app/features/settings/settings.component.ts index 7f7ae39..fdd3b10 100644 --- a/src/app/features/settings/settings.component.ts +++ b/src/app/features/settings/settings.component.ts @@ -15,7 +15,7 @@ import { lucideArrowLeft, } from '@ng-icons/lucide'; -import { ServerDirectoryService, ServerEndpoint } from '../../core/services/server-directory.service'; +import { ServerDirectoryService } from '../../core/services/server-directory.service'; @Component({ selector: 'app-settings', @@ -34,176 +34,7 @@ import { ServerDirectoryService, ServerEndpoint } from '../../core/services/serv lucideArrowLeft, }), ], - template: ` -
-
- - -

Settings

-
- - -
-
-
- -

Server Endpoints

-
- -
- -

- Add multiple server directories to search for rooms across different networks. - The active server will be used for creating and registering new rooms. -

- - -
- @for (server of servers(); track server.id) { -
- -
- - -
-
- {{ server.name }} - @if (server.isActive) { - Active - } -
-

{{ server.url }}

- @if (server.latency !== undefined && server.status === 'online') { -

{{ server.latency }}ms

- } -
- - -
- @if (!server.isActive) { - - } - @if (!server.isDefault) { - - } -
-
- } -
- - -
-

Add New Server

-
-
- - -
- -
- @if (addError()) { -

{{ addError() }}

- } -
-
- - -
-
- -

Connection Settings

-
- -
-
-
-

Auto-reconnect

-

Automatically reconnect when connection is lost

-
- -
- -
-
-

Search all servers

-

Search across all configured server directories

-
- -
-
-
-
- `, + templateUrl: './settings.component.html', }) export class SettingsComponent implements OnInit { private serverDirectory = inject(ServerDirectoryService); diff --git a/src/app/features/shell/title-bar.component.html b/src/app/features/shell/title-bar.component.html new file mode 100644 index 0000000..f0c13a7 --- /dev/null +++ b/src/app/features/shell/title-bar.component.html @@ -0,0 +1,41 @@ +
+
+ + + + {{ roomName() }} + + + +
+ +
+ +
+
+ +
+ {{ username() }} | {{ serverName() }} + Reconnecting… +
+
+
+
+ + + + +
+
+ +
diff --git a/src/app/features/shell/title-bar.component.ts b/src/app/features/shell/title-bar.component.ts index 721ec5d..ecf40ff 100644 --- a/src/app/features/shell/title-bar.component.ts +++ b/src/app/features/shell/title-bar.component.ts @@ -15,49 +15,7 @@ import { WebRTCService } from '../../core/services/webrtc.service'; standalone: true, imports: [CommonModule, NgIcon], viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })], - template: ` -
-
- - - - {{ roomName() }} - - - -
- -
- -
-
- -
- {{ username() }} | {{ serverName() }} - Reconnecting… -
-
-
-
- - - - -
-
- -
- `, + templateUrl: './title-bar.component.html', }) export class TitleBarComponent { private store = inject(Store); diff --git a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.html b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.html new file mode 100644 index 0000000..c3d8236 --- /dev/null +++ b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.html @@ -0,0 +1,70 @@ +@if (showFloatingControls()) { + +
+
+ + + + +
+ + {{ voiceSession()?.roomName || 'Voice' }} +
+ + +
+ + +
+ + + + + + + +
+
+
+} diff --git a/src/app/features/voice/floating-voice-controls.component.ts b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts similarity index 65% rename from src/app/features/voice/floating-voice-controls.component.ts rename to src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts index 3fbbad4..0b0d346 100644 --- a/src/app/features/voice/floating-voice-controls.component.ts +++ b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts @@ -13,10 +13,10 @@ import { lucideArrowLeft, } from '@ng-icons/lucide'; -import { WebRTCService } from '../../core/services/webrtc.service'; -import { VoiceSessionService } from '../../core/services/voice-session.service'; -import * as UsersActions from '../../store/users/users.actions'; -import { selectCurrentUser } from '../../store/users/users.selectors'; +import { WebRTCService } from '../../../core/services/webrtc.service'; +import { VoiceSessionService } from '../../../core/services/voice-session.service'; +import * as UsersActions from '../../../store/users/users.actions'; +import { selectCurrentUser } from '../../../store/users/users.selectors'; @Component({ selector: 'app-floating-voice-controls', @@ -25,7 +25,6 @@ import { selectCurrentUser } from '../../store/users/users.selectors'; viewProviders: [ provideIcons({ lucideMic, - lucideMicOff, lucideMonitor, lucideMonitorOff, lucidePhoneOff, @@ -33,78 +32,7 @@ import { selectCurrentUser } from '../../store/users/users.selectors'; lucideArrowLeft, }), ], - template: ` - @if (showFloatingControls()) { - -
-
- - - - -
- - {{ voiceSession()?.roomName || 'Voice' }} -
- - -
- - -
- - - - - - - -
-
-
- } - `, + templateUrl: './floating-voice-controls.component.html' }) export class FloatingVoiceControlsComponent implements OnInit, OnDestroy { private webrtcService = inject(WebRTCService); diff --git a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html new file mode 100644 index 0000000..75cfaa4 --- /dev/null +++ b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html @@ -0,0 +1,80 @@ +
+ + + + +
+
+
+ + + {{ activeScreenSharer()?.displayName }} is sharing their screen + + + Someone is sharing their screen + +
+
+ +
+ Volume: {{ screenVolume() }}% + +
+ + @if (isLocalShare()) { + + } @else { + + } +
+
+
+ + +
+
+ +

Waiting for screen share...

+
+
+
diff --git a/src/app/features/voice/screen-share-viewer.component.ts b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts similarity index 69% rename from src/app/features/voice/screen-share-viewer.component.ts rename to src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts index 867734a..5694b7b 100644 --- a/src/app/features/voice/screen-share-viewer.component.ts +++ b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts @@ -10,9 +10,9 @@ import { lucideMonitor, } from '@ng-icons/lucide'; -import { WebRTCService } from '../../core/services/webrtc.service'; -import { selectOnlineUsers } from '../../store/users/users.selectors'; -import { User } from '../../core/models'; +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', @@ -26,88 +26,7 @@ import { User } from '../../core/models'; lucideMonitor, }), ], - template: ` -
- - - - -
-
-
- - - {{ activeScreenSharer()?.displayName }} is sharing their screen - - - Someone is sharing their screen - -
-
- -
- Volume: {{ screenVolume() }}% - -
- - @if (isLocalShare()) { - - } @else { - - } -
-
-
- - -
-
- -

Waiting for screen share...

-
-
-
- `, + templateUrl: './screen-share-viewer.component.html', }) export class ScreenShareViewerComponent implements OnDestroy { @ViewChild('screenVideo') videoRef!: ElementRef; @@ -171,19 +90,19 @@ export class ScreenShareViewerComponent implements OnDestroy { effect(() => { const watchingId = this.watchingUserId(); const isWatchingRemote = this.hasStream() && !this.isLocalShare(); - + // Only check if we're actually watching a remote stream if (!watchingId || !isWatchingRemote) return; - + const users = this.onlineUsers(); const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId); - + // If the user is no longer sharing (screenShareState.isSharing is false), stop watching if (watchedUser && watchedUser.screenShareState?.isSharing === false) { this.stopWatching(); return; } - + // Also check if the stream's video tracks are still available const stream = this.webrtcService.getRemoteStream(watchingId); const hasActiveVideo = stream?.getVideoTracks().some(t => t.readyState === 'live'); diff --git a/src/app/features/voice/voice-controls/voice-controls.component.html b/src/app/features/voice/voice-controls/voice-controls.component.html new file mode 100644 index 0000000..fd86ac7 --- /dev/null +++ b/src/app/features/voice/voice-controls/voice-controls.component.html @@ -0,0 +1,180 @@ +
+ + @if (showConnectionError()) { +
+ + {{ connectionErrorMessage() || 'Connection error' }} + +
+ } + + +
+
+ {{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }} +
+
+

+ {{ currentUser()?.displayName || 'Unknown' }} +

+

+ @if (showConnectionError()) { + ● Connection Error + } @else if (isConnected()) { + ● Connected + } @else { + ● Disconnected + } +

+
+ +
+ + +
+ @if (isConnected()) { + + + + + + + + + + + + } +
+ + + @if (showSettings()) { +
+
+

Voice Settings

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

Off by default; viewers will still hear your mic.

+
+ +
+ + +
+
+ +
+ +
+
+
+ } +
diff --git a/src/app/features/voice/voice-controls.component.ts b/src/app/features/voice/voice-controls/voice-controls.component.ts similarity index 69% rename from src/app/features/voice/voice-controls.component.ts rename to src/app/features/voice/voice-controls/voice-controls.component.ts index e4a6a40..9b6c238 100644 --- a/src/app/features/voice/voice-controls.component.ts +++ b/src/app/features/voice/voice-controls/voice-controls.component.ts @@ -15,11 +15,11 @@ import { lucideHeadphones, } from '@ng-icons/lucide'; -import { WebRTCService } from '../../core/services/webrtc.service'; -import { VoiceSessionService } from '../../core/services/voice-session.service'; -import * as UsersActions from '../../store/users/users.actions'; -import { selectCurrentUser } from '../../store/users/users.selectors'; -import { selectCurrentRoom } from '../../store/rooms/rooms.selectors'; +import { WebRTCService } from '../../../core/services/webrtc.service'; +import { VoiceSessionService } from '../../../core/services/voice-session.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; @@ -43,188 +43,7 @@ interface AudioDevice { lucideHeadphones, }), ], - template: ` -
- - @if (showConnectionError()) { -
- - {{ connectionErrorMessage() || 'Connection error' }} - -
- } - - -
-
- {{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }} -
-
-

- {{ currentUser()?.displayName || 'Unknown' }} -

-

- @if (showConnectionError()) { - ● Connection Error - } @else if (isConnected()) { - ● Connected - } @else { - ● Disconnected - } -

-
- -
- - -
- @if (isConnected()) { - - - - - - - - - - - - } -
- - - @if (showSettings()) { -
-
-

Voice Settings

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -

Off by default; viewers will still hear your mic.

-
- -
- - -
-
- -
- -
-
-
- } -
- `, + templateUrl: './voice-controls.component.html', }) export class VoiceControlsComponent implements OnInit, OnDestroy { private webrtcService = inject(WebRTCService);